From b7c819815d18b78e0bc7dd8d91c50c0c33b22e5b Mon Sep 17 00:00:00 2001 From: Henk Date: Thu, 11 Jun 2026 10:34:25 -0700 Subject: [PATCH] dev iteration migration to hivekit --- LICENSE | 2 +- README.md | 4 +- api/README.md | 11 - api/go/vocab/vocab.go | 634 ------- api/js/vocab/vocab.js | 1541 ----------------- api/py/vocab/vocab.py | 605 ------- api/src/vocab/ht-action-classes.yaml | 126 -- api/src/vocab/ht-property-classes.yaml | 403 ----- api/src/vocab/ht-thing-classes.yaml | 473 ----- api/src/vocab/ht-unit-classes.yaml | 180 -- api/src/vocab/wot-vocab.yaml | 113 -- bindings/ipnet/IpNet_test.go | 20 +- bindings/ipnet/cmd/main.go | 5 +- bindings/ipnet/service/IPNetBinding.go | 15 +- bindings/ipnet/service/PubTD.go | 5 +- bindings/ipnet/service/PubValues.go | 6 +- bindings/isy99x/Isy99x_test.go | 21 +- bindings/isy99x/cmd/main.go | 3 +- .../isy99x/service/HandleActionRequest.go | 8 +- .../isy99x/service/HandleConfigRequest.go | 25 +- bindings/isy99x/service/IsyBinding.go | 53 +- bindings/isy99x/service/IsyDimmerThing.go | 8 +- bindings/isy99x/service/IsyGatewayThing.go | 233 +-- bindings/isy99x/service/IsySensorThing.go | 4 +- bindings/isy99x/service/IsySwitchThing.go | 18 +- bindings/isy99x/service/IsyThing.go | 64 +- bindings/isy99x/service/PublishTDs.go | 12 +- bindings/isy99x/service/PublishThingValues.go | 13 +- bindings/owserver/OWServer_test.go | 93 +- bindings/owserver/cmd/main.go | 26 +- bindings/owserver/service/HandleRequest.go | 20 +- bindings/owserver/service/OWServerBinding.go | 56 +- bindings/owserver/service/PublishNodeTDs.go | 3 +- .../owserver/service/PublishNodeValues.go | 4 +- bindings/weather/Weather_test.go | 49 +- bindings/weather/cmd/main.go | 3 +- bindings/weather/service/HandleRequests.go | 28 +- bindings/weather/service/LocationStore.go | 10 +- bindings/weather/service/PublishTDs.go | 10 +- bindings/weather/service/PublishValues.go | 43 +- bindings/weather/service/WeatherBinding.go | 25 +- cmd/hubcli/authcli/AuthAddUserCommand.go | 71 +- cmd/hubcli/authcli/AuthSetPassCommand.go | 9 +- cmd/hubcli/authcli/AuthSetRoleCommand.go | 20 +- cmd/hubcli/certs/CreateCACommand.go | 23 +- cmd/hubcli/certs/ViewCACommand.go | 7 +- cmd/hubcli/directorycli/DirectoryCommands.go | 33 +- cmd/hubcli/directorycli/DiscoCommands.go | 41 +- cmd/hubcli/historycli/HistoryCommands.go | 15 +- cmd/hubcli/idprovcli/IdProvCommands.go | 164 -- cmd/hubcli/launchercli/LauncherCommands.go | 2 +- cmd/hubcli/main.go | 81 +- cmd/hubcli/pubsubcli/PubCommands.go | 2 +- cmd/hubcli/pubsubcli/SubCommands.go | 28 +- cmd/tm2go/README.md | 41 - cmd/tm2go/genagent/GenAgent.go | 38 - cmd/tm2go/genagent/GenServiceAgent.go | 89 - cmd/tm2go/genagent/GenServiceInterface.go | 107 -- cmd/tm2go/genconsumer/GenServiceConsumer.go | 165 -- cmd/tm2go/gentypes/GenActionStructs.go | 90 - cmd/tm2go/gentypes/GenDataSchema.go | 247 --- cmd/tm2go/gentypes/GenSchemaDefinitions.go | 33 - cmd/tm2go/gentypes/GenThingConstants.go | 54 - cmd/tm2go/gentypes/GenTypes.go | 46 - cmd/tm2go/gentypes/Names.go | 118 -- cmd/tm2go/gentypes/SL.go | 52 - cmd/tm2go/listtms/ListTMsCommand.go | 63 - cmd/tm2go/main.go | 275 --- cmd/tm2go/main_test.go | 23 - docs/INSTALL.md | 6 +- docs/README-TD.md | 14 +- docs/TD Challenges.md | 2 + docs/design-overview.md | 60 +- docs/thoughts.md | 2 +- go.mod | 62 +- go.sum | 104 +- lib/agent/Agent.go | 194 --- lib/buckets/BucketBench_test.go | 207 --- lib/buckets/BucketStore_test.go | 624 ------- lib/buckets/CursorCache.go | 212 --- lib/buckets/IBucketStore.go | 152 -- lib/buckets/README.md | 67 - lib/buckets/bucketstore/NewBucketStore.go | 33 - lib/buckets/cmd/pebblecopy.go | 156 -- lib/buckets/kvbtree/KVBTreeBucket.go | 322 ---- lib/buckets/kvbtree/KVBTreeCursor.go | 152 -- lib/buckets/kvbtree/KVBTreeStore.go | 338 ---- lib/buckets/pebble/PebbleBucket.go | 204 --- lib/buckets/pebble/PebbleCursor.go | 159 -- lib/buckets/pebble/PebbleStore.go | 122 -- lib/certs/CreateCA.go | 69 - lib/certs/CreateCertBundle.go | 67 - lib/certs/CreateCerts_test.go | 74 - lib/certs/CreateClientCert.go | 63 - lib/certs/CreateServerCert.go | 105 -- lib/certs/VerifyCert.go | 33 - lib/certs/certs.go | 176 -- lib/certs/certs_test.go | 80 - lib/certs/main_test.go | 33 - lib/clients/ConnectClient.go | 267 --- .../authclient/AuthenticateWithPassword.go | 129 -- lib/clients/discoclient/DirectoryClient.go | 197 --- lib/clients/discoclient/DiscoverTDD.go | 147 -- lib/clients/discoclient/DnsSDScan.go | 72 - lib/clients/hiveotsseclient/ConnectSSE.go | 263 --- .../hiveotsseclient/HiveotSseClient.go | 280 --- lib/clients/httpclient/HttpBasicClient.go | 642 ------- lib/clients/mqttclient/MqttAgentClient.go.old | 210 --- .../mqttclient/MqttClientConnection.go | 722 -------- lib/clients/mqttclient/MqttMessages.go.old | 193 --- lib/clients/tlsclient/ConnectTLS.go | 66 - lib/clients/tlsclient/CreateHttp2TLSClient.go | 59 - lib/clients/tlsclient/NewRequest.go | 33 - lib/clients/tlsclient/TLSClient.go | 317 ---- lib/clients/tlsclient/TLSClient_test.go | 315 ---- lib/clients/wssclient/ConnectWss.go | 157 -- lib/clients/wssclient/WssClient.go | 406 ----- lib/consumedthing/ConsumedThing.go | 516 ------ lib/consumedthing/ConsumedThingsDirectory.go | 215 --- lib/consumedthing/DataSchemaValue.go | 72 - lib/consumedthing/InteractionInput.go | 54 - lib/consumedthing/InteractionOutput.go | 212 --- lib/consumedthing/InteractionOutput_test.go | 98 -- lib/consumedthing/Subscription.go | 8 - lib/consumer/Consumer.go | 588 ------- lib/consumer/RnR.go | 144 -- lib/exposedthing/HandleRequest.go | 13 - lib/exposedthing/ThingValues.go | 109 -- lib/hubagent/AgentHelper.go | 62 - lib/hubagent/HandleRequestMessage.go | 150 -- lib/hubagent/HandleRequestMessage_test.go | 216 --- lib/keys/EcdsaKey.go | 137 -- lib/keys/Ed25519Key.go | 215 --- lib/keys/IHiveKey.go | 94 - lib/keys/RsaKey.go | 224 --- lib/keys/keys.go | 149 -- lib/keys/keys_test.go | 160 -- lib/logging/SetLogging.go | 105 -- lib/logging/SetLogging_test.go | 27 - lib/messaging/ActionStatus.go | 92 - lib/messaging/DummyAuthenticator.go | 139 -- lib/messaging/IAuthenticator.go | 66 - lib/messaging/IClientConnection.go | 28 - lib/messaging/IConnection.go | 130 -- lib/messaging/IMessageConverter.go | 42 - lib/messaging/IServerConnection.go | 14 - lib/messaging/NotificationMessage.go | 94 - lib/messaging/RequestMessage.go | 178 -- lib/messaging/ResponseMessage.go | 185 -- .../converters/PassthroughMessageConverter.go | 82 - .../converters/WssMessageConverter.go | 360 ---- lib/plugin/GetAppEnvironment.go | 226 --- lib/plugin/StartHeartbeat.go | 60 - lib/plugin/StartPlugin.go | 42 +- lib/plugin/WaitForSignal.go | 18 - lib/servers/IMessageServer.go | 57 - lib/servers/ProtocolConfig.go | 107 -- lib/servers/connections/ConnectionManager.go | 229 --- .../connections/ConnectionManager_test.go | 190 -- .../connections/DummyConnection_test.go | 100 -- lib/servers/connections/Subscriptions.go | 86 - lib/servers/discoserver/DiscoveryServer.go | 107 -- lib/servers/discoserver/ServeTDD.go | 173 -- lib/servers/discoserver/ServeTDD_test.go | 145 -- .../hiveotsseserver/HiveotSseServer.go | 229 --- .../HiveotSseServerConnection.go | 443 ----- .../hiveotsseserver/HiveotSseServerRoutes.go | 285 --- lib/servers/httpbasic/AddSessionFromToken.go | 68 - lib/servers/httpbasic/GetRequestParams.go | 100 -- lib/servers/httpbasic/HttpBasicForms.go | 100 -- lib/servers/httpbasic/HttpBasicMessages.go | 53 - lib/servers/httpbasic/HttpBasicRoutes.go | 195 --- lib/servers/httpbasic/HttpBasicServer.go | 214 --- lib/servers/httpbasic/StaticFileWrapper.go | 42 - lib/servers/mqttserver/MqttTransportServer.go | 124 -- lib/servers/tests/actions_test.go | 272 --- lib/servers/tests/connect_test.go | 565 ------ lib/servers/tests/events_test.go | 217 --- lib/servers/tests/properties_test.go | 197 --- lib/servers/tests/td_test.go | 129 -- lib/servers/tlsserver/GetBearerToken.go | 27 - lib/servers/tlsserver/TLSServer.go | 178 -- lib/servers/tlsserver/TLSServer_test.go | 245 --- lib/servers/tlsserver/WriteReply.go | 63 - .../transportmanager/TransportManager.go | 395 ----- lib/servers/wssserver/WssForms.go | 65 - lib/servers/wssserver/WssServer.go | 214 --- lib/servers/wssserver/WssServerConnection.go | 375 ---- lib/testenv/StartMqttTestServer.go.old | 30 - lib/testenv/TestServer.go | 366 ---- runtime/README-runtime.md | 97 +- runtime/Runtime.go | 294 ---- runtime/RuntimeConfig.go | 309 ---- runtime/RuntimeDirectory_test.go | 240 --- runtime/RuntimeValues_test.go | 285 --- runtime/Runtime_test.go | 222 +-- runtime/authn/AuthnAdmin_test.go | 214 --- runtime/authn/AuthnUser_test.go | 152 -- runtime/authn/Authn_test.go | 72 - runtime/authn/README.md | 43 - runtime/authn/api/AdminAgentAPI.go | 154 -- runtime/authn/api/AdminConsumerAPI.go | 88 - runtime/authn/api/AdminTypes.go | 148 -- runtime/authn/api/UserAgentAPI.go | 112 -- runtime/authn/api/UserConsumerAPI.go | 61 - runtime/authn/api/UserTypes.go | 117 -- .../authn/authenticator/Authenticator_test.go | 110 -- .../authn/authenticator/JWTAuthenticator.go | 299 ---- .../authenticator/PasetoAuthenticator.go | 282 --- runtime/authn/authnstore/AuthnFileStore.go | 382 ---- .../authn/authnstore/AuthnFileStore_test.go | 396 ----- runtime/authn/authnstore/IAuthnStore.go | 79 - runtime/authn/config/AuthnConfig.go | 130 -- runtime/authn/service/AuthnAdminService.go | 330 ---- runtime/authn/service/AuthnAgent.go | 46 - runtime/authn/service/AuthnService.go | 74 - runtime/authn/service/AuthnUserService.go | 140 -- runtime/authn/service/LoadCreateKey.go | 64 - runtime/authn/sessions/SessionManager.go | 167 -- runtime/authn/sessions/SessionManager_test.go | 129 -- runtime/authn/tm/admin.json | 261 --- runtime/authn/tm/user.json | 175 -- runtime/authz/Authz_test.go | 113 -- runtime/authz/README.md | 71 - runtime/authz/api/AdminAgentAPI.go | 60 - runtime/authz/api/AdminConsumerAPI.go | 24 - runtime/authz/api/AdminTypes.go | 45 - runtime/authz/api/UserAgentAPI.go | 47 - runtime/authz/api/UserConsumerAPI.go | 16 - runtime/authz/api/UserTypes.go | 117 -- runtime/authz/service/AuthzAgent.go | 99 -- runtime/authz/service/AuthzConfig.go | 185 -- runtime/authz/service/AuthzService.go | 155 -- runtime/authz/service/HasPermission.go | 108 -- runtime/authz/tm/Admin.json | 60 - runtime/authz/tm/User.json | 145 -- runtime/cmd/main.go | 15 +- runtime/digitwin/DigitwinAction_test.go | 219 --- runtime/digitwin/DigitwinBench_test.go | 56 - runtime/digitwin/DigitwinDirectory_test.go | 95 - runtime/digitwin/DigitwinEvent_test.go | 113 -- runtime/digitwin/DigitwinProperty_test.go | 124 -- runtime/digitwin/DigitwinRouter_test.go | 34 - runtime/digitwin/DigitwinService_test.go | 112 -- .../digitwin/DummyTransportBinding_test.go | 45 - runtime/digitwin/api/DigitwinID.go | 26 - runtime/digitwin/api/DirectoryAgentAPI.go | 97 -- runtime/digitwin/api/DirectoryConsumerAPI.go | 48 - runtime/digitwin/api/DirectoryTypes.go | 75 - runtime/digitwin/api/ValuesAgentAPI.go | 113 -- runtime/digitwin/api/ValuesConsumerAPI.go | 56 - runtime/digitwin/api/ValuesTypes.go | 262 --- runtime/digitwin/dthing/DigitwinThing.go | 41 - runtime/digitwin/router/AuthFlow.go | 42 - runtime/digitwin/router/DigitwinRouter.go | 101 -- runtime/digitwin/router/IDigitwinRouter.go | 17 - runtime/digitwin/router/NotificationFlow.go | 125 -- runtime/digitwin/router/RequestFlow.go | 349 ---- runtime/digitwin/router/ResponseFlow.go | 121 -- runtime/digitwin/service/DigitwinAgent.go | 43 - runtime/digitwin/service/DigitwinService.go | 106 -- runtime/digitwin/service/DirectoryService.go | 175 -- runtime/digitwin/service/ValuesService.go | 76 - runtime/digitwin/store/DigitwinInstance.go | 35 - runtime/digitwin/store/DigitwinStore.go | 680 -------- runtime/digitwin/store/DigitwinStore_test.go | 225 --- runtime/digitwin/tm/Directory.json | 305 ---- runtime/digitwin/tm/Values.json | 306 ---- runtime/runtime.go | 362 ++++ services/certs/Certs_test.go | 247 --- services/certs/README.md | 132 -- services/certs/certsapi/AdminCertsAPI.go | 53 - .../certs/certsclient/ManageCertsClient.go | 106 -- services/certs/cmd/main.go | 49 - services/certs/service/letsencrypt/README.md | 5 - .../service/selfsigned/CertsAdminService.go | 272 --- .../certs/service/selfsigned/CertsAgent.go | 27 - .../service/selfsigned/createClientCert.go | 80 - services/certs/tm/certs.json.todo | 1 - services/history/HistoryBench_test.go | 150 -- services/history/History_test.go | 736 -------- services/history/README.md | 91 - services/history/cmd/main.go | 38 - services/history/config/HistoryConfig.go | 27 - services/history/config/history.yaml | 63 - .../history/historyapi/ManageHistoryAPI.go | 64 - services/history/historyapi/ReadHistoryAPI.go | 135 -- .../historyclient/HistoryCursorClient.go | 147 -- .../historyclient/ManageHistoryClient.go | 57 - .../historyclient/ReadHistoryClient.go | 75 - services/history/service/AddHistory.go | 179 -- services/history/service/HistoryAgent.go | 68 - services/history/service/HistoryService.go | 117 -- services/history/service/ManageHistory.go | 119 -- services/history/service/ReadHistory.go | 126 -- services/history/service/ReadHistoryCursor.go | 431 ----- services/hiveoview/cmd/main.go | 33 +- .../hiveoview/src/service/CreateRoutes.go | 3 +- .../hiveoview/src/service/HiveoviewService.go | 86 +- .../hiveoview/src/service/PublishServiceTD.go | 11 +- services/hiveoview/src/service/SseServe.go | 3 +- services/hiveoview/src/session/ClientData.go | 6 +- .../hiveoview/src/session/DashboardModel.go | 4 +- .../hiveoview/src/session/SessionManager.go | 55 +- .../hiveoview/src/session/WebClientSession.go | 32 +- .../hiveoview/src/views/directory/DeleteTD.go | 9 +- .../src/views/directory/GroupByAgent.go | 2 +- .../src/views/history/HistoryPage.go | 10 +- .../src/views/history/HistoryTemplateData.go | 23 +- .../src/views/history/renderLatestValueRow.go | 6 +- .../hiveoview/src/views/login/PostLogin.go | 12 +- services/hiveoview/src/views/login/login.go | 4 +- .../src/views/thing/ActionRequest.go | 15 +- .../hiveoview/src/views/thing/EditProperty.go | 11 +- .../src/views/thing/RenderInputValue.gohtml | 2 +- .../src/views/thing/RenderOutputValue.gohtml | 2 +- .../src/views/thing/RenderThingRaw.go | 7 +- .../views/thingdetails/ThingDetailsPage.go | 17 +- services/hiveoview/src/views/tile/EditTile.go | 9 +- .../hiveoview/src/views/tile/RenderTile.go | 23 +- .../hiveoview/src/views/tile/SelectSources.go | 25 +- .../views/tile/card/RenderCardInput.gohtml | 2 +- .../src/views/tile/renderTileSourceRow.go | 8 +- .../src/views/tile/selectSourcesRow.gohtml | 2 +- services/hiveoview/test/Hiveov_test.go | 96 +- services/idprov/IdProv_test.go | 234 --- services/idprov/README.md | 66 - services/idprov/cmd/main.go | 49 - services/idprov/idprovapi/IdProvUser.go | 62 - services/idprov/idprovapi/ManageIdProv.go | 81 - .../idprovclient/DiscoveryIdProvClient.go.old | 33 - .../idprov/idprovclient/ManageIdProvClient.go | 90 - .../idprovclient/SubmitIdProvRequest.go | 35 - services/idprov/service/IdProvAgent.go | 30 - services/idprov/service/IdProvHttpServer.go | 68 - services/idprov/service/IdProvService.go | 108 -- .../idprov/service/ManageIdProvService.go | 203 --- services/launcher/Launcher_test.go | 30 +- services/launcher/api/AdminAgentAPI.go | 144 +- services/launcher/api/AdminConsumerAPI.go | 45 +- services/launcher/cmd/main.go | 12 +- services/launcher/service/LauncherService.go | 32 +- services/launcher/service/StartStopPlugins.go | 3 +- 343 files changed, 1595 insertions(+), 42347 deletions(-) delete mode 100644 api/README.md delete mode 100644 api/go/vocab/vocab.go delete mode 100644 api/js/vocab/vocab.js delete mode 100644 api/py/vocab/vocab.py delete mode 100644 api/src/vocab/ht-action-classes.yaml delete mode 100644 api/src/vocab/ht-property-classes.yaml delete mode 100644 api/src/vocab/ht-thing-classes.yaml delete mode 100644 api/src/vocab/ht-unit-classes.yaml delete mode 100644 api/src/vocab/wot-vocab.yaml delete mode 100644 cmd/hubcli/idprovcli/IdProvCommands.go delete mode 100644 cmd/tm2go/README.md delete mode 100644 cmd/tm2go/genagent/GenAgent.go delete mode 100644 cmd/tm2go/genagent/GenServiceAgent.go delete mode 100644 cmd/tm2go/genagent/GenServiceInterface.go delete mode 100644 cmd/tm2go/genconsumer/GenServiceConsumer.go delete mode 100644 cmd/tm2go/gentypes/GenActionStructs.go delete mode 100644 cmd/tm2go/gentypes/GenDataSchema.go delete mode 100644 cmd/tm2go/gentypes/GenSchemaDefinitions.go delete mode 100644 cmd/tm2go/gentypes/GenThingConstants.go delete mode 100644 cmd/tm2go/gentypes/GenTypes.go delete mode 100644 cmd/tm2go/gentypes/Names.go delete mode 100644 cmd/tm2go/gentypes/SL.go delete mode 100644 cmd/tm2go/listtms/ListTMsCommand.go delete mode 100644 cmd/tm2go/main.go delete mode 100644 cmd/tm2go/main_test.go delete mode 100644 lib/agent/Agent.go delete mode 100644 lib/buckets/BucketBench_test.go delete mode 100644 lib/buckets/BucketStore_test.go delete mode 100644 lib/buckets/CursorCache.go delete mode 100644 lib/buckets/IBucketStore.go delete mode 100644 lib/buckets/README.md delete mode 100644 lib/buckets/bucketstore/NewBucketStore.go delete mode 100644 lib/buckets/cmd/pebblecopy.go delete mode 100644 lib/buckets/kvbtree/KVBTreeBucket.go delete mode 100644 lib/buckets/kvbtree/KVBTreeCursor.go delete mode 100644 lib/buckets/kvbtree/KVBTreeStore.go delete mode 100644 lib/buckets/pebble/PebbleBucket.go delete mode 100644 lib/buckets/pebble/PebbleCursor.go delete mode 100644 lib/buckets/pebble/PebbleStore.go delete mode 100644 lib/certs/CreateCA.go delete mode 100644 lib/certs/CreateCertBundle.go delete mode 100644 lib/certs/CreateCerts_test.go delete mode 100644 lib/certs/CreateClientCert.go delete mode 100644 lib/certs/CreateServerCert.go delete mode 100644 lib/certs/VerifyCert.go delete mode 100644 lib/certs/certs.go delete mode 100644 lib/certs/certs_test.go delete mode 100644 lib/certs/main_test.go delete mode 100644 lib/clients/ConnectClient.go delete mode 100644 lib/clients/authclient/AuthenticateWithPassword.go delete mode 100644 lib/clients/discoclient/DirectoryClient.go delete mode 100644 lib/clients/discoclient/DiscoverTDD.go delete mode 100644 lib/clients/discoclient/DnsSDScan.go delete mode 100644 lib/clients/hiveotsseclient/ConnectSSE.go delete mode 100644 lib/clients/hiveotsseclient/HiveotSseClient.go delete mode 100644 lib/clients/httpclient/HttpBasicClient.go delete mode 100644 lib/clients/mqttclient/MqttAgentClient.go.old delete mode 100644 lib/clients/mqttclient/MqttClientConnection.go delete mode 100644 lib/clients/mqttclient/MqttMessages.go.old delete mode 100644 lib/clients/tlsclient/ConnectTLS.go delete mode 100644 lib/clients/tlsclient/CreateHttp2TLSClient.go delete mode 100644 lib/clients/tlsclient/NewRequest.go delete mode 100644 lib/clients/tlsclient/TLSClient.go delete mode 100644 lib/clients/tlsclient/TLSClient_test.go delete mode 100644 lib/clients/wssclient/ConnectWss.go delete mode 100644 lib/clients/wssclient/WssClient.go delete mode 100644 lib/consumedthing/ConsumedThing.go delete mode 100644 lib/consumedthing/ConsumedThingsDirectory.go delete mode 100644 lib/consumedthing/DataSchemaValue.go delete mode 100644 lib/consumedthing/InteractionInput.go delete mode 100644 lib/consumedthing/InteractionOutput.go delete mode 100644 lib/consumedthing/InteractionOutput_test.go delete mode 100644 lib/consumedthing/Subscription.go delete mode 100644 lib/consumer/Consumer.go delete mode 100644 lib/consumer/RnR.go delete mode 100644 lib/exposedthing/HandleRequest.go delete mode 100644 lib/exposedthing/ThingValues.go delete mode 100644 lib/hubagent/AgentHelper.go delete mode 100644 lib/hubagent/HandleRequestMessage.go delete mode 100644 lib/hubagent/HandleRequestMessage_test.go delete mode 100644 lib/keys/EcdsaKey.go delete mode 100644 lib/keys/Ed25519Key.go delete mode 100644 lib/keys/IHiveKey.go delete mode 100644 lib/keys/RsaKey.go delete mode 100644 lib/keys/keys.go delete mode 100644 lib/keys/keys_test.go delete mode 100644 lib/logging/SetLogging.go delete mode 100644 lib/logging/SetLogging_test.go delete mode 100644 lib/messaging/ActionStatus.go delete mode 100644 lib/messaging/DummyAuthenticator.go delete mode 100644 lib/messaging/IAuthenticator.go delete mode 100644 lib/messaging/IClientConnection.go delete mode 100644 lib/messaging/IConnection.go delete mode 100644 lib/messaging/IMessageConverter.go delete mode 100644 lib/messaging/IServerConnection.go delete mode 100644 lib/messaging/NotificationMessage.go delete mode 100644 lib/messaging/RequestMessage.go delete mode 100644 lib/messaging/ResponseMessage.go delete mode 100644 lib/messaging/converters/PassthroughMessageConverter.go delete mode 100644 lib/messaging/converters/WssMessageConverter.go delete mode 100644 lib/plugin/GetAppEnvironment.go delete mode 100644 lib/plugin/StartHeartbeat.go delete mode 100644 lib/plugin/WaitForSignal.go delete mode 100644 lib/servers/IMessageServer.go delete mode 100644 lib/servers/ProtocolConfig.go delete mode 100644 lib/servers/connections/ConnectionManager.go delete mode 100644 lib/servers/connections/ConnectionManager_test.go delete mode 100644 lib/servers/connections/DummyConnection_test.go delete mode 100644 lib/servers/connections/Subscriptions.go delete mode 100644 lib/servers/discoserver/DiscoveryServer.go delete mode 100644 lib/servers/discoserver/ServeTDD.go delete mode 100644 lib/servers/discoserver/ServeTDD_test.go delete mode 100644 lib/servers/hiveotsseserver/HiveotSseServer.go delete mode 100644 lib/servers/hiveotsseserver/HiveotSseServerConnection.go delete mode 100644 lib/servers/hiveotsseserver/HiveotSseServerRoutes.go delete mode 100644 lib/servers/httpbasic/AddSessionFromToken.go delete mode 100644 lib/servers/httpbasic/GetRequestParams.go delete mode 100644 lib/servers/httpbasic/HttpBasicForms.go delete mode 100644 lib/servers/httpbasic/HttpBasicMessages.go delete mode 100644 lib/servers/httpbasic/HttpBasicRoutes.go delete mode 100644 lib/servers/httpbasic/HttpBasicServer.go delete mode 100644 lib/servers/httpbasic/StaticFileWrapper.go delete mode 100644 lib/servers/mqttserver/MqttTransportServer.go delete mode 100644 lib/servers/tests/actions_test.go delete mode 100644 lib/servers/tests/connect_test.go delete mode 100644 lib/servers/tests/events_test.go delete mode 100644 lib/servers/tests/properties_test.go delete mode 100644 lib/servers/tests/td_test.go delete mode 100644 lib/servers/tlsserver/GetBearerToken.go delete mode 100644 lib/servers/tlsserver/TLSServer.go delete mode 100644 lib/servers/tlsserver/TLSServer_test.go delete mode 100644 lib/servers/tlsserver/WriteReply.go delete mode 100644 lib/servers/transportmanager/TransportManager.go delete mode 100644 lib/servers/wssserver/WssForms.go delete mode 100644 lib/servers/wssserver/WssServer.go delete mode 100644 lib/servers/wssserver/WssServerConnection.go delete mode 100644 lib/testenv/StartMqttTestServer.go.old delete mode 100644 lib/testenv/TestServer.go delete mode 100644 runtime/Runtime.go delete mode 100644 runtime/RuntimeConfig.go delete mode 100644 runtime/RuntimeDirectory_test.go delete mode 100644 runtime/RuntimeValues_test.go delete mode 100644 runtime/authn/AuthnAdmin_test.go delete mode 100644 runtime/authn/AuthnUser_test.go delete mode 100644 runtime/authn/Authn_test.go delete mode 100644 runtime/authn/README.md delete mode 100644 runtime/authn/api/AdminAgentAPI.go delete mode 100644 runtime/authn/api/AdminConsumerAPI.go delete mode 100644 runtime/authn/api/AdminTypes.go delete mode 100644 runtime/authn/api/UserAgentAPI.go delete mode 100644 runtime/authn/api/UserConsumerAPI.go delete mode 100644 runtime/authn/api/UserTypes.go delete mode 100644 runtime/authn/authenticator/Authenticator_test.go delete mode 100644 runtime/authn/authenticator/JWTAuthenticator.go delete mode 100644 runtime/authn/authenticator/PasetoAuthenticator.go delete mode 100644 runtime/authn/authnstore/AuthnFileStore.go delete mode 100644 runtime/authn/authnstore/AuthnFileStore_test.go delete mode 100644 runtime/authn/authnstore/IAuthnStore.go delete mode 100644 runtime/authn/config/AuthnConfig.go delete mode 100644 runtime/authn/service/AuthnAdminService.go delete mode 100644 runtime/authn/service/AuthnAgent.go delete mode 100644 runtime/authn/service/AuthnService.go delete mode 100644 runtime/authn/service/AuthnUserService.go delete mode 100644 runtime/authn/service/LoadCreateKey.go delete mode 100644 runtime/authn/sessions/SessionManager.go delete mode 100644 runtime/authn/sessions/SessionManager_test.go delete mode 100644 runtime/authn/tm/admin.json delete mode 100644 runtime/authn/tm/user.json delete mode 100644 runtime/authz/Authz_test.go delete mode 100644 runtime/authz/README.md delete mode 100644 runtime/authz/api/AdminAgentAPI.go delete mode 100644 runtime/authz/api/AdminConsumerAPI.go delete mode 100644 runtime/authz/api/AdminTypes.go delete mode 100644 runtime/authz/api/UserAgentAPI.go delete mode 100644 runtime/authz/api/UserConsumerAPI.go delete mode 100644 runtime/authz/api/UserTypes.go delete mode 100644 runtime/authz/service/AuthzAgent.go delete mode 100644 runtime/authz/service/AuthzConfig.go delete mode 100644 runtime/authz/service/AuthzService.go delete mode 100644 runtime/authz/service/HasPermission.go delete mode 100644 runtime/authz/tm/Admin.json delete mode 100644 runtime/authz/tm/User.json delete mode 100644 runtime/digitwin/DigitwinAction_test.go delete mode 100644 runtime/digitwin/DigitwinBench_test.go delete mode 100644 runtime/digitwin/DigitwinDirectory_test.go delete mode 100644 runtime/digitwin/DigitwinEvent_test.go delete mode 100644 runtime/digitwin/DigitwinProperty_test.go delete mode 100644 runtime/digitwin/DigitwinRouter_test.go delete mode 100644 runtime/digitwin/DigitwinService_test.go delete mode 100644 runtime/digitwin/DummyTransportBinding_test.go delete mode 100644 runtime/digitwin/api/DigitwinID.go delete mode 100644 runtime/digitwin/api/DirectoryAgentAPI.go delete mode 100644 runtime/digitwin/api/DirectoryConsumerAPI.go delete mode 100644 runtime/digitwin/api/DirectoryTypes.go delete mode 100644 runtime/digitwin/api/ValuesAgentAPI.go delete mode 100644 runtime/digitwin/api/ValuesConsumerAPI.go delete mode 100644 runtime/digitwin/api/ValuesTypes.go delete mode 100644 runtime/digitwin/dthing/DigitwinThing.go delete mode 100644 runtime/digitwin/router/AuthFlow.go delete mode 100644 runtime/digitwin/router/DigitwinRouter.go delete mode 100644 runtime/digitwin/router/IDigitwinRouter.go delete mode 100644 runtime/digitwin/router/NotificationFlow.go delete mode 100644 runtime/digitwin/router/RequestFlow.go delete mode 100644 runtime/digitwin/router/ResponseFlow.go delete mode 100644 runtime/digitwin/service/DigitwinAgent.go delete mode 100644 runtime/digitwin/service/DigitwinService.go delete mode 100644 runtime/digitwin/service/DirectoryService.go delete mode 100644 runtime/digitwin/service/ValuesService.go delete mode 100644 runtime/digitwin/store/DigitwinInstance.go delete mode 100644 runtime/digitwin/store/DigitwinStore.go delete mode 100644 runtime/digitwin/store/DigitwinStore_test.go delete mode 100644 runtime/digitwin/tm/Directory.json delete mode 100644 runtime/digitwin/tm/Values.json create mode 100644 runtime/runtime.go delete mode 100644 services/certs/Certs_test.go delete mode 100644 services/certs/README.md delete mode 100644 services/certs/certsapi/AdminCertsAPI.go delete mode 100644 services/certs/certsclient/ManageCertsClient.go delete mode 100644 services/certs/cmd/main.go delete mode 100644 services/certs/service/letsencrypt/README.md delete mode 100644 services/certs/service/selfsigned/CertsAdminService.go delete mode 100644 services/certs/service/selfsigned/CertsAgent.go delete mode 100644 services/certs/service/selfsigned/createClientCert.go delete mode 100644 services/certs/tm/certs.json.todo delete mode 100644 services/history/HistoryBench_test.go delete mode 100644 services/history/History_test.go delete mode 100644 services/history/README.md delete mode 100644 services/history/cmd/main.go delete mode 100644 services/history/config/HistoryConfig.go delete mode 100644 services/history/config/history.yaml delete mode 100644 services/history/historyapi/ManageHistoryAPI.go delete mode 100644 services/history/historyapi/ReadHistoryAPI.go delete mode 100644 services/history/historyclient/HistoryCursorClient.go delete mode 100644 services/history/historyclient/ManageHistoryClient.go delete mode 100644 services/history/historyclient/ReadHistoryClient.go delete mode 100644 services/history/service/AddHistory.go delete mode 100644 services/history/service/HistoryAgent.go delete mode 100644 services/history/service/HistoryService.go delete mode 100644 services/history/service/ManageHistory.go delete mode 100644 services/history/service/ReadHistory.go delete mode 100644 services/history/service/ReadHistoryCursor.go delete mode 100644 services/idprov/IdProv_test.go delete mode 100644 services/idprov/README.md delete mode 100644 services/idprov/cmd/main.go delete mode 100644 services/idprov/idprovapi/IdProvUser.go delete mode 100644 services/idprov/idprovapi/ManageIdProv.go delete mode 100644 services/idprov/idprovclient/DiscoveryIdProvClient.go.old delete mode 100644 services/idprov/idprovclient/ManageIdProvClient.go delete mode 100644 services/idprov/idprovclient/SubmitIdProvRequest.go delete mode 100644 services/idprov/service/IdProvAgent.go delete mode 100644 services/idprov/service/IdProvHttpServer.go delete mode 100644 services/idprov/service/IdProvService.go delete mode 100644 services/idprov/service/ManageIdProvService.go diff --git a/LICENSE b/LICENSE index 7fa58b71..aa4deffd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Hive Of Things +Copyright (c) 2026 Hive Of Things Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2229360a..15f5d336 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The Hub for the _Hive-of-Things_ provides a secure [runtime](runtime/README-runt Status Nov 2025: The Hub runtime, services and bindings have been reworked to support the Web-of-things (WoT) Thing Description (TD) specification. It is currently in alpha, functional but breaking changes should be expected. -The Hub is currently further reworked to extract the core library in a reusable 'hive kit' for builing IoT applications. Inspired by go2rtc (for video) the runtime, bindings and services will be migrated to use the stand-alone 'hive kit' modules. +The Hub is currently further reworked to extract the core library in a reusable [Hive-Kit](https://github.com/hiveot/hivekit) for builing IoT applications. Inspired by go2rtc (for video) the runtime, bindings and services will be migrated to use the stand-alone 'hive kit' modules. ## Audience @@ -88,4 +88,4 @@ This project builds on the Web of Things (WoT) standardization by the W3C.org st This project is inspired by the Mozilla Thing draft API [published here](https://iot.mozilla.org/wot/#web-thing-description). However, the Mozilla API is intended to be implemented by Things and is not intended for Things to register themselves. The HiveOT Hub will therefore deviate where necessary. -Many thanks go to Visual Studio Code for providing a free golang IDE. +Many thanks go to Visual Studio Code for providing a free golang IDE, to github for hosting this repository and to the WoT group. diff --git a/api/README.md b/api/README.md deleted file mode 100644 index 0bfdeb49..00000000 --- a/api/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# API Directory - -This directory contains API sources and generated code for golang, javascript and python. - -The API generator can be found in the cmd/ directory. -This: -* Generates the WoT and HiveOT vocabulary from the yaml sources. -* Generates the Service APIs from their TM/TD documents - -* cmd/genvocab generates vocabulary files from the yaml source -* cmd/tm2go generates go client and server sources from the TM/TD (experimental) \ No newline at end of file diff --git a/api/go/vocab/vocab.go b/api/go/vocab/vocab.go deleted file mode 100644 index ff8eeb7c..00000000 --- a/api/go/vocab/vocab.go +++ /dev/null @@ -1,634 +0,0 @@ -// Package vocab with HiveOT and WoT vocabulary names for TD Things, properties, events and actions -// DO NOT EDIT. This file is generated and changes will be overwritten -package vocab - -// type: WoTVocab -// version: 0.1 -// generated: 03 Oct 25 12:32 PDT -// source: github.com/hiveot/hub/api/vocab/wot-vocab.yaml -// description: WoT vocabulary definition. See https://www.w3.org/TR/2020/WD-wot-thing-description11-20201124/#sec-core-vocabulary-definition -const ( - OpCancelAction = "cancelaction" - OpInvokeAction = "invokeaction" - OpObserveAction = "observeaction" - OpObserveAllActions = "observeallactions" - OpObserveAllProperties = "observeallproperties" - OpObserveMultipleProperties = "observemultipleproperties" - OpObserveProperty = "observeproperty" - OpQueryAction = "queryaction" - OpQueryAllActions = "queryallactions" - OpQueryMultipleActions = "querymultipleactions" - OpReadAllProperties = "readallproperties" - OpReadMultipleProperties = "readmultipleproperties" - OpReadProperty = "readproperty" - OpSubscribeAllEvents = "subscribeallevents" - OpSubscribeEvent = "subscribeevent" - OpSubscribeMultipleEvents = "subscribemultipleevents" - OpUnobserveAllProperties = "unobserveallproperties" - OpUnobserveMultipleProperties = "unobservemultipleproperties" - OpUnobserveProperty = "unobserveroperty" - OpUnsubscribeAllEvents = "unsubscribeallevents" - OpUnsubscribeEvent = "unsubscribeevent" - OpUnsubscribeMultipleEvents = "unsubscribemultipleevents" - OpWriteMultipleProperties = "writemultipleproperties" - OpWriteProperty = "writeproperty" - WoTAPIKeySecurityScheme = "APIKeySecurityScheme" - WoTActions = "actions" - WoTAnyURI = "https://www.w3.org/2019/wot/thing/v1" - WoTAtContext = "@context" - WoTAtType = "@type" - WoTBasicSecurityScheme = "BasicSecurityScheme" - WoTBearerSecurityScheme = "BearerSecurityScheme" - WoTConst = "const" - WoTCreated = "created" - WoTDataType = "type" - WoTDataTypeAnyURI = "anyURI" - WoTDataTypeArray = "array" - WoTDataTypeBool = "boolean" - WoTDataTypeDateTime = "dateTime" - WoTDataTypeInteger = "integer" - WoTDataTypeNone = "" - WoTDataTypeNumber = "number" - WoTDataTypeObject = "object" - WoTDataTypeString = "string" - WoTDataTypeUnsignedInt = "unsignedInt" - WoTDescription = "description" - WoTDescriptions = "descriptions" - WoTDigestSecurityScheme = "DigestSecurityScheme" - WoTEnum = "enum" - WoTEvents = "events" - WoTFormat = "format" - WoTForms = "forms" - WoTHref = "href" - WoTID = "id" - WoTInput = "input" - WoTLinks = "links" - WoTMaxItems = "maxItems" - WoTMaxLength = "maxLength" - WoTMaximum = "maximum" - WoTMinItems = "minItems" - WoTMinLength = "minLength" - WoTMinimum = "minimum" - WoTModified = "modified" - WoTNoSecurityScheme = "NoSecurityScheme" - WoTOAuth2SecurityScheme = "OAuth2SecurityScheme" - WoTOperation = "op" - WoTOutput = "output" - WoTPSKSecurityScheme = "PSKSecurityScheme" - WoTProperties = "properties" - WoTReadOnly = "readOnly" - WoTRequired = "required" - WoTSecurity = "security" - WoTSupport = "support" - WoTTitle = "title" - WoTTitles = "titles" - WoTVersion = "version" -) - -// end of WoTVocab - -// type: ActionClasses -// version: 0.1 -// generated: 03 Oct 25 12:32 PDT -// source: github.com/hiveot/hub/api/vocab/ht-action-classes.yaml -// namespace: hiveot -const ( - ActionDimmer = "hiveot:action:dimmer" - ActionDimmerDecrement = "hiveot:action:dimmer:decrement" - ActionDimmerIncrement = "hiveot:action:dimmer:increment" - ActionDimmerSet = "hiveot:action:dimmer:set" - ActionMedia = "hiveot:action:media" - ActionMediaMute = "hiveot:action:media:mute" - ActionMediaNext = "hiveot:action:media:next" - ActionMediaPause = "hiveot:action:media:pause" - ActionMediaPlay = "hiveot:action:media:play" - ActionMediaPrevious = "hiveot:action:media:previous" - ActionMediaUnmute = "hiveot:action:media:unmute" - ActionMediaVolume = "hiveot:action:media:volume" - ActionMediaVolumeDecrease = "hiveot:action:media:volume:decrease" - ActionMediaVolumeIncrease = "hiveot:action:media:volume:increase" - ActionSwitch = "hiveot:action:switch" - ActionSwitchOnOff = "hiveot:action:switch:onoff" - ActionSwitchToggle = "hiveot:action:switch:toggle" - ActionThingDisable = "hiveot:action:thing:disable" - ActionThingEnable = "hiveot:action:thing:enable" - ActionThingStart = "hiveot:action:thing:start" - ActionThingStop = "hiveot:action:thing:stop" - ActionValveClose = "hiveot:action:valve:close" - ActionValveOpen = "hiveot:action:valve:open" -) - -// end of ActionClasses - -// ActionClassesMap maps @type to symbol, title and description -var ActionClassesMap = map[string]struct { - Symbol string - Title string - Description string -}{ - ActionThingDisable: {Symbol: "", Title: "Disable", Description: "Action to disable a thing"}, - ActionThingEnable: {Symbol: "", Title: "Enable", Description: "Action to enable a thing"}, - ActionThingStop: {Symbol: "", Title: "Stop", Description: "Stop a running task"}, - ActionMedia: {Symbol: "", Title: "Media control", Description: "Commands to control media recording and playback"}, - ActionMediaPrevious: {Symbol: "", Title: "Previous", Description: "Previous track or station"}, - ActionMediaUnmute: {Symbol: "", Title: "Unmute", Description: "Unmute audio"}, - ActionMediaVolumeIncrease: {Symbol: "", Title: "Increase volume", Description: "Increase volume"}, - ActionSwitch: {Symbol: "", Title: "Switch", Description: "General switch action"}, - ActionThingStart: {Symbol: "", Title: "Start", Description: "Start running a task"}, - ActionMediaNext: {Symbol: "", Title: "Next", Description: "Next track or station"}, - ActionMediaPlay: {Symbol: "", Title: "Play", Description: "Start or continue playback"}, - ActionDimmerIncrement: {Symbol: "", Title: "Increase dimmer", Description: ""}, - ActionDimmerSet: {Symbol: "", Title: "Set dimmer", Description: "Action to set the dimmer value"}, - ActionValveClose: {Symbol: "", Title: "Close valve", Description: "Action to close the valve"}, - ActionValveOpen: {Symbol: "", Title: "Open valve", Description: "Action to open the valve"}, - ActionMediaPause: {Symbol: "", Title: "Pause", Description: "Pause playback"}, - ActionMediaVolumeDecrease: {Symbol: "", Title: "Decrease volume", Description: "Decrease volume"}, - ActionSwitchToggle: {Symbol: "", Title: "Toggle switch", Description: "Action to toggle the switch"}, - ActionMediaMute: {Symbol: "", Title: "Mute", Description: "Mute audio"}, - ActionMediaVolume: {Symbol: "", Title: "Volume", Description: "Set volume level"}, - ActionDimmer: {Symbol: "", Title: "Dimmer", Description: "General dimmer action"}, - ActionDimmerDecrement: {Symbol: "", Title: "Lower dimmer", Description: ""}, - ActionSwitchOnOff: {Symbol: "", Title: "Set On/Off switch", Description: "Action to set the switch on/off state"}, -} - -// type: PropertyClasses -// version: 0.1 -// generated: 03 Oct 25 12:32 PDT -// source: github.com/hiveot/hub/api/vocab/ht-property-classes.yaml -// namespace: hiveot -const ( - PropAlarmMotion = "hiveot:prop:alarm:motion" - PropAlarmStatus = "hiveot:prop:alarm:status" - PropDevice = "hiveot:prop:device" - PropDeviceBattery = "hiveot:prop:device:battery" - PropDeviceDescription = "hiveot:prop:device:description" - PropDeviceEnabledDisabled = "hiveot:prop:device:enabled-disabled" - PropDeviceFirmwareVersion = "hiveot:prop:device:firmwareversion" - PropDeviceHardwareVersion = "hiveot:prop:device:hardwareversion" - PropDeviceMake = "hiveot:prop:device:make" - PropDeviceModel = "hiveot:prop:device:model" - PropDevicePollinterval = "hiveot:prop:device:pollinterval" - PropDeviceSoftwareVersion = "hiveot:prop:device:softwareversion" - PropDeviceStatus = "hiveot:prop:device:status" - PropDeviceTitle = "hiveot:prop:device:title" - PropElectric = "hiveot:prop:electric" - PropElectricCurrent = "hiveot:prop:electric:current" - PropElectricEnergy = "hiveot:prop:electric:energy" - PropElectricOverload = "hiveot:prop:electric:overload" - PropElectricPower = "hiveot:prop:electric:poer" - PropElectricVoltage = "hiveot:prop:electric:voltage" - PropEnv = "hiveot:prop:env" - PropEnvAcceleration = "hiveot:prop:env:acceleration" - PropEnvAirquality = "hiveot:prop:env:airquality" - PropEnvCO = "hiveot:prop:env:co" - PropEnvCO2 = "hiveot:prop:env:co2" - PropEnvCpuload = "hiveot:prop:env:cpuload" - PropEnvDewpoint = "hiveot:prop:env:dewpoint" - PropEnvFuelFlowrate = "hiveot:prop:env:fuel:flowrate" - PropEnvFuelLevel = "hiveot:prop:env:fuel:level" - PropEnvHumidex = "hiveot:prop:env:humidex" - PropEnvHumidity = "hiveot:prop:env:humidity" - PropEnvLuminance = "hiveot:prop:env:luminance" - PropEnvPrecipitation = "hiveot:prop:env:precipitation" - PropEnvPrecipitationRain = "hiveot:prop:env:precipitation:rain" - PropEnvPrecipitationSnow = "hiveot:prop:env:precipitation:snow" - PropEnvPressure = "hiveot:prop:env:pressure" - PropEnvPressureSeaLevel = "hiveot:prop:env:barometer:msl" - PropEnvPressureSurface = "hiveot:prop:env:barometer:surface" - PropEnvTemperature = "hiveot:prop:env:temperature" - PropEnvTimezone = "hiveot:prop:env:timezone" - PropEnvUV = "hiveot:prop:env:uv" - PropEnvVibration = "hiveot:prop:env:vibration" - PropEnvVolume = "hiveot:prop:env:volume" - PropEnvWaterFlowrate = "hiveot:prop:env:water:flowrate" - PropEnvWaterLevel = "hiveot:prop:env:water:level" - PropEnvWindGusts = "hiveot:prop:env:wind:gusts" - PropEnvWindHeading = "hiveot:prop:env:wind:heading" - PropEnvWindSpeed = "hiveot:prop:env:wind:speed" - PropLocation = "hiveot:prop:location" - PropLocationCity = "hiveot:prop:location:city" - PropLocationLatitude = "hiveot:prop:location:latitude" - PropLocationLongitude = "hiveot:prop:location:longitude" - PropLocationName = "hiveot:prop:location:name" - PropLocationStreet = "hiveot:prop:location:street" - PropLocationZipcode = "hiveot:prop:location:zipcode" - PropMedia = "hiveot:prop:media" - PropMediaMuted = "hiveot:prop:media:muted" - PropMediaPaused = "hiveot:prop:media:paused" - PropMediaPlaying = "hiveot:prop:media:playing" - PropMediaStation = "hiveot:prop:media:station" - PropMediaTrack = "hiveot:prop:media:track" - PropMediaVolume = "hiveot:prop:media:volume" - PropNet = "hiveot:prop:net" - PropNetAddress = "hiveot:prop:net:address" - PropNetConnection = "hiveot:prop:net:connection" - PropNetDomainname = "hiveot:prop:net:domainname" - PropNetGateway = "hiveot:prop:net:gateway" - PropNetHostname = "hiveot:prop:net:hostname" - PropNetIP4 = "hiveot:prop:net:ip4" - PropNetIP6 = "hiveot:prop:net:ip6" - PropNetLatency = "hiveot:prop:net:latency" - PropNetMAC = "hiveot:prop:net:mac" - PropNetMask = "hiveot:prop:net:mask" - PropNetPort = "hiveot:prop:net:port" - PropNetSignalstrength = "hiveot:prop:net:signalstrength" - PropNetSubnet = "hiveot:prop:net:subnet" - PropStatusOnOff = "hiveot:prop:status:onoff" - PropStatusOpenClosed = "hiveot:prop:status:openclosed" - PropStatusStartedStopped = "hiveot:prop:status:started-stopped" - PropStatusYesNo = "hiveot:prop:status:yes-no" - PropSwitch = "hiveot:prop:switch" - PropSwitchDimmer = "hiveot:prop:switch:dimmer" - PropSwitchLight = "hiveot:prop:switch:light" - PropSwitchLocked = "hiveot:prop:switch:locked" - PropSwitchOnOff = "hiveot:prop:switch:onoff" -) - -// end of PropertyClasses - -// PropertyClassesMap maps @type to symbol, title and description -var PropertyClassesMap = map[string]struct { - Symbol string - Title string - Description string -}{ - PropNetLatency: {Symbol: "", Title: "Network latency", Description: "Delay between hub and client"}, - PropSwitch: {Symbol: "", Title: "Switch status", Description: ""}, - PropAlarmMotion: {Symbol: "", Title: "Motion", Description: "Motion detected"}, - PropDeviceBattery: {Symbol: "", Title: "Battery level", Description: "Device battery level"}, - PropDeviceEnabledDisabled: {Symbol: "", Title: "Enabled/Disabled", Description: "Enabled or disabled state"}, - PropDeviceHardwareVersion: {Symbol: "", Title: "Hardware version", Description: ""}, - PropEnv: {Symbol: "", Title: "Environmental property", Description: "Property of environmental sensor"}, - PropMedia: {Symbol: "", Title: "Media commands", Description: "Control of media equipment"}, - PropMediaVolume: {Symbol: "", Title: "Volume", Description: "Media volume setting"}, - PropNetAddress: {Symbol: "", Title: "Address", Description: "Network address"}, - PropEnvFuelFlowrate: {Symbol: "", Title: "Fuel flow rate", Description: ""}, - PropEnvLuminance: {Symbol: "", Title: "Luminance", Description: ""}, - PropEnvUV: {Symbol: "", Title: "UV", Description: ""}, - PropNetMask: {Symbol: "", Title: "Netmask", Description: "Network mask. Example: 255.255.255.0 or 24/8"}, - PropSwitchDimmer: {Symbol: "", Title: "Dimmer value", Description: ""}, - PropEnvWaterFlowrate: {Symbol: "", Title: "Water flow rate", Description: ""}, - PropEnvWindGusts: {Symbol: "", Title: "Wind gusts", Description: "Speed of wind gusts"}, - PropDeviceStatus: {Symbol: "", Title: "Status", Description: "Device status; alive, awake, dead, sleeping"}, - PropDevice: {Symbol: "", Title: "Device attributes", Description: "Attributes describing a device"}, - PropDeviceSoftwareVersion: {Symbol: "", Title: "Software version", Description: ""}, - PropEnvCO2: {Symbol: "", Title: "Carbon dioxide level", Description: "Carbon dioxide level"}, - PropNetPort: {Symbol: "", Title: "Port", Description: "Network port"}, - PropElectric: {Symbol: "", Title: "Electrical properties", Description: "General group of electrical properties"}, - PropLocationCity: {Symbol: "", Title: "City", Description: "City name"}, - PropNetMAC: {Symbol: "", Title: "MAC", Description: "Hardware MAC address"}, - PropNetSignalstrength: {Symbol: "", Title: "Signal strength", Description: "Wireless signal strength"}, - PropNetDomainname: {Symbol: "", Title: "Domain name", Description: "Domainname of the client"}, - PropNetIP4: {Symbol: "", Title: "IP4 address", Description: "Device IP4 address"}, - PropStatusStartedStopped: {Symbol: "", Title: "Started/Stopped", Description: "Started or stopped status"}, - PropEnvCO: {Symbol: "", Title: "Carbon monoxide level", Description: "Carbon monoxide level"}, - PropEnvPressure: {Symbol: "", Title: "Pressure", Description: ""}, - PropDeviceMake: {Symbol: "", Title: "Make", Description: "Device manufacturer"}, - PropEnvDewpoint: {Symbol: "", Title: "Dew point", Description: "Dew point temperature"}, - PropEnvVibration: {Symbol: "", Title: "Vibration", Description: ""}, - PropDeviceTitle: {Symbol: "", Title: "Title", Description: "Device friendly title"}, - PropEnvPressureSurface: {Symbol: "", Title: "Surface level pressure", Description: "Surface level atmospheric pressure"}, - PropLocationStreet: {Symbol: "", Title: "Street", Description: "Street address"}, - PropNet: {Symbol: "", Title: "Network properties", Description: "General network properties"}, - PropStatusOnOff: {Symbol: "", Title: "On/off status", Description: ""}, - PropSwitchOnOff: {Symbol: "", Title: "On/Off switch", Description: ""}, - PropEnvPrecipitationSnow: {Symbol: "", Title: "Snow precipitation", Description: "Precipitation as snow"}, - PropEnvPressureSeaLevel: {Symbol: "", Title: "Sea level pressure", Description: "Sea level equivalent atmospheric pressure"}, - PropMediaTrack: {Symbol: "", Title: "Track", Description: "Selected A/V track"}, - PropDeviceFirmwareVersion: {Symbol: "", Title: "Firmware version", Description: ""}, - PropElectricEnergy: {Symbol: "", Title: "Energy", Description: "Electrical energy consumed"}, - PropElectricOverload: {Symbol: "", Title: "Overload protection", Description: "Cut load on overload"}, - PropEnvCpuload: {Symbol: "", Title: "CPU load level", Description: "Device CPU load level"}, - PropLocationLongitude: {Symbol: "", Title: "Longitude", Description: "Longitude geographic coordinate"}, - PropMediaStation: {Symbol: "", Title: "Station", Description: "Selected radio station"}, - PropLocationName: {Symbol: "", Title: "Location name", Description: "Name of the location"}, - PropElectricCurrent: {Symbol: "", Title: "Current", Description: "Electrical current"}, - PropEnvHumidex: {Symbol: "", Title: "Humidex", Description: ""}, - PropEnvPrecipitation: {Symbol: "", Title: "Precipitation", Description: "Total precipitation of rain and snow"}, - PropEnvTimezone: {Symbol: "", Title: "Timezone", Description: ""}, - PropMediaPlaying: {Symbol: "", Title: "Playing", Description: "Media is playing"}, - PropMediaMuted: {Symbol: "", Title: "Muted", Description: "Audio is muted"}, - PropNetGateway: {Symbol: "", Title: "Gateway", Description: "Network gateway address"}, - PropElectricPower: {Symbol: "", Title: "Power", Description: "Electrical power being consumed"}, - PropEnvAcceleration: {Symbol: "", Title: "Acceleration", Description: ""}, - PropEnvPrecipitationRain: {Symbol: "", Title: "Rain precipitation", Description: "Precipitation as rain"}, - PropEnvTemperature: {Symbol: "", Title: "Temperature", Description: ""}, - PropEnvWaterLevel: {Symbol: "", Title: "Water level", Description: ""}, - PropLocationZipcode: {Symbol: "", Title: "Zip code", Description: "Location ZIP code"}, - PropNetHostname: {Symbol: "", Title: "Hostname", Description: "Hostname of the client"}, - PropNetIP6: {Symbol: "", Title: "IP6 address", Description: "Device IP6 address"}, - PropDevicePollinterval: {Symbol: "", Title: "Polling interval", Description: "Interval to poll for updates"}, - PropElectricVoltage: {Symbol: "", Title: "Voltage", Description: "Electrical voltage potential"}, - PropEnvAirquality: {Symbol: "", Title: "Air quality", Description: "Air quality level"}, - PropNetConnection: {Symbol: "", Title: "Connection", Description: "Connection status, connected, connecting, retrying, disconnected,..."}, - PropStatusYesNo: {Symbol: "", Title: "Yes/No", Description: "Status with yes or no value"}, - PropSwitchLocked: {Symbol: "", Title: "Lock", Description: "Electric lock status"}, - PropDeviceDescription: {Symbol: "", Title: "Description", Description: "Device product description"}, - PropEnvVolume: {Symbol: "", Title: "Volume", Description: ""}, - PropEnvWindHeading: {Symbol: "", Title: "Wind heading", Description: "Direction wind is heading"}, - PropEnvWindSpeed: {Symbol: "", Title: "Wind speed", Description: "Average speed of wind"}, - PropLocationLatitude: {Symbol: "", Title: "Latitude", Description: "Latitude geographic coordinate"}, - PropNetSubnet: {Symbol: "", Title: "Subnet", Description: "Network subnet address. Example: 192.168.0.0"}, - PropSwitchLight: {Symbol: "", Title: "Light switch", Description: ""}, - PropLocation: {Symbol: "", Title: "Location", Description: "General location information"}, - PropMediaPaused: {Symbol: "", Title: "Paused", Description: "Media is paused"}, - PropStatusOpenClosed: {Symbol: "", Title: "Open/Closed status", Description: ""}, - PropAlarmStatus: {Symbol: "", Title: "Alarm state", Description: "Current alarm status"}, - PropDeviceModel: {Symbol: "", Title: "Model", Description: "Device model"}, - PropEnvFuelLevel: {Symbol: "", Title: "Fuel level", Description: ""}, - PropEnvHumidity: {Symbol: "", Title: "Humidity", Description: ""}, -} - -// type: ThingClasses -// version: 0.1 -// generated: 03 Oct 25 12:32 PDT -// source: github.com/hiveot/hub/api/vocab/ht-thing-classes.yaml -// namespace: hiveot -const ( - ThingActuator = "hiveot:thing:actuator" - ThingActuatorAlarm = "hiveot:thing:actuator:alarm" - ThingActuatorBeacon = "hiveot:thing:actuator:beacon" - ThingActuatorDimmer = "hiveot:thing:actuator:dimmer" - ThingActuatorLight = "hiveot:thing:actuator:light" - ThingActuatorLock = "hiveot:thing:actuator:lock" - ThingActuatorMotor = "hiveot:thing:actuator:motor" - ThingActuatorOutput = "hiveot:thing:actuator:output" - ThingActuatorRanged = "hiveot:thing:actuator:ranged" - ThingActuatorRelay = "hiveot:thing:actuator:relay" - ThingActuatorSwitch = "hiveot:thing:actuator:switch" - ThingActuatorValve = "hiveot:thing:actuator:valve" - ThingActuatorValveFuel = "hiveot:thing:actuator:valve:fuel" - ThingActuatorValveWater = "hiveot:thing:actuator:valve:water" - ThingAppliance = "hiveot:thing:appliance" - ThingApplianceDishwasher = "hiveot:thing:appliance:dishwasher" - ThingApplianceDryer = "hiveot:thing:appliance:dryer" - ThingApplianceFreezer = "hiveot:thing:appliance:freezer" - ThingApplianceFridge = "hiveot:thing:appliance:fridge" - ThingApplianceWasher = "hiveot:thing:appliance:washer" - ThingComputer = "hiveot:thing:computer" - ThingComputerCellphone = "hiveot:thing:computer:cellphone" - ThingComputerEmbedded = "hiveot:thing:computer:embedded" - ThingComputerMemory = "hiveot:thing:computer:memory" - ThingComputerPC = "hiveot:thing:computer:pc" - ThingComputerPotsPhone = "hiveot:thing:computer:potsphone" - ThingComputerSatPhone = "hiveot:thing:computer:satphone" - ThingComputerTablet = "hiveot:thing:computer:tablet" - ThingComputerVoipPhone = "hiveot:thing:computer:voipphone" - ThingControl = "hiveot:thing:control" - ThingControlClimate = "hiveot:thing:control:climate" - ThingControlDimmer = "hiveot:thing:control:dimmer" - ThingControlIrrigation = "hiveot:thing:control:irrigation" - ThingControlJoystick = "hiveot:thing:control:joystick" - ThingControlKeypad = "hiveot:thing:control:keypad" - ThingControlPool = "hiveot:thing:control:pool" - ThingControlPushbutton = "hiveot:thing:control:pushbutton" - ThingControlSwitch = "hiveot:thing:control:switch" - ThingControlThermostat = "hiveot:thing:control:thermostat" - ThingControlToggle = "hiveot:thing:control:toggle" - ThingDevice = "hiveot:thing:device" - ThingDeviceBatteryMonitor = "hiveot:thing:device:battery:monitor" - ThingDeviceIndicator = "hiveot:thing:device:indicator" - ThingDeviceTime = "hiveot:thing:device:time" - ThingMedia = "hiveot:thing:media" - ThingMediaAmplifier = "hiveot:thing:media:amplifier" - ThingMediaCamera = "hiveot:thing:media:camera" - ThingMediaMicrophone = "hiveot:thing:media:microphone" - ThingMediaPlayer = "hiveot:thing:media:player" - ThingMediaRadio = "hiveot:thing:media:radio" - ThingMediaReceiver = "hiveot:thing:media:receiver" - ThingMediaSpeaker = "hiveot:thing:media:speaker" - ThingMediaTV = "hiveot:thing:media:tv" - ThingMeter = "hiveot:thing:meter" - ThingMeterElectric = "hiveot:thing:meter:electric" - ThingMeterElectricCurrent = "hiveot:thing:meter:electric:current" - ThingMeterElectricEnergy = "hiveot:thing:meter:electric:energy" - ThingMeterElectricPower = "hiveot:thing:meter:electric:power" - ThingMeterElectricVoltage = "hiveot:thing:meter:electric:voltage" - ThingMeterFuel = "hiveot:thing:meter:fuel" - ThingMeterFuelFlow = "hiveot:thing:meter:fuel:flow" - ThingMeterFuelLevel = "hiveot:thing:meter:fuel:level" - ThingMeterWater = "hiveot:thing:meter:water" - ThingMeterWaterConsumption = "hiveot:thing:meter:water:consumption" - ThingMeterWaterFlow = "hiveot:thing:meter:water:flow" - ThingMeterWaterLevel = "hiveot:thing:meter:water:level" - ThingMeterWind = "hiveot:thing:meter:wind" - ThingNet = "hiveot:thing:net" - ThingNetBluetooth = "hiveot:thing:net:bluetooth" - ThingNetGateway = "hiveot:thing:net:gateway" - ThingNetGatewayCoap = "hiveot:thing:net:gateway:coap" - ThingNetGatewayInsteon = "hiveot:thing:net:gateway:insteon" - ThingNetGatewayOnewire = "hiveot:thing:net:gateway:onewire" - ThingNetGatewayZigbee = "hiveot:thing:net:gateway:zigbee" - ThingNetGatewayZwave = "hiveot:thing:net:gateway:zwave" - ThingNetLora = "hiveot:thing:net:lora" - ThingNetLoraGateway = "hiveot:thing:net:lora:gw" - ThingNetLoraP2P = "hiveot:thing:net:lora:p2p" - ThingNetRouter = "hiveot:thing:net:router" - ThingNetSwitch = "hiveot:thing:net:switch" - ThingNetWifi = "hiveot:thing:net:wifi" - ThingNetWifiAp = "hiveot:thing:net:wifi:ap" - ThingSensor = "hiveot:thing:sensor" - ThingSensorEnvironment = "hiveot:thing:sensor:environment" - ThingSensorInput = "hiveot:thing:sensor:input" - ThingSensorMulti = "hiveot:thing:sensor:multi" - ThingSensorScale = "hiveot:thing:sensor:scale" - ThingSensorSecurity = "hiveot:thing:sensor:security" - ThingSensorSecurityDoorWindow = "hiveot:thing:sensor:security:doorwindow" - ThingSensorSecurityGlass = "hiveot:thing:sensor:security:glass" - ThingSensorSecurityMotion = "hiveot:thing:sensor:security:motion" - ThingSensorSmoke = "hiveot:thing:sensor:smoke" - ThingSensorSound = "hiveot:thing:sensor:sound" - ThingSensorThermometer = "hiveot:thing:sensor:thermometer" - ThingSensorWaterLeak = "hiveot:thing:sensor:water:leak" - ThingService = "hiveot:thing:service" -) - -// end of ThingClasses - -// ThingClassesMap maps @type to symbol, title and description -var ThingClassesMap = map[string]struct { - Symbol string - Title string - Description string -}{ - ThingMediaPlayer: {Symbol: "", Title: "Media player", Description: "CD/DVD/Blueray/USB player of recorded media"}, - ThingMeterWind: {Symbol: "", Title: "Wind", Description: "Dedicated wind meter"}, - ThingSensorThermometer: {Symbol: "", Title: "Thermometer", Description: "Environmental thermometer"}, - ThingApplianceDishwasher: {Symbol: "", Title: "Dishwasher", Description: "Dishwasher"}, - ThingControlToggle: {Symbol: "", Title: "Toggle switch", Description: "Toggle switch input control"}, - ThingMediaCamera: {Symbol: "", Title: "Camera", Description: "Video camera"}, - ThingNetGatewayZwave: {Symbol: "", Title: "ZWave gateway", Description: "Gateway providing access to ZWave devices"}, - ThingSensorWaterLeak: {Symbol: "", Title: "Water leak detector", Description: "Dedicated water leak detector"}, - ThingSensorScale: {Symbol: "", Title: "Scale", Description: "Electronic weigh scale"}, - ThingActuatorBeacon: {Symbol: "", Title: "Beacon", Description: "Location beacon"}, - ThingActuatorMotor: {Symbol: "", Title: "Motor", Description: "Motor driven actuator, such as garage door, blinds, tv lifts"}, - ThingApplianceFridge: {Symbol: "", Title: "Fridge", Description: "Refrigerator appliance"}, - ThingComputerPC: {Symbol: "", Title: "PC/Laptop", Description: "Personal computer/laptop"}, - ThingSensorSecurityDoorWindow: {Symbol: "", Title: "Door/Window sensor", Description: "Dedicated door/window opening security sensor"}, - ThingDevice: {Symbol: "", Title: "Device", Description: "Device of unknown purpose"}, - ThingDeviceIndicator: {Symbol: "", Title: "Indicator", Description: "Visual or audio indicator device"}, - ThingMediaRadio: {Symbol: "", Title: "Radio", Description: "AM or FM radio receiver"}, - ThingMediaReceiver: {Symbol: "", Title: "Receiver", Description: "Audio/video receiver and player"}, - ThingMeter: {Symbol: "", Title: "Meter", Description: "General metering device"}, - ThingMeterElectric: {Symbol: "", Title: "", Description: ""}, - ThingMeterElectricEnergy: {Symbol: "", Title: "Electric energy", Description: "Electrical energy meter"}, - ThingMeterElectricPower: {Symbol: "", Title: "Electrical Power", Description: "Electrical power meter"}, - ThingActuatorDimmer: {Symbol: "", Title: "Dimmer", Description: "Light dimmer"}, - ThingActuatorRelay: {Symbol: "", Title: "Relay", Description: "Generic relay electrical switch"}, - ThingMediaSpeaker: {Symbol: "", Title: "Connected speakers", Description: "Network connected speakers"}, - ThingMeterFuelFlow: {Symbol: "", Title: "Fuel flow rate", Description: "Dedicated fuel flow rate metering device"}, - ThingMeterFuelLevel: {Symbol: "", Title: "Fuel level", Description: "Dedicated fuel level metering device"}, - ThingMeterWater: {Symbol: "", Title: "Water metering device", Description: "General water metering device"}, - ThingNetGatewayInsteon: {Symbol: "", Title: "Insteon gateway", Description: "Gateway providing access to Insteon devices"}, - ThingNetLora: {Symbol: "", Title: "LoRa network device", Description: "Generic Long Range network protocol device"}, - ThingControlPushbutton: {Symbol: "", Title: "Momentary switch", Description: "Momentary push button control input"}, - ThingNetLoraGateway: {Symbol: "", Title: "LoRaWAN gateway", Description: "Gateway providing access to LoRa devices"}, - ThingActuatorSwitch: {Symbol: "", Title: "Switch", Description: "An electric powered on/off switch for powering circuits"}, - ThingComputerVoipPhone: {Symbol: "", Title: "VoIP Phone", Description: "Voice over IP phone"}, - ThingControlPool: {Symbol: "", Title: "Pool control", Description: "Device for controlling pool settings"}, - ThingNetGatewayOnewire: {Symbol: "", Title: "1-Wire gateway", Description: "Gateway providing access to 1-wire devices"}, - ThingNetSwitch: {Symbol: "", Title: "Network switch", Description: "Network switch to connect computer devices to the network"}, - ThingSensorEnvironment: {Symbol: "", Title: "Environmental sensor", Description: "Environmental sensor with one or more features such as temperature, humidity, etc"}, - ThingSensorSecurity: {Symbol: "", Title: "Security", Description: "Generic security sensor"}, - ThingSensorSecurityMotion: {Symbol: "", Title: "Motion sensor", Description: "Dedicated security sensor detecting motion"}, - ThingActuator: {Symbol: "", Title: "Actuator", Description: "Generic actuator"}, - ThingActuatorLock: {Symbol: "", Title: "Lock", Description: "Electronic door lock"}, - ThingApplianceWasher: {Symbol: "", Title: "Washer", Description: "Clothing washer"}, - ThingControlDimmer: {Symbol: "", Title: "Dimmer", Description: "Light dimmer input device"}, - ThingMeterWaterConsumption: {Symbol: "", Title: "Water consumption meter", Description: "Water consumption meter"}, - ThingNetGatewayCoap: {Symbol: "", Title: "CoAP gateway", Description: "Gateway providing access to CoAP devices"}, - ThingNetLoraP2P: {Symbol: "", Title: "LoRa P2P", Description: "LoRa Peer-to-peer network device"}, - ThingActuatorValveWater: {Symbol: "", Title: "Water valve", Description: "Electric powered water valve"}, - ThingMedia: {Symbol: "", Title: "A/V media", Description: "Generic device for audio/video media record or playback"}, - ThingMeterWaterLevel: {Symbol: "", Title: "Water level", Description: "Dedicated water level meter"}, - ThingSensorSmoke: {Symbol: "", Title: "Smoke detector", Description: ""}, - ThingService: {Symbol: "", Title: "Service", Description: "General service for processing data and offering features of interest"}, - ThingComputerCellphone: {Symbol: "", Title: "Cell Phone", Description: "Cellular phone"}, - ThingNet: {Symbol: "", Title: "Network device", Description: "Generic network device"}, - ThingNetWifiAp: {Symbol: "", Title: "Wifi access point", Description: "Wireless access point for IP networks"}, - ThingSensorInput: {Symbol: "", Title: "Input sensor", Description: "General purpose electrical input sensor"}, - ThingActuatorOutput: {Symbol: "", Title: "Output", Description: "General purpose electrical output signal"}, - ThingControlKeypad: {Symbol: "", Title: "Keypad", Description: "Multi-key pad for command input"}, - ThingDeviceBatteryMonitor: {Symbol: "", Title: "Battery Monitor", Description: "Battery monitor and charge controller"}, - ThingMediaMicrophone: {Symbol: "", Title: "Microphone", Description: "Microphone for capturing audio"}, - ThingMeterElectricVoltage: {Symbol: "", Title: "Voltage", Description: "Electrical voltage meter"}, - ThingMeterFuel: {Symbol: "", Title: "Fuel metering device", Description: "General fuel metering device"}, - ThingSensorMulti: {Symbol: "", Title: "Multi sensor", Description: "Sense multiple inputs"}, - ThingActuatorLight: {Symbol: "", Title: "Light", Description: "Smart LED or other light"}, - ThingActuatorRanged: {Symbol: "", Title: "Ranged actuator", Description: "Generic ranged actuator with a set point"}, - ThingApplianceFreezer: {Symbol: "", Title: "Freezer", Description: "Refrigerator freezer"}, - ThingControlSwitch: {Symbol: "", Title: "Input switch", Description: "On or off switch input control"}, - ThingActuatorValveFuel: {Symbol: "", Title: "Fuel valve", Description: "Electric powered fuel valve"}, - ThingComputer: {Symbol: "", Title: "Computing Device", Description: "General purpose computing device"}, - ThingControlClimate: {Symbol: "", Title: "Climate control", Description: "Device for controlling climate of a space"}, - ThingMeterWaterFlow: {Symbol: "", Title: "Water flow", Description: "Dedicated water flow-rate meter"}, - ThingNetGatewayZigbee: {Symbol: "", Title: "Zigbee gateway", Description: "Gateway providing access to Zigbee devices"}, - ThingNetRouter: {Symbol: "", Title: "Network router", Description: "IP ThingNetwork router providing access to other IP networks"}, - ThingSensorSecurityGlass: {Symbol: "", Title: "Glass sensor", Description: "Dedicated sensor for detecting breaking of glass"}, - ThingApplianceDryer: {Symbol: "", Title: "Dryer", Description: "Clothing dryer"}, - ThingComputerSatPhone: {Symbol: "", Title: "Satellite phone", Description: ""}, - ThingComputerTablet: {Symbol: "", Title: "Tablet", Description: "Tablet computer"}, - ThingControl: {Symbol: "", Title: "Input controller", Description: "Generic input controller"}, - ThingNetGateway: {Symbol: "", Title: "Gateway", Description: "Generic gateway device providing access to other devices"}, - ThingActuatorValve: {Symbol: "", Title: "Valve", Description: "Electric powered valve for fluids or gas"}, - ThingAppliance: {Symbol: "", Title: "Appliance", Description: "Appliance to accomplish a particular task for occupant use"}, - ThingComputerEmbedded: {Symbol: "", Title: "Embedded System", Description: "Embedded computing device"}, - ThingComputerMemory: {Symbol: "", Title: "Memory", Description: "Stand-alone memory device such as eeprom or iButtons"}, - ThingControlThermostat: {Symbol: "", Title: "Thermostat", Description: "Thermostat HVAC control"}, - ThingDeviceTime: {Symbol: "", Title: "Clock", Description: "Time tracking device such as clocks and time chips"}, - ThingMediaAmplifier: {Symbol: "", Title: "Audio amplifier", Description: "Audio amplifier with volume controls"}, - ThingMediaTV: {Symbol: "", Title: "TV", Description: "Network connected television"}, - ThingActuatorAlarm: {Symbol: "", Title: "Alarm", Description: "Siren or light alarm"}, - ThingComputerPotsPhone: {Symbol: "", Title: "Land Line", Description: "Plain Old Telephone System, aka landline"}, - ThingControlJoystick: {Symbol: "", Title: "Joystick", Description: "Flight control stick"}, - ThingMeterElectricCurrent: {Symbol: "", Title: "Electric current", Description: "Electrical current meter"}, - ThingNetBluetooth: {Symbol: "", Title: "Bluetooth", Description: "Bluetooth radio"}, - ThingNetWifi: {Symbol: "", Title: "Wifi device", Description: "Generic wifi device"}, - ThingSensor: {Symbol: "", Title: "Sensor", Description: "Generic sensor device"}, - ThingSensorSound: {Symbol: "", Title: "Sound detector", Description: ""}, - ThingControlIrrigation: {Symbol: "", Title: "Irrigation control", Description: "Device for control of an irrigation system"}, -} - -// type: UnitClasses -// version: 0.1 -// generated: 03 Oct 25 12:32 PDT -// source: github.com/hiveot/hub/api/vocab/ht-unit-classes.yaml -// namespace: hiveot -const ( - UnitAmpere = "hiveot:unit:ampere" - UnitCandela = "hiveot:unit:candela" - UnitCelcius = "hiveot:unit:celcius" - UnitCount = "hiveot:unit:count" - UnitDegree = "hiveot:unit:degree" - UnitFahrenheit = "hiveot:unit:fahrenheit" - UnitFoot = "hiveot:unit:foot" - UnitGallon = "hiveot:unit:gallon" - UnitHectoPascal = "hiveot:unit:hectopascal" - UnitKelvin = "hiveot:unit:kelvin" - UnitKilogram = "hiveot:unit:kilogram" - UnitKilometerPerHour = "hiveot:unit:kph" - UnitKilowattHour = "hiveot:unit:kilowatthour" - UnitLiter = "hiveot:unit:liter" - UnitLumen = "hiveot:unit:lumen" - UnitLux = "hiveot:unit:lux" - UnitMercury = "hiveot:unit:mercury" - UnitMeter = "hiveot:unit:meter" - UnitMeterPerSecond = "hiveot:unit:meterspersecond" - UnitMilesPerHour = "hiveot:unit:milesperhour" - UnitMilliBar = "hiveot:unit:millibar" - UnitMilliMeter = "hiveot:unit:millimeter" - UnitMilliSecond = "hiveot:unit:millisecond" - UnitMole = "hiveot:unit:mole" - UnitPSI = "hiveot:unit:psi" - UnitPascal = "hiveot:unit:pascal" - UnitPercent = "hiveot:unit:percent" - UnitPound = "hiveot:unit:pound" - UnitPpm = "hiveot:unit:ppm" - UnitRadian = "hiveot:unit:radian" - UnitSecond = "hiveot:unit:second" - UnitVolt = "hiveot:unit:volt" - UnitWatt = "hiveot:unit:watt" -) - -// end of UnitClasses - -// UnitClassesMap maps @type to symbol, title and description -var UnitClassesMap = map[string]struct { - Symbol string - Title string - Description string -}{ - UnitHectoPascal: {Symbol: "hPa", Title: "Hecto-Pascal", Description: "SI unit of atmospheric pressure. Equal to 100 pascal and 1 millibar."}, - UnitKilometerPerHour: {Symbol: "kph", Title: "Km per hour", Description: "Speed in kilometers per hour"}, - UnitAmpere: {Symbol: "A", Title: "Ampere", Description: "Electrical current in Amperes based on the elementary charge flow per second"}, - UnitCelcius: {Symbol: "°C", Title: "Celcius", Description: "Temperature in Celcius"}, - UnitKilowattHour: {Symbol: "kWh", Title: "Kilowatt-hour", Description: "non-SI unit of energy equivalent to 3.6 megajoules."}, - UnitMilliBar: {Symbol: "mbar", Title: "millibar", Description: "Metric unit of pressure. 1/1000th of a bar. Equal to 100 pascals. Amount of force it takes to move an object weighing a gram, one centimeter in one second."}, - UnitMilliSecond: {Symbol: "ms", Title: "millisecond", Description: "Unit of time in milli-seconds. Equal to 1/1000 of a second."}, - UnitRadian: {Symbol: "", Title: "Radian", Description: "Angle in 0-2pi"}, - UnitCandela: {Symbol: "cd", Title: "Candela", Description: "SI unit of luminous intensity in a given direction. Roughly the same brightness as the common candle."}, - UnitFoot: {Symbol: "ft", Title: "Foot", Description: "Imperial unit of distance. 1 foot equals 0.3048 meters"}, - UnitGallon: {Symbol: "gl", Title: "Gallon", Description: "Unit of volume. 1 Imperial gallon is 4.54609 liters. 1 US liquid gallon is 3.78541 liters. 1 US dry gallon is 4.405 liters. "}, - UnitPascal: {Symbol: "Pa", Title: "Pascal", Description: "SI unit of pressure. Equal to 1 newton of force applied over 1 square meter."}, - UnitPound: {Symbol: "lbs", Title: "Pound", Description: "Imperial unit of weight. Equivalent to 0.453592 Kg. 1 Kg is 2.205 lbs"}, - UnitVolt: {Symbol: "V", Title: "Volt", Description: "SI unit of electric potential; Energy consumption of 1 joule per electric charge of one coulomb"}, - UnitLiter: {Symbol: "l", Title: "Liter", Description: "SI unit of volume equivalent to 1 cubic decimeter."}, - UnitMilliMeter: {Symbol: "mm", Title: "Millimeter", Description: "Size in millimeter"}, - UnitMole: {Symbol: "mol", Title: "Mole", Description: "SI unit of measurement for amount of substance. Eg, molecules."}, - UnitWatt: {Symbol: "W", Title: "Watt", Description: "SI unit of power. Equal to 1 joule per second; or work performed when a current of 1 ampere flows across an electric potential of one volt."}, - UnitCount: {Symbol: "(N)", Title: "Count", Description: ""}, - UnitMercury: {Symbol: "Hg", Title: "Mercury", Description: "Unit of atmospheric pressure in the United States. 1 Hg equals 33.8639 mbar."}, - UnitMeterPerSecond: {Symbol: "m/s", Title: "Meters per second", Description: "SI unit of speed in meters per second"}, - UnitFahrenheit: {Symbol: "F", Title: "Fahrenheit", Description: "Temperature in Fahrenheit"}, - UnitKelvin: {Symbol: "K", Title: "Kelvin", Description: "SI unit of thermodynamic temperature. 0 K represents absolute zero, the absence of all heat. 0 C equals +273.15K"}, - UnitPSI: {Symbol: "PSI", Title: "PSI", Description: "Unit of pressure. Pounds of force per square inch. 1PSI equals 6984 Pascals."}, - UnitDegree: {Symbol: "degree", Title: "Degree", Description: "Angle in 0-360 degrees"}, - UnitLumen: {Symbol: "lm", Title: "Lumen", Description: "SI unit luminous flux. Measure of perceived power of visible light. 1lm = 1 cd steradian"}, - UnitMeter: {Symbol: "m", Title: "Meter", Description: "Distance in meters. 1m=c/299792458"}, - UnitMilesPerHour: {Symbol: "mph", Title: "Miles per hour", Description: "Speed in miles per hour"}, - UnitPercent: {Symbol: "%", Title: "Percent", Description: "Fractions of 100"}, - UnitSecond: {Symbol: "s", Title: "Second", Description: "SI unit of time based on caesium frequency"}, - UnitPpm: {Symbol: "ppm", Title: "PPM", Description: "Parts per million"}, - UnitKilogram: {Symbol: "kg", Title: "Kilogram", Description: ""}, - UnitLux: {Symbol: "lx", Title: "Lux", Description: "SI unit illuminance. Equal to 1 lumen per square meter."}, -} diff --git a/api/js/vocab/vocab.js b/api/js/vocab/vocab.js deleted file mode 100644 index 86abe18e..00000000 --- a/api/js/vocab/vocab.js +++ /dev/null @@ -1,1541 +0,0 @@ -// Package vocab with HiveOT vocabulary names for TD Things, properties, events and actions -// DO NOT EDIT. This file is generated and changes will be overwritten - -// type: WoTVocab -// version: 0.1 -// generated: 18 Nov 25 10:06 PST -// source: github.com/hiveot/hub/api/vocab/wot-vocab.yaml -// description: WoT vocabulary definition. See https://www.w3.org/TR/2020/WD-wot-thing-description11-20201124/#sec-core-vocabulary-definition -export const OpCancelAction = "cancelaction"; -export const OpInvokeAction = "invokeaction"; -export const OpObserveAction = "observeaction"; -export const OpObserveAllActions = "observeallactions"; -export const OpObserveAllProperties = "observeallproperties"; -export const OpObserveMultipleProperties = "observemultipleproperties"; -export const OpObserveProperty = "observeproperty"; -export const OpQueryAction = "queryaction"; -export const OpQueryAllActions = "queryallactions"; -export const OpQueryMultipleActions = "querymultipleactions"; -export const OpReadAllProperties = "readallproperties"; -export const OpReadMultipleProperties = "readmultipleproperties"; -export const OpReadProperty = "readproperty"; -export const OpSubscribeAllEvents = "subscribeallevents"; -export const OpSubscribeEvent = "subscribeevent"; -export const OpSubscribeMultipleEvents = "subscribemultipleevents"; -export const OpUnobserveAllProperties = "unobserveallproperties"; -export const OpUnobserveMultipleProperties = "unobservemultipleproperties"; -export const OpUnobserveProperty = "unobserveroperty"; -export const OpUnsubscribeAllEvents = "unsubscribeallevents"; -export const OpUnsubscribeEvent = "unsubscribeevent"; -export const OpUnsubscribeMultipleEvents = "unsubscribemultipleevents"; -export const OpWriteMultipleProperties = "writemultipleproperties"; -export const OpWriteProperty = "writeproperty"; -export const WoTAPIKeySecurityScheme = "APIKeySecurityScheme"; -export const WoTActions = "actions"; -export const WoTAnyURI = "https://www.w3.org/2019/wot/thing/v1"; -export const WoTAtContext = "@context"; -export const WoTAtType = "@type"; -export const WoTBasicSecurityScheme = "BasicSecurityScheme"; -export const WoTBearerSecurityScheme = "BearerSecurityScheme"; -export const WoTConst = "const"; -export const WoTCreated = "created"; -export const WoTDataType = "type"; -export const WoTDataTypeAnyURI = "anyURI"; -export const WoTDataTypeArray = "array"; -export const WoTDataTypeBool = "boolean"; -export const WoTDataTypeDateTime = "dateTime"; -export const WoTDataTypeInteger = "integer"; -export const WoTDataTypeNone = ""; -export const WoTDataTypeNumber = "number"; -export const WoTDataTypeObject = "object"; -export const WoTDataTypeString = "string"; -export const WoTDataTypeUnsignedInt = "unsignedInt"; -export const WoTDescription = "description"; -export const WoTDescriptions = "descriptions"; -export const WoTDigestSecurityScheme = "DigestSecurityScheme"; -export const WoTEnum = "enum"; -export const WoTEvents = "events"; -export const WoTFormat = "format"; -export const WoTForms = "forms"; -export const WoTHref = "href"; -export const WoTID = "id"; -export const WoTInput = "input"; -export const WoTLinks = "links"; -export const WoTMaxItems = "maxItems"; -export const WoTMaxLength = "maxLength"; -export const WoTMaximum = "maximum"; -export const WoTMinItems = "minItems"; -export const WoTMinLength = "minLength"; -export const WoTMinimum = "minimum"; -export const WoTModified = "modified"; -export const WoTNoSecurityScheme = "NoSecurityScheme"; -export const WoTOAuth2SecurityScheme = "OAuth2SecurityScheme"; -export const WoTOperation = "op"; -export const WoTOutput = "output"; -export const WoTPSKSecurityScheme = "PSKSecurityScheme"; -export const WoTProperties = "properties"; -export const WoTReadOnly = "readOnly"; -export const WoTRequired = "required"; -export const WoTSecurity = "security"; -export const WoTSupport = "support"; -export const WoTTitle = "title"; -export const WoTTitles = "titles"; -export const WoTVersion = "version"; -// end of WoTVocab - -// type: UnitClasses -// version: 0.1 -// generated: 18 Nov 25 10:06 PST -// source: github.com/hiveot/hub/api/vocab/ht-unit-classes.yaml -// namespace: hiveot -export const UnitAmpere = "hiveot:unit:ampere"; -export const UnitCandela = "hiveot:unit:candela"; -export const UnitCelcius = "hiveot:unit:celcius"; -export const UnitCount = "hiveot:unit:count"; -export const UnitDegree = "hiveot:unit:degree"; -export const UnitFahrenheit = "hiveot:unit:fahrenheit"; -export const UnitFoot = "hiveot:unit:foot"; -export const UnitGallon = "hiveot:unit:gallon"; -export const UnitHectoPascal = "hiveot:unit:hectopascal"; -export const UnitKelvin = "hiveot:unit:kelvin"; -export const UnitKilogram = "hiveot:unit:kilogram"; -export const UnitKilometerPerHour = "hiveot:unit:kph"; -export const UnitKilowattHour = "hiveot:unit:kilowatthour"; -export const UnitLiter = "hiveot:unit:liter"; -export const UnitLumen = "hiveot:unit:lumen"; -export const UnitLux = "hiveot:unit:lux"; -export const UnitMercury = "hiveot:unit:mercury"; -export const UnitMeter = "hiveot:unit:meter"; -export const UnitMeterPerSecond = "hiveot:unit:meterspersecond"; -export const UnitMilesPerHour = "hiveot:unit:milesperhour"; -export const UnitMilliBar = "hiveot:unit:millibar"; -export const UnitMilliMeter = "hiveot:unit:millimeter"; -export const UnitMilliSecond = "hiveot:unit:millisecond"; -export const UnitMole = "hiveot:unit:mole"; -export const UnitPSI = "hiveot:unit:psi"; -export const UnitPascal = "hiveot:unit:pascal"; -export const UnitPercent = "hiveot:unit:percent"; -export const UnitPound = "hiveot:unit:pound"; -export const UnitPpm = "hiveot:unit:ppm"; -export const UnitRadian = "hiveot:unit:radian"; -export const UnitSecond = "hiveot:unit:second"; -export const UnitVolt = "hiveot:unit:volt"; -export const UnitWatt = "hiveot:unit:watt"; -// end of UnitClasses - -// UnitClassesMap maps @type to symbol, title and description -export const UnitClassesMap = { - "hiveot:unit:lumen": { - Symbol: "lm", - Title: "Lumen", - Description: - "SI unit luminous flux. Measure of perceived power of visible light. 1lm = 1 cd steradian", - }, - "hiveot:unit:mercury": { - Symbol: "Hg", - Title: "Mercury", - Description: - "Unit of atmospheric pressure in the United States. 1 Hg equals 33.8639 mbar.", - }, - "hiveot:unit:millibar": { - Symbol: "mbar", - Title: "millibar", - Description: - "Metric unit of pressure. 1/1000th of a bar. Equal to 100 pascals. Amount of force it takes to move an object weighing a gram, one centimeter in one second.", - }, - "hiveot:unit:kilogram": { Symbol: "kg", Title: "Kilogram", Description: "" }, - "hiveot:unit:second": { - Symbol: "s", - Title: "Second", - Description: "SI unit of time based on caesium frequency", - }, - "hiveot:unit:count": { Symbol: "(N)", Title: "Count", Description: "" }, - "hiveot:unit:fahrenheit": { - Symbol: "F", - Title: "Fahrenheit", - Description: "Temperature in Fahrenheit", - }, - "hiveot:unit:kelvin": { - Symbol: "K", - Title: "Kelvin", - Description: - "SI unit of thermodynamic temperature. 0 K represents absolute zero, the absence of all heat. 0 C equals +273.15K", - }, - "hiveot:unit:hectopascal": { - Symbol: "hPa", - Title: "Hecto-Pascal", - Description: - "SI unit of atmospheric pressure. Equal to 100 pascal and 1 millibar.", - }, - "hiveot:unit:radian": { - Symbol: "", - Title: "Radian", - Description: "Angle in 0-2pi", - }, - "hiveot:unit:celcius": { - Symbol: "°C", - Title: "Celcius", - Description: "Temperature in Celcius", - }, - "hiveot:unit:pascal": { - Symbol: "Pa", - Title: "Pascal", - Description: - "SI unit of pressure. Equal to 1 newton of force applied over 1 square meter.", - }, - "hiveot:unit:percent": { - Symbol: "%", - Title: "Percent", - Description: "Fractions of 100", - }, - "hiveot:unit:ppm": { - Symbol: "ppm", - Title: "PPM", - Description: "Parts per million", - }, - "hiveot:unit:kph": { - Symbol: "kph", - Title: "Km per hour", - Description: "Speed in kilometers per hour", - }, - "hiveot:unit:liter": { - Symbol: "l", - Title: "Liter", - Description: "SI unit of volume equivalent to 1 cubic decimeter.", - }, - "hiveot:unit:millisecond": { - Symbol: "ms", - Title: "millisecond", - Description: "Unit of time in milli-seconds. Equal to 1/1000 of a second.", - }, - "hiveot:unit:volt": { - Symbol: "V", - Title: "Volt", - Description: - "SI unit of electric potential; Energy consumption of 1 joule per electric charge of one coulomb", - }, - "hiveot:unit:foot": { - Symbol: "ft", - Title: "Foot", - Description: "Imperial unit of distance. 1 foot equals 0.3048 meters", - }, - "hiveot:unit:meter": { - Symbol: "m", - Title: "Meter", - Description: "Distance in meters. 1m=c/299792458", - }, - "hiveot:unit:meterspersecond": { - Symbol: "m/s", - Title: "Meters per second", - Description: "SI unit of speed in meters per second", - }, - "hiveot:unit:milesperhour": { - Symbol: "mph", - Title: "Miles per hour", - Description: "Speed in miles per hour", - }, - "hiveot:unit:pound": { - Symbol: "lbs", - Title: "Pound", - Description: - "Imperial unit of weight. Equivalent to 0.453592 Kg. 1 Kg is 2.205 lbs", - }, - "hiveot:unit:degree": { - Symbol: "degree", - Title: "Degree", - Description: "Angle in 0-360 degrees", - }, - "hiveot:unit:kilowatthour": { - Symbol: "kWh", - Title: "Kilowatt-hour", - Description: "non-SI unit of energy equivalent to 3.6 megajoules.", - }, - "hiveot:unit:millimeter": { - Symbol: "mm", - Title: "Millimeter", - Description: "Size in millimeter", - }, - "hiveot:unit:mole": { - Symbol: "mol", - Title: "Mole", - Description: - "SI unit of measurement for amount of substance. Eg, molecules.", - }, - "hiveot:unit:ampere": { - Symbol: "A", - Title: "Ampere", - Description: - "Electrical current in Amperes based on the elementary charge flow per second", - }, - "hiveot:unit:gallon": { - Symbol: "gl", - Title: "Gallon", - Description: - "Unit of volume. 1 Imperial gallon is 4.54609 liters. 1 US liquid gallon is 3.78541 liters. 1 US dry gallon is 4.405 liters. ", - }, - "hiveot:unit:lux": { - Symbol: "lx", - Title: "Lux", - Description: "SI unit illuminance. Equal to 1 lumen per square meter.", - }, - "hiveot:unit:psi": { - Symbol: "PSI", - Title: "PSI", - Description: - "Unit of pressure. Pounds of force per square inch. 1PSI equals 6984 Pascals.", - }, - "hiveot:unit:watt": { - Symbol: "W", - Title: "Watt", - Description: - "SI unit of power. Equal to 1 joule per second; or work performed when a current of 1 ampere flows across an electric potential of one volt.", - }, - "hiveot:unit:candela": { - Symbol: "cd", - Title: "Candela", - Description: - "SI unit of luminous intensity in a given direction. Roughly the same brightness as the common candle.", - }, -}; - -// type: ActionClasses -// version: 0.1 -// generated: 18 Nov 25 10:06 PST -// source: github.com/hiveot/hub/api/vocab/ht-action-classes.yaml -// namespace: hiveot -export const ActionDimmer = "hiveot:action:dimmer"; -export const ActionDimmerDecrement = "hiveot:action:dimmer:decrement"; -export const ActionDimmerIncrement = "hiveot:action:dimmer:increment"; -export const ActionDimmerSet = "hiveot:action:dimmer:set"; -export const ActionMedia = "hiveot:action:media"; -export const ActionMediaMute = "hiveot:action:media:mute"; -export const ActionMediaNext = "hiveot:action:media:next"; -export const ActionMediaPause = "hiveot:action:media:pause"; -export const ActionMediaPlay = "hiveot:action:media:play"; -export const ActionMediaPrevious = "hiveot:action:media:previous"; -export const ActionMediaUnmute = "hiveot:action:media:unmute"; -export const ActionMediaVolume = "hiveot:action:media:volume"; -export const ActionMediaVolumeDecrease = "hiveot:action:media:volume:decrease"; -export const ActionMediaVolumeIncrease = "hiveot:action:media:volume:increase"; -export const ActionSwitch = "hiveot:action:switch"; -export const ActionSwitchOnOff = "hiveot:action:switch:onoff"; -export const ActionSwitchToggle = "hiveot:action:switch:toggle"; -export const ActionThingDisable = "hiveot:action:thing:disable"; -export const ActionThingEnable = "hiveot:action:thing:enable"; -export const ActionThingStart = "hiveot:action:thing:start"; -export const ActionThingStop = "hiveot:action:thing:stop"; -export const ActionValveClose = "hiveot:action:valve:close"; -export const ActionValveOpen = "hiveot:action:valve:open"; -// end of ActionClasses - -// ActionClassesMap maps @type to symbol, title and description -export const ActionClassesMap = { - "hiveot:action:media": { - Symbol: "", - Title: "Media control", - Description: "Commands to control media recording and playback", - }, - "hiveot:action:media:play": { - Symbol: "", - Title: "Play", - Description: "Start or continue playback", - }, - "hiveot:action:media:volume": { - Symbol: "", - Title: "Volume", - Description: "Set volume level", - }, - "hiveot:action:media:volume:increase": { - Symbol: "", - Title: "Increase volume", - Description: "Increase volume", - }, - "hiveot:action:media:volume:decrease": { - Symbol: "", - Title: "Decrease volume", - Description: "Decrease volume", - }, - "hiveot:action:switch:toggle": { - Symbol: "", - Title: "Toggle switch", - Description: "Action to toggle the switch", - }, - "hiveot:action:valve:close": { - Symbol: "", - Title: "Close valve", - Description: "Action to close the valve", - }, - "hiveot:action:media:previous": { - Symbol: "", - Title: "Previous", - Description: "Previous track or station", - }, - "hiveot:action:dimmer": { - Symbol: "", - Title: "Dimmer", - Description: "General dimmer action", - }, - "hiveot:action:switch": { - Symbol: "", - Title: "Switch", - Description: "General switch action", - }, - "hiveot:action:thing:disable": { - Symbol: "", - Title: "Disable", - Description: "Action to disable a thing", - }, - "hiveot:action:thing:stop": { - Symbol: "", - Title: "Stop", - Description: "Stop a running task", - }, - "hiveot:action:media:next": { - Symbol: "", - Title: "Next", - Description: "Next track or station", - }, - "hiveot:action:media:unmute": { - Symbol: "", - Title: "Unmute", - Description: "Unmute audio", - }, - "hiveot:action:dimmer:decrement": { - Symbol: "", - Title: "Lower dimmer", - Description: "", - }, - "hiveot:action:valve:open": { - Symbol: "", - Title: "Open valve", - Description: "Action to open the valve", - }, - "hiveot:action:media:mute": { - Symbol: "", - Title: "Mute", - Description: "Mute audio", - }, - "hiveot:action:media:pause": { - Symbol: "", - Title: "Pause", - Description: "Pause playback", - }, - "hiveot:action:dimmer:increment": { - Symbol: "", - Title: "Increase dimmer", - Description: "", - }, - "hiveot:action:dimmer:set": { - Symbol: "", - Title: "Set dimmer", - Description: "Action to set the dimmer value", - }, - "hiveot:action:switch:onoff": { - Symbol: "", - Title: "Set On/Off switch", - Description: "Action to set the switch on/off state", - }, - "hiveot:action:thing:enable": { - Symbol: "", - Title: "Enable", - Description: "Action to enable a thing", - }, - "hiveot:action:thing:start": { - Symbol: "", - Title: "Start", - Description: "Start running a task", - }, -}; - -// type: PropertyClasses -// version: 0.1 -// generated: 18 Nov 25 10:06 PST -// source: github.com/hiveot/hub/api/vocab/ht-property-classes.yaml -// namespace: hiveot -export const PropAlarmMotion = "hiveot:prop:alarm:motion"; -export const PropAlarmStatus = "hiveot:prop:alarm:status"; -export const PropDevice = "hiveot:prop:device"; -export const PropDeviceBattery = "hiveot:prop:device:battery"; -export const PropDeviceDescription = "hiveot:prop:device:description"; -export const PropDeviceEnabledDisabled = "hiveot:prop:device:enabled-disabled"; -export const PropDeviceFirmwareVersion = "hiveot:prop:device:firmwareversion"; -export const PropDeviceHardwareVersion = "hiveot:prop:device:hardwareversion"; -export const PropDeviceMake = "hiveot:prop:device:make"; -export const PropDeviceModel = "hiveot:prop:device:model"; -export const PropDevicePollinterval = "hiveot:prop:device:pollinterval"; -export const PropDeviceSoftwareVersion = "hiveot:prop:device:softwareversion"; -export const PropDeviceStatus = "hiveot:prop:device:status"; -export const PropDeviceTitle = "hiveot:prop:device:title"; -export const PropElectric = "hiveot:prop:electric"; -export const PropElectricCurrent = "hiveot:prop:electric:current"; -export const PropElectricEnergy = "hiveot:prop:electric:energy"; -export const PropElectricOverload = "hiveot:prop:electric:overload"; -export const PropElectricPower = "hiveot:prop:electric:poer"; -export const PropElectricVoltage = "hiveot:prop:electric:voltage"; -export const PropEnv = "hiveot:prop:env"; -export const PropEnvAcceleration = "hiveot:prop:env:acceleration"; -export const PropEnvAirquality = "hiveot:prop:env:airquality"; -export const PropEnvCO = "hiveot:prop:env:co"; -export const PropEnvCO2 = "hiveot:prop:env:co2"; -export const PropEnvCpuload = "hiveot:prop:env:cpuload"; -export const PropEnvDewpoint = "hiveot:prop:env:dewpoint"; -export const PropEnvFuelFlowrate = "hiveot:prop:env:fuel:flowrate"; -export const PropEnvFuelLevel = "hiveot:prop:env:fuel:level"; -export const PropEnvHumidex = "hiveot:prop:env:humidex"; -export const PropEnvHumidity = "hiveot:prop:env:humidity"; -export const PropEnvLuminance = "hiveot:prop:env:luminance"; -export const PropEnvPrecipitation = "hiveot:prop:env:precipitation"; -export const PropEnvPrecipitationRain = "hiveot:prop:env:precipitation:rain"; -export const PropEnvPrecipitationSnow = "hiveot:prop:env:precipitation:snow"; -export const PropEnvPressure = "hiveot:prop:env:pressure"; -export const PropEnvPressureSeaLevel = "hiveot:prop:env:barometer:msl"; -export const PropEnvPressureSurface = "hiveot:prop:env:barometer:surface"; -export const PropEnvTemperature = "hiveot:prop:env:temperature"; -export const PropEnvTimezone = "hiveot:prop:env:timezone"; -export const PropEnvUV = "hiveot:prop:env:uv"; -export const PropEnvVibration = "hiveot:prop:env:vibration"; -export const PropEnvVolume = "hiveot:prop:env:volume"; -export const PropEnvWaterFlowrate = "hiveot:prop:env:water:flowrate"; -export const PropEnvWaterLevel = "hiveot:prop:env:water:level"; -export const PropEnvWindGusts = "hiveot:prop:env:wind:gusts"; -export const PropEnvWindHeading = "hiveot:prop:env:wind:heading"; -export const PropEnvWindSpeed = "hiveot:prop:env:wind:speed"; -export const PropLocation = "hiveot:prop:location"; -export const PropLocationCity = "hiveot:prop:location:city"; -export const PropLocationLatitude = "hiveot:prop:location:latitude"; -export const PropLocationLongitude = "hiveot:prop:location:longitude"; -export const PropLocationName = "hiveot:prop:location:name"; -export const PropLocationStreet = "hiveot:prop:location:street"; -export const PropLocationZipcode = "hiveot:prop:location:zipcode"; -export const PropMedia = "hiveot:prop:media"; -export const PropMediaMuted = "hiveot:prop:media:muted"; -export const PropMediaPaused = "hiveot:prop:media:paused"; -export const PropMediaPlaying = "hiveot:prop:media:playing"; -export const PropMediaStation = "hiveot:prop:media:station"; -export const PropMediaTrack = "hiveot:prop:media:track"; -export const PropMediaVolume = "hiveot:prop:media:volume"; -export const PropNet = "hiveot:prop:net"; -export const PropNetAddress = "hiveot:prop:net:address"; -export const PropNetConnection = "hiveot:prop:net:connection"; -export const PropNetDomainname = "hiveot:prop:net:domainname"; -export const PropNetGateway = "hiveot:prop:net:gateway"; -export const PropNetHostname = "hiveot:prop:net:hostname"; -export const PropNetIP4 = "hiveot:prop:net:ip4"; -export const PropNetIP6 = "hiveot:prop:net:ip6"; -export const PropNetLatency = "hiveot:prop:net:latency"; -export const PropNetMAC = "hiveot:prop:net:mac"; -export const PropNetMask = "hiveot:prop:net:mask"; -export const PropNetPort = "hiveot:prop:net:port"; -export const PropNetSignalstrength = "hiveot:prop:net:signalstrength"; -export const PropNetSubnet = "hiveot:prop:net:subnet"; -export const PropStatusOnOff = "hiveot:prop:status:onoff"; -export const PropStatusOpenClosed = "hiveot:prop:status:openclosed"; -export const PropStatusStartedStopped = "hiveot:prop:status:started-stopped"; -export const PropStatusYesNo = "hiveot:prop:status:yes-no"; -export const PropSwitch = "hiveot:prop:switch"; -export const PropSwitchDimmer = "hiveot:prop:switch:dimmer"; -export const PropSwitchLight = "hiveot:prop:switch:light"; -export const PropSwitchLocked = "hiveot:prop:switch:locked"; -export const PropSwitchOnOff = "hiveot:prop:switch:onoff"; -// end of PropertyClasses - -// PropertyClassesMap maps @type to symbol, title and description -export const PropertyClassesMap = { - "hiveot:prop:location:latitude": { - Symbol: "", - Title: "Latitude", - Description: "Latitude geographic coordinate", - }, - "hiveot:prop:net:address": { - Symbol: "", - Title: "Address", - Description: "Network address", - }, - "hiveot:prop:switch:onoff": { - Symbol: "", - Title: "On/Off switch", - Description: "", - }, - "hiveot:prop:electric:poer": { - Symbol: "", - Title: "Power", - Description: "Electrical power being consumed", - }, - "hiveot:prop:location": { - Symbol: "", - Title: "Location", - Description: "General location information", - }, - "hiveot:prop:media:station": { - Symbol: "", - Title: "Station", - Description: "Selected radio station", - }, - "hiveot:prop:env:co": { - Symbol: "", - Title: "Carbon monoxide level", - Description: "Carbon monoxide level", - }, - "hiveot:prop:env:cpuload": { - Symbol: "", - Title: "CPU load level", - Description: "Device CPU load level", - }, - "hiveot:prop:env:temperature": { - Symbol: "", - Title: "Temperature", - Description: "", - }, - "hiveot:prop:net:mask": { - Symbol: "", - Title: "Netmask", - Description: "Network mask. Example: 255.255.255.0 or 24/8", - }, - "hiveot:prop:status:started-stopped": { - Symbol: "", - Title: "Started/Stopped", - Description: "Started or stopped status", - }, - "hiveot:prop:switch:dimmer": { - Symbol: "", - Title: "Dimmer value", - Description: "", - }, - "hiveot:prop:device:make": { - Symbol: "", - Title: "Make", - Description: "Device manufacturer", - }, - "hiveot:prop:env:fuel:flowrate": { - Symbol: "", - Title: "Fuel flow rate", - Description: "", - }, - "hiveot:prop:media": { - Symbol: "", - Title: "Media commands", - Description: "Control of media equipment", - }, - "hiveot:prop:net:mac": { - Symbol: "", - Title: "MAC", - Description: "Hardware MAC address", - }, - "hiveot:prop:status:yes-no": { - Symbol: "", - Title: "Yes/No", - Description: "Status with yes or no value", - }, - "hiveot:prop:env:dewpoint": { - Symbol: "", - Title: "Dew point", - Description: "Dew point temperature", - }, - "hiveot:prop:alarm:motion": { - Symbol: "", - Title: "Motion", - Description: "Motion detected", - }, - "hiveot:prop:media:track": { - Symbol: "", - Title: "Track", - Description: "Selected A/V track", - }, - "hiveot:prop:net:ip6": { - Symbol: "", - Title: "IP6 address", - Description: "Device IP6 address", - }, - "hiveot:prop:net:signalstrength": { - Symbol: "", - Title: "Signal strength", - Description: "Wireless signal strength", - }, - "hiveot:prop:switch": { Symbol: "", Title: "Switch status", Description: "" }, - "hiveot:prop:device:hardwareversion": { - Symbol: "", - Title: "Hardware version", - Description: "", - }, - "hiveot:prop:env:water:level": { - Symbol: "", - Title: "Water level", - Description: "", - }, - "hiveot:prop:env": { - Symbol: "", - Title: "Environmental property", - Description: "Property of environmental sensor", - }, - "hiveot:prop:env:vibration": { - Symbol: "", - Title: "Vibration", - Description: "", - }, - "hiveot:prop:env:wind:speed": { - Symbol: "", - Title: "Wind speed", - Description: "Average speed of wind", - }, - "hiveot:prop:net:domainname": { - Symbol: "", - Title: "Domain name", - Description: "Domainname of the client", - }, - "hiveot:prop:net:subnet": { - Symbol: "", - Title: "Subnet", - Description: "Network subnet address. Example: 192.168.0.0", - }, - "hiveot:prop:switch:locked": { - Symbol: "", - Title: "Lock", - Description: "Electric lock status", - }, - "hiveot:prop:alarm:status": { - Symbol: "", - Title: "Alarm state", - Description: "Current alarm status", - }, - "hiveot:prop:device:enabled-disabled": { - Symbol: "", - Title: "Enabled/Disabled", - Description: "Enabled or disabled state", - }, - "hiveot:prop:device:status": { - Symbol: "", - Title: "Status", - Description: "Device status; alive, awake, dead, sleeping", - }, - "hiveot:prop:env:fuel:level": { - Symbol: "", - Title: "Fuel level", - Description: "", - }, - "hiveot:prop:location:street": { - Symbol: "", - Title: "Street", - Description: "Street address", - }, - "hiveot:prop:location:name": { - Symbol: "", - Title: "Location name", - Description: "Name of the location", - }, - "hiveot:prop:location:zipcode": { - Symbol: "", - Title: "Zip code", - Description: "Location ZIP code", - }, - "hiveot:prop:net": { - Symbol: "", - Title: "Network properties", - Description: "General network properties", - }, - "hiveot:prop:device:model": { - Symbol: "", - Title: "Model", - Description: "Device model", - }, - "hiveot:prop:env:barometer:msl": { - Symbol: "", - Title: "Sea level pressure", - Description: "Sea level equivalent atmospheric pressure", - }, - "hiveot:prop:media:paused": { - Symbol: "", - Title: "Paused", - Description: "Media is paused", - }, - "hiveot:prop:net:hostname": { - Symbol: "", - Title: "Hostname", - Description: "Hostname of the client", - }, - "hiveot:prop:net:latency": { - Symbol: "", - Title: "Network latency", - Description: "Delay between hub and client", - }, - "hiveot:prop:status:onoff": { - Symbol: "", - Title: "On/off status", - Description: "", - }, - "hiveot:prop:status:openclosed": { - Symbol: "", - Title: "Open/Closed status", - Description: "", - }, - "hiveot:prop:device:description": { - Symbol: "", - Title: "Description", - Description: "Device product description", - }, - "hiveot:prop:device:pollinterval": { - Symbol: "", - Title: "Polling interval", - Description: "Interval to poll for updates", - }, - "hiveot:prop:env:precipitation:rain": { - Symbol: "", - Title: "Rain precipitation", - Description: "Precipitation as rain", - }, - "hiveot:prop:env:precipitation:snow": { - Symbol: "", - Title: "Snow precipitation", - Description: "Precipitation as snow", - }, - "hiveot:prop:env:water:flowrate": { - Symbol: "", - Title: "Water flow rate", - Description: "", - }, - "hiveot:prop:location:city": { - Symbol: "", - Title: "City", - Description: "City name", - }, - "hiveot:prop:location:longitude": { - Symbol: "", - Title: "Longitude", - Description: "Longitude geographic coordinate", - }, - "hiveot:prop:net:ip4": { - Symbol: "", - Title: "IP4 address", - Description: "Device IP4 address", - }, - "hiveot:prop:env:barometer:surface": { - Symbol: "", - Title: "Surface level pressure", - Description: "Surface level atmospheric pressure", - }, - "hiveot:prop:env:wind:gusts": { - Symbol: "", - Title: "Wind gusts", - Description: "Speed of wind gusts", - }, - "hiveot:prop:media:volume": { - Symbol: "", - Title: "Volume", - Description: "Media volume setting", - }, - "hiveot:prop:switch:light": { - Symbol: "", - Title: "Light switch", - Description: "", - }, - "hiveot:prop:device:softwareversion": { - Symbol: "", - Title: "Software version", - Description: "", - }, - "hiveot:prop:electric:current": { - Symbol: "", - Title: "Current", - Description: "Electrical current", - }, - "hiveot:prop:electric:voltage": { - Symbol: "", - Title: "Voltage", - Description: "Electrical voltage potential", - }, - "hiveot:prop:env:humidity": { - Symbol: "", - Title: "Humidity", - Description: "", - }, - "hiveot:prop:media:playing": { - Symbol: "", - Title: "Playing", - Description: "Media is playing", - }, - "hiveot:prop:device:battery": { - Symbol: "", - Title: "Battery level", - Description: "Device battery level", - }, - "hiveot:prop:device:firmwareversion": { - Symbol: "", - Title: "Firmware version", - Description: "", - }, - "hiveot:prop:electric:overload": { - Symbol: "", - Title: "Overload protection", - Description: "Cut load on overload", - }, - "hiveot:prop:env:co2": { - Symbol: "", - Title: "Carbon dioxide level", - Description: "Carbon dioxide level", - }, - "hiveot:prop:env:uv": { Symbol: "", Title: "UV", Description: "" }, - "hiveot:prop:media:muted": { - Symbol: "", - Title: "Muted", - Description: "Audio is muted", - }, - "hiveot:prop:electric": { - Symbol: "", - Title: "Electrical properties", - Description: "General group of electrical properties", - }, - "hiveot:prop:env:airquality": { - Symbol: "", - Title: "Air quality", - Description: "Air quality level", - }, - "hiveot:prop:env:timezone": { - Symbol: "", - Title: "Timezone", - Description: "", - }, - "hiveot:prop:net:connection": { - Symbol: "", - Title: "Connection", - Description: - "Connection status, connected, connecting, retrying, disconnected,...", - }, - "hiveot:prop:net:port": { - Symbol: "", - Title: "Port", - Description: "Network port", - }, - "hiveot:prop:env:acceleration": { - Symbol: "", - Title: "Acceleration", - Description: "", - }, - "hiveot:prop:env:humidex": { Symbol: "", Title: "Humidex", Description: "" }, - "hiveot:prop:env:precipitation": { - Symbol: "", - Title: "Precipitation", - Description: "Total precipitation of rain and snow", - }, - "hiveot:prop:env:volume": { Symbol: "", Title: "Volume", Description: "" }, - "hiveot:prop:env:wind:heading": { - Symbol: "", - Title: "Wind heading", - Description: "Direction wind is heading", - }, - "hiveot:prop:net:gateway": { - Symbol: "", - Title: "Gateway", - Description: "Network gateway address", - }, - "hiveot:prop:device": { - Symbol: "", - Title: "Device attributes", - Description: "Attributes describing a device", - }, - "hiveot:prop:device:title": { - Symbol: "", - Title: "Title", - Description: "Device friendly title", - }, - "hiveot:prop:electric:energy": { - Symbol: "", - Title: "Energy", - Description: "Electrical energy consumed", - }, - "hiveot:prop:env:luminance": { - Symbol: "", - Title: "Luminance", - Description: "", - }, - "hiveot:prop:env:pressure": { - Symbol: "", - Title: "Pressure", - Description: "", - }, -}; - -// type: ThingClasses -// version: 0.1 -// generated: 18 Nov 25 10:06 PST -// source: github.com/hiveot/hub/api/vocab/ht-thing-classes.yaml -// namespace: hiveot -export const ThingActuator = "hiveot:thing:actuator"; -export const ThingActuatorAlarm = "hiveot:thing:actuator:alarm"; -export const ThingActuatorBeacon = "hiveot:thing:actuator:beacon"; -export const ThingActuatorDimmer = "hiveot:thing:actuator:dimmer"; -export const ThingActuatorLight = "hiveot:thing:actuator:light"; -export const ThingActuatorLock = "hiveot:thing:actuator:lock"; -export const ThingActuatorMotor = "hiveot:thing:actuator:motor"; -export const ThingActuatorOutput = "hiveot:thing:actuator:output"; -export const ThingActuatorRanged = "hiveot:thing:actuator:ranged"; -export const ThingActuatorRelay = "hiveot:thing:actuator:relay"; -export const ThingActuatorSwitch = "hiveot:thing:actuator:switch"; -export const ThingActuatorValve = "hiveot:thing:actuator:valve"; -export const ThingActuatorValveFuel = "hiveot:thing:actuator:valve:fuel"; -export const ThingActuatorValveWater = "hiveot:thing:actuator:valve:water"; -export const ThingAppliance = "hiveot:thing:appliance"; -export const ThingApplianceDishwasher = "hiveot:thing:appliance:dishwasher"; -export const ThingApplianceDryer = "hiveot:thing:appliance:dryer"; -export const ThingApplianceFreezer = "hiveot:thing:appliance:freezer"; -export const ThingApplianceFridge = "hiveot:thing:appliance:fridge"; -export const ThingApplianceWasher = "hiveot:thing:appliance:washer"; -export const ThingComputer = "hiveot:thing:computer"; -export const ThingComputerCellphone = "hiveot:thing:computer:cellphone"; -export const ThingComputerEmbedded = "hiveot:thing:computer:embedded"; -export const ThingComputerMemory = "hiveot:thing:computer:memory"; -export const ThingComputerPC = "hiveot:thing:computer:pc"; -export const ThingComputerPotsPhone = "hiveot:thing:computer:potsphone"; -export const ThingComputerSatPhone = "hiveot:thing:computer:satphone"; -export const ThingComputerTablet = "hiveot:thing:computer:tablet"; -export const ThingComputerVoipPhone = "hiveot:thing:computer:voipphone"; -export const ThingControl = "hiveot:thing:control"; -export const ThingControlClimate = "hiveot:thing:control:climate"; -export const ThingControlDimmer = "hiveot:thing:control:dimmer"; -export const ThingControlIrrigation = "hiveot:thing:control:irrigation"; -export const ThingControlJoystick = "hiveot:thing:control:joystick"; -export const ThingControlKeypad = "hiveot:thing:control:keypad"; -export const ThingControlPool = "hiveot:thing:control:pool"; -export const ThingControlPushbutton = "hiveot:thing:control:pushbutton"; -export const ThingControlSwitch = "hiveot:thing:control:switch"; -export const ThingControlThermostat = "hiveot:thing:control:thermostat"; -export const ThingControlToggle = "hiveot:thing:control:toggle"; -export const ThingDevice = "hiveot:thing:device"; -export const ThingDeviceBatteryMonitor = "hiveot:thing:device:battery:monitor"; -export const ThingDeviceIndicator = "hiveot:thing:device:indicator"; -export const ThingDeviceTime = "hiveot:thing:device:time"; -export const ThingMedia = "hiveot:thing:media"; -export const ThingMediaAmplifier = "hiveot:thing:media:amplifier"; -export const ThingMediaCamera = "hiveot:thing:media:camera"; -export const ThingMediaMicrophone = "hiveot:thing:media:microphone"; -export const ThingMediaPlayer = "hiveot:thing:media:player"; -export const ThingMediaRadio = "hiveot:thing:media:radio"; -export const ThingMediaReceiver = "hiveot:thing:media:receiver"; -export const ThingMediaSpeaker = "hiveot:thing:media:speaker"; -export const ThingMediaTV = "hiveot:thing:media:tv"; -export const ThingMeter = "hiveot:thing:meter"; -export const ThingMeterElectric = "hiveot:thing:meter:electric"; -export const ThingMeterElectricCurrent = "hiveot:thing:meter:electric:current"; -export const ThingMeterElectricEnergy = "hiveot:thing:meter:electric:energy"; -export const ThingMeterElectricPower = "hiveot:thing:meter:electric:power"; -export const ThingMeterElectricVoltage = "hiveot:thing:meter:electric:voltage"; -export const ThingMeterFuel = "hiveot:thing:meter:fuel"; -export const ThingMeterFuelFlow = "hiveot:thing:meter:fuel:flow"; -export const ThingMeterFuelLevel = "hiveot:thing:meter:fuel:level"; -export const ThingMeterWater = "hiveot:thing:meter:water"; -export const ThingMeterWaterConsumption = - "hiveot:thing:meter:water:consumption"; -export const ThingMeterWaterFlow = "hiveot:thing:meter:water:flow"; -export const ThingMeterWaterLevel = "hiveot:thing:meter:water:level"; -export const ThingMeterWind = "hiveot:thing:meter:wind"; -export const ThingNet = "hiveot:thing:net"; -export const ThingNetBluetooth = "hiveot:thing:net:bluetooth"; -export const ThingNetGateway = "hiveot:thing:net:gateway"; -export const ThingNetGatewayCoap = "hiveot:thing:net:gateway:coap"; -export const ThingNetGatewayInsteon = "hiveot:thing:net:gateway:insteon"; -export const ThingNetGatewayOnewire = "hiveot:thing:net:gateway:onewire"; -export const ThingNetGatewayZigbee = "hiveot:thing:net:gateway:zigbee"; -export const ThingNetGatewayZwave = "hiveot:thing:net:gateway:zwave"; -export const ThingNetLora = "hiveot:thing:net:lora"; -export const ThingNetLoraGateway = "hiveot:thing:net:lora:gw"; -export const ThingNetLoraP2P = "hiveot:thing:net:lora:p2p"; -export const ThingNetRouter = "hiveot:thing:net:router"; -export const ThingNetSwitch = "hiveot:thing:net:switch"; -export const ThingNetWifi = "hiveot:thing:net:wifi"; -export const ThingNetWifiAp = "hiveot:thing:net:wifi:ap"; -export const ThingSensor = "hiveot:thing:sensor"; -export const ThingSensorEnvironment = "hiveot:thing:sensor:environment"; -export const ThingSensorInput = "hiveot:thing:sensor:input"; -export const ThingSensorMulti = "hiveot:thing:sensor:multi"; -export const ThingSensorScale = "hiveot:thing:sensor:scale"; -export const ThingSensorSecurity = "hiveot:thing:sensor:security"; -export const ThingSensorSecurityDoorWindow = - "hiveot:thing:sensor:security:doorwindow"; -export const ThingSensorSecurityGlass = "hiveot:thing:sensor:security:glass"; -export const ThingSensorSecurityMotion = "hiveot:thing:sensor:security:motion"; -export const ThingSensorSmoke = "hiveot:thing:sensor:smoke"; -export const ThingSensorSound = "hiveot:thing:sensor:sound"; -export const ThingSensorThermometer = "hiveot:thing:sensor:thermometer"; -export const ThingSensorWaterLeak = "hiveot:thing:sensor:water:leak"; -export const ThingService = "hiveot:thing:service"; -// end of ThingClasses - -// ThingClassesMap maps @type to symbol, title and description -export const ThingClassesMap = { - "hiveot:thing:control:dimmer": { - Symbol: "", - Title: "Dimmer", - Description: "Light dimmer input device", - }, - "hiveot:thing:control:joystick": { - Symbol: "", - Title: "Joystick", - Description: "Flight control stick", - }, - "hiveot:thing:device": { - Symbol: "", - Title: "Device", - Description: "Device of unknown purpose", - }, - "hiveot:thing:meter:electric:power": { - Symbol: "", - Title: "Electrical Power", - Description: "Electrical power meter", - }, - "hiveot:thing:net:bluetooth": { - Symbol: "", - Title: "Bluetooth", - Description: "Bluetooth radio", - }, - "hiveot:thing:net:gateway:onewire": { - Symbol: "", - Title: "1-Wire gateway", - Description: "Gateway providing access to 1-wire devices", - }, - "hiveot:thing:appliance:dishwasher": { - Symbol: "", - Title: "Dishwasher", - Description: "Dishwasher", - }, - "hiveot:thing:appliance:fridge": { - Symbol: "", - Title: "Fridge", - Description: "Refrigerator appliance", - }, - "hiveot:thing:control:pool": { - Symbol: "", - Title: "Pool control", - Description: "Device for controlling pool settings", - }, - "hiveot:thing:meter:wind": { - Symbol: "", - Title: "Wind", - Description: "Dedicated wind meter", - }, - "hiveot:thing:sensor": { - Symbol: "", - Title: "Sensor", - Description: "Generic sensor device", - }, - "hiveot:thing:sensor:security": { - Symbol: "", - Title: "Security", - Description: "Generic security sensor", - }, - "hiveot:thing:sensor:security:motion": { - Symbol: "", - Title: "Motion sensor", - Description: "Dedicated security sensor detecting motion", - }, - "hiveot:thing:sensor:security:doorwindow": { - Symbol: "", - Title: "Door/Window sensor", - Description: "Dedicated door/window opening security sensor", - }, - "hiveot:thing:actuator:relay": { - Symbol: "", - Title: "Relay", - Description: "Generic relay electrical switch", - }, - "hiveot:thing:control:thermostat": { - Symbol: "", - Title: "Thermostat", - Description: "Thermostat HVAC control", - }, - "hiveot:thing:media:amplifier": { - Symbol: "", - Title: "Audio amplifier", - Description: "Audio amplifier with volume controls", - }, - "hiveot:thing:media:player": { - Symbol: "", - Title: "Media player", - Description: "CD/DVD/Blueray/USB player of recorded media", - }, - "hiveot:thing:media:speaker": { - Symbol: "", - Title: "Connected speakers", - Description: "Network connected speakers", - }, - "hiveot:thing:net:gateway:zwave": { - Symbol: "", - Title: "ZWave gateway", - Description: "Gateway providing access to ZWave devices", - }, - "hiveot:thing:sensor:thermometer": { - Symbol: "", - Title: "Thermometer", - Description: "Environmental thermometer", - }, - "hiveot:thing:sensor:water:leak": { - Symbol: "", - Title: "Water leak detector", - Description: "Dedicated water leak detector", - }, - "hiveot:thing:actuator:motor": { - Symbol: "", - Title: "Motor", - Description: "Motor driven actuator, such as garage door, blinds, tv lifts", - }, - "hiveot:thing:actuator:valve:water": { - Symbol: "", - Title: "Water valve", - Description: "Electric powered water valve", - }, - "hiveot:thing:computer:tablet": { - Symbol: "", - Title: "Tablet", - Description: "Tablet computer", - }, - "hiveot:thing:media:microphone": { - Symbol: "", - Title: "Microphone", - Description: "Microphone for capturing audio", - }, - "hiveot:thing:net:wifi": { - Symbol: "", - Title: "Wifi device", - Description: "Generic wifi device", - }, - "hiveot:thing:sensor:scale": { - Symbol: "", - Title: "Scale", - Description: "Electronic weigh scale", - }, - "hiveot:thing:computer:embedded": { - Symbol: "", - Title: "Embedded System", - Description: "Embedded computing device", - }, - "hiveot:thing:media": { - Symbol: "", - Title: "A/V media", - Description: "Generic device for audio/video media record or playback", - }, - "hiveot:thing:meter:electric:energy": { - Symbol: "", - Title: "Electric energy", - Description: "Electrical energy meter", - }, - "hiveot:thing:meter:water": { - Symbol: "", - Title: "Water metering device", - Description: "General water metering device", - }, - "hiveot:thing:meter:water:level": { - Symbol: "", - Title: "Water level", - Description: "Dedicated water level meter", - }, - "hiveot:thing:actuator:beacon": { - Symbol: "", - Title: "Beacon", - Description: "Location beacon", - }, - "hiveot:thing:computer": { - Symbol: "", - Title: "Computing Device", - Description: "General purpose computing device", - }, - "hiveot:thing:meter:electric:voltage": { - Symbol: "", - Title: "Voltage", - Description: "Electrical voltage meter", - }, - "hiveot:thing:meter:water:consumption": { - Symbol: "", - Title: "Water consumption meter", - Description: "Water consumption meter", - }, - "hiveot:thing:net": { - Symbol: "", - Title: "Network device", - Description: "Generic network device", - }, - "hiveot:thing:sensor:multi": { - Symbol: "", - Title: "Multi sensor", - Description: "Sense multiple inputs", - }, - "hiveot:thing:media:radio": { - Symbol: "", - Title: "Radio", - Description: "AM or FM radio receiver", - }, - "hiveot:thing:meter:electric:current": { - Symbol: "", - Title: "Electric current", - Description: "Electrical current meter", - }, - "hiveot:thing:computer:satphone": { - Symbol: "", - Title: "Satellite phone", - Description: "", - }, - "hiveot:thing:actuator:light": { - Symbol: "", - Title: "Light", - Description: "Smart LED or other light", - }, - "hiveot:thing:appliance:freezer": { - Symbol: "", - Title: "Freezer", - Description: "Refrigerator freezer", - }, - "hiveot:thing:control:irrigation": { - Symbol: "", - Title: "Irrigation control", - Description: "Device for control of an irrigation system", - }, - "hiveot:thing:control:switch": { - Symbol: "", - Title: "Input switch", - Description: "On or off switch input control", - }, - "hiveot:thing:control:toggle": { - Symbol: "", - Title: "Toggle switch", - Description: "Toggle switch input control", - }, - "hiveot:thing:computer:potsphone": { - Symbol: "", - Title: "Land Line", - Description: "Plain Old Telephone System, aka landline", - }, - "hiveot:thing:device:battery:monitor": { - Symbol: "", - Title: "Battery Monitor", - Description: "Battery monitor and charge controller", - }, - "hiveot:thing:net:gateway:zigbee": { - Symbol: "", - Title: "Zigbee gateway", - Description: "Gateway providing access to Zigbee devices", - }, - "hiveot:thing:sensor:environment": { - Symbol: "", - Title: "Environmental sensor", - Description: - "Environmental sensor with one or more features such as temperature, humidity, etc", - }, - "hiveot:thing:device:time": { - Symbol: "", - Title: "Clock", - Description: "Time tracking device such as clocks and time chips", - }, - "hiveot:thing:meter": { - Symbol: "", - Title: "Meter", - Description: "General metering device", - }, - "hiveot:thing:net:gateway": { - Symbol: "", - Title: "Gateway", - Description: "Generic gateway device providing access to other devices", - }, - "hiveot:thing:net:lora:gw": { - Symbol: "", - Title: "LoRaWAN gateway", - Description: "Gateway providing access to LoRa devices", - }, - "hiveot:thing:sensor:input": { - Symbol: "", - Title: "Input sensor", - Description: "General purpose electrical input sensor", - }, - "hiveot:thing:actuator:output": { - Symbol: "", - Title: "Output", - Description: "General purpose electrical output signal", - }, - "hiveot:thing:control:keypad": { - Symbol: "", - Title: "Keypad", - Description: "Multi-key pad for command input", - }, - "hiveot:thing:device:indicator": { - Symbol: "", - Title: "Indicator", - Description: "Visual or audio indicator device", - }, - "hiveot:thing:meter:electric": { Symbol: "", Title: "", Description: "" }, - "hiveot:thing:net:router": { - Symbol: "", - Title: "Network router", - Description: "IP ThingNetwork router providing access to other IP networks", - }, - "hiveot:thing:net:lora": { - Symbol: "", - Title: "LoRa network device", - Description: "Generic Long Range network protocol device", - }, - "hiveot:thing:actuator:alarm": { - Symbol: "", - Title: "Alarm", - Description: "Siren or light alarm", - }, - "hiveot:thing:actuator:switch": { - Symbol: "", - Title: "Switch", - Description: "An electric powered on/off switch for powering circuits", - }, - "hiveot:thing:actuator:valve:fuel": { - Symbol: "", - Title: "Fuel valve", - Description: "Electric powered fuel valve", - }, - "hiveot:thing:appliance:dryer": { - Symbol: "", - Title: "Dryer", - Description: "Clothing dryer", - }, - "hiveot:thing:media:tv": { - Symbol: "", - Title: "TV", - Description: "Network connected television", - }, - "hiveot:thing:net:lora:p2p": { - Symbol: "", - Title: "LoRa P2P", - Description: "LoRa Peer-to-peer network device", - }, - "hiveot:thing:sensor:smoke": { - Symbol: "", - Title: "Smoke detector", - Description: "", - }, - "hiveot:thing:appliance": { - Symbol: "", - Title: "Appliance", - Description: "Appliance to accomplish a particular task for occupant use", - }, - "hiveot:thing:appliance:washer": { - Symbol: "", - Title: "Washer", - Description: "Clothing washer", - }, - "hiveot:thing:computer:memory": { - Symbol: "", - Title: "Memory", - Description: "Stand-alone memory device such as eeprom or iButtons", - }, - "hiveot:thing:computer:voipphone": { - Symbol: "", - Title: "VoIP Phone", - Description: "Voice over IP phone", - }, - "hiveot:thing:meter:water:flow": { - Symbol: "", - Title: "Water flow", - Description: "Dedicated water flow-rate meter", - }, - "hiveot:thing:net:gateway:coap": { - Symbol: "", - Title: "CoAP gateway", - Description: "Gateway providing access to CoAP devices", - }, - "hiveot:thing:actuator": { - Symbol: "", - Title: "Actuator", - Description: "Generic actuator", - }, - "hiveot:thing:actuator:ranged": { - Symbol: "", - Title: "Ranged actuator", - Description: "Generic ranged actuator with a set point", - }, - "hiveot:thing:computer:cellphone": { - Symbol: "", - Title: "Cell Phone", - Description: "Cellular phone", - }, - "hiveot:thing:media:camera": { - Symbol: "", - Title: "Camera", - Description: "Video camera", - }, - "hiveot:thing:meter:fuel:level": { - Symbol: "", - Title: "Fuel level", - Description: "Dedicated fuel level metering device", - }, - "hiveot:thing:net:wifi:ap": { - Symbol: "", - Title: "Wifi access point", - Description: "Wireless access point for IP networks", - }, - "hiveot:thing:service": { - Symbol: "", - Title: "Service", - Description: - "General service for processing data and offering features of interest", - }, - "hiveot:thing:actuator:dimmer": { - Symbol: "", - Title: "Dimmer", - Description: "Light dimmer", - }, - "hiveot:thing:meter:fuel:flow": { - Symbol: "", - Title: "Fuel flow rate", - Description: "Dedicated fuel flow rate metering device", - }, - "hiveot:thing:net:switch": { - Symbol: "", - Title: "Network switch", - Description: "Network switch to connect computer devices to the network", - }, - "hiveot:thing:sensor:security:glass": { - Symbol: "", - Title: "Glass sensor", - Description: "Dedicated sensor for detecting breaking of glass", - }, - "hiveot:thing:actuator:lock": { - Symbol: "", - Title: "Lock", - Description: "Electronic door lock", - }, - "hiveot:thing:computer:pc": { - Symbol: "", - Title: "PC/Laptop", - Description: "Personal computer/laptop", - }, - "hiveot:thing:control": { - Symbol: "", - Title: "Input controller", - Description: "Generic input controller", - }, - "hiveot:thing:control:pushbutton": { - Symbol: "", - Title: "Momentary switch", - Description: "Momentary push button control input", - }, - "hiveot:thing:media:receiver": { - Symbol: "", - Title: "Receiver", - Description: "Audio/video receiver and player", - }, - "hiveot:thing:meter:fuel": { - Symbol: "", - Title: "Fuel metering device", - Description: "General fuel metering device", - }, - "hiveot:thing:net:gateway:insteon": { - Symbol: "", - Title: "Insteon gateway", - Description: "Gateway providing access to Insteon devices", - }, - "hiveot:thing:sensor:sound": { - Symbol: "", - Title: "Sound detector", - Description: "", - }, - "hiveot:thing:actuator:valve": { - Symbol: "", - Title: "Valve", - Description: "Electric powered valve for fluids or gas", - }, - "hiveot:thing:control:climate": { - Symbol: "", - Title: "Climate control", - Description: "Device for controlling climate of a space", - }, -}; diff --git a/api/py/vocab/vocab.py b/api/py/vocab/vocab.py deleted file mode 100644 index 92e46a95..00000000 --- a/api/py/vocab/vocab.py +++ /dev/null @@ -1,605 +0,0 @@ -# Package vocab with HiveOT vocabulary names for TD Things, properties, events and actions -# DO NOT EDIT. This file is generated and changes will be overwritten - -# type: WoTVocab -# version: 0.1 -# generated: 18 Nov 25 13:42 PST -# source: github.com/hiveot/hub/api/vocab/wot-vocab.yaml -# description: WoT vocabulary definition. See https://www.w3.org/TR/2020/WD-wot-thing-description11-20201124/#sec-core-vocabulary-definition -OpCancelAction = "cancelaction" -OpInvokeAction = "invokeaction" -OpObserveAction = "observeaction" -OpObserveAllActions = "observeallactions" -OpObserveAllProperties = "observeallproperties" -OpObserveMultipleProperties = "observemultipleproperties" -OpObserveProperty = "observeproperty" -OpQueryAction = "queryaction" -OpQueryAllActions = "queryallactions" -OpQueryMultipleActions = "querymultipleactions" -OpReadAllProperties = "readallproperties" -OpReadMultipleProperties = "readmultipleproperties" -OpReadProperty = "readproperty" -OpSubscribeAllEvents = "subscribeallevents" -OpSubscribeEvent = "subscribeevent" -OpSubscribeMultipleEvents = "subscribemultipleevents" -OpUnobserveAllProperties = "unobserveallproperties" -OpUnobserveMultipleProperties = "unobservemultipleproperties" -OpUnobserveProperty = "unobserveroperty" -OpUnsubscribeAllEvents = "unsubscribeallevents" -OpUnsubscribeEvent = "unsubscribeevent" -OpUnsubscribeMultipleEvents = "unsubscribemultipleevents" -OpWriteMultipleProperties = "writemultipleproperties" -OpWriteProperty = "writeproperty" -WoTAPIKeySecurityScheme = "APIKeySecurityScheme" -WoTActions = "actions" -WoTAnyURI = "https://www.w3.org/2019/wot/thing/v1" -WoTAtContext = "@context" -WoTAtType = "@type" -WoTBasicSecurityScheme = "BasicSecurityScheme" -WoTBearerSecurityScheme = "BearerSecurityScheme" -WoTConst = "const" -WoTCreated = "created" -WoTDataType = "type" -WoTDataTypeAnyURI = "anyURI" -WoTDataTypeArray = "array" -WoTDataTypeBool = "boolean" -WoTDataTypeDateTime = "dateTime" -WoTDataTypeInteger = "integer" -WoTDataTypeNone = "" -WoTDataTypeNumber = "number" -WoTDataTypeObject = "object" -WoTDataTypeString = "string" -WoTDataTypeUnsignedInt = "unsignedInt" -WoTDescription = "description" -WoTDescriptions = "descriptions" -WoTDigestSecurityScheme = "DigestSecurityScheme" -WoTEnum = "enum" -WoTEvents = "events" -WoTFormat = "format" -WoTForms = "forms" -WoTHref = "href" -WoTID = "id" -WoTInput = "input" -WoTLinks = "links" -WoTMaxItems = "maxItems" -WoTMaxLength = "maxLength" -WoTMaximum = "maximum" -WoTMinItems = "minItems" -WoTMinLength = "minLength" -WoTMinimum = "minimum" -WoTModified = "modified" -WoTNoSecurityScheme = "NoSecurityScheme" -WoTOAuth2SecurityScheme = "OAuth2SecurityScheme" -WoTOperation = "op" -WoTOutput = "output" -WoTPSKSecurityScheme = "PSKSecurityScheme" -WoTProperties = "properties" -WoTReadOnly = "readOnly" -WoTRequired = "required" -WoTSecurity = "security" -WoTSupport = "support" -WoTTitle = "title" -WoTTitles = "titles" -WoTVersion = "version" -# end of WoTVocab - -# type: ActionClasses -# version: 0.1 -# generated: 18 Nov 25 13:42 PST -# source: github.com/hiveot/hub/api/vocab/ht-action-classes.yaml -# namespace: hiveot -ActionDimmer = "hiveot:action:dimmer" -ActionDimmerDecrement = "hiveot:action:dimmer:decrement" -ActionDimmerIncrement = "hiveot:action:dimmer:increment" -ActionDimmerSet = "hiveot:action:dimmer:set" -ActionMedia = "hiveot:action:media" -ActionMediaMute = "hiveot:action:media:mute" -ActionMediaNext = "hiveot:action:media:next" -ActionMediaPause = "hiveot:action:media:pause" -ActionMediaPlay = "hiveot:action:media:play" -ActionMediaPrevious = "hiveot:action:media:previous" -ActionMediaUnmute = "hiveot:action:media:unmute" -ActionMediaVolume = "hiveot:action:media:volume" -ActionMediaVolumeDecrease = "hiveot:action:media:volume:decrease" -ActionMediaVolumeIncrease = "hiveot:action:media:volume:increase" -ActionSwitch = "hiveot:action:switch" -ActionSwitchOnOff = "hiveot:action:switch:onoff" -ActionSwitchToggle = "hiveot:action:switch:toggle" -ActionThingDisable = "hiveot:action:thing:disable" -ActionThingEnable = "hiveot:action:thing:enable" -ActionThingStart = "hiveot:action:thing:start" -ActionThingStop = "hiveot:action:thing:stop" -ActionValveClose = "hiveot:action:valve:close" -ActionValveOpen = "hiveot:action:valve:open" -# end of ActionClasses - -# ActionClassesMap maps @type to symbol, title and description -ActionClassesMap = { - "hiveot:action:media:pause": {"Symbol": "", "Title": "Pause", "Description": "Pause playback"}, - "hiveot:action:media:play": {"Symbol": "", "Title": "Play", "Description": "Start or continue playback"}, - "hiveot:action:media:unmute": {"Symbol": "", "Title": "Unmute", "Description": "Unmute audio"}, - "hiveot:action:media:volume:increase": {"Symbol": "", "Title": "Increase volume", "Description": "Increase volume"}, - "hiveot:action:dimmer:decrement": {"Symbol": "", "Title": "Lower dimmer", "Description": ""}, - "hiveot:action:dimmer:increment": {"Symbol": "", "Title": "Increase dimmer", "Description": ""}, - "hiveot:action:dimmer:set": {"Symbol": "", "Title": "Set dimmer", "Description": "Action to set the dimmer value"}, - "hiveot:action:media:next": {"Symbol": "", "Title": "Next", "Description": "Next track or station"}, - "hiveot:action:media:volume:decrease": {"Symbol": "", "Title": "Decrease volume", "Description": "Decrease volume"}, - "hiveot:action:thing:disable": {"Symbol": "", "Title": "Disable", "Description": "Action to disable a thing"}, - "hiveot:action:thing:enable": {"Symbol": "", "Title": "Enable", "Description": "Action to enable a thing"}, - "hiveot:action:thing:start": {"Symbol": "", "Title": "Start", "Description": "Start running a task"}, - "hiveot:action:valve:close": {"Symbol": "", "Title": "Close valve", "Description": "Action to close the valve"}, - "hiveot:action:valve:open": {"Symbol": "", "Title": "Open valve", "Description": "Action to open the valve"}, - "hiveot:action:media": {"Symbol": "", "Title": "Media control", "Description": "Commands to control media recording and playback"}, - "hiveot:action:media:previous": {"Symbol": "", "Title": "Previous", "Description": "Previous track or station"}, - "hiveot:action:media:volume": {"Symbol": "", "Title": "Volume", "Description": "Set volume level"}, - "hiveot:action:thing:stop": {"Symbol": "", "Title": "Stop", "Description": "Stop a running task"}, - "hiveot:action:dimmer": {"Symbol": "", "Title": "Dimmer", "Description": "General dimmer action"}, - "hiveot:action:switch": {"Symbol": "", "Title": "Switch", "Description": "General switch action"}, - "hiveot:action:switch:onoff": {"Symbol": "", "Title": "Set On/Off switch", "Description": "Action to set the switch on/off state"}, - "hiveot:action:switch:toggle": {"Symbol": "", "Title": "Toggle switch", "Description": "Action to toggle the switch"}, - "hiveot:action:media:mute": {"Symbol": "", "Title": "Mute", "Description": "Mute audio"}, -} - - -# type: PropertyClasses -# version: 0.1 -# generated: 18 Nov 25 13:42 PST -# source: github.com/hiveot/hub/api/vocab/ht-property-classes.yaml -# namespace: hiveot -PropAlarmMotion = "hiveot:prop:alarm:motion" -PropAlarmStatus = "hiveot:prop:alarm:status" -PropDevice = "hiveot:prop:device" -PropDeviceBattery = "hiveot:prop:device:battery" -PropDeviceDescription = "hiveot:prop:device:description" -PropDeviceEnabledDisabled = "hiveot:prop:device:enabled-disabled" -PropDeviceFirmwareVersion = "hiveot:prop:device:firmwareversion" -PropDeviceHardwareVersion = "hiveot:prop:device:hardwareversion" -PropDeviceMake = "hiveot:prop:device:make" -PropDeviceModel = "hiveot:prop:device:model" -PropDevicePollinterval = "hiveot:prop:device:pollinterval" -PropDeviceSoftwareVersion = "hiveot:prop:device:softwareversion" -PropDeviceStatus = "hiveot:prop:device:status" -PropDeviceTitle = "hiveot:prop:device:title" -PropElectric = "hiveot:prop:electric" -PropElectricCurrent = "hiveot:prop:electric:current" -PropElectricEnergy = "hiveot:prop:electric:energy" -PropElectricOverload = "hiveot:prop:electric:overload" -PropElectricPower = "hiveot:prop:electric:poer" -PropElectricVoltage = "hiveot:prop:electric:voltage" -PropEnv = "hiveot:prop:env" -PropEnvAcceleration = "hiveot:prop:env:acceleration" -PropEnvAirquality = "hiveot:prop:env:airquality" -PropEnvCO = "hiveot:prop:env:co" -PropEnvCO2 = "hiveot:prop:env:co2" -PropEnvCpuload = "hiveot:prop:env:cpuload" -PropEnvDewpoint = "hiveot:prop:env:dewpoint" -PropEnvFuelFlowrate = "hiveot:prop:env:fuel:flowrate" -PropEnvFuelLevel = "hiveot:prop:env:fuel:level" -PropEnvHumidex = "hiveot:prop:env:humidex" -PropEnvHumidity = "hiveot:prop:env:humidity" -PropEnvLuminance = "hiveot:prop:env:luminance" -PropEnvPrecipitation = "hiveot:prop:env:precipitation" -PropEnvPrecipitationRain = "hiveot:prop:env:precipitation:rain" -PropEnvPrecipitationSnow = "hiveot:prop:env:precipitation:snow" -PropEnvPressure = "hiveot:prop:env:pressure" -PropEnvPressureSeaLevel = "hiveot:prop:env:barometer:msl" -PropEnvPressureSurface = "hiveot:prop:env:barometer:surface" -PropEnvTemperature = "hiveot:prop:env:temperature" -PropEnvTimezone = "hiveot:prop:env:timezone" -PropEnvUV = "hiveot:prop:env:uv" -PropEnvVibration = "hiveot:prop:env:vibration" -PropEnvVolume = "hiveot:prop:env:volume" -PropEnvWaterFlowrate = "hiveot:prop:env:water:flowrate" -PropEnvWaterLevel = "hiveot:prop:env:water:level" -PropEnvWindGusts = "hiveot:prop:env:wind:gusts" -PropEnvWindHeading = "hiveot:prop:env:wind:heading" -PropEnvWindSpeed = "hiveot:prop:env:wind:speed" -PropLocation = "hiveot:prop:location" -PropLocationCity = "hiveot:prop:location:city" -PropLocationLatitude = "hiveot:prop:location:latitude" -PropLocationLongitude = "hiveot:prop:location:longitude" -PropLocationName = "hiveot:prop:location:name" -PropLocationStreet = "hiveot:prop:location:street" -PropLocationZipcode = "hiveot:prop:location:zipcode" -PropMedia = "hiveot:prop:media" -PropMediaMuted = "hiveot:prop:media:muted" -PropMediaPaused = "hiveot:prop:media:paused" -PropMediaPlaying = "hiveot:prop:media:playing" -PropMediaStation = "hiveot:prop:media:station" -PropMediaTrack = "hiveot:prop:media:track" -PropMediaVolume = "hiveot:prop:media:volume" -PropNet = "hiveot:prop:net" -PropNetAddress = "hiveot:prop:net:address" -PropNetConnection = "hiveot:prop:net:connection" -PropNetDomainname = "hiveot:prop:net:domainname" -PropNetGateway = "hiveot:prop:net:gateway" -PropNetHostname = "hiveot:prop:net:hostname" -PropNetIP4 = "hiveot:prop:net:ip4" -PropNetIP6 = "hiveot:prop:net:ip6" -PropNetLatency = "hiveot:prop:net:latency" -PropNetMAC = "hiveot:prop:net:mac" -PropNetMask = "hiveot:prop:net:mask" -PropNetPort = "hiveot:prop:net:port" -PropNetSignalstrength = "hiveot:prop:net:signalstrength" -PropNetSubnet = "hiveot:prop:net:subnet" -PropStatusOnOff = "hiveot:prop:status:onoff" -PropStatusOpenClosed = "hiveot:prop:status:openclosed" -PropStatusStartedStopped = "hiveot:prop:status:started-stopped" -PropStatusYesNo = "hiveot:prop:status:yes-no" -PropSwitch = "hiveot:prop:switch" -PropSwitchDimmer = "hiveot:prop:switch:dimmer" -PropSwitchLight = "hiveot:prop:switch:light" -PropSwitchLocked = "hiveot:prop:switch:locked" -PropSwitchOnOff = "hiveot:prop:switch:onoff" -# end of PropertyClasses - -# PropertyClassesMap maps @type to symbol, title and description -PropertyClassesMap = { - "hiveot:prop:env:fuel:flowrate": {"Symbol": "", "Title": "Fuel flow rate", "Description": ""}, - "hiveot:prop:net:hostname": {"Symbol": "", "Title": "Hostname", "Description": "Hostname of the client"}, - "hiveot:prop:switch:locked": {"Symbol": "", "Title": "Lock", "Description": "Electric lock status"}, - "hiveot:prop:env:airquality": {"Symbol": "", "Title": "Air quality", "Description": "Air quality level"}, - "hiveot:prop:env:co2": {"Symbol": "", "Title": "Carbon dioxide level", "Description": "Carbon dioxide level"}, - "hiveot:prop:env:pressure": {"Symbol": "", "Title": "Pressure", "Description": ""}, - "hiveot:prop:env:temperature": {"Symbol": "", "Title": "Temperature", "Description": ""}, - "hiveot:prop:env:water:level": {"Symbol": "", "Title": "Water level", "Description": ""}, - "hiveot:prop:device:firmwareversion": {"Symbol": "", "Title": "Firmware version", "Description": ""}, - "hiveot:prop:device:make": {"Symbol": "", "Title": "Make", "Description": "Device manufacturer"}, - "hiveot:prop:env:wind:speed": {"Symbol": "", "Title": "Wind speed", "Description": "Average speed of wind"}, - "hiveot:prop:net": {"Symbol": "", "Title": "Network properties", "Description": "General network properties"}, - "hiveot:prop:switch:onoff": {"Symbol": "", "Title": "On/Off switch", "Description": ""}, - "hiveot:prop:alarm:status": {"Symbol": "", "Title": "Alarm state", "Description": "Current alarm status"}, - "hiveot:prop:electric": {"Symbol": "", "Title": "Electrical properties", "Description": "General group of electrical properties"}, - "hiveot:prop:env:cpuload": {"Symbol": "", "Title": "CPU load level", "Description": "Device CPU load level"}, - "hiveot:prop:env:dewpoint": {"Symbol": "", "Title": "Dew point", "Description": "Dew point temperature"}, - "hiveot:prop:location": {"Symbol": "", "Title": "Location", "Description": "General location information"}, - "hiveot:prop:media:paused": {"Symbol": "", "Title": "Paused", "Description": "Media is paused"}, - "hiveot:prop:status:started-stopped": {"Symbol": "", "Title": "Started/Stopped", "Description": "Started or stopped status"}, - "hiveot:prop:alarm:motion": {"Symbol": "", "Title": "Motion", "Description": "Motion detected"}, - "hiveot:prop:env:uv": {"Symbol": "", "Title": "UV", "Description": ""}, - "hiveot:prop:env:vibration": {"Symbol": "", "Title": "Vibration", "Description": ""}, - "hiveot:prop:env:wind:gusts": {"Symbol": "", "Title": "Wind gusts", "Description": "Speed of wind gusts"}, - "hiveot:prop:switch": {"Symbol": "", "Title": "Switch status", "Description": ""}, - "hiveot:prop:device:model": {"Symbol": "", "Title": "Model", "Description": "Device model"}, - "hiveot:prop:env:humidex": {"Symbol": "", "Title": "Humidex", "Description": ""}, - "hiveot:prop:env:precipitation": {"Symbol": "", "Title": "Precipitation", "Description": "Total precipitation of rain and snow"}, - "hiveot:prop:net:connection": {"Symbol": "", "Title": "Connection", "Description": "Connection status, connected, connecting, retrying, disconnected,..."}, - "hiveot:prop:net:mask": {"Symbol": "", "Title": "Netmask", "Description": "Network mask. Example: 255.255.255.0 or 24/8"}, - "hiveot:prop:status:openclosed": {"Symbol": "", "Title": "Open/Closed status", "Description": ""}, - "hiveot:prop:switch:dimmer": {"Symbol": "", "Title": "Dimmer value", "Description": ""}, - "hiveot:prop:electric:overload": {"Symbol": "", "Title": "Overload protection", "Description": "Cut load on overload"}, - "hiveot:prop:env:luminance": {"Symbol": "", "Title": "Luminance", "Description": ""}, - "hiveot:prop:location:zipcode": {"Symbol": "", "Title": "Zip code", "Description": "Location ZIP code"}, - "hiveot:prop:switch:light": {"Symbol": "", "Title": "Light switch", "Description": ""}, - "hiveot:prop:device:title": {"Symbol": "", "Title": "Title", "Description": "Device friendly title"}, - "hiveot:prop:env:barometer:surface": {"Symbol": "", "Title": "Surface level pressure", "Description": "Surface level atmospheric pressure"}, - "hiveot:prop:net:subnet": {"Symbol": "", "Title": "Subnet", "Description": "Network subnet address. Example: 192.168.0.0"}, - "hiveot:prop:device:battery": {"Symbol": "", "Title": "Battery level", "Description": "Device battery level"}, - "hiveot:prop:electric:voltage": {"Symbol": "", "Title": "Voltage", "Description": "Electrical voltage potential"}, - "hiveot:prop:env:timezone": {"Symbol": "", "Title": "Timezone", "Description": ""}, - "hiveot:prop:media:muted": {"Symbol": "", "Title": "Muted", "Description": "Audio is muted"}, - "hiveot:prop:net:ip6": {"Symbol": "", "Title": "IP6 address", "Description": "Device IP6 address"}, - "hiveot:prop:electric:energy": {"Symbol": "", "Title": "Energy", "Description": "Electrical energy consumed"}, - "hiveot:prop:env:humidity": {"Symbol": "", "Title": "Humidity", "Description": ""}, - "hiveot:prop:env": {"Symbol": "", "Title": "Environmental property", "Description": "Property of environmental sensor"}, - "hiveot:prop:net:port": {"Symbol": "", "Title": "Port", "Description": "Network port"}, - "hiveot:prop:net:gateway": {"Symbol": "", "Title": "Gateway", "Description": "Network gateway address"}, - "hiveot:prop:net:latency": {"Symbol": "", "Title": "Network latency", "Description": "Delay between hub and client"}, - "hiveot:prop:device": {"Symbol": "", "Title": "Device attributes", "Description": "Attributes describing a device"}, - "hiveot:prop:electric:current": {"Symbol": "", "Title": "Current", "Description": "Electrical current"}, - "hiveot:prop:electric:poer": {"Symbol": "", "Title": "Power", "Description": "Electrical power being consumed"}, - "hiveot:prop:env:acceleration": {"Symbol": "", "Title": "Acceleration", "Description": ""}, - "hiveot:prop:env:water:flowrate": {"Symbol": "", "Title": "Water flow rate", "Description": ""}, - "hiveot:prop:media:playing": {"Symbol": "", "Title": "Playing", "Description": "Media is playing"}, - "hiveot:prop:media:track": {"Symbol": "", "Title": "Track", "Description": "Selected A/V track"}, - "hiveot:prop:media:station": {"Symbol": "", "Title": "Station", "Description": "Selected radio station"}, - "hiveot:prop:media": {"Symbol": "", "Title": "Media commands", "Description": "Control of media equipment"}, - "hiveot:prop:device:softwareversion": {"Symbol": "", "Title": "Software version", "Description": ""}, - "hiveot:prop:env:precipitation:rain": {"Symbol": "", "Title": "Rain precipitation", "Description": "Precipitation as rain"}, - "hiveot:prop:env:volume": {"Symbol": "", "Title": "Volume", "Description": ""}, - "hiveot:prop:env:wind:heading": {"Symbol": "", "Title": "Wind heading", "Description": "Direction wind is heading"}, - "hiveot:prop:location:street": {"Symbol": "", "Title": "Street", "Description": "Street address"}, - "hiveot:prop:net:address": {"Symbol": "", "Title": "Address", "Description": "Network address"}, - "hiveot:prop:net:domainname": {"Symbol": "", "Title": "Domain name", "Description": "Domainname of the client"}, - "hiveot:prop:device:hardwareversion": {"Symbol": "", "Title": "Hardware version", "Description": ""}, - "hiveot:prop:env:barometer:msl": {"Symbol": "", "Title": "Sea level pressure", "Description": "Sea level equivalent atmospheric pressure"}, - "hiveot:prop:location:city": {"Symbol": "", "Title": "City", "Description": "City name"}, - "hiveot:prop:location:latitude": {"Symbol": "", "Title": "Latitude", "Description": "Latitude geographic coordinate"}, - "hiveot:prop:location:longitude": {"Symbol": "", "Title": "Longitude", "Description": "Longitude geographic coordinate"}, - "hiveot:prop:net:signalstrength": {"Symbol": "", "Title": "Signal strength", "Description": "Wireless signal strength"}, - "hiveot:prop:status:onoff": {"Symbol": "", "Title": "On/off status", "Description": ""}, - "hiveot:prop:device:enabled-disabled": {"Symbol": "", "Title": "Enabled/Disabled", "Description": "Enabled or disabled state"}, - "hiveot:prop:env:co": {"Symbol": "", "Title": "Carbon monoxide level", "Description": "Carbon monoxide level"}, - "hiveot:prop:env:precipitation:snow": {"Symbol": "", "Title": "Snow precipitation", "Description": "Precipitation as snow"}, - "hiveot:prop:location:name": {"Symbol": "", "Title": "Location name", "Description": "Name of the location"}, - "hiveot:prop:media:volume": {"Symbol": "", "Title": "Volume", "Description": "Media volume setting"}, - "hiveot:prop:net:ip4": {"Symbol": "", "Title": "IP4 address", "Description": "Device IP4 address"}, - "hiveot:prop:net:mac": {"Symbol": "", "Title": "MAC", "Description": "Hardware MAC address"}, - "hiveot:prop:status:yes-no": {"Symbol": "", "Title": "Yes/No", "Description": "Status with yes or no value"}, - "hiveot:prop:device:description": {"Symbol": "", "Title": "Description", "Description": "Device product description"}, - "hiveot:prop:device:pollinterval": {"Symbol": "", "Title": "Polling interval", "Description": "Interval to poll for updates"}, - "hiveot:prop:device:status": {"Symbol": "", "Title": "Status", "Description": "Device status; alive, awake, dead, sleeping"}, - "hiveot:prop:env:fuel:level": {"Symbol": "", "Title": "Fuel level", "Description": ""}, -} - - -# type: ThingClasses -# version: 0.1 -# generated: 18 Nov 25 13:42 PST -# source: github.com/hiveot/hub/api/vocab/ht-thing-classes.yaml -# namespace: hiveot -ThingActuator = "hiveot:thing:actuator" -ThingActuatorAlarm = "hiveot:thing:actuator:alarm" -ThingActuatorBeacon = "hiveot:thing:actuator:beacon" -ThingActuatorDimmer = "hiveot:thing:actuator:dimmer" -ThingActuatorLight = "hiveot:thing:actuator:light" -ThingActuatorLock = "hiveot:thing:actuator:lock" -ThingActuatorMotor = "hiveot:thing:actuator:motor" -ThingActuatorOutput = "hiveot:thing:actuator:output" -ThingActuatorRanged = "hiveot:thing:actuator:ranged" -ThingActuatorRelay = "hiveot:thing:actuator:relay" -ThingActuatorSwitch = "hiveot:thing:actuator:switch" -ThingActuatorValve = "hiveot:thing:actuator:valve" -ThingActuatorValveFuel = "hiveot:thing:actuator:valve:fuel" -ThingActuatorValveWater = "hiveot:thing:actuator:valve:water" -ThingAppliance = "hiveot:thing:appliance" -ThingApplianceDishwasher = "hiveot:thing:appliance:dishwasher" -ThingApplianceDryer = "hiveot:thing:appliance:dryer" -ThingApplianceFreezer = "hiveot:thing:appliance:freezer" -ThingApplianceFridge = "hiveot:thing:appliance:fridge" -ThingApplianceWasher = "hiveot:thing:appliance:washer" -ThingComputer = "hiveot:thing:computer" -ThingComputerCellphone = "hiveot:thing:computer:cellphone" -ThingComputerEmbedded = "hiveot:thing:computer:embedded" -ThingComputerMemory = "hiveot:thing:computer:memory" -ThingComputerPC = "hiveot:thing:computer:pc" -ThingComputerPotsPhone = "hiveot:thing:computer:potsphone" -ThingComputerSatPhone = "hiveot:thing:computer:satphone" -ThingComputerTablet = "hiveot:thing:computer:tablet" -ThingComputerVoipPhone = "hiveot:thing:computer:voipphone" -ThingControl = "hiveot:thing:control" -ThingControlClimate = "hiveot:thing:control:climate" -ThingControlDimmer = "hiveot:thing:control:dimmer" -ThingControlIrrigation = "hiveot:thing:control:irrigation" -ThingControlJoystick = "hiveot:thing:control:joystick" -ThingControlKeypad = "hiveot:thing:control:keypad" -ThingControlPool = "hiveot:thing:control:pool" -ThingControlPushbutton = "hiveot:thing:control:pushbutton" -ThingControlSwitch = "hiveot:thing:control:switch" -ThingControlThermostat = "hiveot:thing:control:thermostat" -ThingControlToggle = "hiveot:thing:control:toggle" -ThingDevice = "hiveot:thing:device" -ThingDeviceBatteryMonitor = "hiveot:thing:device:battery:monitor" -ThingDeviceIndicator = "hiveot:thing:device:indicator" -ThingDeviceTime = "hiveot:thing:device:time" -ThingMedia = "hiveot:thing:media" -ThingMediaAmplifier = "hiveot:thing:media:amplifier" -ThingMediaCamera = "hiveot:thing:media:camera" -ThingMediaMicrophone = "hiveot:thing:media:microphone" -ThingMediaPlayer = "hiveot:thing:media:player" -ThingMediaRadio = "hiveot:thing:media:radio" -ThingMediaReceiver = "hiveot:thing:media:receiver" -ThingMediaSpeaker = "hiveot:thing:media:speaker" -ThingMediaTV = "hiveot:thing:media:tv" -ThingMeter = "hiveot:thing:meter" -ThingMeterElectric = "hiveot:thing:meter:electric" -ThingMeterElectricCurrent = "hiveot:thing:meter:electric:current" -ThingMeterElectricEnergy = "hiveot:thing:meter:electric:energy" -ThingMeterElectricPower = "hiveot:thing:meter:electric:power" -ThingMeterElectricVoltage = "hiveot:thing:meter:electric:voltage" -ThingMeterFuel = "hiveot:thing:meter:fuel" -ThingMeterFuelFlow = "hiveot:thing:meter:fuel:flow" -ThingMeterFuelLevel = "hiveot:thing:meter:fuel:level" -ThingMeterWater = "hiveot:thing:meter:water" -ThingMeterWaterConsumption = "hiveot:thing:meter:water:consumption" -ThingMeterWaterFlow = "hiveot:thing:meter:water:flow" -ThingMeterWaterLevel = "hiveot:thing:meter:water:level" -ThingMeterWind = "hiveot:thing:meter:wind" -ThingNet = "hiveot:thing:net" -ThingNetBluetooth = "hiveot:thing:net:bluetooth" -ThingNetGateway = "hiveot:thing:net:gateway" -ThingNetGatewayCoap = "hiveot:thing:net:gateway:coap" -ThingNetGatewayInsteon = "hiveot:thing:net:gateway:insteon" -ThingNetGatewayOnewire = "hiveot:thing:net:gateway:onewire" -ThingNetGatewayZigbee = "hiveot:thing:net:gateway:zigbee" -ThingNetGatewayZwave = "hiveot:thing:net:gateway:zwave" -ThingNetLora = "hiveot:thing:net:lora" -ThingNetLoraGateway = "hiveot:thing:net:lora:gw" -ThingNetLoraP2P = "hiveot:thing:net:lora:p2p" -ThingNetRouter = "hiveot:thing:net:router" -ThingNetSwitch = "hiveot:thing:net:switch" -ThingNetWifi = "hiveot:thing:net:wifi" -ThingNetWifiAp = "hiveot:thing:net:wifi:ap" -ThingSensor = "hiveot:thing:sensor" -ThingSensorEnvironment = "hiveot:thing:sensor:environment" -ThingSensorInput = "hiveot:thing:sensor:input" -ThingSensorMulti = "hiveot:thing:sensor:multi" -ThingSensorScale = "hiveot:thing:sensor:scale" -ThingSensorSecurity = "hiveot:thing:sensor:security" -ThingSensorSecurityDoorWindow = "hiveot:thing:sensor:security:doorwindow" -ThingSensorSecurityGlass = "hiveot:thing:sensor:security:glass" -ThingSensorSecurityMotion = "hiveot:thing:sensor:security:motion" -ThingSensorSmoke = "hiveot:thing:sensor:smoke" -ThingSensorSound = "hiveot:thing:sensor:sound" -ThingSensorThermometer = "hiveot:thing:sensor:thermometer" -ThingSensorWaterLeak = "hiveot:thing:sensor:water:leak" -ThingService = "hiveot:thing:service" -# end of ThingClasses - -# ThingClassesMap maps @type to symbol, title and description -ThingClassesMap = { - "hiveot:thing:appliance:dryer": {"Symbol": "", "Title": "Dryer", "Description": "Clothing dryer"}, - "hiveot:thing:control:pool": {"Symbol": "", "Title": "Pool control", "Description": "Device for controlling pool settings"}, - "hiveot:thing:media:radio": {"Symbol": "", "Title": "Radio", "Description": "AM or FM radio receiver"}, - "hiveot:thing:meter:wind": {"Symbol": "", "Title": "Wind", "Description": "Dedicated wind meter"}, - "hiveot:thing:net:gateway:zigbee": {"Symbol": "", "Title": "Zigbee gateway", "Description": "Gateway providing access to Zigbee devices"}, - "hiveot:thing:net:wifi": {"Symbol": "", "Title": "Wifi device", "Description": "Generic wifi device"}, - "hiveot:thing:net:wifi:ap": {"Symbol": "", "Title": "Wifi access point", "Description": "Wireless access point for IP networks"}, - "hiveot:thing:actuator:valve": {"Symbol": "", "Title": "Valve", "Description": "Electric powered valve for fluids or gas"}, - "hiveot:thing:control:irrigation": {"Symbol": "", "Title": "Irrigation control", "Description": "Device for control of an irrigation system"}, - "hiveot:thing:media:player": {"Symbol": "", "Title": "Media player", "Description": "CD/DVD/Blueray/USB player of recorded media"}, - "hiveot:thing:meter": {"Symbol": "", "Title": "Meter", "Description": "General metering device"}, - "hiveot:thing:net:gateway:onewire": {"Symbol": "", "Title": "1-Wire gateway", "Description": "Gateway providing access to 1-wire devices"}, - "hiveot:thing:net:router": {"Symbol": "", "Title": "Network router", "Description": "IP ThingNetwork router providing access to other IP networks"}, - "hiveot:thing:net:lora:p2p": {"Symbol": "", "Title": "LoRa P2P", "Description": "LoRa Peer-to-peer network device"}, - "hiveot:thing:sensor:input": {"Symbol": "", "Title": "Input sensor", "Description": "General purpose electrical input sensor"}, - "hiveot:thing:computer:embedded": {"Symbol": "", "Title": "Embedded System", "Description": "Embedded computing device"}, - "hiveot:thing:meter:water:level": {"Symbol": "", "Title": "Water level", "Description": "Dedicated water level meter"}, - "hiveot:thing:actuator:beacon": {"Symbol": "", "Title": "Beacon", "Description": "Location beacon"}, - "hiveot:thing:computer:memory": {"Symbol": "", "Title": "Memory", "Description": "Stand-alone memory device such as eeprom or iButtons"}, - "hiveot:thing:computer:satphone": {"Symbol": "", "Title": "Satellite phone", "Description": ""}, - "hiveot:thing:control": {"Symbol": "", "Title": "Input controller", "Description": "Generic input controller"}, - "hiveot:thing:control:climate": {"Symbol": "", "Title": "Climate control", "Description": "Device for controlling climate of a space"}, - "hiveot:thing:device:time": {"Symbol": "", "Title": "Clock", "Description": "Time tracking device such as clocks and time chips"}, - "hiveot:thing:meter:fuel:level": {"Symbol": "", "Title": "Fuel level", "Description": "Dedicated fuel level metering device"}, - "hiveot:thing:meter:water:consumption": {"Symbol": "", "Title": "Water consumption meter", "Description": "Water consumption meter"}, - "hiveot:thing:net:lora:gw": {"Symbol": "", "Title": "LoRaWAN gateway", "Description": "Gateway providing access to LoRa devices"}, - "hiveot:thing:sensor:security:doorwindow": {"Symbol": "", "Title": "Door/Window sensor", "Description": "Dedicated door/window opening security sensor"}, - "hiveot:thing:service": {"Symbol": "", "Title": "Service", "Description": "General service for processing data and offering features of interest"}, - "hiveot:thing:appliance:fridge": {"Symbol": "", "Title": "Fridge", "Description": "Refrigerator appliance"}, - "hiveot:thing:control:dimmer": {"Symbol": "", "Title": "Dimmer", "Description": "Light dimmer input device"}, - "hiveot:thing:device": {"Symbol": "", "Title": "Device", "Description": "Device of unknown purpose"}, - "hiveot:thing:meter:electric:power": {"Symbol": "", "Title": "Electrical Power", "Description": "Electrical power meter"}, - "hiveot:thing:sensor:security:motion": {"Symbol": "", "Title": "Motion sensor", "Description": "Dedicated security sensor detecting motion"}, - "hiveot:thing:sensor:sound": {"Symbol": "", "Title": "Sound detector", "Description": ""}, - "hiveot:thing:appliance:washer": {"Symbol": "", "Title": "Washer", "Description": "Clothing washer"}, - "hiveot:thing:control:keypad": {"Symbol": "", "Title": "Keypad", "Description": "Multi-key pad for command input"}, - "hiveot:thing:media:microphone": {"Symbol": "", "Title": "Microphone", "Description": "Microphone for capturing audio"}, - "hiveot:thing:meter:electric:energy": {"Symbol": "", "Title": "Electric energy", "Description": "Electrical energy meter"}, - "hiveot:thing:control:switch": {"Symbol": "", "Title": "Input switch", "Description": "On or off switch input control"}, - "hiveot:thing:media:amplifier": {"Symbol": "", "Title": "Audio amplifier", "Description": "Audio amplifier with volume controls"}, - "hiveot:thing:meter:electric:current": {"Symbol": "", "Title": "Electric current", "Description": "Electrical current meter"}, - "hiveot:thing:sensor:security": {"Symbol": "", "Title": "Security", "Description": "Generic security sensor"}, - "hiveot:thing:actuator:output": {"Symbol": "", "Title": "Output", "Description": "General purpose electrical output signal"}, - "hiveot:thing:actuator:ranged": {"Symbol": "", "Title": "Ranged actuator", "Description": "Generic ranged actuator with a set point"}, - "hiveot:thing:computer:potsphone": {"Symbol": "", "Title": "Land Line", "Description": "Plain Old Telephone System, aka landline"}, - "hiveot:thing:computer:voipphone": {"Symbol": "", "Title": "VoIP Phone", "Description": "Voice over IP phone"}, - "hiveot:thing:device:indicator": {"Symbol": "", "Title": "Indicator", "Description": "Visual or audio indicator device"}, - "hiveot:thing:sensor:multi": {"Symbol": "", "Title": "Multi sensor", "Description": "Sense multiple inputs"}, - "hiveot:thing:sensor:thermometer": {"Symbol": "", "Title": "Thermometer", "Description": "Environmental thermometer"}, - "hiveot:thing:actuator:lock": {"Symbol": "", "Title": "Lock", "Description": "Electronic door lock"}, - "hiveot:thing:net:lora": {"Symbol": "", "Title": "LoRa network device", "Description": "Generic Long Range network protocol device"}, - "hiveot:thing:sensor:water:leak": {"Symbol": "", "Title": "Water leak detector", "Description": "Dedicated water leak detector"}, - "hiveot:thing:actuator:valve:water": {"Symbol": "", "Title": "Water valve", "Description": "Electric powered water valve"}, - "hiveot:thing:control:pushbutton": {"Symbol": "", "Title": "Momentary switch", "Description": "Momentary push button control input"}, - "hiveot:thing:device:battery:monitor": {"Symbol": "", "Title": "Battery Monitor", "Description": "Battery monitor and charge controller"}, - "hiveot:thing:media:tv": {"Symbol": "", "Title": "TV", "Description": "Network connected television"}, - "hiveot:thing:sensor:environment": {"Symbol": "", "Title": "Environmental sensor", "Description": "Environmental sensor with one or more features such as temperature, humidity, etc"}, - "hiveot:thing:actuator:valve:fuel": {"Symbol": "", "Title": "Fuel valve", "Description": "Electric powered fuel valve"}, - "hiveot:thing:control:thermostat": {"Symbol": "", "Title": "Thermostat", "Description": "Thermostat HVAC control"}, - "hiveot:thing:net:bluetooth": {"Symbol": "", "Title": "Bluetooth", "Description": "Bluetooth radio"}, - "hiveot:thing:net:gateway:insteon": {"Symbol": "", "Title": "Insteon gateway", "Description": "Gateway providing access to Insteon devices"}, - "hiveot:thing:sensor": {"Symbol": "", "Title": "Sensor", "Description": "Generic sensor device"}, - "hiveot:thing:sensor:smoke": {"Symbol": "", "Title": "Smoke detector", "Description": ""}, - "hiveot:thing:sensor:scale": {"Symbol": "", "Title": "Scale", "Description": "Electronic weigh scale"}, - "hiveot:thing:actuator": {"Symbol": "", "Title": "Actuator", "Description": "Generic actuator"}, - "hiveot:thing:appliance": {"Symbol": "", "Title": "Appliance", "Description": "Appliance to accomplish a particular task for occupant use"}, - "hiveot:thing:appliance:freezer": {"Symbol": "", "Title": "Freezer", "Description": "Refrigerator freezer"}, - "hiveot:thing:media:receiver": {"Symbol": "", "Title": "Receiver", "Description": "Audio/video receiver and player"}, - "hiveot:thing:meter:electric": {"Symbol": "", "Title": "", "Description": ""}, - "hiveot:thing:net:gateway": {"Symbol": "", "Title": "Gateway", "Description": "Generic gateway device providing access to other devices"}, - "hiveot:thing:net:gateway:zwave": {"Symbol": "", "Title": "ZWave gateway", "Description": "Gateway providing access to ZWave devices"}, - "hiveot:thing:net:switch": {"Symbol": "", "Title": "Network switch", "Description": "Network switch to connect computer devices to the network"}, - "hiveot:thing:actuator:switch": {"Symbol": "", "Title": "Switch", "Description": "An electric powered on/off switch for powering circuits"}, - "hiveot:thing:appliance:dishwasher": {"Symbol": "", "Title": "Dishwasher", "Description": "Dishwasher"}, - "hiveot:thing:computer": {"Symbol": "", "Title": "Computing Device", "Description": "General purpose computing device"}, - "hiveot:thing:computer:tablet": {"Symbol": "", "Title": "Tablet", "Description": "Tablet computer"}, - "hiveot:thing:media": {"Symbol": "", "Title": "A/V media", "Description": "Generic device for audio/video media record or playback"}, - "hiveot:thing:media:camera": {"Symbol": "", "Title": "Camera", "Description": "Video camera"}, - "hiveot:thing:media:speaker": {"Symbol": "", "Title": "Connected speakers", "Description": "Network connected speakers"}, - "hiveot:thing:meter:fuel": {"Symbol": "", "Title": "Fuel metering device", "Description": "General fuel metering device"}, - "hiveot:thing:actuator:motor": {"Symbol": "", "Title": "Motor", "Description": "Motor driven actuator, such as garage door, blinds, tv lifts"}, - "hiveot:thing:computer:cellphone": {"Symbol": "", "Title": "Cell Phone", "Description": "Cellular phone"}, - "hiveot:thing:computer:pc": {"Symbol": "", "Title": "PC/Laptop", "Description": "Personal computer/laptop"}, - "hiveot:thing:control:joystick": {"Symbol": "", "Title": "Joystick", "Description": "Flight control stick"}, - "hiveot:thing:meter:electric:voltage": {"Symbol": "", "Title": "Voltage", "Description": "Electrical voltage meter"}, - "hiveot:thing:meter:fuel:flow": {"Symbol": "", "Title": "Fuel flow rate", "Description": "Dedicated fuel flow rate metering device"}, - "hiveot:thing:meter:water": {"Symbol": "", "Title": "Water metering device", "Description": "General water metering device"}, - "hiveot:thing:meter:water:flow": {"Symbol": "", "Title": "Water flow", "Description": "Dedicated water flow-rate meter"}, - "hiveot:thing:actuator:alarm": {"Symbol": "", "Title": "Alarm", "Description": "Siren or light alarm"}, - "hiveot:thing:actuator:light": {"Symbol": "", "Title": "Light", "Description": "Smart LED or other light"}, - "hiveot:thing:actuator:relay": {"Symbol": "", "Title": "Relay", "Description": "Generic relay electrical switch"}, - "hiveot:thing:control:toggle": {"Symbol": "", "Title": "Toggle switch", "Description": "Toggle switch input control"}, - "hiveot:thing:net": {"Symbol": "", "Title": "Network device", "Description": "Generic network device"}, - "hiveot:thing:net:gateway:coap": {"Symbol": "", "Title": "CoAP gateway", "Description": "Gateway providing access to CoAP devices"}, - "hiveot:thing:sensor:security:glass": {"Symbol": "", "Title": "Glass sensor", "Description": "Dedicated sensor for detecting breaking of glass"}, - "hiveot:thing:actuator:dimmer": {"Symbol": "", "Title": "Dimmer", "Description": "Light dimmer"}, -} - - -# type: UnitClasses -# version: 0.1 -# generated: 18 Nov 25 13:42 PST -# source: github.com/hiveot/hub/api/vocab/ht-unit-classes.yaml -# namespace: hiveot -UnitAmpere = "hiveot:unit:ampere" -UnitCandela = "hiveot:unit:candela" -UnitCelcius = "hiveot:unit:celcius" -UnitCount = "hiveot:unit:count" -UnitDegree = "hiveot:unit:degree" -UnitFahrenheit = "hiveot:unit:fahrenheit" -UnitFoot = "hiveot:unit:foot" -UnitGallon = "hiveot:unit:gallon" -UnitHectoPascal = "hiveot:unit:hectopascal" -UnitKelvin = "hiveot:unit:kelvin" -UnitKilogram = "hiveot:unit:kilogram" -UnitKilometerPerHour = "hiveot:unit:kph" -UnitKilowattHour = "hiveot:unit:kilowatthour" -UnitLiter = "hiveot:unit:liter" -UnitLumen = "hiveot:unit:lumen" -UnitLux = "hiveot:unit:lux" -UnitMercury = "hiveot:unit:mercury" -UnitMeter = "hiveot:unit:meter" -UnitMeterPerSecond = "hiveot:unit:meterspersecond" -UnitMilesPerHour = "hiveot:unit:milesperhour" -UnitMilliBar = "hiveot:unit:millibar" -UnitMilliMeter = "hiveot:unit:millimeter" -UnitMilliSecond = "hiveot:unit:millisecond" -UnitMole = "hiveot:unit:mole" -UnitPSI = "hiveot:unit:psi" -UnitPascal = "hiveot:unit:pascal" -UnitPercent = "hiveot:unit:percent" -UnitPound = "hiveot:unit:pound" -UnitPpm = "hiveot:unit:ppm" -UnitRadian = "hiveot:unit:radian" -UnitSecond = "hiveot:unit:second" -UnitVolt = "hiveot:unit:volt" -UnitWatt = "hiveot:unit:watt" -# end of UnitClasses - -# UnitClassesMap maps @type to symbol, title and description -UnitClassesMap = { - "hiveot:unit:kilogram": {"Symbol": "kg", "Title": "Kilogram", "Description": ""}, - "hiveot:unit:count": {"Symbol": "(N)", "Title": "Count", "Description": ""}, - "hiveot:unit:percent": {"Symbol": "%", "Title": "Percent", "Description": "Fractions of 100"}, - "hiveot:unit:mercury": {"Symbol": "Hg", "Title": "Mercury", "Description": "Unit of atmospheric pressure in the United States. 1 Hg equals 33.8639 mbar."}, - "hiveot:unit:millisecond": {"Symbol": "ms", "Title": "millisecond", "Description": "Unit of time in milli-seconds. Equal to 1/1000 of a second."}, - "hiveot:unit:mole": {"Symbol": "mol", "Title": "Mole", "Description": "SI unit of measurement for amount of substance. Eg, molecules."}, - "hiveot:unit:second": {"Symbol": "s", "Title": "Second", "Description": "SI unit of time based on caesium frequency"}, - "hiveot:unit:ppm": {"Symbol": "ppm", "Title": "PPM", "Description": "Parts per million"}, - "hiveot:unit:ampere": {"Symbol": "A", "Title": "Ampere", "Description": "Electrical current in Amperes based on the elementary charge flow per second"}, - "hiveot:unit:celcius": {"Symbol": "°C", "Title": "Celcius", "Description": "Temperature in Celcius"}, - "hiveot:unit:degree": {"Symbol": "degree", "Title": "Degree", "Description": "Angle in 0-360 degrees"}, - "hiveot:unit:fahrenheit": {"Symbol": "F", "Title": "Fahrenheit", "Description": "Temperature in Fahrenheit"}, - "hiveot:unit:lux": {"Symbol": "lx", "Title": "Lux", "Description": "SI unit illuminance. Equal to 1 lumen per square meter."}, - "hiveot:unit:foot": {"Symbol": "ft", "Title": "Foot", "Description": "Imperial unit of distance. 1 foot equals 0.3048 meters"}, - "hiveot:unit:pound": {"Symbol": "lbs", "Title": "Pound", "Description": "Imperial unit of weight. Equivalent to 0.453592 Kg. 1 Kg is 2.205 lbs"}, - "hiveot:unit:watt": {"Symbol": "W", "Title": "Watt", "Description": "SI unit of power. Equal to 1 joule per second; or work performed when a current of 1 ampere flows across an electric potential of one volt."}, - "hiveot:unit:candela": {"Symbol": "cd", "Title": "Candela", "Description": "SI unit of luminous intensity in a given direction. Roughly the same brightness as the common candle."}, - "hiveot:unit:gallon": {"Symbol": "gl", "Title": "Gallon", "Description": "Unit of volume. 1 Imperial gallon is 4.54609 liters. 1 US liquid gallon is 3.78541 liters. 1 US dry gallon is 4.405 liters. "}, - "hiveot:unit:meter": {"Symbol": "m", "Title": "Meter", "Description": "Distance in meters. 1m=c/299792458"}, - "hiveot:unit:milesperhour": {"Symbol": "mph", "Title": "Miles per hour", "Description": "Speed in miles per hour"}, - "hiveot:unit:psi": {"Symbol": "PSI", "Title": "PSI", "Description": "Unit of pressure. Pounds of force per square inch. 1PSI equals 6984 Pascals."}, - "hiveot:unit:volt": {"Symbol": "V", "Title": "Volt", "Description": "SI unit of electric potential; Energy consumption of 1 joule per electric charge of one coulomb"}, - "hiveot:unit:kph": {"Symbol": "kph", "Title": "Km per hour", "Description": "Speed in kilometers per hour"}, - "hiveot:unit:meterspersecond": {"Symbol": "m/s", "Title": "Meters per second", "Description": "SI unit of speed in meters per second"}, - "hiveot:unit:hectopascal": {"Symbol": "hPa", "Title": "Hecto-Pascal", "Description": "SI unit of atmospheric pressure. Equal to 100 pascal and 1 millibar."}, - "hiveot:unit:millimeter": {"Symbol": "mm", "Title": "Millimeter", "Description": "Size in millimeter"}, - "hiveot:unit:kilowatthour": {"Symbol": "kWh", "Title": "Kilowatt-hour", "Description": "non-SI unit of energy equivalent to 3.6 megajoules."}, - "hiveot:unit:liter": {"Symbol": "l", "Title": "Liter", "Description": "SI unit of volume equivalent to 1 cubic decimeter."}, - "hiveot:unit:kelvin": {"Symbol": "K", "Title": "Kelvin", "Description": "SI unit of thermodynamic temperature. 0 K represents absolute zero, the absence of all heat. 0 C equals +273.15K"}, - "hiveot:unit:radian": {"Symbol": "", "Title": "Radian", "Description": "Angle in 0-2pi"}, - "hiveot:unit:lumen": {"Symbol": "lm", "Title": "Lumen", "Description": "SI unit luminous flux. Measure of perceived power of visible light. 1lm = 1 cd steradian"}, - "hiveot:unit:millibar": {"Symbol": "mbar", "Title": "millibar", "Description": "Metric unit of pressure. 1/1000th of a bar. Equal to 100 pascals. Amount of force it takes to move an object weighing a gram, one centimeter in one second."}, - "hiveot:unit:pascal": {"Symbol": "Pa", "Title": "Pascal", "Description": "SI unit of pressure. Equal to 1 newton of force applied over 1 square meter."}, -} diff --git a/api/src/vocab/ht-action-classes.yaml b/api/src/vocab/ht-action-classes.yaml deleted file mode 100644 index 8967e23c..00000000 --- a/api/src/vocab/ht-action-classes.yaml +++ /dev/null @@ -1,126 +0,0 @@ -# ht-action-classes.yaml -# -# By: HiveOT project (github.com/hiveot/hub) -# Subject: Vocabulary definitions for HiveOT WoT action classes. -# Version: 0.1 -# -# The property vocabulary defines the classes for property, event and action types -# defined with the @type attribute in the WoT DataSchema. -# -# This file is a source for the vocabulary generator utility, which produces -# golang, javascript and python code than can be used directly in the services. -# -# Clients can also load this map from file to present translated title and description -# -# -ActionClasses: - version: "0.1" - link: "github.com/hiveot/hub/api/vocab/ht-action-classes.yaml" - namespace: "hiveot" - vocab: - # hiveot:action:media commands to control media playback - ActionMedia: - class: "hiveot:action:media" - title: "Media control" - description: "Commands to control media recording and playback" - ActionMediaMute: - class: "hiveot:action:media:mute" - title: "Mute" - description: "Mute audio" - ActionMediaNext: - class: "hiveot:action:media:next" - title: "Next" - description: "Next track or station" - ActionMediaPause: - class: "hiveot:action:media:pause" - title: "Pause" - description: "Pause playback" - ActionMediaPlay: - class: "hiveot:action:media:play" - title: "Play" - description: "Start or continue playback" - ActionMediaPrevious: - class: "hiveot:action:media:previous" - title: "Previous" - description: "Previous track or station" - ActionMediaUnmute: - class: "hiveot:action:media:unmute" - title: "Unmute" - description: "Unmute audio" - ActionMediaVolume: - class: "hiveot:action:media:volume" - title: "Volume" - description: "Set volume level" - ActionMediaVolumeIncrease: - class: "hiveot:action:media:volume:increase" - title: "Increase volume" - description: "Increase volume" - ActionMediaVolumeDecrease: - class: "hiveot:action:media:volume:decrease" - title: "Decrease volume" - description: "Decrease volume" - - # actions for switches and dimmers - ActionDimmer: - class: "hiveot:action:dimmer" - title: "Dimmer" - description: "General dimmer action" - ActionDimmerDecrement: - class: "hiveot:action:dimmer:decrement" - title: "Lower dimmer" - description: "" - ActionDimmerIncrement: - class: "hiveot:action:dimmer:increment" - title: "Increase dimmer" - description: "" - ActionDimmerSet: - class: "hiveot:action:dimmer:set" - title: "Set dimmer" - description: "Action to set the dimmer value" - - ActionSwitch: - class: "hiveot:action:switch" - title: "Switch" - description: "General switch action" - # ActionSwitchOff: - # class: "hiveot:action:switch:off" - # title: "Switch off" - # description: "Action to turn the switch off" - # ActionSwitchOn: - # class: "hiveot:action:switch:on" - # title: "Switch on" - # description: "Action to turn the switch on" - ActionSwitchOnOff: - class: "hiveot:action:switch:onoff" - title: "Set On/Off switch" - description: "Action to set the switch on/off state" - ActionSwitchToggle: - class: "hiveot:action:switch:toggle" - title: "Toggle switch" - description: "Action to toggle the switch" - - ActionThingDisable: - class: "hiveot:action:thing:disable" - title: "Disable" - description: "Action to disable a thing" - ActionThingEnable: - class: "hiveot:action:thing:enable" - title: "Enable" - description: "Action to enable a thing" - ActionThingStart: - class: "hiveot:action:thing:start" - title: "Start" - description: "Start running a task" - ActionThingStop: - class: "hiveot:action:thing:stop" - title: "Stop" - description: "Stop a running task" - - ActionValveClose: - class: "hiveot:action:valve:close" - title: "Close valve" - description: "Action to close the valve" - ActionValveOpen: - class: "hiveot:action:valve:open" - title: "Open valve" - description: "Action to open the valve" diff --git a/api/src/vocab/ht-property-classes.yaml b/api/src/vocab/ht-property-classes.yaml deleted file mode 100644 index 8bd1c0d7..00000000 --- a/api/src/vocab/ht-property-classes.yaml +++ /dev/null @@ -1,403 +0,0 @@ -# ht-props-classes.yaml -# -# By: HiveOT project (github.com/hiveot/hub) -# Subject: Vocabulary definitions for HiveOT WoT property and event classes. -# Version: 0.1 -# -# The property vocabulary contains classes for defining property and event types -# using the @type attribute in the WoT DataSchema. Events use the same classes -# as properties, as events the latest event value can be made available as an attribute. -# In this case they use the same instance ID and the same property class. -# -# This file is a source for the vocabulary generator utility, which produces -# golang, javascript and python code than can be used directly in the services. -# -# Clients can also load this yaml map from file to present translated title and description. -# -PropertyClasses: - version: "0.1" - link: "github.com/hiveot/hub/api/vocab/ht-property-classes.yaml" - namespace: "hiveot" - vocab: - # hiveot:prop:alarm properties for alarm status - # TODO: determine better subcategory - look into existing standards - PropAlarmStatus: - class: "hiveot:prop:alarm:status" - title: "Alarm state" - description: "Current alarm status" - PropAlarmMotion: - class: "hiveot:prop:alarm:motion" - title: "Motion" - description: "Motion detected" - - # hiveot:prop:device - # Device attributes not covered by electrical, network or media categories - PropDevice: - class: "hiveot:prop:device" - title: "Device attributes" - description: "Attributes describing a device" - PropDeviceBattery: - class: "hiveot:prop:device:battery" - title: "Battery level" - description: "Device battery level" - PropDeviceDescription: - class: "hiveot:prop:device:description" - title: "Description" - description: "Device product description" # also TD.Description - PropDeviceEnabledDisabled: - class: "hiveot:prop:device:enabled-disabled" - title: "Enabled/Disabled" - description: "Enabled or disabled state" - enum: - disabled: "Disabled" - enabled: "Enabled" - PropDeviceFirmwareVersion: - class: "hiveot:prop:device:firmwareversion" - title: "Firmware version" - description: "" - PropDeviceHardwareVersion: - class: "hiveot:prop:device:hardwareversion" - title: "Hardware version" - description: "" - PropDeviceMake: - class: "hiveot:prop:device:make" - title: "Make" - description: "Device manufacturer" - PropDeviceModel: - class: "hiveot:prop:device:model" - title: "Model" - description: "Device model" - PropDevicePollinterval: - class: "hiveot:prop:device:pollinterval" - title: "Polling interval" - description: "Interval to poll for updates" - PropDeviceSoftwareVersion: - class: "hiveot:prop:device:softwareversion" - title: "Software version" - description: "" - PropDeviceStatus: - class: "hiveot:prop:device:status" - enum: - alive: "Alive" - awake: "Awake" - dead: "Dead" - sleeping: "Sleeping" - title: "Status" - description: "Device status; alive, awake, dead, sleeping" - PropDeviceTitle: - class: "hiveot:prop:device:title" - title: "Title" - description: "Device friendly title" - - # hiveot:prop:electric group of electrical properties - PropElectric: - class: "hiveot:prop:electric" - title: "Electrical properties" - description: "General group of electrical properties" - PropElectricCurrent: - class: "hiveot:prop:electric:current" - title: "Current" - description: "Electrical current" - PropElectricEnergy: - class: "hiveot:prop:electric:energy" - title: "Energy" - description: "Electrical energy consumed" - PropElectricPower: - class: "hiveot:prop:electric:poer" - title: "Power" - description: "Electrical power being consumed" - PropElectricVoltage: - class: "hiveot:prop:electric:voltage" - title: "Voltage" - description: "Electrical voltage potential" - PropElectricOverload: - class: "hiveot:prop:electric:overload" - title: "Overload protection" - description: "Cut load on overload" - - # hy:prop:env - properties with environmental sensors - PropEnv: - class: "hiveot:prop:env" - title: "Environmental property" - description: "Property of environmental sensor" - PropEnvAcceleration: - class: "hiveot:prop:env:acceleration" - title: "Acceleration" - description: "" - PropEnvAirquality: - class: "hiveot:prop:env:airquality" - title: "Air quality" - description: "Air quality level" - PropEnvCO: - class: "hiveot:prop:env:co" - title: "Carbon monoxide level" - description: "Carbon monoxide level" - PropEnvCO2: - class: "hiveot:prop:env:co2" - title: "Carbon dioxide level" - description: "Carbon dioxide level" - PropEnvCpuload: - class: "hiveot:prop:env:cpuload" - title: "CPU load level" - description: "Device CPU load level" - PropEnvDewpoint: - class: "hiveot:prop:env:dewpoint" - title: "Dew point" - description: "Dew point temperature" - PropEnvFuelLevel: - class: "hiveot:prop:env:fuel:level" - title: "Fuel level" - description: "" - PropEnvFuelFlowrate: - class: "hiveot:prop:env:fuel:flowrate" - title: "Fuel flow rate" - description: "" - PropEnvHumidex: - class: "hiveot:prop:env:humidex" - title: "Humidex" - description: "" - PropEnvHumidity: - class: "hiveot:prop:env:humidity" - title: "Humidity" - description: "" - PropEnvLuminance: - class: "hiveot:prop:env:luminance" - title: "Luminance" - description: "" - PropEnvPrecipitation: - class: "hiveot:prop:env:precipitation" - title: "Precipitation" - description: "Total precipitation of rain and snow" - PropEnvPrecipitationRain: - class: "hiveot:prop:env:precipitation:rain" - title: "Rain precipitation" - description: "Precipitation as rain" - PropEnvPrecipitationSnow: - class: "hiveot:prop:env:precipitation:snow" - title: "Snow precipitation" - description: "Precipitation as snow" - PropEnvPressure: - class: "hiveot:prop:env:pressure" - title: "Pressure" - description: "" - PropEnvPressureSurface: - class: "hiveot:prop:env:barometer:surface" - title: "Surface level pressure" - description: "Surface level atmospheric pressure" - PropEnvPressureSeaLevel: - class: "hiveot:prop:env:barometer:msl" - title: "Sea level pressure" - description: "Sea level equivalent atmospheric pressure" - PropEnvTemperature: - class: "hiveot:prop:env:temperature" - title: "Temperature" - description: "" - PropEnvTimezone: - class: "hiveot:prop:env:timezone" - title: "Timezone" - description: "" - PropEnvUV: - class: "hiveot:prop:env:uv" - title: "UV" - description: "" - PropEnvVibration: - class: "hiveot:prop:env:vibration" - title: "Vibration" - description: "" - PropEnvVolume: - class: "hiveot:prop:env:volume" - title: "Volume" - description: "" - PropEnvWaterFlowrate: - class: "hiveot:prop:env:water:flowrate" - title: "Water flow rate" - description: "" - PropEnvWaterLevel: - class: "hiveot:prop:env:water:level" - title: "Water level" - description: "" - PropEnvWindGusts: - class: "hiveot:prop:env:wind:gusts" - title: "Wind gusts" - description: "Speed of wind gusts" - PropEnvWindHeading: - class: "hiveot:prop:env:wind:heading" - title: "Wind heading" - description: "Direction wind is heading" - PropEnvWindSpeed: - class: "hiveot:prop:env:wind:speed" - title: "Wind speed" - description: "Average speed of wind" - - # hiveot:prop:location with location related properties - PropLocation: - class: "hiveot:prop:location" - title: "Location" - description: "General location information" - PropLocationStreet: - class: "hiveot:prop:location:street" - title: "Street" - description: "Street address" - PropLocationCity: - class: "hiveot:prop:location:city" - title: "City" - description: "City name" - PropLocationLatitude: - class: "hiveot:prop:location:latitude" - title: "Latitude" - description: "Latitude geographic coordinate" - PropLocationLongitude: - class: "hiveot:prop:location:longitude" - title: "Longitude" - description: "Longitude geographic coordinate" - PropLocationName: - class: "hiveot:prop:location:name" - title: "Location name" - description: "Name of the location" - PropLocationZipcode: - class: "hiveot:prop:location:zipcode" - title: "Zip code" - description: "Location ZIP code" - - # hiveot:prop:media - properties with media status and configuration - PropMedia: - class: "hiveot:prop:media" - title: "Media commands" - description: "Control of media equipment" - PropMediaPlaying: - class: "hiveot:prop:media:playing" - title: "Playing" - description: "Media is playing" - PropMediaPaused: - class: "hiveot:prop:media:paused" - title: "Paused" - description: "Media is paused" - PropMediaMuted: - class: "hiveot:prop:media:muted" - title: "Muted" - description: "Audio is muted" - PropMediaVolume: - class: "hiveot:prop:media:volume" - title: "Volume" - description: "Media volume setting" - PropMediaTrack: - class: "hiveot:prop:media:track" - title: "Track" - description: "Selected A/V track" - PropMediaStation: - class: "hiveot:prop:media:station" - title: "Station" - description: "Selected radio station" - - # hiveot:prop:net - network related properties - PropNet: - class: "hiveot:prop:net" - title: "Network properties" - description: "General network properties" - PropNetAddress: - class: "hiveot:prop:net:address" - title: "Address" - description: "Network address" - PropNetConnection: - class: "hiveot:prop:net:connection" - title: "Connection" - description: "Connection status, connected, connecting, retrying, disconnected,..." - PropNetDomainname: - class: "hiveot:prop:net:domainname" - title: "Domain name" - description: "Domainname of the client" - PropNetGateway: - class: "hiveot:prop:net:gateway" - title: "Gateway" - description: "Network gateway address" - PropNetHostname: - class: "hiveot:prop:net:hostname" - title: "Hostname" - description: "Hostname of the client" - PropNetIP4: - class: "hiveot:prop:net:ip4" - title: "IP4 address" - description: "Device IP4 address" - PropNetIP6: - class: "hiveot:prop:net:ip6" - title: "IP6 address" - description: "Device IP6 address" - PropNetLatency: - class: "hiveot:prop:net:latency" - title: "Network latency" - description: "Delay between hub and client" - PropNetMAC: - class: "hiveot:prop:net:mac" - title: "MAC" - description: "Hardware MAC address" - PropNetMask: - class: "hiveot:prop:net:mask" - title: "Netmask" - description: "Network mask. Example: 255.255.255.0 or 24/8" - PropNetPort: - class: "hiveot:prop:net:port" - title: "Port" - description: "Network port" - PropNetSignalstrength: - class: "hiveot:prop:net:signalstrength" - title: "Signal strength" - description: "Wireless signal strength" - PropNetSubnet: - class: "hiveot:prop:net:subnet" - title: "Subnet" - description: "Network subnet address. Example: 192.168.0.0" - - # hiveot:prop:status - general status values - PropStatusOnOff: - class: "hiveot:prop:status:onoff" - enum: - on: "On" - off: "Off" - title: "On/off status" - description: "" - PropStatusOpenClosed: - class: "hiveot:prop:status:openclosed" - enum: - open: "Open" - closed: "Closed" - title: "Open/Closed status" - description: "" - PropStatusStartedStopped: - class: "hiveot:prop:status:started-stopped" - enum: - started: "Started" - stopped: "Stopped" - title: "Started/Stopped" - description: "Started or stopped status" - PropStatusYesNo: - class: "hiveot:prop:status:yes-no" - enum: - no: "No" - yes: "Yes" - title: "Yes/No" - description: "Status with yes or no value" - - # hiveot:prop:switch - Switch status - PropSwitch: - class: "hiveot:prop:switch" - title: "Switch status" - description: "" - PropSwitchOnOff: - class: "hiveot:prop:switch:onoff" - enum: - on: "On" - off: "Off" - title: "On/Off switch" - description: "" - PropSwitchDimmer: - class: "hiveot:prop:switch:dimmer" - title: "Dimmer value" - description: "" - PropSwitchLight: - class: "hiveot:prop:switch:light" - title: "Light switch" - description: "" - PropSwitchLocked: - class: "hiveot:prop:switch:locked" - title: "Lock" - description: "Electric lock status" diff --git a/api/src/vocab/ht-thing-classes.yaml b/api/src/vocab/ht-thing-classes.yaml deleted file mode 100644 index c7cd86cb..00000000 --- a/api/src/vocab/ht-thing-classes.yaml +++ /dev/null @@ -1,473 +0,0 @@ -# ht-thing-classes.yaml -# -# By: HiveOT project (github.com/hiveot/hub) -# Subject: Vocabulary definitions for HiveOT device classes. -# Version: 0.1 -# -# This includes default Thing title and description. -# -# This file is a source for the vocabulary generator utility, which produces -# golang, javascript and python code than can be used directly in the services. -# -# Clients can also load this yaml map from file to present translated title and description. -# -ThingClasses: - version: "0.1" - link: "github.com/hiveot/hub/api/vocab/ht-thing-classes.yaml" - namespace: "hiveot" - vocab: - # hiveot:thing:actuator devices - ThingActuator: - class: "hiveot:thing:actuator" - title: "Actuator" - description: "Generic actuator" - ThingActuatorAlarm: - class: "hiveot:thing:actuator:alarm" - title: "Alarm" - description: "Siren or light alarm" - ThingActuatorBeacon: - class: "hiveot:thing:actuator:beacon" - title: "Beacon" - description: "Location beacon" - ThingActuatorDimmer: - class: "hiveot:thing:actuator:dimmer" - title: "Dimmer" - description: "Light dimmer" - ThingActuatorLock: - class: "hiveot:thing:actuator:lock" - title: "Lock" - description: "Electronic door lock" - ThingActuatorLight: - class: "hiveot:thing:actuator:light" - title: "Light" - description: "Smart LED or other light" - ThingActuatorMotor: - class: "hiveot:thing:actuator:motor" - title: "Motor" - description: "Motor driven actuator, such as garage door, blinds, tv lifts" - ThingActuatorOutput: - class: "hiveot:thing:actuator:output" - title: "Output" - description: "General purpose electrical output signal" - ThingActuatorRanged: - class: "hiveot:thing:actuator:ranged" - title: "Ranged actuator" - description: "Generic ranged actuator with a set point" - ThingActuatorRelay: - class: "hiveot:thing:actuator:relay" - title: "Relay" - description: "Generic relay electrical switch" - ThingActuatorSwitch: - class: "hiveot:thing:actuator:switch" - title: "Switch" - description: "An electric powered on/off switch for powering circuits" - ThingActuatorValve: - class: "hiveot:thing:actuator:valve" - title: "Valve" - description: "Electric powered valve for fluids or gas" - ThingActuatorValveWater: - class: "hiveot:thing:actuator:valve:water" - title: "Water valve" - description: "Electric powered water valve" - ThingActuatorValveFuel: - class: "hiveot:thing:actuator:valve:fuel" - title: "Fuel valve" - description: "Electric powered fuel valve" - - # hiveot:thing:appliance devices - ThingAppliance: - class: "hiveot:thing:appliance" - title: "Appliance" - description: "Appliance to accomplish a particular task for occupant use" - ThingApplianceDishwasher: - class: "hiveot:thing:appliance:dishwasher" - title: "Dishwasher" - description: "Dishwasher" - ThingApplianceDryer: - class: "hiveot:thing:appliance:dryer" - title: "Dryer" - description: "Clothing dryer" - ThingApplianceFreezer: - class: "hiveot:thing:appliance:freezer" - title: "Freezer" - description: "Refrigerator freezer" - ThingApplianceFridge: - class: "hiveot:thing:appliance:fridge" - title: "Fridge" - description: "Refrigerator appliance" - ThingApplianceWasher: - class: "hiveot:thing:appliance:washer" - title: "Washer" - description: "Clothing washer" - - # hiveot:thing:computer devices including phones - ThingComputer: - class: "hiveot:thing:computer" - title: "Computing Device" - description: "General purpose computing device" - ThingComputerCellphone: - class: "hiveot:thing:computer:cellphone" - title: "Cell Phone" - description: "Cellular phone" - ThingComputerEmbedded: - class: "hiveot:thing:computer:embedded" - title: "Embedded System" - description: "Embedded computing device" - ThingComputerMemory: - class: "hiveot:thing:computer:memory" - title: "Memory" - description: "Stand-alone memory device such as eeprom or iButtons" - ThingComputerPC: - class: "hiveot:thing:computer:pc" - title: "PC/Laptop" - description: "Personal computer/laptop" - ThingComputerPotsPhone: - class: "hiveot:thing:computer:potsphone" - title: "Land Line" - description: "Plain Old Telephone System, aka landline" - ThingComputerSatPhone: - class: "hiveot:thing:computer:satphone" - title: "Satellite phone" - description: - ThingComputerTablet: - class: "hiveot:thing:computer:tablet" - title: "Tablet" - description: "Tablet computer" - ThingComputerVoipPhone: - class: "hiveot:thing:computer:voipphone" - title: "VoIP Phone" - description: "Voice over IP phone" - - # hiveot:thing:control input devices for capturing input commands. - # controls that directly drive outputs belong in the actuator section. - ThingControl: - class: "hiveot:thing:control" - title: "Input controller" - description: "Generic input controller" - ThingControlClimate: - class: "hiveot:thing:control:climate" - title: "Climate control" - description: "Device for controlling climate of a space" - ThingControlDimmer: - class: "hiveot:thing:control:dimmer" - title: "Dimmer" - description: "Light dimmer input device" - ThingControlIrrigation: - class: "hiveot:thing:control:irrigation" - title: "Irrigation control" - description: "Device for control of an irrigation system" - ThingControlJoystick: - class: "hiveot:thing:control:joystick" - title: "Joystick" - description: "Flight control stick" - ThingControlKeypad: - class: "hiveot:thing:control:keypad" - title: "Keypad" - description: "Multi-key pad for command input" - ThingControlPool: - class: "hiveot:thing:control:pool" - title: "Pool control" - description: "Device for controlling pool settings" - ThingControlPushbutton: - class: "hiveot:thing:control:pushbutton" - title: "Momentary switch" - description: "Momentary push button control input" - ThingControlSwitch: - class: "hiveot:thing:control:switch" - title: "Input switch" - description: "On or off switch input control" - ThingControlThermostat: - class: "hiveot:thing:control:thermostat" - title: "Thermostat" - description: "Thermostat HVAC control" - ThingControlToggle: - class: "hiveot:thing:control:toggle" - title: "Toggle switch" - description: "Toggle switch input control" - - ThingDevice: - class: "hiveot:thing:device" - title: "Device" - description: "Device of unknown purpose" - ThingDeviceBatteryMonitor: - class: "hiveot:thing:device:battery:monitor" - title: "Battery Monitor" - description: "Battery monitor and charge controller" - ThingDeviceTime: - class: "hiveot:thing:device:time" - title: "Clock" - description: "Time tracking device such as clocks and time chips" - ThingDeviceIndicator: - class: "hiveot:thing:device:indicator" - title: "Indicator" - description: "Visual or audio indicator device" - - # hiveot:thing:media - devices for consuming or producing audio/visual media content - ThingMedia: - class: "hiveot:thing:media" - title: "A/V media" - description: "Generic device for audio/video media record or playback" - ThingMediaAmplifier: - class: "hiveot:thing:media:amplifier" - title: "Audio amplifier" - description: "Audio amplifier with volume controls" - ThingMediaCamera: - class: "hiveot:thing:media:camera" - title: "Camera" - description: "Video camera" - ThingMediaMicrophone: - class: "hiveot:thing:media:microphone" - title: "Microphone" - description: "Microphone for capturing audio" - ThingMediaPlayer: - class: "hiveot:thing:media:player" - title: "Media player" - description: "CD/DVD/Blueray/USB player of recorded media" - ThingMediaRadio: - class: "hiveot:thing:media:radio" - title: "Radio" - description: "AM or FM radio receiver" - ThingMediaReceiver: - class: "hiveot:thing:media:receiver" - title: "Receiver" - description: "Audio/video receiver and player" - ThingMediaSpeaker: - class: "hiveot:thing:media:speaker" - title: "Connected speakers" - description: "Network connected speakers" - ThingMediaTV: - class: "hiveot:thing:media:tv" - title: "TV" - description: "Network connected television" - - # hiveot:thing:meter - metering devices for electricity, water, fuel - ThingMeter: - class: "hiveot:thing:meter" - title: "Meter" - description: "General metering device" - ThingMeterElectric: - class: "hiveot:thing:meter:electric" - ThingMeterElectricCurrent: - class: "hiveot:thing:meter:electric:current" - title: "Electric current" - description: "Electrical current meter" - ThingMeterElectricEnergy: - class: "hiveot:thing:meter:electric:energy" - title: "Electric energy" - description: "Electrical energy meter" - ThingMeterElectricPower: - class: "hiveot:thing:meter:electric:power" - title: "Electrical Power" - description: "Electrical power meter" - ThingMeterElectricVoltage: - class: "hiveot:thing:meter:electric:voltage" - title: "Voltage" - description: "Electrical voltage meter" - ThingMeterFuel: - class: "hiveot:thing:meter:fuel" - title: "Fuel metering device" - description: "General fuel metering device" - ThingMeterFuelFlow: - class: "hiveot:thing:meter:fuel:flow" - title: "Fuel flow rate" - description: "Dedicated fuel flow rate metering device" - ThingMeterFuelLevel: - class: "hiveot:thing:meter:fuel:level" - title: "Fuel level" - description: "Dedicated fuel level metering device" - ThingMeterWater: - class: "hiveot:thing:meter:water" - title: "Water metering device" - description: "General water metering device" - ThingMeterWaterConsumption: - class: "hiveot:thing:meter:water:consumption" - title: "Water consumption meter" - description: "Water consumption meter" - ThingMeterWaterFlow: - class: "hiveot:thing:meter:water:flow" - title: "Water flow" - description: "Dedicated water flow-rate meter" - ThingMeterWaterLevel: - class: "hiveot:thing:meter:water:level" - title: "Water level" - description: "Dedicated water level meter" - ThingMeterWind: - class: "hiveot:thing:meter:wind" - title: "Wind" - description: "Dedicated wind meter" - - # hiveot:thing:net - devices to facilitate network machine communication - ThingNet: - class: "hiveot:thing:net" - title: "Network device" - description: "Generic network device" - ThingNetBluetooth: - class: "hiveot:thing:net:bluetooth" - title: "Bluetooth" - description: "Bluetooth radio" - ThingNetGateway: - class: "hiveot:thing:net:gateway" - title: "Gateway" - description: "Generic gateway device providing access to other devices" - ThingNetGatewayCoap: - class: "hiveot:thing:net:gateway:coap" - title: "CoAP gateway" - description: "Gateway providing access to CoAP devices" - ThingNetGatewayInsteon: - class: "hiveot:thing:net:gateway:insteon" - title: "Insteon gateway" - description: "Gateway providing access to Insteon devices" - ThingNetGatewayOnewire: - class: "hiveot:thing:net:gateway:onewire" - title: "1-Wire gateway" - description: "Gateway providing access to 1-wire devices" - ThingNetGatewayZwave: - class: "hiveot:thing:net:gateway:zwave" - title: "ZWave gateway" - description: "Gateway providing access to ZWave devices" - ThingNetGatewayZigbee: - class: "hiveot:thing:net:gateway:zigbee" - title: "Zigbee gateway" - description: "Gateway providing access to Zigbee devices" - ThingNetRouter: - class: "hiveot:thing:net:router" - title: "Network router" - description: "IP ThingNetwork router providing access to other IP networks" - ThingNetSwitch: - class: "hiveot:thing:net:switch" - title: "Network switch" - description: "Network switch to connect computer devices to the network" - ThingNetWifi: - class: "hiveot:thing:net:wifi" - title: "Wifi device" - description: "Generic wifi device" - ThingNetWifiAp: - class: "hiveot:thing:net:wifi:ap" - title: "Wifi access point" - description: "Wireless access point for IP networks" - ThingNetLora: - class: "hiveot:thing:net:lora" - title: "LoRa network device" - description: "Generic Long Range network protocol device" - ThingNetLoraGateway: - class: "hiveot:thing:net:lora:gw" - title: "LoRaWAN gateway" - description: "Gateway providing access to LoRa devices" - ThingNetLoraP2P: - class: "hiveot:thing:net:lora:p2p" - title: "LoRa P2P" - description: "LoRa Peer-to-peer network device" - - # hiveot:thing:sensor - devices designed to observe features of interest - ThingSensor: - class: "hiveot:thing:sensor" - title: "Sensor" - description: "Generic sensor device" - ThingSensorEnvironment: - class: "hiveot:thing:sensor:environment" - title: "Environmental sensor" - description: "Environmental sensor with one or more features such as temperature, humidity, etc" - ThingSensorInput: - class: "hiveot:thing:sensor:input" - title: "Input sensor" - description: "General purpose electrical input sensor" - ThingSensorMulti: - class: "hiveot:thing:sensor:multi" - title: "Multi sensor" - description: "Sense multiple inputs" - ThingSensorSecurity: - class: "hiveot:thing:sensor:security" - title: "Security" - description: "Generic security sensor" - ThingSensorSecurityGlass: - class: "hiveot:thing:sensor:security:glass" - title: "Glass sensor" - description: "Dedicated sensor for detecting breaking of glass" - ThingSensorSecurityMotion: - class: "hiveot:thing:sensor:security:motion" - title: "Motion sensor" - description: "Dedicated security sensor detecting motion" - ThingSensorSecurityDoorWindow: - class: "hiveot:thing:sensor:security:doorwindow" - title: "Door/Window sensor" - description: "Dedicated door/window opening security sensor" - ThingSensorSmoke: - class: "hiveot:thing:sensor:smoke" - title: "Smoke detector" - description: "" - ThingSensorSound: - class: "hiveot:thing:sensor:sound" - title: "Sound detector" - description: "" - ThingSensorThermometer: - class: "hiveot:thing:sensor:thermometer" - title: "Thermometer" - description: "Environmental thermometer" - ThingSensorWaterLeak: - class: "hiveot:thing:sensor:water:leak" - title: "Water leak detector" - description: "Dedicated water leak detector" - ThingSensorScale: - class: "hiveot:thing:sensor:scale" - title: "Scale" - description: "Electronic weigh scale" - - # hiveot:thing:service - Software that processes data and offers features of interest - # TBD - ThingService: - class: "hiveot:thing:service" - title: "Service" - description: "General service for processing data and offering features of interest" -# ThingServiceAdapter: -# class: "hiveot:thing:service:adapter" -# title: "Protocol adapter" -# description: "Protocol adapter/binding for integration with another protocol" -# ThingServiceAuth: -# class: "hiveot:thing:service:auth" -# title: "Authentication service" -# description: "" -# ThingServiceAutomation: -# class: "hiveot:thing:service:automation" -# title: "Automation service" -# description: "" -# ThingServiceDirectory: -# class: "hiveot:thing:service:directory" -# title: "Directory service" -# description: "" -# ThingServiceHistory: -# class: "hiveot:thing:service:history" -# title: "History service" -# description: "" -# ThingServiceImage: -# class: "hiveot:thing:service:image" -# title: "Image classification" -# description: "" -# ThingServiceTTS: -# class: "hiveot:thing:service:tts" -# title: "Text to speech" -# description: "" -# ThingServiceSTT: -# class: "hiveot:thing:service:stt" -# title: "Speech to text" -# description: "" -# ThingServiceTranslation: -# class: "hiveot:thing:service:translation" -# title: "Language translation service" -# description: "" -# ThingServiceStore: -# class: "hiveot:thing:service:store" -# title: "Data storage" -# description: "" -# ThingServiceWeather: -# class: "hiveot:thing:service:weather" -# title: "Weather service" -# description: "General weather service" -# ThingServiceWeatherCurrent: -# class: "hiveot:thing:service:weather:current" -# title: "Current weather" -# description: "" -# ThingServiceWeatherForecast: -# class: "hiveot:thing:service:weather:forecast" -# title: "Weather forecast" -# description: "" -# diff --git a/api/src/vocab/ht-unit-classes.yaml b/api/src/vocab/ht-unit-classes.yaml deleted file mode 100644 index c5ec137f..00000000 --- a/api/src/vocab/ht-unit-classes.yaml +++ /dev/null @@ -1,180 +0,0 @@ -# ht-unit-classes.yaml -# -# By: HiveOT project (github.com/hiveot/hub) -# Subject: Vocabulary definitions for HiveOT WoT units. -# Version: 0.1 -# -# The units vocabulary contains classes for defining SI standard unit names. -# -# These will eventually be replaced by a standards schema such as schema.org -# -UnitClasses: - version: "0.1" - link: "github.com/hiveot/hub/api/vocab/ht-unit-classes.yaml" - namespace: "hiveot" - vocab: - UnitAmpere: - class: "hiveot:unit:ampere" - symbol: "A" - title: "Ampere" - description: "Electrical current in Amperes based on the elementary charge flow per second" - UnitCelcius: - class: "hiveot:unit:celcius" - symbol: "°C" - title: "Celcius" - description: "Temperature in Celcius" - UnitCandela: - class: "hiveot:unit:candela" - symbol: "cd" - title: "Candela" - description: "SI unit of luminous intensity in a given direction. Roughly the same brightness as the common candle." - UnitCount: - class: "hiveot:unit:count" - symbol: "(N)" - title: "Count" - Description: "Count of numbers" - UnitDegree: - class: "hiveot:unit:degree" - symbol: "degree" - title: "Degree" - description: "Angle in 0-360 degrees" - UnitFahrenheit: - class: "hiveot:unit:fahrenheit" - symbol: "F" - title: "Fahrenheit" - description: "Temperature in Fahrenheit" - UnitFoot: - class: "hiveot:unit:foot" - symbol: "ft" - title: "Foot" - description: "Imperial unit of distance. 1 foot equals 0.3048 meters" - UnitGallon: - class: "hiveot:unit:gallon" - symbol: "gl" - title: "Gallon" - description: "Unit of volume. 1 Imperial gallon is 4.54609 liters. 1 US liquid gallon is 3.78541 liters. 1 US dry gallon is 4.405 liters. " - UnitKelvin: - class: "hiveot:unit:kelvin" - symbol: "K" - title: "Kelvin" - description: "SI unit of thermodynamic temperature. 0 K represents absolute zero, the absence of all heat. 0 C equals +273.15K" - UnitKilogram: - class: "hiveot:unit:kilogram" - symbol: "kg" - title: "Kilogram" - Description: "SI unit of mass based on Planck constant" - UnitKilometerPerHour: - class: "hiveot:unit:kph" - symbol: "kph" - title: "Km per hour" - description: "Speed in kilometers per hour" - UnitKilowattHour: - class: "hiveot:unit:kilowatthour" - symbol: "kWh" - title: "Kilowatt-hour" - description: "non-SI unit of energy equivalent to 3.6 megajoules." - UnitLiter: - class: "hiveot:unit:liter" - symbol: "l" - title: "Liter" - description: "SI unit of volume equivalent to 1 cubic decimeter." - UnitLumen: - class: "hiveot:unit:lumen" - symbol: "lm" - title: "Lumen" - description: "SI unit luminous flux. Measure of perceived power of visible light. 1lm = 1 cd steradian" - UnitLux: - class: "hiveot:unit:lux" - symbol: "lx" - title: "Lux" - description: "SI unit illuminance. Equal to 1 lumen per square meter." - UnitMercury: - class: "hiveot:unit:mercury" - symbol: "Hg" - title: "Mercury" - description: "Unit of atmospheric pressure in the United States. 1 Hg equals 33.8639 mbar." - UnitMeter: - class: "hiveot:unit:meter" - symbol: "m" - title: "Meter" - description: "Distance in meters. 1m=c/299792458" - UnitMeterPerSecond: - class: "hiveot:unit:meterspersecond" - symbol: "m/s" - title: "Meters per second" - description: "SI unit of speed in meters per second" - UnitMilesPerHour: - class: "hiveot:unit:milesperhour" - symbol: "mph" - title: "Miles per hour" - description: "Speed in miles per hour" - UnitMilliBar: - class: "hiveot:unit:millibar" - symbol: "mbar" - title: "millibar" - description: "Metric unit of pressure. 1/1000th of a bar. Equal to 100 pascals. Amount of force it takes to move an object weighing a gram, one centimeter in one second." - UnitMilliMeter: - class: "hiveot:unit:millimeter" - symbol: "mm" - title: "Millimeter" - description: "Size in millimeter" - UnitMilliSecond: - class: "hiveot:unit:millisecond" - symbol: "ms" - title: "millisecond" - description: "Unit of time in milli-seconds. Equal to 1/1000 of a second." - UnitMole: - class: "hiveot:unit:mole" - symbol: "mol" - title: "Mole" - description: "SI unit of measurement for amount of substance. Eg, molecules." - UnitHectoPascal: - class: "hiveot:unit:hectopascal" - symbol: "hPa" - title: "Hecto-Pascal" - description: "SI unit of atmospheric pressure. Equal to 100 pascal and 1 millibar." - UnitPascal: - class: "hiveot:unit:pascal" - symbol: "Pa" - title: "Pascal" - description: "SI unit of pressure. Equal to 1 newton of force applied over 1 square meter." - UnitPercent: - class: "hiveot:unit:percent" - symbol: "%" - title: "Percent" - description: "Fractions of 100" - UnitPound: - class: "hiveot:unit:pound" - symbol: "lbs" - title: "Pound" - description: "Imperial unit of weight. Equivalent to 0.453592 Kg. 1 Kg is 2.205 lbs" - UnitPpm: - class: "hiveot:unit:ppm" - symbol: "ppm" - title: "PPM" - description: "Parts per million" - UnitPSI: - class: "hiveot:unit:psi" - symbol: "PSI" - title: "PSI" - description: "Unit of pressure. Pounds of force per square inch. 1PSI equals 6984 Pascals." - UnitRadian: - class: "hiveot:unit:radian" - symbol: "" - title: "Radian" - description: "Angle in 0-2pi" - UnitSecond: - class: "hiveot:unit:second" - symbol: "s" - title: "Second" - description: "SI unit of time based on caesium frequency" - UnitWatt: - class: "hiveot:unit:watt" - symbol: "W" - title: "Watt" - description: "SI unit of power. Equal to 1 joule per second; or work performed when a current of 1 ampere flows across an electric potential of one volt." - UnitVolt: - class: "hiveot:unit:volt" - symbol: "V" - title: "Volt" - description: "SI unit of electric potential; Energy consumption of 1 joule per electric charge of one coulomb" diff --git a/api/src/vocab/wot-vocab.yaml b/api/src/vocab/wot-vocab.yaml deleted file mode 100644 index 2b7d4c1c..00000000 --- a/api/src/vocab/wot-vocab.yaml +++ /dev/null @@ -1,113 +0,0 @@ -# definition of WoT vocabulary used to generate source files - -# This is subject to change as definitions for the query parameters, arguments and responses need to be included. -WoTVocab: - version: "0.1" - link: "github.com/hiveot/hub/api/vocab/wot-vocab.yaml" - namespace: "" - description: "WoT vocabulary definition. See https://www.w3.org/TR/2020/WD-wot-thing-description11-20201124/#sec-core-vocabulary-definition" - vocab: - WoTAtType: "@type" - WoTAtContext: "@context" - WoTAnyURI: "https://www.w3.org/2019/wot/thing/v1" - WoTActions: "actions" - WoTCreated: "created" - WoTConst: "const" - WoTDataType: "type" - WoTDataTypeAnyURI: "anyURI" - WoTDataTypeArray: "array" - WoTDataTypeBool: "boolean" - WoTDataTypeDateTime: "dateTime" - WoTDataTypeInteger: "integer" - WoTDataTypeUnsignedInt: "unsignedInt" - WoTDataTypeNumber: "number" - WoTDataTypeObject: "object" - WoTDataTypeString: "string" - WoTDataTypeNone: "" - WoTDescription: "description" - WoTDescriptions: "descriptions" - WoTEnum: "enum" - WoTEvents: "events" - WoTFormat: "format" - WoTForms: "forms" - WoTHref: "href" - WoTID: "id" - WoTInput: "input" - WoTLinks: "links" - WoTMaximum: "maximum" - WoTMaxItems: "maxItems" - WoTMaxLength: "maxLength" - WoTMinimum: "minimum" - WoTMinItems: "minItems" - WoTMinLength: "minLength" - WoTModified: "modified" - WoTOperation: "op" - WoTOutput: "output" - WoTProperties: "properties" - WoTReadOnly: "readOnly" - WoTRequired: "required" - WoTSecurity: "security" - WoTSupport: "support" - WoTTitle: "title" - WoTTitles: "titles" - WoTVersion: "version" - - # additional security schemas - # Intended for use by Hub services. - WoTNoSecurityScheme: "NoSecurityScheme" - WoTBasicSecurityScheme: "BasicSecurityScheme" - WoTDigestSecurityScheme: "DigestSecurityScheme" - WoTAPIKeySecurityScheme: "APIKeySecurityScheme" - WoTBearerSecurityScheme: "BearerSecurityScheme" - WoTPSKSecurityScheme: "PSKSecurityScheme" - WoTOAuth2SecurityScheme: "OAuth2SecurityScheme" - - #--- Consumer HTTP Protocol binding operations - # Properties - OpObserveAllProperties: "observeallproperties" - OpObserveMultipleProperties: "observemultipleproperties" - OpObserveProperty: "observeproperty" - OpReadAllProperties: "readallproperties" - OpReadMultipleProperties: "readmultipleproperties" - OpReadProperty: "readproperty" - OpWriteProperty: "writeproperty" - OpWriteMultipleProperties: "writemultipleproperties" - OpUnobserveAllProperties: "unobserveallproperties" - OpUnobserveMultipleProperties: "unobservemultipleproperties" - OpUnobserveProperty: "unobserveroperty" - - # Events - OpSubscribeAllEvents: "subscribeallevents" - OpSubscribeEvent: "subscribeevent" - OpSubscribeMultipleEvents: "subscribemultipleevents" - OpUnsubscribeAllEvents: "unsubscribeallevents" - OpUnsubscribeEvent: "unsubscribeevent" - OpUnsubscribeMultipleEvents: "unsubscribemultipleevents" - - # Actions - OpCancelAction: "cancelaction" - OpInvokeAction: "invokeaction" - # intended for dashboards that show actions being invoked by other consumers - OpObserveAction: "observeaction" # future operation? - OpObserveAllActions: "observeallactions" # future operation? - OpQueryAction: "queryaction" - OpQueryAllActions: "queryallactions" - OpQueryMultipleActions: "querymultipleactions" - #OpUnobserveAction: "unobserveaction" # future operation? - #OpUnobserveAllActions: "unobserveallactions" # future operation? - - # Request statuses -# StatusCompleted: "completed" -# StatusFailed: "failed" -# StatusPending: "pending" -# StatusRunning: "running" - -#--- non wot-standard operations --- - -# consumer operations not in the TD spec -# TODO: look for a standard approach in the auth spec -# HTOpLogin: "login" -# HTOpLogout: "logout" -# HTOpRefresh: "refresh" -# HTOpReadEvent: "readevent" # not standard -# HTOpReadAllEvents: "readallevents" # not standard diff --git a/bindings/ipnet/IpNet_test.go b/bindings/ipnet/IpNet_test.go index 743c01e6..52c1a948 100644 --- a/bindings/ipnet/IpNet_test.go +++ b/bindings/ipnet/IpNet_test.go @@ -6,31 +6,33 @@ import ( "testing" "time" + "github.com/hiveot/hivekit/go/modules/authn" + "github.com/hiveot/hivekit/go/testenv" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/ipnet/config" "github.com/hiveot/hub/bindings/ipnet/service" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/testenv" "github.com/stretchr/testify/require" ) var tempFolder string -var ts *testenv.TestServer +var testEnv *testenv.TestEnv const agentUsesWSS = true // TestMain run test server and use the project test folder as the home folder. // All tests are run using the simulation file. func TestMain(m *testing.M) { + var stopFn func() // setup environment tempFolder = path.Join(os.TempDir(), "test-ipnet") - logging.SetLogging("info", "") + utils.SetLogging("info", "") // - ts = testenv.StartTestServer(true) + testEnv, stopFn = testenv.StartTestEnv("") result := m.Run() time.Sleep(time.Millisecond) - ts.Stop() + stopFn() if result == 0 { _ = os.RemoveAll(tempFolder) } @@ -46,9 +48,9 @@ func TestStartStop(t *testing.T) { cfg.ScanAsRoot = false svc := service.NewIpNetBinding(cfg) - ag, _ := ts.AddConnectService("ipnet") - defer ag.Disconnect() - err := svc.Start(ag) + cc1, _ := testEnv.NewConnectedClient("ipnet", authn.ClientRoleService) + defer cc1.Close() + err := svc.Start(cc1) require.NoError(t, err) defer svc.Stop() diff --git a/bindings/ipnet/cmd/main.go b/bindings/ipnet/cmd/main.go index 8aceae98..1bd058c1 100644 --- a/bindings/ipnet/cmd/main.go +++ b/bindings/ipnet/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "github.com/hiveot/hivekit/go/modules/factory" "github.com/hiveot/hub/bindings/ipnet/config" "github.com/hiveot/hub/bindings/ipnet/service" "github.com/hiveot/hub/lib/plugin" @@ -8,10 +9,10 @@ import ( // Run the ipnet service binding func main() { - env := plugin.GetAppEnvironment("", true) + env := factory.NewAppEnvironment("", true) cfg := config.NewIPNetConfig() _ = env.LoadConfig(&cfg) svc := service.NewIpNetBinding(cfg) - plugin.StartPlugin(svc, env.ClientID, env.CertsDir, env.ServerURL) + plugin.StartPlugin(svc, env.AppID, env.CertsDir, env.ServerURL) } diff --git a/bindings/ipnet/service/IPNetBinding.go b/bindings/ipnet/service/IPNetBinding.go index d172ea52..a2959969 100644 --- a/bindings/ipnet/service/IPNetBinding.go +++ b/bindings/ipnet/service/IPNetBinding.go @@ -5,10 +5,11 @@ import ( "log/slog" "time" + "github.com/hiveot/hivekit/go/api/msg" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/ipnet/config" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" "github.com/hiveot/hub/lib/plugin" ) @@ -23,8 +24,8 @@ type IPNetBinding struct { } // ActionHandler handle action requests -func (svc *IPNetBinding) ActionHandler(req *messaging.RequestMessage, - _ messaging.IConnection) (resp *messaging.ResponseMessage) { +func (svc *IPNetBinding) ActionHandler(req *msg.RequestMessage, + _ transport.IConnection) (resp *msg.ResponseMessage) { resp = req.CreateResponse(nil, fmt.Errorf("unknown action '%s'", req.Name)) slog.Warn(resp.Error.String()) @@ -34,14 +35,14 @@ func (svc *IPNetBinding) ActionHandler(req *messaging.RequestMessage, // Start the binding func (svc *IPNetBinding) Start(ag *agent.Agent) (err error) { if svc.config.LogLevel != "" { - logging.SetLogging(svc.config.LogLevel, "") + utils.SetLogging(svc.config.LogLevel, "") } slog.Info("Starting the IpNet binding", "logLevel", svc.config.LogLevel) svc.ag = ag // register the action handler - svc.ag.SetRequestHandler(svc.ActionHandler) + svc.ag.SetAppRequestHook(svc.ActionHandler) // publish this binding's TD document err = svc.PubBindingTD() diff --git a/bindings/ipnet/service/PubTD.go b/bindings/ipnet/service/PubTD.go index 3ac3fc51..cc8fe7b9 100644 --- a/bindings/ipnet/service/PubTD.go +++ b/bindings/ipnet/service/PubTD.go @@ -5,7 +5,6 @@ import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" ) @@ -60,7 +59,7 @@ func (svc *IPNetBinding) MakeDeviceTD(deviceInfo *IPDeviceInfo) *td.TD { func (svc *IPNetBinding) PubBindingTD() error { tdi := svc.MakeBindingTD() tdJSON, _ := jsoniter.MarshalToString(tdi) - err := digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + err := svc.ag.WriteTD(tdJSON) //err := svc.ag.UpdateThing(tdi) if err != nil { slog.Error("failed publishing service TD. Continuing...", @@ -72,7 +71,7 @@ func (svc *IPNetBinding) PubBindingTD() error { func (svc *IPNetBinding) PubDeviceTD(deviceInfo *IPDeviceInfo) error { tdi := svc.MakeDeviceTD(deviceInfo) tdJSON, _ := jsoniter.MarshalToString(tdi) - err := digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + err := svc.ag.WriteTD(tdJSON) //err := svc.ag.UpdateThing(tdi) if err != nil { slog.Error("failed publishing device TD. Continuing...", diff --git a/bindings/ipnet/service/PubValues.go b/bindings/ipnet/service/PubValues.go index ceec630f..ab05bfa9 100644 --- a/bindings/ipnet/service/PubValues.go +++ b/bindings/ipnet/service/PubValues.go @@ -20,8 +20,8 @@ func (svc *IPNetBinding) MakeDeviceProps(deviceInfo *IPDeviceInfo) map[string]st return pv } -func (svc *IPNetBinding) PubBindingProps() { +func (svc *IPNetBinding) PubBindingProps(onlyChanges bool) { thingID := svc.config.AgentID - _ = svc.ag.PubProperty(thingID, vocab.PropDevicePollinterval, svc.config.PollInterval) - _ = svc.ag.PubProperty(thingID, "deviceCount", len(svc.devicesMap)) + svc.ag.PubProperty(thingID, vocab.PropDevicePollinterval, svc.config.PollInterval, onlyChanges) + svc.ag.PubProperty(thingID, "deviceCount", len(svc.devicesMap), onlyChanges) } diff --git a/bindings/isy99x/Isy99x_test.go b/bindings/isy99x/Isy99x_test.go index 08c86754..6406810e 100644 --- a/bindings/isy99x/Isy99x_test.go +++ b/bindings/isy99x/Isy99x_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" + "github.com/hiveot/hivekit/go/testenv" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/isy99x/config" "github.com/hiveot/hub/bindings/isy99x/service" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/testenv" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,25 +30,26 @@ const agentID = "isy99x" // set in TestMain var tempDir = path.Join(os.TempDir(), "test-isy99x") -var ts *testenv.TestServer +var testEnv *testenv.TestEnv // TestMain run test server and use the project test folder as the home folder. // All tests are run using the simulation file. func TestMain(m *testing.M) { // setup environment + var closeFn func() cwd, _ := os.Getwd() simulationRoot := "file://" + path.Join(cwd, "test") - logging.SetLogging("info", "") + utils.SetLogging("info", "") appConfig.IsyAddress = simulationRoot // - ts = testenv.StartTestServer(true) + testEnv, closeFn = testenv.StartTestEnv("") result := m.Run() time.Sleep(time.Second) - ts.Stop() + closeFn() if result == 0 { _ = os.RemoveAll(tempDir) } @@ -61,7 +62,7 @@ func TestStartStop(t *testing.T) { _ = os.Remove(nodesFile) // appconfig, read from test/isy99.yaml, contains simulated gateway file - hc, _ := ts.AddConnectService(agentID) + hc, _ := testEnv.AddConnectService(agentID) defer hc.Disconnect() svc := service.NewIsyBinding(appConfig) @@ -83,7 +84,7 @@ func TestBadAddress(t *testing.T) { t.Logf("---%s---\n", t.Name()) _ = os.Remove(nodesFile) - hc, _ := ts.AddConnectService(agentID) + hc, _ := testEnv.AddConnectService(agentID) defer hc.Disconnect() // error case - use real url @@ -103,7 +104,7 @@ func TestIsyAppPoll(t *testing.T) { t.Logf("---%s---\n", t.Name()) _ = os.Remove(nodesFile) // appconfig, read from test/isy99.yaml, contains simulated gateway file - hc, _ := ts.AddConnectService(agentID) + hc, _ := testEnv.AddConnectService(agentID) defer hc.Disconnect() svc := service.NewIsyBinding(appConfig) @@ -124,7 +125,7 @@ func TestSwitch(t *testing.T) { _ = os.Remove(nodesFile) // appconfig, read from test/isy99.yaml, contains simulated gateway file - hc, _ := ts.AddConnectService(agentID) + hc, _ := testEnv.AddConnectService(agentID) defer hc.Disconnect() svc := service.NewIsyBinding(appConfig) diff --git a/bindings/isy99x/cmd/main.go b/bindings/isy99x/cmd/main.go index 00fc2fda..bfde7ee6 100644 --- a/bindings/isy99x/cmd/main.go +++ b/bindings/isy99x/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "github.com/hiveot/hivekit/go/modules/factory" "github.com/hiveot/hub/bindings/isy99x/config" "github.com/hiveot/hub/bindings/isy99x/service" "github.com/hiveot/hub/lib/plugin" @@ -9,7 +10,7 @@ import ( // Start the ISY99x protocol binding func main() { - env := plugin.GetAppEnvironment("", true) + env := factory.NewAppEnvironment("", true) cfg := config.NewIsy99xConfig() _ = env.LoadConfig(&cfg) binding := service.NewIsyBinding(cfg) diff --git a/bindings/isy99x/service/HandleActionRequest.go b/bindings/isy99x/service/HandleActionRequest.go index 4a2f3012..f1d8075d 100644 --- a/bindings/isy99x/service/HandleActionRequest.go +++ b/bindings/isy99x/service/HandleActionRequest.go @@ -4,16 +4,16 @@ import ( "fmt" "log/slog" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" ) // HandleRequest passes the action request to the associated Thing. -func (svc *IsyBinding) handleRequest(req *messaging.RequestMessage, - _ messaging.IConnection) (resp *messaging.ResponseMessage) { +// This distributes the requests to the thing identified in the thingID +func (svc *IsyBinding) HandleRequest(req *msg.RequestMessage, replyTo msg.ResponseHandler) error { if req.Operation == td.OpWriteProperty { - return svc.handleConfigRequest(req) + return svc.handleConfigRequest(req, replyTo) } slog.Info("handleActionRequest", diff --git a/bindings/isy99x/service/HandleConfigRequest.go b/bindings/isy99x/service/HandleConfigRequest.go index 88fbccb0..5de32e6a 100644 --- a/bindings/isy99x/service/HandleConfigRequest.go +++ b/bindings/isy99x/service/HandleConfigRequest.go @@ -4,14 +4,13 @@ import ( "fmt" "log/slog" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" ) // handleConfigRequest for handling device configuration changes -func (svc *IsyBinding) handleConfigRequest(req *messaging.RequestMessage) (resp *messaging.ResponseMessage) { +func (svc *IsyBinding) handleConfigRequest(req *msg.RequestMessage, replyTo msg.ResponseHandler) error { slog.Info("handleConfigRequest", slog.String("thingID", req.ThingID), @@ -20,38 +19,38 @@ func (svc *IsyBinding) handleConfigRequest(req *messaging.RequestMessage) (resp // configuring the binding doesn't require a connection with the gateway if req.ThingID == svc.thingID { - resp = svc.HandleWriteBindingProperty(req) - return + resp := svc.HandleWriteBindingProperty(req) + return replyTo(resp) } if !svc.isyAPI.IsConnected() { // this is a delivery failure - resp = req.CreateResponse(nil, fmt.Errorf("no connection with the gateway")) + resp := req.CreateResponse(nil, fmt.Errorf("no connection with the gateway")) slog.Warn(resp.Error.String()) - return resp + return replyTo(resp) } // pass request to the Thing isyThing := svc.IsyGW.GetIsyThing(req.ThingID) if isyThing == nil { - resp = req.CreateResponse(nil, fmt.Errorf("handleConfigRequest: thing '%s' not found", req.ThingID)) + resp := req.CreateResponse(nil, fmt.Errorf("handleConfigRequest: thing '%s' not found", req.ThingID)) slog.Warn(resp.Error.String()) - return + return replyTo(resp) } - resp = isyThing.HandleConfigRequest(req) + resp := isyThing.HandleConfigRequest(req) // publish changed values after returning go func() { values := isyThing.GetPropValues(true) - _ = svc.ag.PubProperties(isyThing.GetID(), values) + svc.PubProperties(isyThing.GetID(), values, true) // re-submit the TD if the title changes if req.Name == td.WoTTitle { tdi := isyThing.MakeTD() tdJSON, _ := jsoniter.MarshalToString(tdi) - _ = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + svc.WriteTD(tdJSON) //_ = svc.ag.UpdateThing(tdi) } }() - return resp + return replyTo(resp) } diff --git a/bindings/isy99x/service/IsyBinding.go b/bindings/isy99x/service/IsyBinding.go index d546b8b8..16eceeb5 100644 --- a/bindings/isy99x/service/IsyBinding.go +++ b/bindings/isy99x/service/IsyBinding.go @@ -8,15 +8,13 @@ import ( "sync" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/isy99x/config" "github.com/hiveot/hub/bindings/isy99x/service/isy" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/exposedthing" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/plugin" ) // IsyBinding is the protocol binding for managing the ISY99x Insteon gateway @@ -24,10 +22,10 @@ import ( // // or modify this code for multiple isyAPI instances type IsyBinding struct { + *agent.Agent // Configuration of this protocol binding config *config.Isy99xConfig - ag *agent.Agent thingID string // ID of the binding Thing isyAPI *isy.IsyAPI // methods for communicating met ISY gateway device @@ -40,9 +38,6 @@ type IsyBinding struct { // product identification map by {cat}.{subcat} prodMap map[string]InsteonProduct - //binding property values - propValues *exposedthing.ThingValues - mu sync.Mutex stopHeartbeatFn func() } @@ -64,12 +59,12 @@ func (svc *IsyBinding) GetBindingPropValues(onlyChanges bool) map[string]any { // onIsyEvent publishes the event sent by one of the ISY thing. func (svc *IsyBinding) onIsyEvent(thingID string, evName string, value any) { - _ = svc.ag.PubEvent(thingID, evName, value) + svc.ag.PubEvent(thingID, evName, value) } // HandleWriteBindingProperty configures the binding. func (svc *IsyBinding) HandleWriteBindingProperty( - req *messaging.RequestMessage) *messaging.ResponseMessage { + req *msg.RequestMessage) *msg.ResponseMessage { err := fmt.Errorf("unknown configuration request '%s' from '%s'", req.Name, req.SenderID) // connection settings to connect to the gateway @@ -146,23 +141,18 @@ func (svc *IsyBinding) MakeBindingTD() *td.TD { // If no connection can be made the heartbeat will retry periodically until stopped. // // This publishes a TD for this binding, starts a background polling heartbeat. -func (svc *IsyBinding) Start(ag *agent.Agent) (err error) { +func (svc *IsyBinding) Start() (err error) { slog.Info("Starting Isy99x binding") - svc.ag = ag - svc.thingID = ag.GetClientID() + if svc.config.LogLevel != "" { - logging.SetLogging(svc.config.LogLevel, "") + utils.SetLogging(svc.config.LogLevel, "") } svc.prodMap, err = LoadProductMapCSV("") //// 'IsyThings' use the 'isy connection' to talk to the gateway - svc.isyAPI = isy.NewIsyAPI() - svc.IsyGW = NewIsyGateway(svc.prodMap, svc.onIsyEvent) + svc.IsyGW = NewIsyGateway(svc.isyAPI, svc.prodMap, svc.onIsyEvent) _ = svc.isyAPI.Connect(svc.config.IsyAddress, svc.config.LoginName, svc.config.Password) - svc.IsyGW.Init(svc.isyAPI) - - // subscribe to action and property write requests - svc.ag.SetRequestHandler(svc.handleRequest) + svc.IsyGW.Start() // last, start polling heartbeat svc.stopHeartbeatFn = svc.startHeartbeat() @@ -180,7 +170,7 @@ func (svc *IsyBinding) startHeartbeat() (stopFn func()) { var forceRepublish bool var err error - stopFn = plugin.StartHeartbeat(time.Second, func() { + stopFn = utils.StartHeartbeat(time.Second, func() { tdCountDown-- pollCountDown-- republishCountDown-- @@ -191,7 +181,7 @@ func (svc *IsyBinding) startHeartbeat() (stopFn func()) { if !isConnected { // if the connection dropped, send an event if svc.wasConnected { - _ = svc.ag.PubEvent(svc.thingID, vocab.PropNetConnection, isConnected) + svc.PubEvent(svc.thingID, vocab.PropNetConnection, isConnected) } err = svc.isyAPI.Connect(svc.config.IsyAddress, svc.config.LoginName, svc.config.Password) if err == nil { @@ -200,7 +190,7 @@ func (svc *IsyBinding) startHeartbeat() (stopFn func()) { } isConnected = svc.isyAPI.IsConnected() if isConnected { - _ = svc.ag.PubEvent(svc.thingID, vocab.PropNetConnection, isConnected) + svc.PubEvent(svc.thingID, vocab.PropNetConnection, isConnected) } } svc.wasConnected = isConnected @@ -244,10 +234,15 @@ func (svc *IsyBinding) Stop() { } // NewIsyBinding creates a new instance of the ISY99x protocol binding service -func NewIsyBinding(cfg *config.Isy99xConfig) *IsyBinding { - svc := IsyBinding{ - config: cfg, - propValues: exposedthing.NewThingValues(), +// +// appID is the application instance ID +// cfg configures gateway access +func NewIsyBinding(appID string, cfg *config.Isy99xConfig) *IsyBinding { + + svc := &IsyBinding{ + Agent: agent.NewAgent(appID, nil), + config: cfg, + isyAPI: isy.NewIsyAPI(), } - return &svc + return svc } diff --git a/bindings/isy99x/service/IsyDimmerThing.go b/bindings/isy99x/service/IsyDimmerThing.go index 60fba3e1..bd6f6ef9 100644 --- a/bindings/isy99x/service/IsyDimmerThing.go +++ b/bindings/isy99x/service/IsyDimmerThing.go @@ -4,10 +4,10 @@ import ( "errors" "fmt" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/messaging" + "github.com/hiveot/hivekit/go/modules/agent" ) // IsyDimmerThing is a general-purpose dimmer switch @@ -49,14 +49,14 @@ func (it *IsyDimmerThing) MakeTD() *td.TD { return tdi } -func (it *IsyDimmerThing) HandleConfigRequest(req *messaging.RequestMessage) *messaging.ResponseMessage { +func (it *IsyDimmerThing) HandleConfigRequest(req *msg.RequestMessage) *msg.ResponseMessage { return req.CreateResponse(nil, errors.New("unknown config: "+req.Name)) } // HandleActionRequest handles request to execute an action on this device // actionID string as defined in the action affordance // newValue is not used as these actions do not carry a parameter -func (it *IsyDimmerThing) HandleActionRequest(ag *agent.Agent, req *messaging.RequestMessage) *messaging.ResponseMessage { +func (it *IsyDimmerThing) HandleActionRequest(ag *agent.Agent, req *msg.RequestMessage) *msg.ResponseMessage { var restPath = "" var newValue = "" // FIXME: req keys are node attributes keys, not vocab @types (or are they?) diff --git a/bindings/isy99x/service/IsyGatewayThing.go b/bindings/isy99x/service/IsyGatewayThing.go index 91609e0b..4e211ef8 100644 --- a/bindings/isy99x/service/IsyGatewayThing.go +++ b/bindings/isy99x/service/IsyGatewayThing.go @@ -10,10 +10,8 @@ import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" "github.com/hiveot/hub/bindings/isy99x/service/isy" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/exposedthing" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" ) @@ -33,13 +31,11 @@ const ( // IsyGatewayThing is a Thing representing the ISY gateway device. // This implements IThing interface. type IsyGatewayThing struct { + *agent.Agent // REST/SOAP/WS connection to the ISY hub ic *isy.IsyAPI - // The gateway thingID - thingID string - // map of ISY product ID's prodMap map[string]InsteonProduct @@ -50,7 +46,7 @@ type IsyGatewayThing struct { newNodeFound bool // current property values of this thing - propValues *exposedthing.ThingValues + // propValues *ThingValues // protect access to the 'things' map mux sync.RWMutex @@ -162,7 +158,7 @@ func nodeID2ThingID(nodeID string) string { } // AddIsyThing adds a representing of an Insteon device -func (igw *IsyGatewayThing) AddIsyThing(node *isy.IsyNode) error { +func (t *IsyGatewayThing) AddIsyThing(node *isy.IsyNode) error { var isyThing IIsyThing var err error @@ -173,7 +169,7 @@ func (igw *IsyGatewayThing) AddIsyThing(node *isy.IsyNode) error { cat, _ := strconv.ParseInt(parts[0], 10, 16) subCat, _ := strconv.ParseInt(parts[1], 10, 16) productID := fmt.Sprintf("0x%02X.0x%02X", cat, subCat) - prodInfo := igw.prodMap[productID] + prodInfo := t.prodMap[productID] hwVersion := parts[3] // determine what device this is using: @@ -181,82 +177,77 @@ func (igw *IsyGatewayThing) AddIsyThing(node *isy.IsyNode) error { //the category determines the high level device type switch cat { case 0: // general controller, tabletop/remote/touch panel - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 1: // dimmer control - isyThing = NewIsyDimmerThing(igw.evHandler) + isyThing = NewIsyDimmerThing(t.evHandler) break case 2: // OnOff switch - isyThing = NewIsySwitchThing(igw.evHandler) + isyThing = NewIsySwitchThing(t.evHandler) break case 3: // network bridge - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 4: // irrigation control - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 5: // climate control - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 6: // pool/spa control - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 7: // sensor switch - isyThing = NewIsySensorThing(igw.evHandler) + isyThing = NewIsySensorThing(t.evHandler) break case 9: // energy meter/management - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 14: // window/blinds - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break case 15: // access control/ door lock - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) break default: // unknown general purpose thing - isyThing = NewIsyThing(igw.evHandler) + isyThing = NewIsyThing(t.evHandler) } if isyThing != nil { thingID := nodeID2ThingID(node.Address) - isyThing.Init(igw.ic, thingID, node, prodInfo, hwVersion) - igw.mux.Lock() - igw.things[isyThing.GetID()] = isyThing - igw.mux.Unlock() + isyThing.Init(t.ic, thingID, node, prodInfo, hwVersion) + t.mux.Lock() + t.things[isyThing.GetID()] = isyThing + t.mux.Unlock() } return err } // GetIsyThing returns the ISY device Thing with the given ThingID // Returns nil of a thing with this ID doesn't exist -func (igw *IsyGatewayThing) GetIsyThing(thingID string) IIsyThing { - igw.mux.RLock() - defer igw.mux.RUnlock() - it, _ := igw.things[thingID] +func (t *IsyGatewayThing) GetIsyThing(thingID string) IIsyThing { + t.mux.RLock() + defer t.mux.RUnlock() + it, _ := t.things[thingID] return it } // GetIsyThingByNodeID returns the ISY device Thing with the given Node address/ID // Returns nil if a thing with this ID doesn't exist -func (igw *IsyGatewayThing) GetIsyThingByNodeID(nodeID string) IIsyThing { +func (t *IsyGatewayThing) GetIsyThingByNodeID(nodeID string) IIsyThing { thingID := nodeID2ThingID(nodeID) - igw.mux.RLock() - defer igw.mux.RUnlock() - it, _ := igw.things[thingID] + t.mux.RLock() + defer t.mux.RUnlock() + it, _ := t.things[thingID] return it } -// GetID return the gateway thingID -func (igw *IsyGatewayThing) GetID() string { - return igw.thingID -} - // GetIsyThings returns a list of ISY devices for publishing TD or values as updated in // the last call to ReadIsyThings(). -func (igw *IsyGatewayThing) GetIsyThings() []IIsyThing { - igw.mux.RLock() - defer igw.mux.RUnlock() - thingList := make([]IIsyThing, 0, len(igw.things)) - for _, it := range igw.things { +func (t *IsyGatewayThing) GetIsyThings() []IIsyThing { + t.mux.RLock() + defer t.mux.RUnlock() + thingList := make([]IIsyThing, 0, len(t.things)) + for _, it := range t.things { thingList = append(thingList, it) } return thingList @@ -264,33 +255,23 @@ func (igw *IsyGatewayThing) GetIsyThings() []IIsyThing { // GetPropValues returns the current or changed property values. // onlyChanges only provides changed properties -func (igw *IsyGatewayThing) GetPropValues(onlyChanges bool) map[string]any { - values := igw.propValues.GetValues(onlyChanges) - // TODO: add event values. Currently the TD does not list events. - return values -} - -// Init re-initializes the gateway Thing for use and load the gateway configuration/ -// This removes prior use nodes for a fresh start. -func (igw *IsyGatewayThing) Init(ic *isy.IsyAPI) { - igw.ic = ic - igw.thingID = ic.GetID() - igw.things = make(map[string]IIsyThing) - igw.propValues = exposedthing.NewThingValues() - - // values are used in TD title and description - _ = igw.ReadGatewayValues() -} +// func (t *IsyGatewayThing) GetPropValues(onlyChanges bool) map[string]any { +// state := t.Agent.GetState(t.GetThingID()) +// values := state.GetAllProperties() +// // values := t.propValues.GetValues(onlyChanges) +// // TODO: add event values. Currently the TD does not list events. +// return values +// } // MakeTD returns the Gateway TD document // This returns nil if the gateway wasn't initialized -func (igw *IsyGatewayThing) MakeTD() *td.TD { - if igw.ic == nil { +func (t *IsyGatewayThing) MakeTD() *td.TD { + if t.ic == nil { return nil } - tdi := td.NewTD(igw.thingID, igw.Configuration.DeviceSpecs.Model, vocab.DeviceNetGateway) - tdi.Description = igw.Configuration.DeviceSpecs.Make + "-" + igw.Configuration.DeviceSpecs.Model + tdi := td.NewTD(t.GetThingID(), t.Configuration.DeviceSpecs.Model, vocab.DeviceNetGateway) + tdi.Description = t.Configuration.DeviceSpecs.Make + "-" + t.Configuration.DeviceSpecs.Model //--- device read-only attributes tdi.AddPropertyAsString(vocab.PropDeviceMake, "Manufacturer", ""). @@ -321,9 +302,9 @@ func (igw *IsyGatewayThing) MakeTD() *td.TD { prop.ReadOnly = false prop = tdi.AddPropertyAsString(vocab.PropNetIP4, "IP4 address", "") prop.Description = "Configure gateway fix IP address" - prop.ReadOnly = igw.Network.Interface.IsDHCP == false + prop.ReadOnly = t.Network.Interface.IsDHCP == false prop = tdi.AddPropertyAsString(vocab.PropNetPort, "Port", "Gateway connection port") - prop.ReadOnly = igw.Network.Interface.IsDHCP == false + prop.ReadOnly = t.Network.Interface.IsDHCP == false prop = tdi.AddPropertyAsString(PropIDLogin, "Login Name", "Gateway login name") prop.ReadOnly = false prop = tdi.AddPropertyAsString(PropIDPassword, "Password", "Gateway password (hidden)") @@ -367,10 +348,10 @@ func (igw *IsyGatewayThing) MakeTD() *td.TD { //} // PubTD read and publishes the gateway's TD -func (svc *IsyGatewayThing) PubTD(ag *agent.Agent) (err error) { - tdi := svc.MakeTD() +func (t *IsyGatewayThing) PubTD(ag *agent.Agent) (err error) { + tdi := t.MakeTD() tdJSON, _ := jsoniter.MarshalToString(tdi) - err = digitwin.ThingDirectoryUpdateThing(ag.Consumer, tdJSON) + err = ag.WriteTD(tdJSON) //err = ag.UpdateThing(tdi) if err != nil { err = fmt.Errorf("failed publishing ISY gateway TD: %w", err) @@ -380,69 +361,73 @@ func (svc *IsyGatewayThing) PubTD(ag *agent.Agent) (err error) { return nil } -// ReadGatewayValues reads ISY gateway properties. +// ReadGatewayValues reads ISY gateway properties and publish changes. // This loads the gateway 'Configuration', 'System', 'Time' and 'Network' data. // See also: https://wiki.universal-devices.com/index.php?title=ISY_Developers:API:REST_Interface#Return_Values_/_Codes -func (igw *IsyGatewayThing) ReadGatewayValues() (err error) { - if igw.ic == nil { +func (t *IsyGatewayThing) ReadGatewayValues() (err error) { + if t.ic == nil { return fmt.Errorf("No ISY connection") } const NTP_OFFSET = 2208988800 - err = igw.ic.SendRequest("GET", "/rest/config", "", &igw.Configuration) + err = t.ic.SendRequest("GET", "/rest/config", "", &t.Configuration) if err == nil { - err = igw.ic.SendRequest("GET", "/rest/sys", "", &igw.System) + err = t.ic.SendRequest("GET", "/rest/sys", "", &t.System) } if err == nil { - err = igw.ic.SendRequest("GET", "/rest/time", "", &igw.Time) + err = t.ic.SendRequest("GET", "/rest/time", "", &t.Time) } if err == nil { - err = igw.ic.SendRequest("GET", "/rest/network", "", &igw.Network) + err = t.ic.SendRequest("GET", "/rest/network", "", &t.Network) } - pv := igw.propValues - - pv.SetValue(vocab.PropDeviceMake, igw.Configuration.DeviceSpecs.Make) - pv.SetValue(vocab.PropDeviceModel, igw.Configuration.DeviceSpecs.Model) - pv.SetValue(vocab.PropDeviceSoftwareVersion, igw.Configuration.AppVersion) - pv.SetValue(vocab.PropNetMAC, igw.Configuration.Root.ID) - pv.SetValue(vocab.PropDeviceDescription, igw.Configuration.Product.Description) - pv.SetValue(td.WoTTitle, igw.Configuration.Root.Name) // custom name - pv.SetValue(vocab.PropNetIP4, igw.Network.Interface.IP) - pv.SetValue(vocab.PropNetPort, igw.Network.WebServer.HttpPort) - - pv.SetValue("productID", igw.Configuration.Product.ID) - pv.SetValue(PropIDDHCP, strconv.FormatBool(igw.Network.Interface.IsDHCP)) // true or false - //pv.SetValue(PropIDLogin, igw.Configuration.LoginName) - - // isy provides NTP stamp in local time, not in GMT! - sunrise := int64(igw.Time.Sunrise-igw.Time.TMZOffset) - NTP_OFFSET - pv.SetValue(PropIDSunrise, time.Unix(sunrise, 0).Format(time.TimeOnly)) - sunset := int64(igw.Time.Sunset-igw.Time.TMZOffset) - NTP_OFFSET - pv.SetValue(PropIDSunset, time.Unix(sunset, 0).Format(time.TimeOnly)) // seconds since epoc - - pv.SetValue(PropIDNTPHost, igw.System.NTPHost) - pv.SetValue(PropIDNTPEnabled, strconv.FormatBool(igw.System.NTPEnabled)) - pv.SetValue(PropIDTMZOffset, strconv.FormatInt(int64(igw.Time.TMZOffset), 10)) - pv.SetValue(PropIDDSTEnabled, strconv.FormatBool(igw.Time.DST)) + thingID := t.GetThingID() + sunrise := int64(t.Time.Sunrise-t.Time.TMZOffset) - NTP_OFFSET + sunset := int64(t.Time.Sunset-t.Time.TMZOffset) - NTP_OFFSET + + props := map[string]any{ + vocab.PropDeviceMake: t.Configuration.DeviceSpecs.Make, + vocab.PropDeviceModel: t.Configuration.DeviceSpecs.Model, + vocab.PropDeviceSoftwareVersion: t.Configuration.AppVersion, + vocab.PropNetMAC: t.Configuration.Root.ID, + vocab.PropDeviceDescription: t.Configuration.Product.Description, + td.WoTTitle: t.Configuration.Root.Name, // custom name + vocab.PropNetIP4: t.Network.Interface.IP, + vocab.PropNetPort: t.Network.WebServer.HttpPort, + + "productID": t.Configuration.Product.ID, + PropIDDHCP: strconv.FormatBool(t.Network.Interface.IsDHCP), // true or false + //pv.SetValue(PropIDLogin, igw.Configuration.LoginName) + + // isy provides NTP stamp in local time, not in GMT! + PropIDSunrise: time.Unix(sunrise, 0).Format(time.TimeOnly), + PropIDSunset: time.Unix(sunset, 0).Format(time.TimeOnly), // seconds since epoc + + PropIDNTPHost: t.System.NTPHost, + PropIDNTPEnabled: strconv.FormatBool(t.System.NTPEnabled), + PropIDTMZOffset: strconv.FormatInt(int64(t.Time.TMZOffset), 10), + PropIDDSTEnabled: strconv.FormatBool(t.Time.DST), + } + t.PubProperties(thingID, props, true) + return err } // ReadIsyThings reads the ISY Node list and update the collection of ISY Things -func (igw *IsyGatewayThing) ReadIsyThings() error { - if igw.ic == nil { +func (t *IsyGatewayThing) ReadIsyThings() error { + if t.ic == nil { return fmt.Errorf("No ISY connection") } - isyNodes, err := igw.ic.ReadNodes() + isyNodes, err := t.ic.ReadNodes() if err != nil { return err } for _, node := range isyNodes.Nodes { - it := igw.GetIsyThingByNodeID(node.Address) + it := t.GetIsyThingByNodeID(node.Address) if it == nil { - err = igw.AddIsyThing(node) + err = t.AddIsyThing(node) if err != nil { slog.Error("Error adding ISY device. Ignored.", "err", err) } @@ -462,39 +447,55 @@ func (igw *IsyGatewayThing) ReadIsyThings() error { // // Each ISY Thing will be updated with the latest status. It is up to them // to notify their uses with an event if the status has changed. -func (igw *IsyGatewayThing) ReadIsyNodeValues() error { - if igw.ic == nil { +func (t *IsyGatewayThing) ReadIsyNodeValues() error { + if t.ic == nil { return fmt.Errorf("No ISY connection") } isyStatus := isy.IsyStatus{} - err := igw.ic.SendRequest("GET", "/rest/status", "", &isyStatus) + err := t.ic.SendRequest("GET", "/rest/status", "", &isyStatus) for _, node := range isyStatus.Nodes { propID := node.Prop.ID newValue := node.Prop.Value uom := node.Prop.UOM - it := igw.GetIsyThingByNodeID(node.Address) + it := t.GetIsyThingByNodeID(node.Address) if it != nil { err = it.HandleValueUpdate(propID, uom, newValue) } else { // new node found, refresh the node list - igw.newNodeFound = true + t.newNodeFound = true } } return err } +// Start initializes the gateway for use and read its values +// if Start fails then retry periodically until a connection can be established +func (t *IsyGatewayThing) Start() error { + // t.ic = ic + // t.SetThingID = ic.GetID() + // t.things = make(map[string]IIsyThing) + // t.propValues = exposedthing.NewThingValues() + + // values are used in TD title and description + return t.ReadGatewayValues() +} + // NewIsyGateway creates a new instance of the ISY gateway device representation. // prodMap can be retrieved with LoadProductMapCSV() // Call Init() before use. -func NewIsyGateway(prodMap map[string]InsteonProduct, evHandler IsyEventHandler) *IsyGatewayThing { +func NewIsyGateway(ic *isy.IsyAPI, + prodMap map[string]InsteonProduct, evHandler IsyEventHandler) *IsyGatewayThing { + thingID := ic.GetID() isyGW := &IsyGatewayThing{ - evHandler: evHandler, - prodMap: prodMap, - things: make(map[string]IIsyThing), - propValues: exposedthing.NewThingValues(), + Agent: agent.NewAgent(thingID, nil), + + ic: ic, + evHandler: evHandler, + prodMap: prodMap, + things: make(map[string]IIsyThing), } return isyGW } diff --git a/bindings/isy99x/service/IsySensorThing.go b/bindings/isy99x/service/IsySensorThing.go index 1fa5586a..c6c4ec8b 100644 --- a/bindings/isy99x/service/IsySensorThing.go +++ b/bindings/isy99x/service/IsySensorThing.go @@ -3,8 +3,8 @@ package service import ( "errors" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" ) // IsySensorThing is a general-purpose sensor @@ -18,7 +18,7 @@ func (it *IsySensorThing) GetPropValues(onlyChanges bool) map[string]any { return propValues } -func (it *IsySensorThing) HandleConfigRequest(req *messaging.RequestMessage) *messaging.ResponseMessage { +func (it *IsySensorThing) HandleConfigRequest(req *msg.RequestMessage) *msg.ResponseMessage { // TODO: sensor configuration return req.CreateResponse(nil, errors.New("unknown config: "+req.Name)) } diff --git a/bindings/isy99x/service/IsySwitchThing.go b/bindings/isy99x/service/IsySwitchThing.go index 7ed63782..32444953 100644 --- a/bindings/isy99x/service/IsySwitchThing.go +++ b/bindings/isy99x/service/IsySwitchThing.go @@ -5,12 +5,11 @@ import ( "log/slog" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/isy99x/service/isy" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/messaging" ) // IsySwitchThing is a general-purpose on/off switch @@ -32,8 +31,8 @@ func (it *IsySwitchThing) GetPropValues(onlyChanges bool) map[string]any { // HandleActionRequest handles request to execute an action on this device // actionID string as defined in the action affordance // newValue is not used as these actions do not carry a parameter -func (it *IsySwitchThing) HandleActionRequest( - ag *agent.Agent, req *messaging.RequestMessage) *messaging.ResponseMessage { +func (it *IsySwitchThing) HandleActionRequest(req *msg.RequestMessage, replyTo msg.ResponseHandler) error { + // ag *agent.Agent, req *msg.RequestMessage) *msg.ResponseMessage { var restPath = "" var newValue = "" var input bool @@ -51,7 +50,8 @@ func (it *IsySwitchThing) HandleActionRequest( // unknown req newValue = "" err := fmt.Errorf("HandleRequest. Unknown req: %s", req.Name) - return req.CreateResponse(nil, err) + resp := req.CreateResponse(nil, err) + return replyTo(resp) } // Post a new value @@ -60,7 +60,8 @@ func (it *IsySwitchThing) HandleActionRequest( // read the result. As this takes a while, retry every second for 5 seconds if err != nil { - return req.CreateResponse(nil, err) + resp := req.CreateResponse(nil, err) + return replyTo(resp) } // return a 'running' status while reading back the result //send a 'running' ActionStatus message @@ -68,7 +69,7 @@ func (it *IsySwitchThing) HandleActionRequest( // in the background poll for status update until completed go func() { - var resp *messaging.ResponseMessage + var resp *msg.ResponseMessage hasUpdated := false // TODO: clean this up. Use websocket instead of repeated polling. for i := 0; i < 5; i++ { @@ -104,7 +105,8 @@ func (it *IsySwitchThing) HandleActionRequest( // FIXME: send notification in the background for all subscribers. //go it.PublishAllThingValues() } - _ = ag.GetConnection().SendResponse(resp) + replyTo(resp) + }() // send the response async when done return nil diff --git a/bindings/isy99x/service/IsyThing.go b/bindings/isy99x/service/IsyThing.go index f5193431..f7709c57 100644 --- a/bindings/isy99x/service/IsyThing.go +++ b/bindings/isy99x/service/IsyThing.go @@ -5,12 +5,11 @@ import ( "strings" "sync" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" "github.com/hiveot/hub/bindings/isy99x/service/isy" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/exposedthing" - "github.com/hiveot/hub/lib/messaging" ) // mapping from insteon device category to TD device type @@ -38,9 +37,9 @@ type IIsyThing interface { // GetPropValues returns the property values of the thing GetPropValues(onlyChanges bool) map[string]any // HandleActionRequest passes incoming actions to the Thing for execution - HandleActionRequest(ag *agent.Agent, req *messaging.RequestMessage) *messaging.ResponseMessage + HandleActionRequest(ag *agent.Agent, req *msg.RequestMessage) *msg.ResponseMessage // HandleConfigRequest passes configuration changes to the Thing for execution - HandleConfigRequest(req *messaging.RequestMessage) *messaging.ResponseMessage + HandleConfigRequest(req *msg.RequestMessage) *msg.ResponseMessage // HandleValueUpdate updates the Thing properties with value obtained via the ISY gateway HandleValueUpdate(propID string, uom string, newValue string) error // Init assigns the ISY connection and node this Thing represents @@ -53,6 +52,8 @@ type IIsyThing interface { // Intended for building specialized Things or for defining a basic Thing. // This implements the IThing interface. type IsyThing struct { + *agent.Agent + // The node ID, also used as the ThingID nodeID string @@ -65,9 +66,6 @@ type IsyThing struct { // ISY device info productInfo InsteonProduct - // propValues holds the values of the thing properties - propValues *exposedthing.ThingValues - // protect access to property values mux sync.RWMutex @@ -86,10 +84,10 @@ func (it *IsyThing) GetID() string { } // GetPropValues returns the property values, set with read -func (it *IsyThing) GetPropValues(onlyChanges bool) map[string]any { - propValues := it.propValues.GetValues(onlyChanges) - return propValues -} +// func (it *IsyThing) GetPropValues(onlyChanges bool) map[string]any { +// propValues := it.propValues.GetValues(onlyChanges) +// return propValues +// } // GetOutputValue returns the default 'value' property //func (it *IsyThing) GetOutputValue() (string, bool) { @@ -98,35 +96,35 @@ func (it *IsyThing) GetPropValues(onlyChanges bool) map[string]any { // HandleActionRequest invokes the action handler of the specialized thing func (it *IsyThing) HandleActionRequest( - ag *agent.Agent, req *messaging.RequestMessage) *messaging.ResponseMessage { + ag *agent.Agent, req *msg.RequestMessage) *msg.ResponseMessage { err := fmt.Errorf("HandleRequest not supported for this thing") return req.CreateResponse(nil, err) } // HandleConfigRequest invokes the config handler of the specialized thing -func (it *IsyThing) HandleConfigRequest(req *messaging.RequestMessage) *messaging.ResponseMessage { +func (it *IsyThing) HandleConfigRequest(req *msg.RequestMessage, replyTo msg.ResponseHandler) error { // The title is the friendly name of the node if req.Name == td.WoTTitle { newName := req.ToString(0) err := it.isyAPI.Rename(it.nodeID, newName) if err == nil { - // TODO: use WebSocket to receive confirmation of change + // TODO: use ISY WebSocket to receive confirmation of change _ = it.HandleValueUpdate(td.WoTTitle, "", newName) } - return req.CreateResponse(nil, err) + resp := req.CreateResponse(nil, err) + return replyTo(resp) } err := fmt.Errorf("HandleConfigRequest not supported for this thing") - return req.CreateResponse(nil, err) + return err } // HandleValueUpdate provides an update of the Thing's value. // Invoked by the gateway thing when polling node values. // This submits an event to the registered callback if the value differs. func (it *IsyThing) HandleValueUpdate(propID string, uom string, newValue string) (err error) { - it.mux.Lock() - it.propValues.SetValue(propID, newValue) - it.mux.Unlock() + tstate := it.GetState("") + tstate.SetProperty(propID, newValue) return err } @@ -147,28 +145,30 @@ func (it *IsyThing) Init(ic *isy.IsyAPI, thingID string, node *isy.IsyNode, prod it.nodeID = node.Address it.thingID = thingID it.productInfo = prodInfo - it.propValues = exposedthing.NewThingValues() + enabledDisabled := "enabled" if strings.ToLower(node.Enabled) != "true" { enabledDisabled = "disabled" } - pv := exposedthing.NewThingValues() - it.propValues = pv - pv.SetValue("deviceType", it.deviceType) - pv.SetValue("flag", fmt.Sprintf("0x%X", node.Flag)) - pv.SetValue(vocab.PropDeviceEnabledDisabled, enabledDisabled) - pv.SetValue(vocab.PropDeviceDescription, prodInfo.ProductName) - pv.SetValue(td.WoTTitle, node.Name) - pv.SetValue(vocab.PropDeviceModel, prodInfo.Model) - pv.SetValue(vocab.PropDeviceHardwareVersion, hwVersion) - pv.SetValue("nodeType", node.Type) + + props := map[string]any{ + "deviceType": it.deviceType, + "flag": fmt.Sprintf("0x%X", node.Flag), + vocab.PropDeviceEnabledDisabled: enabledDisabled, + vocab.PropDeviceDescription: prodInfo.ProductName, + td.WoTTitle: node.Name, + vocab.PropDeviceModel: prodInfo.Model, + vocab.PropDeviceHardwareVersion: hwVersion, + "nodeType": node.Type, + } + it.PubProperties("", props, false) } // MakeTD return a basic TD document that describes the Thing represented here. // The parent should add properties, events and actions specific to their capabilities. func (it *IsyThing) MakeTD() *td.TD { title := it.productInfo.ProductName - titleProp, _ := it.propValues.GetValue(td.WoTTitle) + titleProp, _ := it.GetState("").GetProperty(td.WoTTitle) if titleProp != nil { title, _ = titleProp.(string) } diff --git a/bindings/isy99x/service/PublishTDs.go b/bindings/isy99x/service/PublishTDs.go index 474bc101..c70484bc 100644 --- a/bindings/isy99x/service/PublishTDs.go +++ b/bindings/isy99x/service/PublishTDs.go @@ -3,9 +3,9 @@ package service import ( "errors" "fmt" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" "log/slog" + + jsoniter "github.com/json-iterator/go" ) // PublishTDs reads and publishes the TD document of the binding, @@ -15,8 +15,7 @@ func (svc *IsyBinding) PublishTDs() (err error) { tdi := svc.MakeBindingTD() tdJSON, _ := jsoniter.MarshalToString(tdi) - err = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) - //err = svc.ag.UpdateThing(tdi) + err = svc.WriteTD(tdJSON) if err != nil { err = fmt.Errorf("failed publishing binding TD: %w", err) slog.Error(err.Error()) @@ -27,7 +26,8 @@ func (svc *IsyBinding) PublishTDs() (err error) { return errors.New("not connected to the gateway") } - err = svc.IsyGW.PubTD(svc.ag) + // use the binding's agent to write TDs (why exactly?) + err = svc.IsyGW.PubTD(svc.Agent) if err == nil { // read and publish the node TDs err = svc.IsyGW.ReadIsyThings() @@ -40,7 +40,7 @@ func (svc *IsyBinding) PublishTDs() (err error) { for _, thing := range svc.IsyGW.GetIsyThings() { tdi = thing.MakeTD() tdJSON, _ = jsoniter.MarshalToString(tdi) - err = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + err = svc.WriteTD(tdJSON) if err != nil { slog.Error("failed publishing Thing TD", "thingID", tdi.ID, "err", err.Error()) diff --git a/bindings/isy99x/service/PublishThingValues.go b/bindings/isy99x/service/PublishThingValues.go index a431fac1..0cf8069f 100644 --- a/bindings/isy99x/service/PublishThingValues.go +++ b/bindings/isy99x/service/PublishThingValues.go @@ -7,7 +7,7 @@ import ( func (svc *IsyBinding) PubEvents(thingID string, evMap map[string]any) { for k, v := range evMap { - _ = svc.ag.PubEvent(thingID, k, v) + svc.ag.PubEvent(thingID, k, v) } } @@ -20,12 +20,7 @@ func (svc *IsyBinding) PublishAllThingValues(onlyChanges bool) error { propMap := svc.GetBindingPropValues(onlyChanges) if len(propMap) > 0 { // no use continuing if publishing fails - err := svc.ag.PubProperties(svc.thingID, propMap) - if err != nil { - err = fmt.Errorf("failed publishing ISY binding props: %w", err) - slog.Error(err.Error()) - return err - } + svc.ag.PubProperties(svc.thingID, propMap) } // read the gateway properties err := svc.IsyGW.ReadGatewayValues() @@ -34,9 +29,9 @@ func (svc *IsyBinding) PublishAllThingValues(onlyChanges bool) error { slog.Error(err.Error()) return err } - propMap = svc.IsyGW.GetPropValues(onlyChanges) + propMap = svc.IsyGW.Agent.GetPropValues() if len(propMap) > 0 { - _ = svc.ag.PubProperties(svc.IsyGW.GetID(), propMap) + svc.PubProperties(svc.IsyGW.GetThingID(), propMap, onlyChanges) } // read and publish each gateway connected node on the gateway diff --git a/bindings/owserver/OWServer_test.go b/bindings/owserver/OWServer_test.go index 0f668251..93cb8262 100644 --- a/bindings/owserver/OWServer_test.go +++ b/bindings/owserver/OWServer_test.go @@ -8,18 +8,15 @@ import ( "testing" "time" + "github.com/hiveot/hivekit/go/api/msg" + "github.com/hiveot/hivekit/go/modules/authn" + "github.com/hiveot/hivekit/go/testenv" "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/owserver/config" "github.com/hiveot/hub/bindings/owserver/service" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/testenv" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" ) // TODO: switch for testing with real owserver @@ -28,7 +25,7 @@ var tempFolder string var storePath string var owsConfig config.OWServerConfig var owsSimulationFile string // simulation file -var ts *testenv.TestServer +var testEnv *testenv.TestEnv const agentID = "owserver" @@ -40,6 +37,7 @@ const device1ID = "C100100000267C7E" // <-- from the simulation file func TestMain(m *testing.M) { // setup environment var err error + var cancelFn func() tempFolder = path.Join(os.TempDir(), "test-owserver") storePath = path.Join(tempFolder, "owserver-state") cwd, _ := os.Getwd() @@ -47,20 +45,20 @@ func TestMain(m *testing.M) { owsSimulationFile = "file://" + path.Join(homeFolder, "owserver-simulation.xml") // uncomment the next line to discover and test with a real owserver //owsSimulationFile = "" - logging.SetLogging("info", "") + utils.SetLogging("info", "") owsConfig = *config.NewConfig() owsConfig.OWServerURL = owsSimulationFile // - ts = testenv.StartTestServer(true) - if ts == nil { + testEnv, cancelFn = testenv.StartTestEnv("") + if testEnv == nil { panic("unable to start test server: " + err.Error()) } result := m.Run() time.Sleep(time.Millisecond * 10) println("stopping testserver") - ts.Stop() + cancelFn() if result == 0 { _ = os.RemoveAll(tempFolder) } @@ -70,14 +68,15 @@ func TestMain(m *testing.M) { func TestStartStop(t *testing.T) { t.Logf("---%s---\n", t.Name()) - svc := service.NewOWServerBinding(storePath, &owsConfig) + // ag1, cc1, _ := testEnv.NewRCAgent(agentID, nil) + cc1, _ := testEnv.NewConnectedClient(agentID, authn.ClientRoleAgent) + svc := service.NewOWServerBinding(agentID, storePath, &owsConfig) - ag, _, _ := ts.AddConnectAgent(agentID) //connected := ag.IsConnected() //require.Equal(t, true, connected) - defer ag.Disconnect() + defer cc1.Close() - err := svc.Start(ag) + err := svc.Start() require.NoError(t, err) // give heartbeat time to run time.Sleep(time.Millisecond * 1) @@ -89,20 +88,22 @@ func TestPoll(t *testing.T) { const userID = "user1" t.Logf("---%s---\n", t.Name()) - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - svc := service.NewOWServerBinding(storePath, &owsConfig) + ag1, cc1, _ := testEnv.NewRCAgent(agentID, nil) + defer cc1.Close() + defer ag1.Stop() + co1, cc2, _ := testEnv.NewConnectedConsumer(userID, authn.ClientRoleManager, false) + defer co1.Stop() + defer cc2.Close() + svc := service.NewOWServerBinding(ag1, storePath, &owsConfig) // Count the number of received TD events err := co1.ObserveProperty("", "") err = co1.Subscribe("", "") require.NoError(t, err) - co1.SetNotificationHandler(func(msg *messaging.NotificationMessage) { - slog.Info("received notification", "operation", msg.Operation, "id", msg.Name) + co1.SetNotificationHook(func(notif *msg.NotificationMessage) { + slog.Info("received notification", "type", notif.AffordanceType, "id", notif.Name) var value interface{} - err2 := utils.DecodeAsObject(msg.Value, &value) + err2 := notif.Decode(&value) assert.NoError(t, err2) tdCount.Add(1) @@ -110,7 +111,7 @@ func TestPoll(t *testing.T) { assert.NoError(t, err) // start the service which publishes TDs - err = svc.Start(ag1) + err = svc.Start() require.NoError(t, err) // give heartbeat a chance to run. stop will wait for it to complete @@ -121,8 +122,8 @@ func TestPoll(t *testing.T) { assert.GreaterOrEqual(t, tdCount.Load(), int32(4)) // get events from the digitwin - dThingID := digitwin.MakeDigitwinID(agentID, device1ID) - events, err := digitwin.ThingValuesReadAllEvents(co1, dThingID) + // dThingID := digitwin.MakeDigitwinID(agentID, device1ID) + events, err := co1.ReadAllEvents(device1ID) require.NoError(t, err) // this thing has 5 sensors and 4 alarm events require.Equal(t, 9, len(events)) @@ -131,14 +132,14 @@ func TestPoll(t *testing.T) { func TestPollInvalidEDSAddress(t *testing.T) { t.Logf("---%s---\n", t.Name()) - hc, _, _ := ts.AddConnectAgent(agentID) - defer hc.Disconnect() + ag1, cc1, _ := testEnv.NewRCAgent(agentID, nil) + defer cc1.Close() badConfig := owsConfig // copy badConfig.OWServerURL = "http://invalidAddress/" - svc := service.NewOWServerBinding(storePath, &badConfig) + svc := service.NewOWServerBinding(ag1, storePath, &badConfig) - err := svc.Start(hc) + err := svc.Start() assert.NoError(t, err) // give heartbeat a chance to run. stop will wait for it to complete time.Sleep(time.Millisecond * 1) @@ -152,15 +153,15 @@ func TestAction(t *testing.T) { t.Logf("---%s---\n", t.Name()) const user1ID = "operator1" // node in test data - var dThingID = digitwin.MakeDigitwinID(agentID, device1ID) + // var dThingID = digitwin.MakeDigitwinID(agentID, device1ID) var actionName = "RelayFunction" // the action attribute as defined by the device var actionValue = "1" - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() + ag1, cc1, _ := testEnv.NewRCAgent(agentID, nil) + defer cc1.Close() - svc := service.NewOWServerBinding(storePath, &owsConfig) - err := svc.Start(ag1) + svc := service.NewOWServerBinding(ag1, storePath, &owsConfig) + err := svc.Start() require.NoError(t, err) defer svc.Stop() @@ -168,10 +169,10 @@ func TestAction(t *testing.T) { time.Sleep(time.Millisecond * 10) // note that the simulation file doesn't support writes so this logs an error - co1, _, _ := ts.AddConnectConsumer(user1ID, authz.ClientRoleOperator) + co1, cc2, _ := testEnv.NewConnectedConsumer(user1ID, authn.ClientRoleOperator, false) require.NoError(t, err) - defer co1.Disconnect() - err = co1.WriteProperty(dThingID, actionName, &actionValue, true) + defer cc2.Close() + err = co1.WriteProperty(device1ID, actionName, &actionValue, true) //err = co1.SendRequest(dThingID, actionName, &actionValue, nil) // can't write to a simulation @@ -186,11 +187,11 @@ func TestConfig(t *testing.T) { var configName = "LEDState" var configValue = "1" - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() + ag1, cc1, _ := testEnv.NewRCAgent(agentID, nil) + defer cc1.Close() - svc := service.NewOWServerBinding(storePath, &owsConfig) - err := svc.Start(ag1) + svc := service.NewOWServerBinding(ag1, storePath, &owsConfig) + err := svc.Start() require.NoError(t, err) defer svc.Stop() @@ -198,10 +199,10 @@ func TestConfig(t *testing.T) { time.Sleep(time.Millisecond * 10) // note that the simulation file doesn't support writes so this logs an error - co1, _, _ := ts.AddConnectConsumer(user1ID, authz.ClientRoleManager) - defer co1.Disconnect() - dThingID := digitwin.MakeDigitwinID(agentID, device1ID) - err = co1.WriteProperty(dThingID, configName, &configValue, true) + co1, cc2, _ := testEnv.NewConnectedConsumer(user1ID, authn.ClientRoleManager, false) + defer cc2.Close() + // dThingID := digitwin.MakeDigitwinID(agentID, device1ID) + err = co1.WriteProperty(device1ID, configName, &configValue, true) // can't write to a simulation file. Write should fail. assert.Error(t, err) diff --git a/bindings/owserver/cmd/main.go b/bindings/owserver/cmd/main.go index 14ffcb90..6e9841b0 100644 --- a/bindings/owserver/cmd/main.go +++ b/bindings/owserver/cmd/main.go @@ -3,17 +3,35 @@ package main import ( "path" + "github.com/hiveot/hivekit/go/modules/factory" + factorypkg "github.com/hiveot/hivekit/go/modules/factory/pkg" "github.com/hiveot/hub/bindings/owserver/config" "github.com/hiveot/hub/bindings/owserver/service" "github.com/hiveot/hub/lib/plugin" ) +// Recipe for a simple device that provides a standalone server for a counter module. + +var mods = map[string]factory.ModuleDefinition{ + client.NewClientModuleType: { + Constructor: client.NewClientFactory, + }, + // owserver is the application module + owserver.OwServerBindingModuleType: { + Constructor: owserver.OWServerBindingFactory, + }, +} + // Start the OWServer protocol binding func main() { - env := plugin.GetAppEnvironment("", true) + // TODO: migrate to use the factory with a recipe + env := factory.NewAppEnvironment("", true) + factory := factorypkg.NewModuleFactory(env, mods) + err := factory.Start() + serviceID := env.AppID cfg := config.NewConfig() _ = env.LoadConfig(&cfg) - storePath := path.Join(env.StoresDir, env.ClientID) - binding := service.NewOWServerBinding(storePath, cfg) - plugin.StartPlugin(binding, env.ClientID, env.CertsDir, env.ServerURL) + storePath := path.Join(env.StoresDir, env.AppID) + binding := service.NewOWServerBinding(serviceID, storePath, cfg) + plugin.StartPlugin(binding, serviceID, env.CertsDir, env.ServerURL) } diff --git a/bindings/owserver/service/HandleRequest.go b/bindings/owserver/service/HandleRequest.go index a30112a5..bd2d37a6 100644 --- a/bindings/owserver/service/HandleRequest.go +++ b/bindings/owserver/service/HandleRequest.go @@ -5,17 +5,17 @@ import ( "log/slog" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hub/bindings/owserver/service/eds" - "github.com/hiveot/hub/lib/messaging" ) const MaxUpdateWaitTime = 10 // HandleRequest handles action or property write requests // For 1-wire, configuration and actions are the same thing -func (svc *OWServerBinding) HandleRequest(req *messaging.RequestMessage, - _ messaging.IConnection) *messaging.ResponseMessage { +func (svc *OWServerBinding) HandleRequest( + req *msg.RequestMessage, replyTo msg.ResponseHandler) error { slog.Info("HandleRequest", slog.String("op", req.Operation), @@ -30,20 +30,20 @@ func (svc *OWServerBinding) HandleRequest(req *messaging.RequestMessage, err = svc.SetTitle(req) deviceTitleByte, _ := svc.customTitles.Get(req.ThingID) resp := req.CreateResponse(string(deviceTitleByte), err) - return resp + return replyTo(resp) } // This is a node update err = svc.WriteNode(req) if err != nil { resp := req.CreateResponse(nil, err) - return resp + return replyTo(resp) } // Actions do not return a value if req.Operation == td.OpInvokeAction { resp := req.CreateResponse(nil, nil) - return resp + return replyTo(resp) } // in the background poll the result a few times until the requested @@ -55,7 +55,7 @@ func (svc *OWServerBinding) HandleRequest(req *messaging.RequestMessage, } // complete or fail the request resp := req.CreateResponse(newValue, err) - _ = svc.ag.SendResponse(resp) + replyTo(resp) // finally do a full refresh that sends notifications to subscribers _ = svc.RefreshPropertyValues(false) @@ -69,7 +69,7 @@ func (svc *OWServerBinding) HandleRequest(req *messaging.RequestMessage, // SetTitle configures a new title of a thing // // OWServer doesn't support this so store this in the service store. -func (svc *OWServerBinding) SetTitle(req *messaging.RequestMessage) (err error) { +func (svc *OWServerBinding) SetTitle(req *msg.RequestMessage) (err error) { valueStr := req.ToString(0) @@ -86,7 +86,7 @@ func (svc *OWServerBinding) SetTitle(req *messaging.RequestMessage) (err error) // WriteNode validates and writes the new value to the node // in 1-wire actions and configuration is the same thing -func (svc *OWServerBinding) WriteNode(req *messaging.RequestMessage) (err error) { +func (svc *OWServerBinding) WriteNode(req *msg.RequestMessage) (err error) { var attr eds.OneWireAttr valueStr := req.ToString(0) @@ -127,7 +127,7 @@ func (svc *OWServerBinding) WriteNode(req *messaging.RequestMessage) (err error) // WaitForValueUpdate waits for the node value to be applied func (svc *OWServerBinding) WaitForValueUpdate( - req *messaging.RequestMessage, seconds int) (newValue string, hasUpdated bool) { + req *msg.RequestMessage, seconds int) (newValue string, hasUpdated bool) { var err error valueStr := req.ToString(0) diff --git a/bindings/owserver/service/OWServerBinding.go b/bindings/owserver/service/OWServerBinding.go index be5e6097..4105c67e 100644 --- a/bindings/owserver/service/OWServerBinding.go +++ b/bindings/owserver/service/OWServerBinding.go @@ -7,14 +7,12 @@ import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/modules/bucketstore" + "github.com/hiveot/hivekit/go/modules/bucketstore/internal/stores/kvbtree" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/owserver/config" "github.com/hiveot/hub/bindings/owserver/service/eds" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/kvbtree" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" ) @@ -28,8 +26,10 @@ const customTitlesKey = "customTitles" // OWServerBinding is the hub protocol binding plugin for capturing 1-wire OWServer V2 Data type OWServerBinding struct { + *agent.Agent + // Connecting ID and service ID of this binding - agentID string + // serviceID string // Configuration of this protocol binding config *config.OWServerConfig @@ -49,9 +49,9 @@ type OWServerBinding struct { values map[string]map[string]NodeValueStamp // persistent store with device titles - store buckets.IBucketStore + store bucketstore.IBucketStore // the user edited node names - customTitles buckets.IBucket + customTitles bucketstore.IBucket // nodes by thingID. Used in handling action requests nodes map[string]*eds.OneWireNode @@ -67,7 +67,7 @@ type OWServerBinding struct { func (svc *OWServerBinding) CreateBindingTD() *td.TD { // This binding exposes the TD of itself. // Currently its configuration comes from file. - tdi := td.NewTD(svc.agentID, "OWServer binding", vocab.DeviceTypeService) + tdi := td.NewTD(svc.GetThingID(), "OWServer binding", vocab.DeviceTypeService) tdi.Description = "Driver for the OWServer V2 Gateway 1-wire interface" prop := tdi.AddProperty(bindingMake, "Developer", "", td.DataTypeString). @@ -102,19 +102,17 @@ func (svc *OWServerBinding) GetBindingPropValues() map[string]any { // This publishes a TD for this binding, starts a background heartbeat. // // ag is the agent connection for receiving requests and sending responses. -func (svc *OWServerBinding) Start(ag *agent.Agent) (err error) { +func (svc *OWServerBinding) Start() (err error) { slog.Info("Starting OWServer binding") if svc.config.LogLevel != "" { - logging.SetLogging(svc.config.LogLevel, "") + utils.SetLogging(svc.config.LogLevel, "") } - svc.ag = ag - svc.agentID = ag.GetClientID() // Create the adapter for the OWServer 1-wire gateway svc.edsAPI = eds.NewEdsAPI( svc.config.OWServerURL, svc.config.OWServerLogin, svc.config.OWServerPassword) // subscribe to action and configuration requests - svc.ag.SetRequestHandler(svc.HandleRequest) + svc.ag.SetAppRequestHook(svc.HandleRequest) // open the store err = svc.store.Open() @@ -127,14 +125,13 @@ func (svc *OWServerBinding) Start(ag *agent.Agent) (err error) { tdi := svc.CreateBindingTD() svc.things[tdi.ID] = tdi tdJSON, _ := jsoniter.MarshalToString(tdi) - err = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) - //err = svc.ag.UpdateThing(tdi) + err = svc.ag.WriteTD(tdJSON) if err != nil { slog.Error("failed publishing service TD. Continuing...", slog.String("err", err.Error())) } else { props := svc.GetBindingPropValues() - err = ag.PubProperties(tdi.ID, props) + svc.ag.PubProperties(tdi.ID, props) } // last, start polling heartbeat @@ -153,7 +150,7 @@ func (svc *OWServerBinding) startHeartBeat() (stopFn func()) { var pollCountDown = 0 var republishCountDown = 0 - stopFn = plugin.StartHeartbeat(time.Second, func() { + stopFn = utils.StartHeartbeat(time.Second, func() { tdCountDown-- pollCountDown-- republishCountDown-- @@ -161,10 +158,7 @@ func (svc *OWServerBinding) startHeartBeat() (stopFn func()) { // polling nodes and values takes one call. // Since this can take some time, check if client is closed before using it. nodes, err := svc.PollNodes() - svc.mux.RLock() - isConnected := svc.ag.IsConnected() - svc.mux.RUnlock() - if err == nil && isConnected { + if err == nil { if tdCountDown <= 0 { // Every TDInterval publish the full TD's err = svc.PublishNodeTDs(nodes) @@ -201,15 +195,27 @@ func (svc *OWServerBinding) Stop() { slog.Info("OWServer binding stopped") } -// NewOWServerBinding creates a new OWServer Protocol Binding service +// NewOWServerBinding creates a new OWServer Protocol Binding agent +// This agent can be linked to a server module or to a RC client. +// Intended to be used with the factory. // +// serviceID is the service thingID. Typically the environment appID +// storePath is the location to store the ?. "" for in-memory storage. // config holds the configuration of the service -func NewOWServerBinding(storePath string, config *config.OWServerConfig) *OWServerBinding { +func NewOWServerBinding( + serviceID string, storePath string, config *config.OWServerConfig) *OWServerBinding { + + // what should the binding ID be? + // -> thingID to be addressed + // what ID does the factory use? + // -> the factory creates a client or server module with the RC clientID + // -> the clientID can be the appID. it must match the server auth store := kvbtree.NewKVStore(storePath) // these are from hub configuration svc := &OWServerBinding{ + Agent: agent.NewAgent(serviceID, nil), config: config, store: store, values: make(map[string]map[string]NodeValueStamp), diff --git a/bindings/owserver/service/PublishNodeTDs.go b/bindings/owserver/service/PublishNodeTDs.go index 2d0d5cc1..419e6411 100644 --- a/bindings/owserver/service/PublishNodeTDs.go +++ b/bindings/owserver/service/PublishNodeTDs.go @@ -4,7 +4,6 @@ import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" "github.com/hiveot/hub/bindings/owserver/service/eds" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" ) @@ -128,7 +127,7 @@ func (svc *OWServerBinding) PublishNodeTD(node *eds.OneWireNode) (err error) { tdi := svc.CreateTDFromNode(node) svc.things[tdi.ID] = tdi tdJSON, _ := jsoniter.MarshalToString(tdi) - err = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + svc.ag.WriteTD(tdJSON) //err = svc.ag.UpdateThing(td) return err } diff --git a/bindings/owserver/service/PublishNodeValues.go b/bindings/owserver/service/PublishNodeValues.go index 56c88cce..0ddc1a28 100644 --- a/bindings/owserver/service/PublishNodeValues.go +++ b/bindings/owserver/service/PublishNodeValues.go @@ -81,7 +81,7 @@ func (svc *OWServerBinding) PublishNodeValues(nodes []*eds.OneWireNode, force bo slog.String("thingID", thingID), slog.String("attrID", attrID), slog.String("value", attr.Value)) - err = svc.ag.PubEvent(nodeTD.ID, attrID, value) + svc.ag.PubEvent(nodeTD.ID, attrID, value) evCount++ } } @@ -98,7 +98,7 @@ func (svc *OWServerBinding) PublishNodeValues(nodes []*eds.OneWireNode, force bo slog.String("thingID", thingID), slog.Int("count", len(propMap)), ) - err = svc.ag.PubProperties(thingID, propMap) + svc.ag.PubProperties(thingID, propMap) propCount += len(propMap) } } diff --git a/bindings/weather/Weather_test.go b/bindings/weather/Weather_test.go index e027fdb4..9d9941a1 100644 --- a/bindings/weather/Weather_test.go +++ b/bindings/weather/Weather_test.go @@ -7,14 +7,13 @@ import ( "testing" "time" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/modules/authn" + "github.com/hiveot/hivekit/go/testenv" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/weather/config" "github.com/hiveot/hub/bindings/weather/providers" "github.com/hiveot/hub/bindings/weather/service" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/testenv" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -43,17 +42,19 @@ var storePath string var tempFolder string var weatherConfig = config.NewWeatherConfig() -func Setup() (ts *testenv.TestServer, ag *agent.Agent, stopFn func()) { - logging.SetLogging("warn", "") - ts = testenv.StartTestServer(true) +func Setup() (testEnv *testenv.TestEnv, ag *agent.Agent, stopFn func()) { + var cancelFn func() - ag, _, _ = ts.AddConnectAgent(agentID) - logging.SetLogging("info", "") + utils.SetLogging("warn", "") + testEnv, cancelFn = testenv.StartTestEnv("") - return ts, ag, func() { - logging.SetLogging("warn", "") - ag.Disconnect() - ts.Stop() + ag, _, _ = testEnv.NewRCAgent(agentID, nil) + utils.SetLogging("info", "") + + return testEnv, ag, func() { + utils.SetLogging("warn", "") + ag.Stop() + cancelFn() } } @@ -65,7 +66,7 @@ func TestMain(m *testing.M) { storePath = path.Join(tempFolder, "openmeteo-config") _ = os.RemoveAll(storePath) - logging.SetLogging("info", "") + utils.SetLogging("info", "") //ts = testenv.StartTestServer(true) result := m.Run() @@ -78,12 +79,13 @@ func TestMain(m *testing.M) { func TestStartStop(t *testing.T) { t.Logf("---%s---\n", t.Name()) + _, ag, stopFn := Setup() defer stopFn() svc := service.NewWeatherBinding(storePath, weatherConfig) + err := svc.Start() - err := svc.Start(ag) require.NoError(t, err) // give heartbeat time to run time.Sleep(time.Millisecond * 1) @@ -116,7 +118,8 @@ func TestPollFromService(t *testing.T) { defer stopFn() svc := service.NewWeatherBinding(storePath, weatherConfig) - err := svc.Start(ag) + err := svc.Start() + require.NoError(t, err) err = svc.LocationStore().Add(testLocation1) err = svc.LocationStore().Add(testLocation2) @@ -143,11 +146,12 @@ func TestDisableCurrent(t *testing.T) { t.Logf("---%s---\n", t.Name()) client1ID := "client1" - ts, ag, stopFn := Setup() + testEnv, ag, stopFn := Setup() defer stopFn() svc := service.NewWeatherBinding(storePath, weatherConfig) - err := svc.Start(ag) + err := svc.Start() + require.NoError(t, err) err = svc.AddLocation(testLocation1) err = svc.AddLocation(testLocation2) @@ -157,10 +161,11 @@ func TestDisableCurrent(t *testing.T) { // give heartbeat time to run time.Sleep(time.Millisecond * 1) - co1, _, _ := ts.AddConnectConsumer(client1ID, authz.ClientRoleAdmin) - defer co1.Disconnect() + co1, cc1, _ := testEnv.NewConnectedConsumer(client1ID, authn.ClientRoleAdmin, false) + defer co1.Stop() + defer cc1.Stop() - thingID := digitwin.MakeDigitwinID(ag.GetClientID(), testLocation1.ID) + thingID := testLocation1.ID err = co1.WriteProperty(thingID, service.PropNameCurrentEnabled, false, true) require.NoError(t, err) diff --git a/bindings/weather/cmd/main.go b/bindings/weather/cmd/main.go index 5e785fb5..429d7255 100644 --- a/bindings/weather/cmd/main.go +++ b/bindings/weather/cmd/main.go @@ -4,6 +4,7 @@ import ( "log/slog" "path" + "github.com/hiveot/hivekit/go/modules/factory" "github.com/hiveot/hub/bindings/weather/config" "github.com/hiveot/hub/bindings/weather/service" "github.com/hiveot/hub/lib/plugin" @@ -11,7 +12,7 @@ import ( // Start the Weather binding func main() { - env := plugin.GetAppEnvironment("", true) + env := factory.NewAppEnvironment("", true) cfg := config.NewWeatherConfig() err := env.LoadConfig(&cfg) if err != nil { diff --git a/bindings/weather/service/HandleRequests.go b/bindings/weather/service/HandleRequests.go index a567873a..485f3549 100644 --- a/bindings/weather/service/HandleRequests.go +++ b/bindings/weather/service/HandleRequests.go @@ -4,20 +4,21 @@ import ( "fmt" "log/slog" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/weather/config" - "github.com/hiveot/hub/lib/messaging" ) // HandleRequest passes the action and config request to the associated Thing. -func (svc *WeatherBinding) handleRequest(req *messaging.RequestMessage, - c messaging.IConnection) (resp *messaging.ResponseMessage) { +func (svc *WeatherBinding) HandleRequest( + req *msg.RequestMessage, replyTo msg.ResponseHandler) error { + var err error if req.Operation == td.OpWriteProperty { - return svc.handleConfigRequest(req, c) + return svc.handleConfigRequest(req, replyTo) } slog.Info("handleActionRequest", @@ -38,13 +39,14 @@ func (svc *WeatherBinding) handleRequest(req *messaging.RequestMessage, } else { err = fmt.Errorf("handleActionRequest: unknown operation '%s' for thing '%s'", req.Operation, req.ThingID) } - resp = req.CreateResponse(nil, err) + resp := req.CreateResponse(nil, err) slog.Warn(resp.Error.String()) - return resp + return replyTo(resp) } -func (svc *WeatherBinding) handleConfigRequest(req *messaging.RequestMessage, - _ messaging.IConnection) (resp *messaging.ResponseMessage) { +func (svc *WeatherBinding) handleConfigRequest( + req *msg.RequestMessage, replyTo msg.ResponseHandler) error { + var err error var newValue any // if newValue is set the property is published @@ -56,9 +58,9 @@ func (svc *WeatherBinding) handleConfigRequest(req *messaging.RequestMessage, // If this is a preconfigured location it cannot be modified loc, found := svc.locationStore.Get(req.ThingID) if !found { - resp = req.CreateResponse(nil, fmt.Errorf("handleConfigRequest: Location '%s' not found", req.ThingID)) + resp := req.CreateResponse(nil, fmt.Errorf("handleConfigRequest: Location '%s' not found", req.ThingID)) slog.Warn(resp.Error.String()) - return resp + return replyTo(resp) } switch req.Name { @@ -102,14 +104,14 @@ func (svc *WeatherBinding) handleConfigRequest(req *messaging.RequestMessage, if err != nil { slog.Warn(err.Error()) } - resp = req.CreateResponse(req.Input, err) + resp := req.CreateResponse(req.Input, err) // If a new value is set, update the location and publish the result if err == nil && newValue != nil { svc.locationStore.Update(loc) go func() { - _ = svc.ag.PubProperty(req.ThingID, req.Name, newValue) + svc.PubProperty(req.ThingID, req.Name, newValue) }() } - return resp + return replyTo(resp) } diff --git a/bindings/weather/service/LocationStore.go b/bindings/weather/service/LocationStore.go index cf16c8ed..db0a53ae 100644 --- a/bindings/weather/service/LocationStore.go +++ b/bindings/weather/service/LocationStore.go @@ -4,16 +4,16 @@ import ( "fmt" "sync" + "github.com/hiveot/hivekit/go/modules/bucketstore" + bucketstorepkg "github.com/hiveot/hivekit/go/modules/bucketstore/pkg" "github.com/hiveot/hub/bindings/weather/config" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/kvbtree" jsoniter "github.com/json-iterator/go" ) // LocationStore stores configured locationStore in a bucket store type LocationStore struct { - bucketStore buckets.IBucketStore - locationsBucket buckets.IBucket + bucketStore bucketstore.IBucketStore + locationsBucket bucketstore.IBucket locations []config.WeatherLocation // lock location updates mux sync.RWMutex @@ -112,7 +112,7 @@ func (svc *LocationStore) Update(loc config.WeatherLocation) { } func NewLocationStore(storePath string) *LocationStore { - bucketStore := kvbtree.NewKVStore(storePath) + bucketStore := bucketstorepkg.NewKVBTreeStore(storePath) store := &LocationStore{ bucketStore: bucketStore, diff --git a/bindings/weather/service/PublishTDs.go b/bindings/weather/service/PublishTDs.go index c63b376e..09128b71 100644 --- a/bindings/weather/service/PublishTDs.go +++ b/bindings/weather/service/PublishTDs.go @@ -3,9 +3,9 @@ package service import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" "github.com/hiveot/hub/bindings/weather/config" "github.com/hiveot/hub/bindings/weather/providers" - "github.com/hiveot/hub/lib/agent" ) // read-only properties @@ -221,9 +221,10 @@ func CreateTDOfLocation(defaultCfg *config.WeatherConfig, cfg *config.WeatherLoc // PublishBindingTD publishes the TD of the binding itself func PublishBindingTD(ag *agent.Agent, cfg *config.WeatherConfig) error { - thingID := ag.GetClientID() + thingID := ag.GetThingID() tdoc := CreateBindingTD(thingID) - err := ag.UpdateThing(tdoc) + tdJson, _ := td.MarshalTD(tdoc) + err := ag.WriteTD(tdJson) if err == nil { err = PublishBindingProperties(ag, thingID, cfg) } @@ -244,7 +245,8 @@ func PublishLocationTDs(ag *agent.Agent, defaultCfg *config.WeatherConfig, locat // PublishLocationTD publishes the TD of the given location and its current values func PublishLocationTD(ag *agent.Agent, defaultCfg *config.WeatherConfig, loc config.WeatherLocation) error { tdoc := CreateTDOfLocation(defaultCfg, &loc) - err := ag.UpdateThing(tdoc) + tdJson, _ := td.MarshalTD(tdoc) + err := ag.WriteTD(tdJson) if err == nil { err = PublishLocationProperties(ag, loc.ID, loc) } diff --git a/bindings/weather/service/PublishValues.go b/bindings/weather/service/PublishValues.go index 5eac1208..f36f48b6 100644 --- a/bindings/weather/service/PublishValues.go +++ b/bindings/weather/service/PublishValues.go @@ -7,10 +7,10 @@ import ( "time" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/agent" "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/weather/config" "github.com/hiveot/hub/bindings/weather/providers" - "github.com/hiveot/hub/lib/agent" ) // Poll for current and forecast weather updates and publish events. @@ -56,7 +56,7 @@ func (svc *WeatherBinding) Poll() error { slog.String("temp", currentWeather.Temperature), slog.String("showers", currentWeather.Showers), ) - err2 = PublishCurrent(svc.ag, loc.ID, currentWeather) + PublishCurrent(svc.Agent, loc.ID, currentWeather) } // keep track of the last error but don't stop polling if err2 != nil { @@ -104,8 +104,8 @@ func PublishBindingProperties(ag *agent.Agent, thingID string, cfg *config.Weath PropNameWeatherProvider: cfg.DefaultProvider, PropNameUnitsWindSpeed: cfg.WindSpeedUnits, } - err := ag.PubProperties(thingID, propMap) - return err + ag.PubProperties(thingID, propMap) + return nil } // PublishLocationProperties publish attributes and configuration of a location @@ -120,16 +120,13 @@ func PublishLocationProperties(ag *agent.Agent, thingID string, config config.We vocab.PropLocationLongitude: config.Longitude, vocab.PropLocationName: config.Name, } - err := ag.PubProperties(thingID, propMap) - return err + ag.PubProperties(thingID, propMap) + return nil } // PublishCurrent publish events with the current weather -func PublishCurrent(ag *agent.Agent, thingID string, current providers.CurrentWeather) error { - err := ag.PubProperty(thingID, PropNameCurrentUpdated, current.Updated) - if err != nil { - return err - } +func PublishCurrent(ag *agent.Agent, thingID string, current providers.CurrentWeather) { + ag.PubProperty(thingID, PropNameCurrentUpdated, current.Updated) // convert wind speed to configured units windSpeed := utils.DecodeAsNumber(current.WindSpeed) @@ -137,21 +134,19 @@ func PublishCurrent(ag *agent.Agent, thingID string, current providers.CurrentWe // convert wind gusts to configured units windGusts := utils.DecodeAsNumber(current.WindGusts) - _ = ag.PubEvent(thingID, vocab.PropEnvHumidity, current.Humidity) - _ = ag.PubEvent(thingID, vocab.PropEnvPrecipitation, current.Precipitation) - _ = ag.PubEvent(thingID, vocab.PropEnvPressureSeaLevel, current.AtmoPressureMsl) - _ = ag.PubEvent(thingID, vocab.PropEnvPressureSurface, current.AtmoPressureSurface) - _ = ag.PubEvent(thingID, vocab.PropEnvPrecipitationRain, current.Rain) - _ = ag.PubEvent(thingID, vocab.PropEnvPrecipitationSnow, current.Snowfall) - _ = ag.PubEvent(thingID, vocab.PropEnvPrecipitation, current.Precipitation) - _ = ag.PubEvent(thingID, vocab.PropEnvTemperature, current.Temperature) - _ = ag.PubEvent(thingID, vocab.PropEnvWindHeading, current.WindHeading) + ag.PubEvent(thingID, vocab.PropEnvHumidity, current.Humidity) + ag.PubEvent(thingID, vocab.PropEnvPrecipitation, current.Precipitation) + ag.PubEvent(thingID, vocab.PropEnvPressureSeaLevel, current.AtmoPressureMsl) + ag.PubEvent(thingID, vocab.PropEnvPressureSurface, current.AtmoPressureSurface) + ag.PubEvent(thingID, vocab.PropEnvPrecipitationRain, current.Rain) + ag.PubEvent(thingID, vocab.PropEnvPrecipitationSnow, current.Snowfall) + ag.PubEvent(thingID, vocab.PropEnvPrecipitation, current.Precipitation) + ag.PubEvent(thingID, vocab.PropEnvTemperature, current.Temperature) + ag.PubEvent(thingID, vocab.PropEnvWindHeading, current.WindHeading) // todo: configure unit windGustsKph := math.Round(float64(windGusts) * 3.6) windSpeedKph := math.Round(float64(windSpeed) * 3.6) - _ = ag.PubEvent(thingID, vocab.PropEnvWindGusts, windGustsKph) // m/s -> km/h - _ = ag.PubEvent(thingID, vocab.PropEnvWindSpeed, windSpeedKph) // m/s -> km/h - - return err + ag.PubEvent(thingID, vocab.PropEnvWindGusts, windGustsKph) // m/s -> km/h + ag.PubEvent(thingID, vocab.PropEnvWindSpeed, windSpeedKph) // m/s -> km/h } diff --git a/bindings/weather/service/WeatherBinding.go b/bindings/weather/service/WeatherBinding.go index 5d87446b..a64ef3af 100644 --- a/bindings/weather/service/WeatherBinding.go +++ b/bindings/weather/service/WeatherBinding.go @@ -5,10 +5,10 @@ import ( "sync" "time" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/bindings/weather/config" "github.com/hiveot/hub/bindings/weather/providers" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/plugin" ) // the key under which custom Thing titles are stored in the state service @@ -17,10 +17,10 @@ const DefaultWeatherProvider = providers.OpenMeteoProviderID // WeatherBinding is the hub protocol binding plugin for integrating with Open-Meteo weather provider. type WeatherBinding struct { - cfg *config.WeatherConfig + // this binding is an agent + *agent.Agent - // hub client to publish TDs and values and receive actions - ag *agent.Agent + cfg *config.WeatherConfig // Supported weather providers providers map[string]providers.IWeatherProvider @@ -43,7 +43,7 @@ type WeatherBinding struct { func (svc *WeatherBinding) AddLocation(loc config.WeatherLocation) error { err := svc.locationStore.Add(loc) if err == nil { - err = PublishLocationTD(svc.ag, svc.cfg, loc) + err = PublishLocationTD(svc.Agent, svc.cfg, loc) } return err } @@ -77,8 +77,7 @@ func (svc *WeatherBinding) RemoveLocation(thingID string) { } // Start the weather binding using the given agent -func (svc *WeatherBinding) Start(ag *agent.Agent) error { - svc.ag = ag +func (svc *WeatherBinding) Start() error { // load the saved and add the pre-configured locations // Note that this will remove any modifications @@ -89,17 +88,14 @@ func (svc *WeatherBinding) Start(ag *agent.Agent) error { } if err == nil { - err = PublishBindingTD(ag, svc.cfg) + err = PublishBindingTD(svc.Agent, svc.cfg) } if err == nil { - err = PublishLocationTDs(ag, svc.cfg, svc.locationStore) + err = PublishLocationTDs(svc.Agent, svc.cfg, svc.locationStore) } if err == nil { slog.Info("Starting heartBeat") - svc.stopFn = plugin.StartHeartbeat(time.Second*60, svc.heartBeat) - - // handle config requests - ag.SetRequestHandler(svc.handleRequest) + svc.stopFn = utils.StartHeartbeat(time.Second*60, svc.heartBeat) } if err != nil { svc.Stop() @@ -123,7 +119,6 @@ func NewWeatherBinding(storePath string, cfg *config.WeatherConfig) *WeatherBind svc := &WeatherBinding{ cfg: cfg, providers: make(map[string]providers.IWeatherProvider), - ag: nil, locationStore: NewLocationStore(storePath), currentWeather: make(map[string]providers.CurrentWeather), lastCurrentPoll: make(map[string]time.Time), diff --git a/cmd/hubcli/authcli/AuthAddUserCommand.go b/cmd/hubcli/authcli/AuthAddUserCommand.go index 81bf453b..0d84a621 100644 --- a/cmd/hubcli/authcli/AuthAddUserCommand.go +++ b/cmd/hubcli/authcli/AuthAddUserCommand.go @@ -1,27 +1,27 @@ package authcli import ( + "crypto" "errors" "fmt" "log/slog" "os" "path" + "github.com/hiveot/hivekit/go/modules/authn" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/keys" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" "github.com/urfave/cli/v2" ) // AuthAddUserCommand adds a user func AuthAddUserCommand(hc **consumer.Consumer) *cli.Command { displayName := "" - var role string = string(authz.ClientRoleViewer) + var role string = string(authn.ClientRoleViewer) rolesTxt := fmt.Sprintf("[%s, %s, %s, %s]", - authz.ClientRoleViewer, authz.ClientRoleOperator, - authz.ClientRoleManager, authz.ClientRoleAdmin, + authn.ClientRoleViewer, authn.ClientRoleOperator, + authn.ClientRoleManager, authn.ClientRoleAdmin, ) return &cli.Command{ @@ -142,14 +142,17 @@ func AuthRoleCommand(hc **consumer.Consumer) *cli.Command { // HandleAddUser adds a user and displays a temporary password func HandleAddUser( - hc *consumer.Consumer, loginID string, displayName string, role string) (err error) { + co *consumer.Consumer, loginID string, displayName string, role string) (err error) { newPassword := GeneratePassword(9, true) + authnAdmin := authnpkg.NewAuthnAdminClient(co) + + token, err := authnAdmin.AddClient(loginID, displayName, authn.ClientRoleViewer, "") + _ = token + authnAdmin.SetClientPassword(loginID, newPassword) + prof, _ := authnAdmin.GetClientProfile(loginID) + _ = authnAdmin.UpdateClientProfile(prof) - err = authn.AdminAddConsumer(hc, loginID, displayName, newPassword) - prof, _ := authn.AdminGetClientProfile(hc, loginID) - _ = authn.AdminUpdateClientProfile(hc, prof) - _ = authz.AdminSetClientRole(hc, loginID, authz.ClientRole(role)) if err != nil { fmt.Println("Error: " + err.Error()) } else if newPassword != "" { @@ -167,29 +170,32 @@ func HandleAddUser( // displayName is optional // certsDir with directory to store keys/token func HandleAddService( - hc *consumer.Consumer, serviceID string, displayName string, certsDir string) (err error) { - var kp keys.IHiveKey + co *consumer.Consumer, serviceID string, displayName string, certsDir string) (err error) { + + var pubKey crypto.PublicKey + var privKey crypto.PrivateKey //TODO: use standardized extensions from launcher keyFile := serviceID + ".key" // if a key exists, use it keyPath := path.Join(certsDir, keyFile) if _, err = os.Stat(keyPath); errors.Is(err, os.ErrNotExist) { - kp = keys.NewEcdsaKey() - err = kp.ExportPrivateToFile(keyPath) + privKey, pubKey = utils.NewEcdsaKey() + err = utils.SavePrivateKey(privKey, keyPath) // ExportPrivateToFile(keyPath) pubKeyPath := path.Join(certsDir, serviceID+".pub") - err = kp.ExportPublicToFile(pubKeyPath) + err = utils.SavePublicKey(pubKey, pubKeyPath) fmt.Printf("New private/public keys written to file '%s'\n", keyPath) } else { - kp = keys.NewEcdsaKey() - err = kp.ImportPrivateFromFile(keyPath) + privKey, pubKey, err = utils.LoadPrivateKey(keyPath) fmt.Printf("Private key loaded from file '%s'\n", keyPath) } if err != nil { slog.Error("Failed creating or loading key", "err", err.Error()) return } - authToken, err := authn.AdminAddService(hc, serviceID, displayName, kp.ExportPrivate()) + authAdmin := authnpkg.NewAuthnAdminClient(co) + pubKeyPem := utils.PublicKeyToPem(pubKey) + authToken, err := authAdmin.AddClient(serviceID, displayName, authn.ClientRoleService, pubKeyPem) _ = authToken if err != nil { slog.Error("Failed adding service", @@ -217,21 +223,23 @@ func HandleAddService( } // HandleListClients shows a list of user profiles -func HandleListClients(hc *consumer.Consumer) (err error) { +func HandleListClients(co *consumer.Consumer) (err error) { + + authnClient := authnpkg.NewAuthnAdminClient(co) + authnClient.SetRequestSink(co) - profileList, err := authn.AdminGetProfiles(hc) + profileList, err := authnClient.GetProfiles() fmt.Println("Users") fmt.Println("Login ID Display Name Role Modified") fmt.Println("-------- ------------ ---- -------") for _, profile := range profileList { - if profile.ClientType == authn.ClientTypeConsumer { - role, _ := authz.AdminGetClientRole(hc, profile.ClientID) + if profile.Role != authn.ClientRoleAgent && profile.Role != authn.ClientRoleService { fmt.Printf("%-20s %-25s %-15s %s\n", profile.ClientID, profile.DisplayName, - role, - utils.FormatDateTime(profile.Updated, ""), + profile.Role, + utils.FormatDateTime(profile.TimeUpdated, ""), ) } } @@ -240,11 +248,11 @@ func HandleListClients(hc *consumer.Consumer) (err error) { fmt.Println("SenderID Type Modified") fmt.Println("-------- ---- -------") for _, profile := range profileList { - if profile.ClientType != authn.ClientTypeConsumer { + if profile.Role == authn.ClientRoleAgent || profile.Role == authn.ClientRoleService { fmt.Printf("%-20s %-15s %s\n", profile.ClientID, - profile.ClientType, - utils.FormatDateTime(profile.Updated, ""), + profile.Role, + utils.FormatDateTime(profile.TimeUpdated, ""), ) } } @@ -252,8 +260,9 @@ func HandleListClients(hc *consumer.Consumer) (err error) { } // HandleRemoveClient removes a user -func HandleRemoveClient(hc *consumer.Consumer, clientID string) (err error) { - err = authn.AdminRemoveClient(hc, clientID) +func HandleRemoveClient(co *consumer.Consumer, clientID string) (err error) { + authnClient := authnpkg.NewAuthnAdminClient(co) + authnClient.RemoveClient(clientID) if err != nil { fmt.Println("Error: " + err.Error()) diff --git a/cmd/hubcli/authcli/AuthSetPassCommand.go b/cmd/hubcli/authcli/AuthSetPassCommand.go index 601de600..4c15de8a 100644 --- a/cmd/hubcli/authcli/AuthSetPassCommand.go +++ b/cmd/hubcli/authcli/AuthSetPassCommand.go @@ -5,8 +5,8 @@ import ( "strings" "time" - "github.com/hiveot/hub/lib/consumer" - authn "github.com/hiveot/hub/runtime/authn/api" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/urfave/cli/v2" "golang.org/x/exp/rand" ) @@ -36,11 +36,12 @@ func AuthSetPasswordCommand(hc **consumer.Consumer) *cli.Command { // // loginID is the ID or email of the user // newPassword can be empty to auto-generate a password -func HandleSetPassword(hc *consumer.Consumer, loginID string, newPassword string) error { +func HandleSetPassword(co *consumer.Consumer, loginID string, newPassword string) error { if newPassword == "" { newPassword = GeneratePassword(9, true) } - err := authn.AdminSetClientPassword(hc, loginID, newPassword) + authnClient := authnpkg.NewAuthnAdminClient(co) + err := authnClient.SetClientPassword(loginID, newPassword) if err != nil { fmt.Println("Error: " + err.Error()) diff --git a/cmd/hubcli/authcli/AuthSetRoleCommand.go b/cmd/hubcli/authcli/AuthSetRoleCommand.go index 7b7a8274..f3e867ad 100644 --- a/cmd/hubcli/authcli/AuthSetRoleCommand.go +++ b/cmd/hubcli/authcli/AuthSetRoleCommand.go @@ -4,16 +4,17 @@ import ( "fmt" "strings" - "github.com/hiveot/hub/lib/consumer" - authz "github.com/hiveot/hub/runtime/authz/api" + "github.com/hiveot/hivekit/go/modules/authn" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/urfave/cli/v2" ) // AuthSetRoleCommand changes a user's role func AuthSetRoleCommand(hc **consumer.Consumer) *cli.Command { validRoles := []string{ - string(authz.ClientRoleViewer), string(authz.ClientRoleOperator), - string(authz.ClientRoleManager), string(authz.ClientRoleAdmin), + string(authn.ClientRoleViewer), string(authn.ClientRoleOperator), + string(authn.ClientRoleManager), string(authn.ClientRoleAdmin), } return &cli.Command{ @@ -39,10 +40,15 @@ func AuthSetRoleCommand(hc **consumer.Consumer) *cli.Command { // // loginID is the ID or email of the user // newPassword can be empty to auto-generate a password -func HandleSetRole(hc *consumer.Consumer, loginID string, newRole string) error { - - err := authz.AdminSetClientRole(hc, loginID, authz.ClientRole(newRole)) +func HandleSetRole(co *consumer.Consumer, loginID string, newRole string) error { + authnClient := authnpkg.NewAuthnAdminClient(co) + prof, err := authnClient.GetClientProfile(loginID) + if err != nil { + return err + } + prof.Role = newRole + err = authnClient.UpdateClientProfile(prof) if err != nil { //fmt.Println("Error: " + err.Error()) } else { diff --git a/cmd/hubcli/certs/CreateCACommand.go b/cmd/hubcli/certs/CreateCACommand.go index cdb2fa28..a13636b4 100644 --- a/cmd/hubcli/certs/CreateCACommand.go +++ b/cmd/hubcli/certs/CreateCACommand.go @@ -6,7 +6,8 @@ import ( "os" "path" - "github.com/hiveot/hub/lib/certs" + "github.com/hiveot/hivekit/go/modules/certs" + certspkg "github.com/hiveot/hivekit/go/modules/certs/pkg" "github.com/urfave/cli/v2" ) @@ -71,15 +72,17 @@ func HandleCreateCACert(certsFolder string, validityDays int, force bool) error return fmt.Errorf("CA key alread exists in '%s'", caKeyPath) } } - - caCert, caKey, err := certs.CreateCA("Hub CA", validityDays) - if err != nil { - return err - } - err = certs.SaveX509CertToPEM(caCert, caCertPath) - if err == nil { - err = caKey.ExportPrivateToFile(caKeyPath) - } + // the certsservice creates the CA cert and key on start + svc := certspkg.NewCertsService(certsFolder) + err := svc.Start() + // caCert, caKey, err := svc.CreateCACert("Hub CA", validityDays) + // if err != nil { + // return err + // } + // err = certutils.SaveX509CertToPEM(caCert, caCertPath) + // if err == nil { + // err = caKey.ExportPrivateToFile(caKeyPath) + // } slog.Info("Generated CA certificate", "caCertPath", caCertPath, "caKeyPath", caKeyPath) return err diff --git a/cmd/hubcli/certs/ViewCACommand.go b/cmd/hubcli/certs/ViewCACommand.go index dadc61eb..3243f0d9 100644 --- a/cmd/hubcli/certs/ViewCACommand.go +++ b/cmd/hubcli/certs/ViewCACommand.go @@ -6,7 +6,8 @@ import ( "path" "time" - "github.com/hiveot/hub/lib/certs" + "github.com/hiveot/hivekit/go/modules/certs" + "github.com/hiveot/hivekit/go/modules/certs/certutils" "github.com/urfave/cli/v2" ) @@ -35,7 +36,7 @@ func ViewCACommand(certsFolder *string) *cli.Command { func HandleViewCACert(certsFolder string) error { caCertPath := path.Join(certsFolder, certs.DefaultCaCertFile) - caCert, err := certs.LoadX509CertFromPEM(caCertPath) + caCert, err := certutils.LoadX509CertFromPEM(caCertPath) if err != nil { slog.Error("Unable to load the CA certificate", "err", err) return err @@ -50,7 +51,7 @@ func HandleViewCACert(certsFolder string) error { fmt.Println() hubCertPath := path.Join(certsFolder, "hubCert.pem") - serverCert, err := certs.LoadX509CertFromPEM(hubCertPath) + serverCert, err := certutils.LoadX509CertFromPEM(hubCertPath) if err != nil { slog.Error("Unable to load the server certificate", "err", err) return err diff --git a/cmd/hubcli/directorycli/DirectoryCommands.go b/cmd/hubcli/directorycli/DirectoryCommands.go index d9d632a1..7ce9543c 100644 --- a/cmd/hubcli/directorycli/DirectoryCommands.go +++ b/cmd/hubcli/directorycli/DirectoryCommands.go @@ -7,9 +7,9 @@ import ( "log/slog" "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/modules/consumer" + directorypkg "github.com/hiveot/hivekit/go/modules/directory/pkg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/urfave/cli/v2" ) @@ -47,7 +47,9 @@ func DirectoryListCommand(co **consumer.Consumer) *cli.Command { // HandleListDirectory lists the directory content func HandleListDirectory(co *consumer.Consumer) (err error) { // todo: iterate with offset and limit - tdListJson, err := digitwin.ThingDirectoryRetrieveAllThings(co, 300, 0) + dirClient := directorypkg.NewDirectoryClient("", co) + + tdListJson, err := dirClient.RetrieveAllThings(300, 0) tdList, err2 := td.UnmarshalTDList(tdListJson) if err != nil || err2 != nil { @@ -84,12 +86,14 @@ func HandleListDirectory(co *consumer.Consumer) (err error) { // HandleListThing lists details of a Thing in the directory func HandleListThing(co *consumer.Consumer, thingID string) error { - tdDocJson, err := digitwin.ThingDirectoryRetrieveThing(co, thingID) + dirClient := directorypkg.NewDirectoryClient("", co) + + tdDocJson, err := dirClient.RetrieveThing(thingID) tdDoc, err2 := td.UnmarshalTD(tdDocJson) if err != nil || err2 != nil { return err } - propValueMap, err := digitwin.ThingValuesReadAllProperties(co, thingID) + propValueMap, err := co.ReadAllProperties(thingID) if err != nil { slog.Error("Unable to read directory:", "err", err) @@ -108,8 +112,8 @@ func HandleListThing(co *consumer.Consumer, thingID string) error { for _, key := range keys { prop, found := tdDoc.Properties[key] if found && prop.ReadOnly { - value := propValueMap[key] - valueStr := utils.DecodeAsString(value.Data, 15) + propValue := propValueMap[key] + valueStr := utils.DecodeAsString(propValue, 15) fmt.Printf(" %-30s %-40.40s %s%-15.15s%s %-.80s\n", key, prop.Title, utils.COGreen, valueStr, utils.COReset, prop.Description) } @@ -121,8 +125,8 @@ func HandleListThing(co *consumer.Consumer, thingID string) error { for _, key := range keys { prop, found := tdDoc.Properties[key] if found && !prop.ReadOnly { - value := propValueMap[key] - valueStr := utils.DecodeAsString(value.Data, 15) + propValue := propValueMap[key] + valueStr := utils.DecodeAsString(propValue, 15) fmt.Printf(" %-30s %-40.40s %-10.10s %s%-15.15s%s %-.80s\n", key, prop.Title, prop.Type, utils.COBlue, valueStr, utils.COReset, prop.Description) } @@ -131,7 +135,7 @@ func HandleListThing(co *consumer.Consumer, thingID string) error { fmt.Println(utils.COYellow + "\nEvents:") fmt.Println(" ID EventType Title DataType Value Description") fmt.Println(" ---------------------------------- ------------------------ --------------------------------------- --------- -------------- -----------" + utils.COReset) - eventValueMap, err := digitwin.ThingValuesReadAllEvents(co, thingID) + eventValueMap, err := co.ReadAllEvents(thingID) keys = utils.OrderedMapKeys(tdDoc.Events) for _, key := range keys { ev := tdDoc.Events[key] @@ -151,13 +155,13 @@ func HandleListThing(co *consumer.Consumer, thingID string) error { fmt.Println(utils.CORed + "\nActions:") fmt.Println(" ID ActionType Title Arg(s) Value Description") fmt.Println(" ---------------------------------- ------------------------ --------------------------------------- --------- -------------- -----------" + utils.COReset) - actionValueMap, err := digitwin.ThingValuesReadAllProperties(co, thingID) + actionValueMap, err := co.QueryAllActions(thingID) keys = utils.OrderedMapKeys(tdDoc.Actions) for _, key := range keys { action := tdDoc.Actions[key] dataType := "(n/a)" - value := actionValueMap[key] - valueStr := utils.DecodeAsString(value.Data, 15) + actionResp := actionValueMap[key] + valueStr := utils.DecodeAsString(actionResp.Output, 15) if action.Input != nil { dataType = action.Input.Type //initialValue = action.Input.InitialValue @@ -171,7 +175,8 @@ func HandleListThing(co *consumer.Consumer, thingID string) error { // HandleListThingVerbose lists a Thing full TD func HandleListThingVerbose(co *consumer.Consumer, thingID string) error { - tdJSON, err := digitwin.ThingDirectoryRetrieveThing(co, thingID) + dirClient := directorypkg.NewDirectoryClient("", co) + tdJSON, err := dirClient.RetrieveThing(thingID) if err != nil { return err diff --git a/cmd/hubcli/directorycli/DiscoCommands.go b/cmd/hubcli/directorycli/DiscoCommands.go index f9175f7e..226be49d 100644 --- a/cmd/hubcli/directorycli/DiscoCommands.go +++ b/cmd/hubcli/directorycli/DiscoCommands.go @@ -5,8 +5,8 @@ import ( "fmt" "time" - "github.com/hiveot/hub/lib/clients/discoclient" - "github.com/hiveot/hub/lib/clients/tlsclient" + discoverypkg "github.com/hiveot/hivekit/go/modules/transport/discovery/pkg" + tlsclientpkg "github.com/hiveot/hivekit/go/modules/transport/tlsclient/pkg" jsoniter "github.com/json-iterator/go" "github.com/urfave/cli/v2" ) @@ -14,7 +14,7 @@ import ( // DiscoListCommand lists discovered Thing and Directory servers // // authToken used to read the TD -func DiscoListCommand(authToken *string) *cli.Command { +func DiscoListCommand(clientID string, authToken *string) *cli.Command { var readtd = false return &cli.Command{ Name: "disco", @@ -33,21 +33,25 @@ func DiscoListCommand(authToken *string) *cli.Command { if cCtx.NArg() != 0 { return fmt.Errorf("no arguments expected") } - err := HandleDiscover(readtd, *authToken) + err := HandleDiscover(clientID, *authToken, readtd) return err }, } } // HandleDiscover prints a list of discovered Things and Directories -func HandleDiscover(readtd bool, authToken string) error { - - allRecords := discoclient.DiscoverTDD("", time.Second*2, false) +func HandleDiscover(clientID string, authToken string, readtd bool) error { + discoClient := discoverypkg.NewDiscoveryClient() + allRecords, err := discoClient.DiscoverDirectories("", time.Second*2, false, nil) + if err != nil { + fmt.Println("Discovery failed: ", err.Error()) + return err + } //hiveotRecords := discovery.DiscoverTDD("", "hiveot", time.Second*2, false) //allRecords := append(hiveotRecords, wotRecords...) // create a client for reading TD's - fmt.Println("Address Port Instance Type Scheme TD path") + fmt.Println("Address Port Instance Type Schema TD path") fmt.Println("------- ----- -------- ---- ------ -------") for _, entry := range allRecords { fmt.Printf("%-25s %6d %-11s %10s %-8s %s\n", @@ -55,16 +59,25 @@ func HandleDiscover(readtd bool, authToken string) error { entry.Port, entry.Instance, entry.Type, - entry.Scheme, + entry.Schema, entry.TD, ) if readtd { - hostPort := fmt.Sprintf("%s:%d", entry.Addr, entry.Port) - cl := tlsclient.NewTLSClient(hostPort, nil, nil, 0) - cl.SetAuthToken(authToken) - tdJSON, code, err := cl.Get(entry.TD) var tdObj map[string]any - err = jsoniter.Unmarshal(tdJSON, &tdObj) + + hostPort := fmt.Sprintf("%s:%d", entry.Addr, entry.Port) + cl := tlsclientpkg.NewTLSClient(hostPort, nil, 0) + err := cl.AuthenticateWithToken(clientID, authToken) + if err != nil { + break + } + tdJSON, code, err2 := cl.Get(entry.TD) + err = err2 + _ = code + if err == nil { + err = jsoniter.Unmarshal(tdJSON, &tdObj) + } + tdPretty, _ := json.MarshalIndent(tdObj, "", " ") if err == nil { diff --git a/cmd/hubcli/historycli/HistoryCommands.go b/cmd/hubcli/historycli/HistoryCommands.go index 98edf8d0..f4c0017b 100644 --- a/cmd/hubcli/historycli/HistoryCommands.go +++ b/cmd/hubcli/historycli/HistoryCommands.go @@ -3,9 +3,9 @@ package historycli import ( "fmt" + "github.com/hiveot/hivekit/go/modules/consumer" + historypkg "github.com/hiveot/hivekit/go/modules/history/pkg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/services/history/historyclient" "github.com/urfave/cli/v2" ) @@ -110,13 +110,14 @@ func HistoryListCommand(hc **consumer.Consumer) *cli.Command { //} // HandleListEvents lists the history content -func HandleListEvents(hc *consumer.Consumer, dThingID string, name string, limit int) error { +func HandleListEvents(co *consumer.Consumer, dThingID string, name string, limit int) error { // FIXME: hc has a bootstrap algo to read the needed TD //histTD := hc.GetTD(historyapi.ReadHistoryServiceID) //f := histTD.GetForm(td.OpInvokeAction) - rd := historyclient.NewReadHistoryClient(hc) + hc := historypkg.NewReadHistoryClient(co) + hc.SetRequestSink(co) - cursor, releaseFn, err := rd.GetCursor(dThingID, name) + cursor, releaseFn, err := hc.GetCursor(dThingID, name) defer releaseFn() if err != nil { return err @@ -124,7 +125,7 @@ func HandleListEvents(hc *consumer.Consumer, dThingID string, name string, limit fmt.Println("ThingID Timestamp Event Value (truncated)") fmt.Println("----------- --------- ----- ---------------- ") count := 0 - for tv, valid, err := cursor.Last(); err == nil && valid && count < limit; tv, valid, err = cursor.Prev() { + for tv, valid, err := hc.Last(cursor); err == nil && valid && count < limit; tv, valid, err = hc.Prev(cursor) { count++ value := utils.DecodeAsString(tv.Data, 30) @@ -177,7 +178,7 @@ func HandleListEvents(hc *consumer.Consumer, dThingID string, name string, limit //func HandleListLatestEvents( // hc hubclient.IConsumer, agentID string, thingID string) error { -// rd := historyclient.NewReadHistoryClient(hc) +// rd := historypkg.NewReadHistoryClient(hc) // // props, err := rd.GetLatest(agentID, thingID, nil) // diff --git a/cmd/hubcli/idprovcli/IdProvCommands.go b/cmd/hubcli/idprovcli/IdProvCommands.go deleted file mode 100644 index 11222c18..00000000 --- a/cmd/hubcli/idprovcli/IdProvCommands.go +++ /dev/null @@ -1,164 +0,0 @@ -package idprovcli - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/services/idprov/idprovapi" - "github.com/hiveot/hub/services/idprov/idprovclient" - "github.com/urfave/cli/v2" -) - -// ProvisionPreApproveCommand -// prov preapprove [] -func ProvisionPreApproveCommand(hc **consumer.Consumer) *cli.Command { - return &cli.Command{ - Name: "idppreapprove", - Usage: "Preapprove a device for automated provisioning", - ArgsUsage: " []", - Category: "provisioning", - Action: func(cCtx *cli.Context) error { - if cCtx.NArg() < 2 { - return fmt.Errorf("expected 2 or 3 arguments. Got %d instead", cCtx.NArg()) - } - deviceID := cCtx.Args().First() - pubKey := cCtx.Args().Get(1) - mac := cCtx.Args().Get(2) - err := HandlePreApprove(*hc, deviceID, pubKey, mac) - fmt.Println("preapprved device: ", deviceID) - return err - }, - } -} - -// ProvisionApproveRequestCommand -// prov approve -func ProvisionApproveRequestCommand(hc **consumer.Consumer) *cli.Command { - return &cli.Command{ - Name: "idpapprove", - Usage: "Approve a pending provisioning request", - ArgsUsage: "", - Category: "provisioning", - Action: func(cCtx *cli.Context) error { - if cCtx.NArg() != 1 { - return fmt.Errorf("expected 1 arguments. Got %d instead", cCtx.NArg()) - } - deviceID := cCtx.Args().First() - err := HandleApproveRequest(*hc, deviceID) - return err - }, - } -} - -func ProvisionListCommand(hc **consumer.Consumer) *cli.Command { - return &cli.Command{ - Name: "idplist", - Usage: "List provisioning requests", - Category: "provisioning", - Action: func(cCtx *cli.Context) error { - err := HandleListRequests(*hc) - return err - }, - } -} - -func ProvisionRequestCommand(hc **consumer.Consumer) *cli.Command { - return &cli.Command{ - Name: "idpsubmit", - Usage: "Submit a provisioning request", - ArgsUsage: " []", - Category: "provisioning", - Action: func(cCtx *cli.Context) error { - if cCtx.NArg() < 2 { - return fmt.Errorf("expected 2 or 3 arguments. Got %d instead", cCtx.NArg()) - } - deviceID := cCtx.Args().First() - pubKey := cCtx.Args().Get(1) - mac := "" - if cCtx.NArg() == 3 { - mac = cCtx.Args().Get(2) - } - - err := HandleSubmitRequest(*hc, deviceID, pubKey, mac) - return err - }, - } -} - -// HandlePreApprove adds a device to the list of pre-approved devices -// -// deviceID is the ID of the device to pre-approve -// pubKey device's public key -func HandlePreApprove(hc *consumer.Consumer, deviceID string, pubKey string, mac string) error { - cl := idprovclient.NewIdProvManageClient(hc) - approvals := []idprovapi.PreApprovedClient{{ - ClientID: deviceID, - ClientType: authn.ClientTypeAgent, - MAC: mac, - PubKey: pubKey, - }} - - err := cl.PreApproveDevices(approvals) - return err -} - -// HandleApproveRequest -// -// deviceID is the ID of the device to approve -func HandleApproveRequest(hc *consumer.Consumer, deviceID string) error { - cl := idprovclient.NewIdProvManageClient(hc) - err := cl.ApproveRequest(deviceID, authn.ClientTypeAgent) - - return err -} - -func HandleListRequests(hc *consumer.Consumer) error { - cl := idprovclient.NewIdProvManageClient(hc) - provStatus, err := cl.GetRequests(true, false, false) - if err != nil { - return err - } - - // pending - fmt.Println("Pending requests:") - fmt.Printf("Agent ID Request Time\n") - fmt.Printf("-------------------- ------------\n") - for _, provStatus := range provStatus { - fmt.Printf("%-22s %s\n", - provStatus.ClientID, - utils.FormatMSE(provStatus.ReceivedMSE, true)) - } - - // others - provStatus, err = cl.GetRequests(false, true, true) - fmt.Println() - fmt.Println("Non-pending requests:") - fmt.Printf("Agent ID Request Time Approved Time\n") - fmt.Printf("-------------------- ------------------- -------------\n") - for _, provStatus := range provStatus { - // a certificate is assigned when generated - fmt.Printf("%-22s %s %s\n", - provStatus.ClientID, - utils.FormatMSE(provStatus.ReceivedMSE, true), - utils.FormatMSE(provStatus.ApprovedMSE, true)) - } - - return err -} - -// HandleSubmitRequest requests a provisioning token -// -// deviceID is the ID of the device requesting a token -// pubKey is the public key to use, or use \"" to accept device offered key -func HandleSubmitRequest(hc *consumer.Consumer, deviceID string, pubKey string, mac string) error { - cl := idprovclient.NewIdProvManageClient(hc) - status, token, err := cl.SubmitRequest(deviceID, pubKey, mac) - _ = status - _ = HandleListRequests(hc) - if token != "" { - println("Received token: ", token) - } - return err -} diff --git a/cmd/hubcli/launchercli/LauncherCommands.go b/cmd/hubcli/launchercli/LauncherCommands.go index d68db8fd..a1795f43 100644 --- a/cmd/hubcli/launchercli/LauncherCommands.go +++ b/cmd/hubcli/launchercli/LauncherCommands.go @@ -4,8 +4,8 @@ import ( "fmt" "time" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" launcher "github.com/hiveot/hub/services/launcher/api" "github.com/urfave/cli/v2" ) diff --git a/cmd/hubcli/main.go b/cmd/hubcli/main.go index b721a03e..e8769218 100644 --- a/cmd/hubcli/main.go +++ b/cmd/hubcli/main.go @@ -5,19 +5,18 @@ import ( "log/slog" "os" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/modules/factory" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/modules/transport/clients" "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/cmd/hubcli/authcli" "github.com/hiveot/hub/cmd/hubcli/certs" "github.com/hiveot/hub/cmd/hubcli/directorycli" "github.com/hiveot/hub/cmd/hubcli/historycli" - "github.com/hiveot/hub/cmd/hubcli/idprovcli" "github.com/hiveot/hub/cmd/hubcli/launchercli" "github.com/hiveot/hub/cmd/hubcli/pubsubcli" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/plugin" "github.com/urfave/cli/v2" ) @@ -31,7 +30,7 @@ var nowrap bool // commandline: hubcli command options func main() { - var hc *consumer.Consumer + var co *consumer.Consumer var verbose bool var loginID = "admin" var password = "" @@ -41,12 +40,12 @@ func main() { var authToken string // environment defaults - env := plugin.GetAppEnvironment("", false) + env := factory.NewAppEnvironment("", false) homeDir = env.HomeDir certsDir = env.CertsDir //defaultHome := env.HomeDir // to detect changes to the home directory - logging.SetLogging("warning", "") + utils.SetLogging("warning", "") nowrap = false app := &cli.App{ @@ -94,19 +93,19 @@ func main() { }, }, Before: func(c *cli.Context) (err error) { - var cc messaging.IClientConnection + var cc transport.ITransportClient // reload env in case home changes - env = plugin.GetAppEnvironment(homeDir, false) + env = factory.NewAppEnvironment(homeDir, false) certsDir = env.CertsDir if verbose { - logging.SetLogging("info", "") + utils.SetLogging("info", "") } if nowrap { fmt.Printf(utils.WrapOff) } // most commands need auth - authToken, err = clients.LoadToken(loginID, certsDir) + authToken = env.GetAppToken() // TODO: cleanup: don't connect for these commands cmd := c.Args().First() @@ -114,21 +113,30 @@ func main() { return nil } - if err != nil && password == "" { - return fmt.Errorf("missing authentication token: %w", err) + if authToken == "" && password == "" { + return fmt.Errorf("hubcli: missing authentication token") } - caCert, _ := clients.LoadCA(certsDir) + caCert, _ := env.GetCA() if password != "" { - cc, authToken, err = clients.ConnectWithPassword(loginID, password, caCert, serverURL, "", 0) + cc, err = clients.NewTransportClient("", serverURL, caCert) + if err == nil { + authncl := authnpkg.NewUserAuthnHttpClient(serverURL, caCert) + authToken, err = authncl.LoginWithPassword(loginID, password) + } + cc, err = clients.NewTransportClient("", serverURL, caCert) + err = cc.AuthenticateWithToken(loginID, authToken) } else { - cc, err = clients.ConnectWithToken(loginID, authToken, caCert, serverURL, 0) + cc, err = clients.NewTransportClient("", serverURL, caCert) + err = cc.AuthenticateWithToken(loginID, authToken) } if err != nil { slog.Error("Unable to connect to the server", "err", err) return fmt.Errorf("unable to connect to the hub") } - hc = consumer.NewConsumer(cc, 0) + co = consumer.NewConsumer(nil) + co.SetRequestSink(cc) + cc.SetNotificationSink(co) return nil }, // commands arguments are passed by reference so they are updated in the Before section @@ -137,31 +145,26 @@ func main() { certs.CreateCACommand(&certsDir), certs.ViewCACommand(&certsDir), - authcli.AuthAddUserCommand(&hc), - authcli.AuthAddServiceCommand(&hc, &env.CertsDir), - authcli.AuthListClientsCommand(&hc), - authcli.AuthRemoveClientCommand(&hc), - authcli.AuthSetRoleCommand(&hc), - authcli.AuthSetPasswordCommand(&hc), + authcli.AuthAddUserCommand(&co), + authcli.AuthAddServiceCommand(&co, &env.CertsDir), + authcli.AuthListClientsCommand(&co), + authcli.AuthRemoveClientCommand(&co), + authcli.AuthSetRoleCommand(&co), + authcli.AuthSetPasswordCommand(&co), - launchercli.LauncherListCommand(&hc), - launchercli.LauncherStartCommand(&hc), - launchercli.LauncherStopCommand(&hc), + launchercli.LauncherListCommand(&co), + launchercli.LauncherStartCommand(&co), + launchercli.LauncherStopCommand(&co), - directorycli.DirectoryListCommand(&hc), - directorycli.DiscoListCommand(&authToken), + directorycli.DirectoryListCommand(&co), + directorycli.DiscoListCommand(loginID, &authToken), //historycli.HistoryLatestCommand(&hc), - historycli.HistoryListCommand(&hc), + historycli.HistoryListCommand(&co), - pubsubcli.PubActionCommand(&hc), - pubsubcli.SubEventsCommand(&hc), - pubsubcli.SubTDCommand(&hc), - - idprovcli.ProvisionListCommand(&hc), - idprovcli.ProvisionRequestCommand(&hc), - idprovcli.ProvisionApproveRequestCommand(&hc), - idprovcli.ProvisionPreApproveCommand(&hc), + pubsubcli.PubActionCommand(&co), + pubsubcli.SubEventsCommand(&co), + pubsubcli.SubTDCommand(&co), }, } diff --git a/cmd/hubcli/pubsubcli/PubCommands.go b/cmd/hubcli/pubsubcli/PubCommands.go index 1dc5d8d5..a3dca399 100644 --- a/cmd/hubcli/pubsubcli/PubCommands.go +++ b/cmd/hubcli/pubsubcli/PubCommands.go @@ -3,7 +3,7 @@ package pubsubcli import ( "fmt" - "github.com/hiveot/hub/lib/consumer" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/urfave/cli/v2" ) diff --git a/cmd/hubcli/pubsubcli/SubCommands.go b/cmd/hubcli/pubsubcli/SubCommands.go index 6a1495b4..2bf7362d 100644 --- a/cmd/hubcli/pubsubcli/SubCommands.go +++ b/cmd/hubcli/pubsubcli/SubCommands.go @@ -4,11 +4,11 @@ import ( "fmt" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/modules/directory" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" jsoniter "github.com/json-iterator/go" "github.com/araddon/dateparse" @@ -54,16 +54,16 @@ func SubEventsCommand(hc **consumer.Consumer) *cli.Command { } // HandleSubTD subscribes and prints TD publications -func HandleSubTD(hc *consumer.Consumer) error { +func HandleSubTD(co *consumer.Consumer) error { - err := hc.Subscribe(digitwin.ThingDirectoryDThingID, digitwin.ThingDirectoryEventThingUpdated) + err := co.Subscribe(directory.DefaultDirectoryThingID, directory.ThingUpdatedEvent) if err != nil { return err } - hc.SetResponseHandler(func(resp *messaging.ResponseMessage) error { + co.SetNotificationHook(func(resp *msg.NotificationMessage) { // only look for TD events, ignore directed events - if resp.Name != digitwin.ThingDirectoryEventThingUpdated { - return nil + if resp.Name != directory.ThingUpdatedEvent { + return } var tdi td.TD @@ -76,7 +76,7 @@ func HandleSubTD(hc *consumer.Consumer) error { fmt.Printf("%-20.20s %-35.35s %-30.30s %-30.30s %-30.30s\n", "", resp.ThingID, tdi.Title, tdi.AtType, timeStr) } - return nil + return }) fmt.Printf("Sender ID Thing ID Title @type Timestamp \n") fmt.Printf("------------------- ---------------------------------- ----------------------------- ----------------------------- ------------------------------\n") @@ -86,14 +86,14 @@ func HandleSubTD(hc *consumer.Consumer) error { } // HandleSubEvents subscribes and prints events -func HandleSubEvents(hc *consumer.Consumer, thingID string, name string) error { +func HandleSubEvents(co *consumer.Consumer, thingID string, name string) error { fmt.Printf("Subscribing to thingID: '%s', name: '%s'\n\n", thingID, name) fmt.Printf("Time Agent ID Thing ID Event Name Value\n") fmt.Printf("--------------- --------------- ----------------------------- ----------------------------- ---------\n") - err := hc.Subscribe(thingID, name) - hc.SetNotificationHandler(func(msg *messaging.NotificationMessage) { + err := co.Subscribe(thingID, name) + co.SetNotificationHook(func(msg *msg.NotificationMessage) { createdTime, _ := dateparse.ParseAny(msg.Timestamp) timeStr := createdTime.Format("15:04:05.000") @@ -112,8 +112,8 @@ func HandleSubEvents(hc *consumer.Consumer, thingID string, name string) error { // } // } //} - if msg.ThingID == digitwin.ThingDirectoryDThingID && - msg.Name == digitwin.ThingDirectoryEventThingUpdated { + if msg.ThingID == directory.DefaultDirectoryThingID && + msg.Name == directory.ThingUpdatedEvent { var tdi td.TD tdJSON := msg.ToString(0) _ = jsoniter.UnmarshalFromString(tdJSON, &tdi) diff --git a/cmd/tm2go/README.md b/cmd/tm2go/README.md deleted file mode 100644 index dfeedccd..00000000 --- a/cmd/tm2go/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# tm2go - -Generate go types, agent service handler and consumer handler from a WoT TM/TD document. Any security and communication data in the source is ignored as those are provided by the hub messaging layer. - -Usage: tm2go generate all package/tm/source.json [-o out] [-l lang] - -```Arguments: - generate is the command to generate the go code and write it into the output directory - all generate the 'types', 'agent', and 'consumer' code - tm.json JSON file containing a Thing Model/Description document in the package tm directory. -``` - -```Options: - -r, --recursive - Iterate directory tree in search of json files in tdd directories - -f, --force - Force generating new API code even if it is newer - --outdir "../api" - Set the output directory to store the api code, relative to the TM/TD sourcefile. - ``` - -This generates API source files in the output directory. The filenames consist of the -ThingID prefix and the suffix of: 'AgentAPI.go', 'ConsumerAPI.go', and 'Types.go' for -each type of API. - -### Examples - -Generate the API of the Directory TD: -> tm2go go run cmd/tm2go/main.go generate all ./runtime/digitwin/tm/Directory.json - -Output: -> ./runtime/digitwin/api/AdminTypes.go -> ./runtime/digitwin/api/AdminAgentAPI.go -> ./runtime/digitwin/api/AdminConsumerAPI.go -``` - -``` -Generate all API code and store them into the /tmp/tm2go directory: -> tm2go go run cmd/tm2go/main.go generate -outdir /tmp/tm2go -r all ./ - -``` diff --git a/cmd/tm2go/genagent/GenAgent.go b/cmd/tm2go/genagent/GenAgent.go deleted file mode 100644 index dd405d77..00000000 --- a/cmd/tm2go/genagent/GenAgent.go +++ /dev/null @@ -1,38 +0,0 @@ -package genagent - -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/cmd/tm2go/gentypes" -) - -// GenAgent is the main function to generate the agent request handler that -// maps requests to service calls and returns the service result. -func GenAgent(l *gentypes.SL, agentID string, tdi *td.TD) (err error) { - - serviceID := gentypes.ToTitle(tdi.ID) - - genImports(l, agentID, serviceID) - GenServiceInterface(l, agentID, serviceID, tdi) - GenServiceAgent(l, agentID, serviceID, tdi) - return nil -} - -// imports needed for the agent -func genImports(l *gentypes.SL, agentID string, serviceID string) { - - l.Add("// Package %s with the agent request handler for using service '%s'", - agentID, serviceID) - l.Add("// This builds a service agent that calls the service.") - l.Add("// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten.") - l.Add("// Generated %s. ", time.Now().Format("02 Jan 2006 15:04 MST")) - l.Add("package %s", agentID) - - l.Add("") - l.Add("import \"errors\"") - //l.Add("import \"github.com/hiveot/hub/lib/messaging/messaging\"") - l.Add("import \"github.com/hiveot/hivekit/go/utils\"") - l.Add("import \"github.com/hiveot/hub/lib/messaging\"") - l.Add("") -} diff --git a/cmd/tm2go/genagent/GenServiceAgent.go b/cmd/tm2go/genagent/GenServiceAgent.go deleted file mode 100644 index 60cd6028..00000000 --- a/cmd/tm2go/genagent/GenServiceAgent.go +++ /dev/null @@ -1,89 +0,0 @@ -package genagent - -import ( - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/cmd/tm2go/gentypes" -) - -// GenServiceAgent generates a function that returns a request handler that invokes -// linked service methods. -// It unmarshals the request input, invokes the implementation and generates a -// response message to be returned. -// -// The signature is: NewHandleAction(service I...Service) *HandleRequest -// -// func (agent *...)NewHandleAction( -// consumerID string, dThingID string, actionName string, input any, correlationID string -func GenServiceAgent(l *gentypes.SL, agentID, serviceID string, td1 *td.TD) { - // ServiceType is the type of the service implementation that handles the messages - //serviceType := ToTitle(td1.GetID()) + "Service" - interfaceName := "I" + serviceID + "Service" - - l.Add("") - l.Add("// NewHandle%sRequest returns an agent handler for Thing '%s' requests.", serviceID, td1.ID) - l.Add("//") - l.Add("// This unmarshalls the request payload into an args struct and passes it to the service") - l.Add("// that implements the corresponding interface method.") - l.Add("// ") - l.Add("// This returns the marshalled response data or an error.") - l.Add("func NewHandle%sRequest(svc %s)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) {", - serviceID, interfaceName) - l.Indent++ - l.Add("return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage {") - - l.Indent++ - - l.Add("var output any") - l.Add("var err error") - l.Add("switch msg.Name {") - l.Indent++ - for name, action := range td1.Actions { - GenRequestMethodHandler(l, serviceID, name, action) - } - l.Add("default:") - l.Add(" err = errors.New(\"Unknown Method '\"+msg.Name+\"' of service '\"+msg.ThingID+\"'\")") - l.Indent-- - l.Add("}") - - l.Add("return msg.CreateResponse(output,err)") - l.Indent-- - l.Add("}") - l.Indent-- - l.Add("}") -} - -// GenRequestMethodHandler adds for handling a service action request. -// -// This unmarshal the request, invokes the service, and marshals the response -// name of the action affordance in the TD -func GenRequestMethodHandler(l *gentypes.SL, serviceTitle string, name string, action *td.ActionAffordance) { - methodName := gentypes.Name2ID(name) - // build the argument string - argsString := "msg.SenderID" // all handlers receive the sender ID - l.Add("case \"%s\":", name) - l.Indent++ - if action.Input != nil { - if action.Input.Type == "object" && action.Input.Properties != nil { - // objects are passed by their struct type - l.Add("args := %s%sArgs{}", serviceTitle, methodName) - } else { - // native types are passed as-is - goType := gentypes.GoTypeFromSchema(action.Input) - l.Add("var args %s", goType) - } - l.Add("err = utils.DecodeAsObject(msg.Input, &args)") - - argsString += ", args" - } - // build the result string, either an error or a response struct with an error - resultString := "err" - if action.Output != nil { - resultString = "output, err" - } - l.Add("if err == nil {") - l.Add(" %s = svc.%s(%s)", resultString, methodName, argsString) - l.Add("} else {") - l.Add(" err = errors.New(\"bad function argument: \" + err.Error())") - l.Add("}") - l.Indent-- -} diff --git a/cmd/tm2go/genagent/GenServiceInterface.go b/cmd/tm2go/genagent/GenServiceInterface.go deleted file mode 100644 index 277ed79a..00000000 --- a/cmd/tm2go/genagent/GenServiceInterface.go +++ /dev/null @@ -1,107 +0,0 @@ -package genagent - -import ( - "fmt" - "regexp" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/cmd/tm2go/gentypes" -) - -// GenServiceInterface generates the interface the service has to implement. -// -// agentID is this package name, eg: the agent for this service -// serviceID is the ThingID of the service capitalized -func GenServiceInterface(l *gentypes.SL, agentID, serviceID string, tdi *td.TD) { - // ServiceType is the interface of the service. Interface names start with 'I' - interfaceName := "I" + serviceID + "Service" - l.Add("") - l.Add("// %s defines the interface of the '%s' service", interfaceName, serviceID) - l.Add("//") - l.Add("// This defines a method for each of the actions in the TD. ") - l.Add("// ") - l.Add("type %s interface {", interfaceName) - - sortedActionNames := utils.OrderedMapKeys(tdi.Actions) - for _, name := range sortedActionNames { - action := tdi.Actions[name] - GenInterfaceMethod(l, serviceID, name, action) - } - l.Add("}") -} - -// GenInterfaceMethod adds a method definition for an action in the TD. -// -// > methodName( args ArgsType ) (resp RespType, err error) -// -// TODO: this code to generate a method signature is similar to that in -// GenServiceConsumer.GenActionMethod -// -// The generated method arguments are the senderID and the value. -// The value is either a native type or a struct, based on the TDD definition -func GenInterfaceMethod(l *gentypes.SL, serviceTitle string, name string, action *td.ActionAffordance) { - - // 1. build the input arguments. All methods receive the sender client ID. - methodName := gentypes.Name2ID(name) - argsString := "senderID string" - - // add the service input parameters - if action.Input != nil { - argName := getParamName("args", action.Input) - goType := "" - if action.Input.Type == "object" && action.Input.Properties != nil { - goType = fmt.Sprintf("%s%sArgs", serviceTitle, methodName) - } else { - goType = gentypes.GoTypeFromSchema(action.Input) - } - argsString = fmt.Sprintf("senderID string, %s %s", argName, goType) - } - // output always returns an error - respString := "error" - if action.Output != nil { - respName := getParamName("resp", action.Output) - goType := gentypes.GoTypeFromSchema(action.Output) - - // if the output is an object with a schema then use schema as the type - if action.Output.Type == "array" { - // the type of an array is determined by its 'items' dataschema: - // when items is an object dataschema then the type is a predefined RespType - itemsType := action.Output.ArrayItems - if itemsType != nil && itemsType.Type == "object" && itemsType.Schema == "" { - // this special array-of-objects case has to be handled here as the - // type is already predefined in the Types file. - respName = serviceTitle + methodName - goType = fmt.Sprintf("%sResp", respName) - } else { - // otherwise use as-is - goType = gentypes.GoTypeFromSchema(action.Output) - } - } - - //} - respString = fmt.Sprintf("(%s %s, err error)", respName, goType) - } - l.Add("") - l.Add(" // %s %s", methodName, action.Title) - if action.Description != "" { - l.Add(" // %s", action.Description) - } - if action.Output != nil && action.Output.Description != "" { - desc := gentypes.FirstToLower(action.Output.Description) - l.Add(" // This returns a %s", desc) - } - l.Add(" %s(%s) %s", methodName, argsString, respString) -} - -// Generate a parameter name from the schema title. -// Parameter names start with lower case and consist only of alpha-num chars -// Intended to make the api more readable. -func getParamName(defaultName string, ds *td.DataSchema) string { - if ds.Title == "" { - return defaultName - } - str := regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(ds.Title, "") - str = gentypes.FirstToLower(str) - return str -} diff --git a/cmd/tm2go/genconsumer/GenServiceConsumer.go b/cmd/tm2go/genconsumer/GenServiceConsumer.go deleted file mode 100644 index 6dbf2815..00000000 --- a/cmd/tm2go/genconsumer/GenServiceConsumer.go +++ /dev/null @@ -1,165 +0,0 @@ -package genconsumer - -import ( - "fmt" - "regexp" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/cmd/tm2go/gentypes" -) - -// GenServiceConsumer generates a consumer function for invoking Thing actions. -// -// This consumer will marshal the API parameters into an action argument struct and -// invoke the method using the provided messaging transport. -// -// The TD document must be a digital twin received version -func GenServiceConsumer(l *gentypes.SL, agentID string, tdi *td.TD) error { - - serviceID := gentypes.ToTitle(tdi.ID) - actionKeys := utils.OrderedMapKeys(tdi.Actions) - genImports(l, agentID, serviceID) - for _, key := range actionKeys { - action := tdi.Actions[key] - GenActionMethod(l, serviceID, key, action) - } - l.Add("") - return nil -} - -// imports needed for the agent -func genImports(l *gentypes.SL, agentID string, serviceID string) { - - l.Add("// Package %s with the agent request handler for using service '%s'", - agentID, serviceID) - l.Add("// This builds a service consumer that send a service request.") - l.Add("// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten.") - l.Add("// Generated %s. ", time.Now().Format("02 Jan 2006 15:04 MST")) - l.Add("package %s", agentID) - - l.Add("") - //l.Add("import \"errors\"") - // l.Add("import \"github.com/hiveot/hub/lib/messaging\"") - //l.Add("import \"github.com/hiveot/hivekit/go/utils\"") - l.Add("import \"github.com/hiveot/hub/lib/consumer\"") - l.Add("") -} - -// GenActionMethod generates a client function from an action affordance. -// -// For multi-arguments the format of the generated client is: -// > func(hc hubclient.IConsumerlient, arg1 type, arg2 type, ...)(resp type, err error) { -// > var args = ArgTypeNameArgs{arg1, arg2, ...} -// > err = hc.Rpc(DThingID, MethodName, &args, &resp) -// > return -// > } -// -// serviceTitle title-case thingID of the service without the agent prefix -// key with the service action method. -// action affordance describing the input and output parameters -func GenActionMethod(l *gentypes.SL, serviceTitle string, key string, action *td.ActionAffordance) { - argsString := "hc *consumer.Consumer" - respString := "err error" - invokeArgs := "nil" - invokeResp := "nil" - // this holds lines for assigning input arguments to the arguments struct - // the server is expecting. - argStructLine := "" - serviceDigitwinID := serviceTitle + "DThingID" // as defined in GenGoAPI - - methodName := serviceTitle + gentypes.Name2ID(key) - // input arguments: none, single or multiple - if action.Input != nil { - argName := getParamName("args", action.Input) - goType := "" - // client arguments are in alpha name order - // create a list of fields from the input - if action.Input.Type == "object" && action.Input.Properties != nil { - // argsGoType is generated by GenActionStructs - argsGoType := fmt.Sprintf("%sArgs", methodName) - // list of argument names separated by comma for populating the args struct - // the order is alphabetical so it matches that of the args struct - argNames := "" - // multiple arguments - // arguments are in alphabetic order - keys := utils.OrderedMapKeys(action.Input.Properties) - for _, key := range keys { - ds := action.Input.Properties[key] - //argName := getParamName("args", ds) - argName = key - goType = gentypes.GoTypeFromSchema(ds) - if len(argsString) > 0 { - argsString += ", " - } - if len(argNames) > 0 { - argNames += ", " - } - argsString += fmt.Sprintf("%s %s", argName, goType) - argNames += argName - } - argStructLine = fmt.Sprintf("var args = %s{%s}", argsGoType, argNames) - invokeArgs = "&args" - } else { - // single argument - goType = gentypes.GoTypeFromSchema(action.Input) - argsString += fmt.Sprintf(", %s %s", argName, goType) - invokeArgs = "&" + argName - } - } - // add a response struct as output argument - if action.Output != nil { - respName := getParamName("resp", action.Output) - goType := "" - // if the output is an object with a schema then use schema as the type - if action.Output.Type == "array" { - // the type of an array is determined by its 'items' dataschema: - // when items is an object dataschema then the type is a predefined RespType - itemsType := action.Output.ArrayItems - if itemsType != nil && itemsType.Type == "object" && itemsType.Schema == "" { - // this special array-of-objects case has to be handled here as the - // type is already predefined in the Types file. - goType = fmt.Sprintf("%sResp", methodName) - } else { - // otherwise use as-is - goType = gentypes.GoTypeFromSchema(action.Output) - } - } else { // use a native response type - goType = gentypes.GoTypeFromSchema(action.Output) - } - //} - respString = fmt.Sprintf("%s %s, err error", respName, goType) - invokeResp = "&" + respName - } - // Function declaration - l.Indent = 0 - l.Add("") - l.Add("// %s client method - %s.", methodName, action.Title) - if len(action.Description) > 0 { - l.Add("// %s", action.Description) - } - l.Add("func %s(%s)(%s){", methodName, argsString, respString) - l.Indent++ - - // if the input has multiple arguments then add the Args struct - l.Add("%s", argStructLine) // go-1.24 warns about non-constant format string - - l.Add("err = hc.Rpc(\"%s\", %s, %sMethod, %s, %s)", - td.OpInvokeAction, serviceDigitwinID, methodName, invokeArgs, invokeResp) - l.Add("return") - l.Indent-- - l.Add("}") -} - -// Generate a parameter name from the schema title. -// Parameter names start with lower case and consist only of alpha-num chars -// Intended to make the api more readable. -func getParamName(defaultName string, ds *td.DataSchema) string { - if ds.Title == "" { - return defaultName - } - str := regexp.MustCompile(`[^a-zA-Z0-9]+`).ReplaceAllString(ds.Title, "") - str = gentypes.FirstToLower(str) - return str -} diff --git a/cmd/tm2go/gentypes/GenActionStructs.go b/cmd/tm2go/gentypes/GenActionStructs.go deleted file mode 100644 index cc75caf3..00000000 --- a/cmd/tm2go/gentypes/GenActionStructs.go +++ /dev/null @@ -1,90 +0,0 @@ -package gentypes - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" -) - -// GenActionStructs generates argument and response structs for actions defined in the TD. -// This returns and array of lines of code or an error -// Action structs are defined if the input or output types are of type object. -func GenActionStructs(l *SL, agentID, serviceID string, td *td.TD) { - l.Indent = 0 - l.Add("//--- Argument and Response struct for action of Thing '%s' ---", td.ID) - l.Add("") - - actionKeys := utils.OrderedMapKeys(td.Actions) - for _, key := range actionKeys { - action := td.Actions[key] - methodName := Name2ID(key) - // define a constants for this action method name - l.Add("const %s%sMethod = \"%s\"", serviceID, methodName, key) - l.Add("") - // define structs for action method arguments and responses - GenActionArgs(l, serviceID, key, action) - GenActionResp(l, serviceID, key, action) - } -} - -// GenActionArgs generates the arguments struct of the given action. -// -// Argument structs are named the '{name}'Args where key is modified to remove -// invalid chars. -// If the affordance has no input or it is not an object, then no type is generated. -func GenActionArgs(l *SL, serviceTitle string, key string, action *td.ActionAffordance) { - - // don't generate an args struct when there is no input or it isn't an object - if action.Input == nil || action.Input.Type != "object" { - return - } - // the input is a regular struct. Define this as a args struct. - typeName := Name2ID(key) - l.Indent = 0 - l.Add("// %s%sArgs defines the arguments of the %s function", serviceTitle, typeName, key) - l.Add("// %s - %s", action.Title, action.Description) - GenDescription(l, action.Input.Description, action.Input.Comments) - if action.Input.Schema != "" && action.Output != nil { - // use ref type as arg type - titleType := ToTitle(action.Output.Schema) - l.Add("type %s%sArgs %s", serviceTitle, typeName, titleType) - } else { - l.Add("type %s%sArgs struct {", serviceTitle, typeName) - // input is a dataschema which can be a native value or an object with multiple fields - // if this is a native value then name it 'Input' - //attrList := GetSchemaAttrs("Input", action.Input) - l.Indent++ - GenDataSchemaFields(l, "input", action.Input) - l.Indent-- - l.Add("}") - } - l.Add("") -} - -// GenActionResp generates the response type of the given action, if any. -// -// This defines a "{name}Resp" output struct that is returned by the action. -// If the affordance has no output, or it is not an object, then no type is generated. -func GenActionResp(l *SL, serviceTitle string, key string, action *td.ActionAffordance) { - // don't generate a response struct when there is no output or it isn't an object - if action.Output == nil { - // don't generate a response struct when output is a native type (non-object) - return - } else if action.Output.Type == "object" || action.Output.Type == "array" { - // the output is a regular struct. Define this as a response struct. - typeName := Name2ID(key) - schemaType := fmt.Sprintf("%s%sResp", serviceTitle, typeName) - l.Indent = 0 - l.Add("// %s defines the response of the %s function", schemaType, key) - l.Add("// %s - %s", action.Title, action.Description) - GenDescription(l, action.Output.Description, action.Output.Comments) - - // add the output schema to the response struct - // object: add individual fields - // array: add array with struct of fields - _ = GenDataSchemaObject(l, schemaType, action.Output) - - l.Add("") - } -} diff --git a/cmd/tm2go/gentypes/GenDataSchema.go b/cmd/tm2go/gentypes/GenDataSchema.go deleted file mode 100644 index 46cac048..00000000 --- a/cmd/tm2go/gentypes/GenDataSchema.go +++ /dev/null @@ -1,247 +0,0 @@ -package gentypes - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "golang.org/x/exp/slices" -) - -func GenDataSchema(l *SL, schemaName string, ds *td.DataSchema) (err error) { - schemaTypeName := ToTitle(schemaName) - - if ds.Type == "object" { - err = GenDataSchemaObject(l, schemaTypeName, ds) - } else if ds.Enum != nil { - // define the agent scoped enum - GenDataSchemaEnum(l, schemaTypeName, ds) - } else if ds.OneOf != nil { - // define the agent scoped onoff - err = GenDataSchemaOneOf(l, schemaTypeName, ds) - } else if ds.Default != "" { - // define a agent scoped constant - GenDataSchemaConst(l, schemaTypeName, ds) - } else if ds.Const != nil { - GenDataSchemaConst(l, schemaTypeName, ds) - } else { - // this is a native type. Nothing to do here. - err = nil - } - return err -} - -// GenDataSchemaObject generates a golang type of the given name for the dataschema. -// -// 1. If the dataschema has a 'schema' reference then use this as the type -// > type {typeName} {schemafield} -// -// 2. If the dataschema is a map (1 properties with "" key) -// > type {typeName} map[string]{propstype} -// -// 3. Default define a struct -// -// > type {typeName} struct { -// ... properties -// } -// -// data struct or map scoped to the agent from schema definition -func GenDataSchemaObject(l *SL, typeName string, ds *td.DataSchema) (err error) { - l.Add("// %s defines a %s data schema.", typeName, ds.Title) - GenDescription(l, ds.Description, ds.Comments) - - // 1. if ds.schema field is set then use it instead of a struct - if ds.Schema != "" { - l.Add("type %s %s", typeName, ds.Schema) - } else if len(ds.Properties) == 1 && ds.Properties[""] != nil { - // 2. if dataschema is a map - mapSchema := ds.Properties[""] - if mapSchema.Schema != "" { - l.Add("type %s map[string]%s", typeName, mapSchema.Schema) - } else { - l.Add("type %s map[string]struct{", typeName) - l.Indent++ - // define an agent wide data struct - err = GenDataSchemaFields(l, typeName, mapSchema) - l.Indent-- - - l.Add("}") - } - } else { - // 3. if dataschema is an object or array of objects - // in both cases the content is a list of struct fields - if ds.Type == "array" { - l.Add("type %s []struct {", typeName) - l.Indent++ - // NOTE: this currently only support a single array data type, similar to object - err = GenDataSchemaFields(l, typeName, ds.ArrayItems) - l.Indent-- - } else { - l.Add("type %s struct {", typeName) - - l.Indent++ - // define an agent wide data struct - err = GenDataSchemaFields(l, typeName, ds) - l.Indent-- - } - l.Add("}") - } - l.Add("") - return err -} - -// GenDataSchemaFields generates the fields of a golang struct from its DataSchema. -// Intended for generating fields in action, event, property affordances and schema definitions. -// -// l is the output lines with generated source code -// name is the field name of the dataschema if there is only a single field -// ds is the dataschema to generate -func GenDataSchemaFields(l *SL, name string, ds *td.DataSchema) (err error) { - // Each field of a dataschema is a native type or an object - if len(ds.Properties) > 0 { - err = GenSchemaAttr(l, ds.Properties) - } else { - // This dataschema has a single field - props := map[string]*td.DataSchema{name: ds} - err = GenSchemaAttr(l, props) - } - return err -} - -// GenDescription generates the comments from description and comments -func GenDescription(l *SL, description string, comments []string) { - if description != "" { - l.Add("//") - l.Add("// %s", description) - } - if comments != nil { - //l.Add("//") - for _, row := range comments { - l.Add("// %s", row) - } - } -} - -// GenSchemaAttr generates the attribute fields of a dataschema -// -// attrMap contains a map of attribute name with their description -// -// func GenSchemaAttr(l *SL, attrList []SchemaAttr) { -func GenSchemaAttr(l *SL, attrMap map[string]*td.DataSchema) (err error) { - - names := utils.OrderedMapKeys(attrMap) - for _, key := range names { - attr := attrMap[key] - keyTitle := ToTitle(key) - goType := GoTypeFromSchema(attr) - l.Add("") - l.Add("// %s with %s", keyTitle, attr.Title) - GenDescription(l, attr.Description, attr.Comments) - if attr.Properties != nil { - // nested struct - err = GenSchemaAttr(l, attr.Properties) - // } else if attr.Schema != "" { - // field is a reference to a dataschema - // typeName := ToTitle(attr.Schema) - // l.Add("%s %s", keyTitle, typeName) - } else if len(attr.AdditionalProperties) > 0 { - // field is a map of dataschema - //GenSchemaAttr(l, attr.AdditionalProperties) - } else { - isRequired := false - if attr.Required != nil { - isRequired = slices.Contains(attr.Required, key) - } - if !isRequired { - // optional struct field is a pointer - if attr.Schema != "" || goType == "struct" { - l.Add("%s *%s `json:\"%s,omitempty\"`", keyTitle, goType, key) - } else { - l.Add("%s %s `json:\"%s,omitempty\"`", keyTitle, goType, key) - } - } else { - // required field must be non-nil - l.Add("%s %s `json:\"%s\"`", keyTitle, goType, key) - } - - } - } - return err -} - -// GenDataSchemaConst generates a constant value from schema definition -func GenDataSchemaConst(l *SL, title string, ds *td.DataSchema) { - l.Add("") - l.Add("// %s constant with %s", title, ds.Description) - l.Add("const %s = %s", title, ds.Const) -} - -// GenDataSchemaEnum generates enum type and constants from schema definition -func GenDataSchemaEnum(l *SL, enumTypeName string, ds *td.DataSchema) { - - goType := GoTypeFromSchema(ds) - - l.Add("// %s enumerator", enumTypeName) - GenDescription(l, ds.Description, ds.Comments) - l.Add("type %s %s", enumTypeName, goType) - l.Add("const (") - l.Indent++ - for _, value := range ds.Enum { - enumValueName := "" - if ds.Type == "string" { - enumValueName = ToTitle(value.(string)) - } else { - enumValueName = fmt.Sprintf("%v", value) - } - valueTitle := enumTypeName + enumValueName - // MyEnumName EnumType = EnumValue - if ds.Type == "string" { - l.Add("%s %s = \"%s\"", valueTitle, enumTypeName, value) - } else { - l.Add("%s %s = %v", valueTitle, enumTypeName, value) - } - } - l.Indent-- - l.Add(")") - l.Add("") -} - -// GenDataSchemaOneOf generates enum constants from 'onOff' schema definition -func GenDataSchemaOneOf(l *SL, enumTypeName string, ds *td.DataSchema) (err error) { - goType := GoTypeFromSchema(ds) - l.Add("// %s enumerator", enumTypeName) - GenDescription(l, ds.Description, ds.Comments) - l.Add("type %s %s", enumTypeName, goType) - l.Add("const (") - l.Indent++ - for _, dsEnum := range ds.OneOf { - enumValue := dsEnum.Const - if enumValue == nil { - err = fmt.Errorf("missing oneOf value in 'const' field") - l.Add("// Error: Misssing oneOf value in 'const' field") - continue - } - enumValueName := "" - // The enum name is the typename followed by the value in title case - if ds.Type == "string" { - enumValueName = ToTitle(enumValue.(string)) - } else { - enumValueName = fmt.Sprintf("%v", enumValue) - } - valueTitle := enumTypeName + enumValueName - l.Add("") - l.Add("// %s for %s", valueTitle, dsEnum.Title) - GenDescription(l, dsEnum.Description, dsEnum.Comments) - //if ds.Type == "string" { - // l.Add("%s = \"%s\"", valueTitle, enumValue) - //} else { - l.Add("%s %s = \"%v\"", valueTitle, enumTypeName, enumValue) - //} - //GenSchemaDefConst(l, enumValueName, &dsEnum) - } - - l.Indent-- - l.Add(")") - l.Add("") - return err -} diff --git a/cmd/tm2go/gentypes/GenSchemaDefinitions.go b/cmd/tm2go/gentypes/GenSchemaDefinitions.go deleted file mode 100644 index 5a94cfac..00000000 --- a/cmd/tm2go/gentypes/GenSchemaDefinitions.go +++ /dev/null @@ -1,33 +0,0 @@ -package gentypes - -import ( - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" -) - -// GenSchemaDefinitions generates golang structs from the dataschema in the SchemaDefinitions section -func GenSchemaDefinitions(l *SL, td1 *td.TD) error { - - //agentID, _ := td.SplitDigiTwinThingID(td1.ID) - - if len(td1.SchemaDefinitions) > 0 { - l.Add("") - l.Add("//--- Schema definitions of Thing '%s' ---", td1.ID) - l.Add("") - } - - keys := utils.OrderedMapKeys(td1.SchemaDefinitions) - - for _, schemaName := range keys { - dataSchema := td1.SchemaDefinitions[schemaName] - schemaTypeName := ToTitle(schemaName) - - err := GenDataSchema(l, schemaTypeName, &dataSchema) - if err != nil { - l.Add("// Aborted due to error: %s", err.Error()) - l.Add("") - return err - } - } - return nil -} diff --git a/cmd/tm2go/gentypes/GenThingConstants.go b/cmd/tm2go/gentypes/GenThingConstants.go deleted file mode 100644 index 704b3a7c..00000000 --- a/cmd/tm2go/gentypes/GenThingConstants.go +++ /dev/null @@ -1,54 +0,0 @@ -package gentypes - -import ( - "github.com/hiveot/hivekit/go/api/td" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// GenThingConstantsFromTD generates the thing constants from the TD. -// -// This generates: -// - thing identifiers: -// thing agent ID: {ThingTitle}AgentID = "agentID" -// thing service ID: {ThingTitle}ServiceID = "thingID" -// thing digital twin ID: {ThingTitle}DThingID = "dtw:agentID:thingID" -// - property names {ThingTitle}PropName = "name" -// - event names {ThingTitle}EventName = "name" -// - action names {ThingTitle}ActionName = "name" -func GenThingConstantsFromTD(l *SL, agentID, serviceID string, td1 *td.TD) { - dThingID := digitwin.MakeDigitwinID(agentID, serviceID) - - // thing identifiers - l.Add("//--- Constants ---") - l.Add("") - l.Add("// %sAgentID is the account ID of the agent managing the Thing.", serviceID) - l.Add("const %sAgentID = \"%s\"", serviceID, agentID) - l.Add("") - l.Add("// %sServiceID is the thingID of the device/service as used by agents.", serviceID) - l.Add("// Agents use this to publish events and subscribe to actions") - l.Add("const %sServiceID = \"%s\"", serviceID, serviceID) - l.Add("") - l.Add("// %sDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID", serviceID) - l.Add("// Consumers use this to publish actions and subscribe to events") - l.Add("const %sDThingID = \"%s\"", serviceID, dThingID) - l.Add("") - - // property names - l.Add("// Property, Event and Action names") - l.Add("const (") - l.Indent++ - for name, _ := range td1.Properties { - nameAsID := Name2ID(name) - l.Add("%sProp%s = \"%s\"", serviceID, nameAsID, name) - } - for name, _ := range td1.Events { - nameAsID := Name2ID(name) - l.Add("%sEvent%s = \"%s\"", serviceID, nameAsID, name) - } - for name, _ := range td1.Actions { - nameAsID := Name2ID(name) - l.Add("%sAction%s = \"%s\"", serviceID, nameAsID, name) - } - l.Indent-- - l.Add(")") -} diff --git a/cmd/tm2go/gentypes/GenTypes.go b/cmd/tm2go/gentypes/GenTypes.go deleted file mode 100644 index a743f9c6..00000000 --- a/cmd/tm2go/gentypes/GenTypes.go +++ /dev/null @@ -1,46 +0,0 @@ -package gentypes - -import ( - "encoding/json" - "time" - - "github.com/hiveot/hivekit/go/api/td" -) - -// GenTypes is the main function to generate the types API based on a TD -// This contains all constants, enums, and data schemas used in the TD -func GenTypes(l *SL, agentID string, tdi *td.TD) (err error) { - - serviceID := ToTitle(tdi.ID) - - genImports(l, agentID, serviceID) - GenThingConstantsFromTD(l, agentID, serviceID, tdi) - err = GenSchemaDefinitions(l, tdi) - GenActionStructs(l, agentID, serviceID, tdi) - - // last embed the original TD in json so the agent can publish it - tdi.ID = serviceID - tdDoc, _ := json.Marshal(tdi) - l.Add("") - l.Add("// %sTD contains the raw TD of this service for publication to the Hub", serviceID) - l.Add("const %sTD = `%s`", serviceID, tdDoc) - return err -} - -func genImports(l *SL, agentID string, serviceID string) { - - l.Add("// Package %s with types of service '%s' from agent '%s'", - agentID, serviceID, agentID) - l.Add("// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten.") - l.Add("// Intended for use by both service agents and consumer.") - l.Add("// Generated %s. ", time.Now().Format("02 Jan 2006 15:04 MST")) - l.Add("package %s", agentID) - - l.Add("") - //l.Add("import \"errors\"") - //l.Add("import \"github.com/hiveot/hub/lib/messaging/messaging\"") - //l.Add("import \"github.com/hiveot/hivekit/go/utils\"") - //l.Add("import \"github.com/hiveot/hub/lib/messaging\"") - l.Add("") - -} diff --git a/cmd/tm2go/gentypes/Names.go b/cmd/tm2go/gentypes/Names.go deleted file mode 100644 index 6403c095..00000000 --- a/cmd/tm2go/gentypes/Names.go +++ /dev/null @@ -1,118 +0,0 @@ -package gentypes - -import ( - "strings" - "unicode" - "unicode/utf8" - - "github.com/hiveot/hivekit/go/api/td" -) - -// This file contains naming helper functions -// * Name2ID turns a TD key into a valid identifier -// * ToTitle turns words in a string into a valid title case -// * GoTypeFromSchema converts a dataschema type into a golang type - -// GoTypeFromSchema returns the golang type of a dataschema type, -// or the non-standard type if this not a WoT type. -// -// If the type is an object and the 'schema' field contains a type name then use -// the schema name as the type. -// -// return the schema value as the type. -func GoTypeFromSchema(ds *td.DataSchema) string { - switch ds.Type { - case td.DataTypeAnyURI: - return "string" - case td.DataTypeArray: - // the actual type is in a dataschema under 'items' - if ds.ArrayItems != nil { - arrayType := GoTypeFromSchema(ds.ArrayItems) - return "[]" + arrayType - } else { - // unknown type - return "[]interface{}" - } - case td.DataTypeDateTime: - return "string" - case td.DataTypeBool: - return "bool" - case td.DataTypeInteger: - return "int64" - case td.DataTypeNumber: - return "float64" - case td.DataTypeString: - return "string" - case td.DataTypeUnsignedInt: - return "uint64" - case td.DataTypeObject: - if ds.Schema != "" { - // Only local references are supported - return ToTitle(ds.Schema) - } else if ds.Properties != nil { - // support map types - if ds.Properties[""] != nil { - // this is a map - mapType := ds.Properties[""] - if mapType.Schema != "" { - return "map[string]" + mapType.Schema - } else { - return "map[string]" + mapType.Type - } - } - return "nested properties not supported" - } else { - return "map[string]interface{}" - } - case "": - return "any" - default: - // inject the given type. This is not json-schema standard - return ds.Type - } -} - -// Name2ID turns a TD name into a valid identifier string -// This removes invalid characters and capitalizes the result -func Name2ID(key string) string { - keyAsBytes := []byte(key) - // keep only valid identifier chars - n := 0 - for _, b := range keyAsBytes { - if ('a' <= b && b <= 'z') || - ('A' <= b && b <= 'Z') || - ('0' <= b && b <= '9') || - b == '_' { - keyAsBytes[n] = b - n++ - } - } - // first char must be upper case - id := ToTitle(string(keyAsBytes[:n])) - return id -} - -// ToTitle converts each word in the given string to title case -func ToTitle(s string) string { - words := strings.Fields(s) - for n, word := range words { - words[n] = strings.Title(word) - } - // join without spaces - s2 := strings.Join(words, "") - return s2 -} - -// FirstToLower converts the first character of a string to lower case -// credits: https://stackoverflow.com/questions/75988064/make-first-letter-of-string-lower-case-in-golang -func FirstToLower(s string) string { - r, size := utf8.DecodeRuneInString(s) - if r == utf8.RuneError && size <= 1 { - return s - } - lc := unicode.ToLower(r) - if r == lc { - return s - } - return string(lc) + s[size:] -} diff --git a/cmd/tm2go/gentypes/SL.go b/cmd/tm2go/gentypes/SL.go deleted file mode 100644 index 8a7b838b..00000000 --- a/cmd/tm2go/gentypes/SL.go +++ /dev/null @@ -1,52 +0,0 @@ -package gentypes - -import ( - "fmt" - "os" - "strings" -) - -// simple indentation string -const spaces = " " -const indentSize = 4 - -// SL is a simple helper for building string lists -type SL struct { - Indent int - Lines []string -} - -// Add indented text to the string list -func (l *SL) Add(s string, args ...any) *SL { - if l.Indent > 80/indentSize { - l.Indent = 80 / indentSize - } - s2 := spaces[:l.Indent*indentSize] + fmt.Sprintf(s, args...) - l.Lines = append(l.Lines, s2) - return l -} - -//// Remove removes a row from the list while maintaining order -//// This is slower than RemoveNoOrder but maintains order and does not modify -//// the original slice. -//// see also: https://stackoverflow.com/questions/37334119/how-to-delete-an-element-from-a-slice-in-golang -//func (l *SL) Remove(row int) { -// Remove(l.Lines, row) -//} -// -//// RemoveFast is a fast way to remove a row from the list. -//// This does not maintain order and modifies the existing slice. -//func (l *SL) RemoveFast(row int) { -// RemoveFast(l.Lines, row) -//} - -func (l *SL) Size() int { - return len(l.Lines) -} - -// Write the Lines to file -func (l *SL) Write(outfile string) error { - data := strings.Join(l.Lines, "\n") - err := os.WriteFile(outfile, []byte(data), 0644) - return err -} diff --git a/cmd/tm2go/listtms/ListTMsCommand.go b/cmd/tm2go/listtms/ListTMsCommand.go deleted file mode 100644 index 2306133c..00000000 --- a/cmd/tm2go/listtms/ListTMsCommand.go +++ /dev/null @@ -1,63 +0,0 @@ -package listtms - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/hiveot/hivekit/go/api/td" -) - -// HandleTMScan scan and display a list of TM (TD model) documents -func HandleTMScan(rootDir string) error { - - fmt.Printf("Filename Size (KB) Title @Type #props #events #actions\n") - fmt.Printf("----------------------------------- --------- ----------------------------------- --------------------- --- --- ---\n") - - // recursively iterate all directories looking for tm/*.json - err := filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error { - // look for tdd directories - if d.Name() == "tm" { - HandleListTMs(path) - } - return nil - }) - return err -} - -// HandleListTMs displays a list of TM documents -func HandleListTMs(tmDir string) { - - fmt.Printf("\n%s:\n", tmDir) - td.ForEachTD(tmDir, func(sourceFile string, tmDoc *td.TD) { - stat, err := os.Stat(sourceFile) - if err != nil { - return - } - sizeKb := stat.Size() / 1024 - if err != nil { - fmt.Printf("%-35.35s %9d ERROR: %s\n", stat.Name(), sizeKb, err.Error()) - } else { - fmt.Printf("%-35.35s %9d %-35.35s %-20.20s %9d %8d %9d\n", stat.Name(), sizeKb, - tmDoc.Title, tmDoc.AtType, - len(tmDoc.Properties), len(tmDoc.Events), len(tmDoc.Actions)) - } - }) - - ////fmt.Printf("Filename Size (KB) Title @Type #props #events #actions\n") - ////fmt.Printf("----------------------------------- --------- ----------------------------------- --------------------- --- --- ---\n") - //for _, entry := range entries { - // fullpath := filepath.Join(tmDir, entry.Name()) - // tdi, err := td.RetrieveThing(fullpath) - // finfo, _ := entry.Info() - // sizeKb := finfo.Size() / 1024 - // if err != nil { - // fmt.Printf("%-35.35s %9d ERROR: %s\n", entry.Name(), sizeKb, err.Error()) - // } else { - // fmt.Printf("%-35.35s %9d %-35.35s %-20.20s %9d %8d %9d\n", entry.Name(), sizeKb, - // tdi.Title, tdi.AtType, - // len(tdi.Properties), len(tdi.Events), len(tdi.Actions)) - // } - //} - //return nil -} diff --git a/cmd/tm2go/main.go b/cmd/tm2go/main.go deleted file mode 100644 index 4a0f8576..00000000 --- a/cmd/tm2go/main.go +++ /dev/null @@ -1,275 +0,0 @@ -package main - -import ( - "fmt" - "log/slog" - "os" - "path" - "path/filepath" - "strings" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/cmd/tm2go/genagent" - "github.com/hiveot/hub/cmd/tm2go/genconsumer" - "github.com/hiveot/hub/cmd/tm2go/gentypes" - "github.com/hiveot/hub/cmd/tm2go/listtms" - "github.com/hiveot/hub/lib/logging" - "github.com/urfave/cli/v2" -) - -const TypesAPISuffix = "Types.go" -const AgentAPISuffix = "AgentAPI.go" -const ConsumerAPISuffix = "ConsumerAPI.go" - -const Version = `0.3-alpha` -const TMDir = "tm" -const APIDir = "api" - -// CLI for generating API's from Thing Model/Description Documents (TM) -func main() { - logging.SetLogging("warning", "") - var recursive bool - //var outFile = "" - var outDir = path.Join("..", APIDir) - var force bool - - app := &cli.App{ - EnableBashCompletion: true, - Name: "tm2go", - Usage: "HiveOT API code generator for golang from a TD document definitions", - Version: Version, - - // commands arguments are passed by reference so they are updated in the Before section - Commands: []*cli.Command{ - - &cli.Command{ - Name: "list", - Usage: "List the TD's available in all packages", - UsageText: "list [package-directory]", - Action: func(cCtx *cli.Context) error { - packageDir, _ := filepath.Abs(cCtx.Args().First()) - return listtms.HandleTMScan(packageDir) - }, - }, - &cli.Command{ - Name: "generate", - Usage: "Generate golang code from a TD.", - Args: true, - UsageText: "generate [-f] [-r] types|agent|consumer|all source" + - "\nWhere:" + - "\n types instructs to generate only the constants and dataschema types from the TD." + - "\n agent instructs to generate the service api and request handler code." + - "\n consumer instructs to generate the client api code." + - "\n all instructs to generate types, agent and consumer code." + - "\n source is the path to the TD document or 'tdd' directory holding one or more sources.", - UseShortOptionHandling: true, - - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "recursive", - Aliases: []string{"r"}, - Usage: "recurse to find 'tdd' subdirectories", - Value: recursive, - Destination: &recursive, - }, - &cli.BoolFlag{ - Name: "force", - Usage: "force generate even when up to date", - Aliases: []string{"f"}, - Value: force, - Destination: &force, - }, - &cli.StringFlag{ - Name: "outdir", - Usage: "output directory to generate sources, relative to sourcefile", - Value: outDir, - Destination: &outDir, - }, - }, - Action: func(cCtx *cli.Context) (err error) { - if cCtx.NArg() < 2 { - return fmt.Errorf("Expected a source type and a source file or directory") - } - genType := cCtx.Args().First() - // Determine the package directory (used as agentID) - sourceFiles := []string{} - source, _ := filepath.Abs(cCtx.Args().Get(1)) - sourceStat, err := os.Stat(source) - if err != nil { - return err - } - if sourceStat.IsDir() { - // source is a directory with source files - sourceFiles = LocateSources(source, recursive) - } else { - // source is the file - sourceFiles = append(sourceFiles, source) - } - if genType == "all" { - err = GenerateSources("types", sourceFiles, outDir, force) - if err == nil { - err = GenerateSources("agent", sourceFiles, outDir, force) - } - if err == nil { - err = GenerateSources("consumer", sourceFiles, outDir, force) - } - } else { - err = GenerateSources(genType, sourceFiles, outDir, force) - } - if err != nil { - slog.Error("generate error", "err", err.Error()) - } - return err - }, - }, - }, - } - app.Suggest = true - app.HideHelpCommand = true - if err := app.Run(os.Args); err != nil { - println("ERROR: ", err.Error()) - os.Exit(1) - //helpArgs := append(os.Args, "-h") - //_ = app.Run(helpArgs) - } -} - -// LocateSources locates all tm/file.json sources in the given directory or below -func LocateSources(rootDir string, recursive bool) (sources []string) { - sources = make([]string, 0) - - if recursive { - // recursively iterate all directories looking for tm/*.json - _ = filepath.Walk(rootDir, func(path string, finfo os.FileInfo, err error) error { - if err != nil { - fmt.Printf("Error: %s\n", err.Error()) - return err - } - // look for .json files in tdd directories - if !finfo.IsDir() { - sourceDir := filepath.Dir(path) - if strings.HasSuffix(sourceDir, TMDir) { - if strings.HasSuffix(finfo.Name(), "json") { - sources = append(sources, path) - } - } - } - return nil - }) - } else { - // locate all the .json files in the given rootDir - entries, err := os.ReadDir(rootDir) - if err != nil { - return sources - } - for _, entry := range entries { - finfo, _ := entry.Info() - if finfo.IsDir() { - // looking for files, not subdirectories - continue - } - name := finfo.Name() - if strings.HasSuffix(path.Ext(name), TMDir) { - fullPath := filepath.Join(rootDir, name) - sources = append(sources, fullPath) - } - } - } - return sources -} - -// GenerateSources finds TDs and generate the source code in its api directory -func GenerateSources(gentype string, tdFiles []string, outDir string, force bool) (err error) { - - for _, tdFile := range tdFiles { - sourceDir := filepath.Dir(tdFile) - packageDir := filepath.Dir(sourceDir) - agentID := filepath.Base(packageDir) - outDirAbs := outDir - if !filepath.IsAbs(outDir) { - outDirAbs = filepath.Join(sourceDir, outDir) - } - err = GenerateSource(gentype, agentID, tdFile, outDirAbs, force) - if err != nil { - return err - } - } - return err -} - -// GenerateSource generates source code for the given TD JSON file -// -// gentype is one of {types, agent, consumer} to generate - also used as filename suffix. -// agentID is the service agent ID -// tdi is the TD to save -// fileName is the name of the json input and used for the go output file -// outDir is the directory to write the file in -// force generate file even when existing output is newer -// -// The package name is the parent directory name, for example: 'digitwin' in digitwin/tm/directory.json -func GenerateSource(gentype string, agentID string, sourceFile string, outDir string, force bool) error { - var err error - var outfilePath string - - tdi, err := td.ReadTDFromFile(sourceFile) - if err != nil { - err = fmt.Errorf("GenerateSource failed: %w", err) - return err - } - err = os.MkdirAll(outDir, 0755) - if err != nil { - return err - } - - // get the output name without file extension - outFileName := filepath.Base(sourceFile) - outFileName = strings.TrimSuffix(outFileName, filepath.Ext(outFileName)) - outFileName = gentypes.ToTitle(outFileName) - - sourceStat, _ := os.Stat(sourceFile) - l := &gentypes.SL{} - if gentype == "types" { - outfilePath = filepath.Join(outDir, outFileName+TypesAPISuffix) - outfileStat, _ := os.Stat(outfilePath) - if !force && outfileStat != nil && outfileStat.ModTime().After(sourceStat.ModTime()) { - fmt.Printf("GenerateSource: Destination %s is already up to date. Not updated.\n", outfilePath) - return nil - } else { - err = gentypes.GenTypes(l, agentID, tdi) - } - - } else if gentype == "agent" { - outfilePath = filepath.Join(outDir, outFileName+AgentAPISuffix) - outfileStat, _ := os.Stat(outfilePath) - if !force && outfileStat != nil && outfileStat.ModTime().After(sourceStat.ModTime()) { - fmt.Printf("GenerateSource: Destination %s is newer. Not updated.\n", outfilePath) - return nil - } else { - err = genagent.GenAgent(l, agentID, tdi) - } - - } else if gentype == "consumer" { - outfilePath = filepath.Join(outDir, outFileName+ConsumerAPISuffix) - outfileStat, _ := os.Stat(outfilePath) - if !force && outfileStat != nil && outfileStat.ModTime().After(sourceStat.ModTime()) { - fmt.Printf("GenerateSource: Destination %s is newer. Not updated.\n", outfilePath) - return nil - } else { - err = genconsumer.GenServiceConsumer(l, agentID, tdi) - } - - } else { - err = fmt.Errorf("GenerateSource: unknown generate command '%s'", gentype) - } - if err != nil { - return err - } - // 4: Save the types - err = l.Write(outfilePath) - if err != nil { - fmt.Printf("GenerateSource: Failed to generated %s into %s: %s\n", gentype, outfilePath, err.Error()) - } else { - fmt.Printf("GenerateSource: Generated %s into %s\n", gentype, outfilePath) - } - return err -} diff --git a/cmd/tm2go/main_test.go b/cmd/tm2go/main_test.go deleted file mode 100644 index 88b57da8..00000000 --- a/cmd/tm2go/main_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package main - -const testSource1 = "../../runtime/digitwin/tm/Values.json" -const testSource2 = "../../runtime/digitwin/tm/Directory.json" -const testSource3 = "../../runtime/authn/tm/admin.json" -const testSource4 = "../../runtime/authn/tm/user.json" - -// Helper for testing the types generator -// func Test_types(t *testing.T) { -// logging.SetLogging("info", "") -// cwd, _ := os.Getwd() -// sourceFiles := []string{path.Join(cwd, testSource1), -// path.Join(cwd, testSource2), -// path.Join(cwd, testSource3), -// path.Join(cwd, testSource4), -// } - -// outDir := path.Join(os.TempDir(), "test-types") -// force := true -// err := GenerateSources("types", sourceFiles, outDir, force) -// assert.NoError(t, err) -// main() -// } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9a9a1df5..e0ca01a4 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -33,9 +33,9 @@ Most debian/ubuntu or other linux systems already have git, make and go installe Note that hiveot doesn't need any of these to run. The binaries are self-contained. -### Check Golang version 1.22 or higher +### Check Golang version 1.25 or higher -Verify a recent version of golang is installed, eg v1.22 or up +Verify a recent version of golang is installed, eg v1.25 or up > go version @@ -43,6 +43,8 @@ Installing and upgrading golang is out of scope of these instructions. ### Setup Node v22 +(alternatives are considered - Deno or Bun) + Install node version manager: ```sh diff --git a/docs/README-TD.md b/docs/README-TD.md index d85a8090..eb5e1a1c 100644 --- a/docs/README-TD.md +++ b/docs/README-TD.md @@ -37,7 +37,7 @@ The digital twin TD will have an updated Thing ID, TD Forms and authentication i The TD documents contains a set of attributes to describe a Thing. The attributes used in HiveOT are: | name | description | -|---------------------|-------------------------------------------------------| +| ------------------- | ----------------------------------------------------- | | @context | "http://www.w3.org/ns/td" | | @type | Thing device type as per vocabulary | | id | Unique Thing ID, option in WoT but required in HiveOT | @@ -49,7 +49,7 @@ The TD documents contains a set of attributes to describe a Thing. The attribute | events | Map of event objects as submitted by the Thing | | schemaDefinitions | data schema for use in multiple actions or events | | Forms | Top level operations on Things | -| Links | Definition of alternative result dataschemas | +| Links | Definition of alternative result dataschemas | | Security | Names of security definitions | | SecurityDefinitions | Security definitions for authentication with the hub | @@ -135,7 +135,7 @@ When sharing digital twin things between hubs, the hub prefixes the digital twin ## Thing Properties -Thing Properties describe the Thing attributes, state and configuration, and are identified by their key in the TD property map. +Thing Properties describe the Thing attributes, internal state and configuration, and are defined in the TD property map. The WoT TD describes properties with the [PropertyAffordance](https://www.w3.org/TR/wot-thing-description11/#propertyaffordance). This is a sub-class of an [interaction affordance](https://www.w3.org/TR/wot-thing-description11/#interactionaffordance) and [dataschema](https://www.w3.org/TR/wot-thing-description11/#dataschema). @@ -174,8 +174,8 @@ Property names are not used as a classification or purpose of the property. For Properties are defined with the so-called [property affordance](https://www.w3.org/TR/wot-thing-description11/#propertyaffordance). The property affordance defines a set of attributes used to describe the property. | Attribute | description | -|--------------|-------------------------------------------------------------------------------------| -| @type | property type classification (see [1]) | +| ------------ | ----------------------------------------------------------------------------------- | +| @type | property type classification (see [1]) | | type | WoT defined data-type: string, number, integer, boolean, object, array, or null [2] | | schema | Name of the schema defined in the 'schemaDefinitions' section in the TD | | title | Short human readable label for the property. | @@ -192,7 +192,7 @@ Properties are defined with the so-called [property affordance](https://www.w3.o | maxLength | type string: Maximum length of a string | | *isSensor* | the property is a sensor output (not a WoT field) | | *isActuator* | the property is an actuator (not a WoT field) | -| forms | in development [4] | +| forms | in development [5] | Notes: @@ -209,7 +209,7 @@ HiveOT uses the ground rule that changes to external state are reported through Examples of external state are environmental sensors and electrical inputs. A temperature sensor reports changes to the temperature as events. A motion sensor reports motion detection as an event. -In most cases events are not represented as properties as they do not reflect an internal state. +In most cases events do not have a corresponding as property as they do not reflect an internal state. Hiveot uses the @type field to classify a property but this is too fine of a granularity for this purpose and harder to apply consistently. diff --git a/docs/TD Challenges.md b/docs/TD Challenges.md index d817a799..f049bf47 100644 --- a/docs/TD Challenges.md +++ b/docs/TD Challenges.md @@ -1,5 +1,7 @@ # Questions related to the use of TD in HiveOT +Written in 2024. To be updated. + 3. How to write multiple properties? [solved] > Use-case: user applies changes to multiple properties values in one request. > Solution: Don't support writing multiple properties. Use writeproperty instead. This avoids the ambiguity of the payload. Writing properties is rare enough so it isn't needed. diff --git a/docs/design-overview.md b/docs/design-overview.md index ce7475aa..1714ed29 100644 --- a/docs/design-overview.md +++ b/docs/design-overview.md @@ -11,6 +11,8 @@ Each Hub is a consumer of one or more information agents such as IoT devices and The heart of a Hub is the 'Digital Twin Runtime' which contains Digital Twins of Things and supports interaction through various transport protocols. A Digital Twin is a digital replica of an actual Thing. It contains the Thing Description (TD) document and property, event and action state values of the Thing. Writing a property on a Digital Twin will be passed on to the actual device (exposed Thing). Changes to the state of the device will update the digital twin, which in turn notify consumers. Consumers can use any of the supported protocol bindings to connect to the Hub and retrieve Thing information. +The Hub is built using [HiveKit](http://github.com/hiveot/hivekit) which provides modules for building Web-Of-Things (WoT) capable applications. + ![](digitwin-overview.jpg) *Digital Twin Runtime Overview* @@ -47,54 +49,14 @@ Last but not least, the plan is for a bridge service that supports exchange of s # Hub Transport Protocols -The Hub includes several transport protocols out of the box. A transport protocol can support consumers and agents with endpoints for reading, writing and observing of properties, invoking actions and subscribing to events from digital twin Things. - -When digitwin TD documents are requested, these protocols inject 'Forms' that describe the supported operations. - -The Hub also includes a client library for various programming languages that provides a consumed-thing implementation for consumers and a exposed-thing implementation for use by agents. +The Hub includes transport protocols provided by HiveKit. A transport protocol can support consumers and agents. Agents can be configured running a server or use reverse-connections to connect to the Hub. The transport protocols implement endpoints for reading, writing and observing of properties, invoking actions and subscribing to events from digital twin Things. -Initially 3 transport protocols are supported: The hiveot HTTPS/SSE-SC, hiveot websocket, and WoT websocket (strawman) transports. The hiveot protocols use the standard RequestMessage and ResponseMessage envelopes as-is while the WoT websocket (strawman) transport maps to the woT websocket message types. The MQTT protocol server and client bindings will be added next. +When a consumer requests TD documents from the Hub directory, these protocols inject 'Forms' that describe the protocol used to connect to the hub. Use of external protocol servers such as Redis can be accommodated in the future with the caveat that account management must be synchronized between the authn service and the protocol server. Client account ID must match between the server and the runtime. Use of a 3rd party authentication server such as OAuth might be supported in the future but is currently out of scope. -Last but not least, agents connect to the hub instead of the other way around. While this connection reversal is independent from the role of consumer and agent, it does mean that the client side must be able to handle requests from the hub to subscribe to events and observe properties. - -## HTTPS/SSE/WS protocols - -The HTTPS transport protocol open a listening TLS port for clients (both agents and consumers) to connect to. The server supports both the SSE and Websocket sub-protocols for establishing two-way communication. - -The transport injects Forms into the digitwin TD document describing the endpoints for operations for using properties, events and actions on the Thing's digital twin. - -All requests must use TLS. Requests must carry a valid authentication token, except for the login request. - -#### HiveOT SSE Sub-protocol - -The hub server supports a modified SSE (Server Side Events) single-connection sub-protocol. This sub-protocol, named 'sse-hiveot', uses the RequestMessage and ResponseMessage envelopes. These envelopes include the operation, thingID and affordance name. This enables the use of a single sse-connection to pass requests and responses for different operations, Things and affordances. To subscribe to events and observe properties the http endpoint is invoked as defined in the Form generated by this subprotocol. - - -#### HiveOT WebSocket Sub-protocol - -The HiveOT websocket sub-protocol simply passes the RequestMessage and ResponseMessages as-is over the connection. Requests to subscribe to events and observe properties are handled by the protocol binding. - -#### WoT WebSocket Sub-protocol - -The WoT websocket protocol specification is currently being reviewed and is subject to change. This transport protocol implementation currently implements a message mapping from RequestMessage to WoT websocket message types. The reverse takes place for ResponseMessages. - -#### Consumer - -The golang library includes a Consumer type for use by, .. well, consumers. The Consumer provides an API for supported operations and converts the request to a standed RequestMessage. The reverse happens on the Response side. A consumer instance works with any hiveot transport protocol binding. - -Consumer applications can use the Consumer instance for convenience. It also supports an RPC api that matches asynchronous responses with their request using the message correlationID. - -### MQTT (planned) - -MQTT and other message bus protocols support publish and subscribe sessions natively. The message bus server must integrate with the hub's authentication and authorization services to ensure property security. - -The Mqtt protocol binding injects Forms in the digitwin TD that describes how to perform operations on the digitwin Thing, as described in the WoT specification. - -### CoAP (planned) +HiveOT agents connect to the hub using Reverse Connections (RC) instead of the other way around. These agents often expose multiple Things that are reachable through the Hub. Non-hiveot devices are accomodated by uploading a TD of the device that describes the protocol needed to connect. The Hub Runtime uses the HiveKit router to establish a connection if the transport protocol is support and credentials are available. -CoAP protocol support is planned for the future. This can be implemented as an embedded transport protocol or as a separate Agent plugin for CoAP devices which can be located separate from the Hub. The agent uses the http or mqtt transport protocol to connect to the Hub. This is to be decided. # Security @@ -102,9 +64,9 @@ Security in HiveOT is a first-class multi-layered feature of HiveOT. The first layer of protection is provided by simply not allowing direct connections between consumers and IoT device agents. Instead, agents and consumers authenticate and connect to the Hub and send their messages to the Digital Twin Runtime who routes the message. The second layer of protection is provided by 'requiring' that IoT device agents do not listen on any network ports, unless this is required for their functionality. Instead, agents connect to the Hub as a client. -For 3rd party IoT devices that do not support HiveOT rules, a protocol binding plugin can provide a bridge between device and the Hub, allowing the IoT device to be placed on a separate secured network segment or behind a firewall. +For 3rd party IoT devices that do not support HiveOT rules, the HiveKit Router can use the device TD to connect to the device and expose its digital twin. Ideally these devices are placed on a separate secured network segment or behind a firewall. -The third layer of protection is the using of short-lived JWT authentication tokens for consumers, that are linked to their session. If a valid token is obtained by another client then it cannot be used as each connection has its own session. Tokens expire within weeks. Consumers must reconnect before expiry to refresh their token, or login with their password again. This is a consumer facing security. Services and agents use a single persistent session but are still required to periodically refresh their authentication token. +The third layer of protection is the using of short-lived authentication tokens for consumers, that are linked to their session. If a valid token is obtained by another client then it cannot be used as each connection has its own session. Tokens expire within weeks. Consumers must reconnect before expiry to refresh their token, or login with their password again. This is a consumer facing security. Services and agents use a single persistent session but are still required to periodically refresh their authentication token. Use of passwordless login is planned for the future. The fourth layer of protection is provided through role based authorization. Consumers are only able to access Things based on their role. A viewer role does not allow sending actions to Things for example. @@ -117,11 +79,11 @@ By default, all connections require TLS to ensure encrypted communication. Out of the box, the hub uses a self-signed certificate. For local usage in edge devices this can be sufficient as long as consumers have access to the CA certificate. Support for lets-encrypt is planned for the future. -The Hub supports multiple forms of connection authentication depending on the communication protocol used. As a minimum a login/password, JWT token and pub/private key authentication are supported. +The Hub supports multiple forms of connection authentication depending on the communication protocol used. As a minimum a login/password, JWT/Paseto tokens and client certificate authentication is supported. All clients must be known to the Hub before they can connect with their credentials. Future consideration is the use of 3rd party auth providers. -Authentication is managed through the embedded 'authn' service. +Authentication is managed through the HiveKit 'authn' service which is embedded in the Hub. ## Authorization @@ -129,11 +91,11 @@ Authorization determines which clients can subscribe to events and publish actio ### Agent Authorization -Agents connect to the Hub. After authentication, the hub subscribes to the agents to receive events and observe properties. Yes, this is done over a reverse connection. +HiveOT Agents connect to the Hub. After authentication, the hub subscribes to the agents to receive events and observe properties. This takes place over a reverse connection. Agents must use a pre-provided auth token or certificate. They can refresh the token and store it for later use. -On startup, agents can register their own authorization rules with the roles that are allowed to use the things or services they offer. This service is provided by the authz service. The agent invokes the action of the authz service Thing. The hub middleware applies these rules to consumers that use the digital twin Thing. +Authorization rules for access to services must be configured in the Hub authz configuration section. ### Consumer Authorization diff --git a/docs/thoughts.md b/docs/thoughts.md index eba1d8f1..5db0cf3d 100644 --- a/docs/thoughts.md +++ b/docs/thoughts.md @@ -1,4 +1,4 @@ -# Scratchpad Notes +# Scratchpad Notes (2024) These are some notes on thoughts and challenges that have come up. More notes in README-TD.md diff --git a/go.mod b/go.mod index 37586840..937d4e65 100644 --- a/go.mod +++ b/go.mod @@ -1,62 +1,55 @@ module github.com/hiveot/hub -go 1.25.0 +go 1.25.8 // can't use go.work. See https://github.com/golang/go/issues/50750 replace github.com/hiveot/hivekit/go => ../hivekit/go require ( aidanwoods.dev/go-paseto v1.6.0 - github.com/alexedwards/argon2id v1.0.0 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de - github.com/cockroachdb/pebble v1.1.5 - github.com/dchest/uniuri v1.2.0 - github.com/eclipse/paho.golang v0.23.0 - github.com/fsnotify/fsnotify v1.9.0 - github.com/go-chi/chi/v5 v5.2.5 - github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/gorilla/websocket v1.5.3 - github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 - github.com/hiveot/hivekit/go v0.0.0-20260205212135-65c0126cb783 + github.com/fsnotify/fsnotify v1.10.1 + github.com/go-chi/chi v1.5.5 + github.com/go-chi/chi/v5 v5.3.0 + github.com/hiveot/hivekit/go v0.0.0-20260608062358-39ed8979195d github.com/huin/goupnp v1.3.0 github.com/json-iterator/go v1.1.12 - github.com/lmittmann/tint v1.1.3 github.com/mostlygeek/arp v0.0.0-20170424181311-541a2129847a - github.com/rs/cors v1.11.1 github.com/samber/lo v1.52.0 github.com/stretchr/testify v1.11.1 github.com/struCoder/pidusage v0.2.1 github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 - github.com/thanhpk/randstr v1.0.6 - github.com/tidwall/btree v1.8.1 - github.com/tmaxmax/go-sse v0.11.0 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.49.0 - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 - golang.org/x/net v0.52.0 - golang.org/x/sys v0.42.0 + golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 gopkg.in/yaml.v3 v3.0.1 ) require ( aidanwoods.dev/go-result v0.3.1 // indirect github.com/DataDog/zstd v1.5.7 // indirect + github.com/alexedwards/argon2id v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cockroachdb/errors v1.12.0 // indirect + github.com/cockroachdb/errors v1.13.0 // indirect github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect - github.com/cockroachdb/redact v1.1.6 // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect + github.com/cockroachdb/redact v1.1.8 // indirect github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cstockton/go-conv v1.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/getsentry/sentry-go v0.42.0 // indirect + github.com/getsentry/sentry-go v0.46.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/snappy v1.0.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lmittmann/tint v1.1.3 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -65,15 +58,22 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/prometheus/common v0.68.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect + github.com/rs/cors v1.11.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/tidwall/btree v1.8.1 // indirect + github.com/tmaxmax/go-sse v0.11.0 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/mod v0.34.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index 06dc201f..6a4b55eb 100644 --- a/go.sum +++ b/go.sum @@ -16,46 +16,54 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= -github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= -github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g= +github.com/cockroachdb/errors v1.13.0 h1:BoCcJeiP9hpBJDETkX19qi8Tb8So37srSsp3stTaDMQ= +github.com/cockroachdb/errors v1.13.0/go.mod h1:bjxt/4E5+OyuAnacpTIU9rn2mzPu1VlthvHP+xpROq0= github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 h1:pU88SPhIFid6/k0egdR5V6eALQYq2qbSmukrkgIh/0A= github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILMqgNeV5jiqR4j+sTuvQNHdf2chuKj1M5k= github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= -github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4314= -github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7KTaA= +github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw= github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb h1:3bCgBvB8PbJVMX1ouCcSIxvsqKPYM7gs72o0zC76n9g= github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cstockton/go-conv v1.0.0 h1:zj/q/0MpQ/97XfiC9glWiohO8lhgR4TTnHYZifLTv6I= +github.com/cstockton/go-conv v1.0.0/go.mod h1:HuiHkkRgOA0IoBNPC7ysG7kNpjDYlgM7Kj62yQPxjy4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= -github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= -github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= -github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= -github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/getsentry/sentry-go v0.46.2 h1:1jhYwrKGa3sIpo/y5iDNXS5wDoT7I1KNzMHrnK6ojns= +github.com/getsentry/sentry-go v0.46.2/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grandcat/zeroconf v1.0.1-0.20230119201135-e4f60f8407b1 h1:cNb52t5fkWv8ZiicKWnc2eZnhsCCoH7WmRBMIbMp04Q= @@ -66,8 +74,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -99,14 +107,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA= +github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -123,8 +131,6 @@ github.com/struCoder/pidusage v0.2.1 h1:dFiEgUDkubeIj0XA1NpQ6+8LQmKrLi7NiIQl86E6 github.com/struCoder/pidusage v0.2.1/go.mod h1:bewtP2KUA1TBUyza5+/PCpSQ6sc/H6jJbIKAzqW86BA= github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI= github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI= -github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= -github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tmaxmax/go-sse v0.11.0 h1:nogmJM6rJUoOLoAwEKeQe5XlVpt9l7N82SS1jI7lWFg= @@ -136,25 +142,37 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM= +golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -164,8 +182,8 @@ golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -187,8 +205,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -201,20 +219,26 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/lib/agent/Agent.go b/lib/agent/Agent.go deleted file mode 100644 index b99677dd..00000000 --- a/lib/agent/Agent.go +++ /dev/null @@ -1,194 +0,0 @@ -package agent - -import ( - "fmt" - "log/slog" - "sync/atomic" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" -) - -// ThingDirectoryDThingID is the Digitwin ThingID of the runtime Directory Service -// This is duplicated from DirectoryConsumerAPI.go to avoid a compile-time dependency -// on the runtime. (would be circular). -// What would be better is to determine this from discovery but this is a lot -// of work just for cleanliness's sake. -// Used to update the directory with TD's using this agent. -const ThingDirectoryDThingID = "dtw:digitwin:ThingDirectory" -const ThingDirectoryUpdateThingMethod = "updateThing" - -// Agent provides the messaging functions needed by hub agents. -// Agents are also consumers as they are able to invoke services. -// -// Hub agents receive requests and return responses. -// The underlying transport protocol binding handles subscription. -type Agent struct { - *consumer.Consumer - - // the application's request handler set with SetRequestHandler - // intended for sub-protocols that can receive requests. (agents) - appRequestHandlerPtr atomic.Pointer[messaging.RequestHandler] -} - -// OnRequest passes a request to the application request handler and returns the response. -// Handler must be set by agent subclasses during init. -// This logs an error if no agent handler is set. -func (ag *Agent) onRequest( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - // handle requests if any - hPtr := ag.appRequestHandlerPtr.Load() - if hPtr == nil { - err := fmt.Errorf("Received request but no handler is set") - resp := req.CreateResponse(nil, err) - return resp - } - resp := (*hPtr)(req, c) - return resp -} - -// PubActionProgress helper for agents to send a 'running' ActionStatus notification -// -// This sends an ActionStatus message with status of running. -func (ag *Agent) PubActionProgress(req messaging.RequestMessage, value any) error { - status := messaging.ActionStatus{ - //AgentID: ag.GetClientID(), - ActionID: req.CorrelationID, - Input: req.Input, - Name: req.Name, - Output: value, - SenderID: ag.GetClientID(), - State: messaging.StatusRunning, - ThingID: req.ThingID, - TimeRequested: req.Created, - TimeUpdated: utils.FormatNowUTCMilli(), - } - - resp := messaging.NewNotificationMessage(td.OpInvokeAction, req.ThingID, req.Name, status) - return ag.GetConnection().SendNotification(resp) -} - -// PubEvent helper for agents to send an event to subscribers. -// -// The underlying transport protocol binding handles the subscription mechanism -// as the agent itself doesn't track subscriptions. -func (ag *Agent) PubEvent(thingID string, name string, value any) error { - - // This is a response to subscription request. - // for now assume this is a hub connection and the hub wants all events - notif := messaging.NewNotificationMessage(td.OpSubscribeEvent, thingID, name, value) - slog.Info("PubEvent", - "thingID", thingID, - "name", name, - "value", notif.ToString(50), - ) - - return ag.GetConnection().SendNotification(notif) -} - -// PubProperty helper for agents to publish a property value notification to observers. -// -// The underlying transport protocol binding handles the subscription mechanism. -func (ag *Agent) PubProperty(thingID string, name string, value any) error { - // This is a response to an observation request. - // send the property update as a response to the observe request - notif := messaging.NewNotificationMessage(td.OpObserveProperty, thingID, name, value) - slog.Info("PubProperty", - "thingID", thingID, - "name", notif.Name, - "value", notif.ToString(50), - ) - return ag.GetConnection().SendNotification(notif) -} - -// PubProperties helper for agents to publish a map of property values -// -// The underlying transport protocol binding handles the subscription mechanism. -func (ag *Agent) PubProperties(thingID string, propMap map[string]any) error { - // Implicit rule: if no name is provided the data is a map - // the transport adds the correlationID of the subscription. - notif := messaging.NewNotificationMessage(td.OpObserveAllProperties, thingID, "", propMap) - - slog.Info("PubProperties", - "thingID", thingID, - "nrProps", len(propMap), - "value", notif.ToString(50), - ) - return ag.GetConnection().SendNotification(notif) -} - -// SendNotification sends a property or event notification message -func (ag *Agent) SendNotification(notif *messaging.NotificationMessage) error { - return ag.GetConnection().SendNotification(notif) -} - -// SendResponse sends a response for a previous request -func (ag *Agent) SendResponse(resp *messaging.ResponseMessage) error { - return ag.GetConnection().SendResponse(resp) -} - -// SetRequestHandler set the application handler for incoming requests -func (ag *Agent) SetRequestHandler(cb messaging.RequestHandler) { - if cb == nil { - ag.appRequestHandlerPtr.Store(nil) - } else { - ag.appRequestHandlerPtr.Store(&cb) - } -} - -// UpdateThing helper for agents to publish an update of a TD in the directory -// Note that this depends on the runtime directory service. -// -// FIXME: change to use directory forms -func (ag *Agent) UpdateThing(tdoc *td.TD) error { - slog.Info("UpdateThing", slog.String("id", tdoc.ID)) - - // TD is sent as JSON - tdJson, _ := jsoniter.MarshalToString(tdoc) - err := ag.Rpc(td.OpInvokeAction, ThingDirectoryDThingID, ThingDirectoryUpdateThingMethod, - tdJson, nil) - return err -} - -// NewAgent creates a new agent instance for serving requests and sending responses. -// Since agents are also consumers, they can also send requests and receive responses. -// -// Agents can be connected to when running a server or connect to a hub or gateway as client. -// -// This is a wrapper around the ClientConnection that provides WoT response messages -// publishing properties and events to subscribers and publishing a TD. -func NewAgent(cc messaging.IConnection, - connHandler messaging.ConnectionHandler, - notifHandler messaging.NotificationHandler, - reqHandler messaging.RequestHandler, - respHandler messaging.ResponseHandler, - timeout time.Duration) *Agent { - - if timeout == 0 { - timeout = consumer.DefaultRpcTimeout - } - - //consumer := NewConsumer(cc, respHandler, connHandler, timeout) - agent := Agent{ - //Consumer: Consumer{ - // cc: cc, - // rnrChan: NewRnRChan(), - // rpcTimeout: timeout, - //}, - } - agent.Consumer = consumer.NewConsumer(cc, timeout) - agent.SetConnectHandler(connHandler) - agent.SetNotificationHandler(notifHandler) - agent.SetRequestHandler(reqHandler) - agent.SetResponseHandler(respHandler) - //cc.SetNotificationHandler(agent.onNotification) - //cc.SetResponseHandler(agent.onResponse) - //cc.SetConnectHandler(agent.onConnect) - cc.SetRequestHandler(agent.onRequest) - return &agent -} diff --git a/lib/buckets/BucketBench_test.go b/lib/buckets/BucketBench_test.go deleted file mode 100644 index 3c78695b..00000000 --- a/lib/buckets/BucketBench_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package buckets_test - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/thanhpk/randstr" - - "github.com/hiveot/hub/lib/logging" -) - -// $ go test -bench=Benchmark_bucket -benchtime=3s -run=^# (skip unit tests) -// cpu: Intel(R) Core(TM) i5-4570S CPU @ 2.90GHz -// -// Database: kvbtree-1.7 (us) pebble-1.1 (us) -// --- with 1K records existing in the DB --- -// Set 1 0.4 2.5 4900 -// Set Multiple 1 0.5 2.5 5000 -// Get 1 0.3 0.7 1.1 -// Get Multiple 1 0.8 1.8 1.5 -// Seek 1 0.4 16 1.6 -// Next 1 0.6 17 (163-450) 1.7 -// Set 1000x 280 2900 (2100-2300) 5200000 (5 sec!) -// Set Multiple 1000 290 2100 10000 -// Get 1000x 140 1000 ( 710-1000) 1180 -// Get Multiple 1000 270 1400 (1200-1400) 740 -// Seek 1000x 150 7000 (1540-2500) 280 -// Next 1000x 140 1900 ( 420-1310) 190 -// -// --- with 100K existing records in DB --- -// Set 1 0.5 2.3 11000 -// Set Multiple 1 0.6 2.6 11700 -// Get 1 0.3 0.7 1.2 -// Get Multiple 1 0.9 1.8 1.4 -// Seek 1 0.5 12 1.7 -// Next 1 0.7 14 (3.5-1300?) 1.7 -// Set 1000x 350 2900 (2700-2900) 12000000 (12sec!???) -// Set Multiple 1000 330 2800 14300 -// Get 1000x 180 740 ( 690-1600) 1238 -// Get Multiple 1000 320 1200 800 -// Seek 1000x 190 11000 (1800-2600) 340 -// Next 1000x 140 990 ( 370-1400) 210 -// -// Observations: -// - kvbtree, an in-memory btree, is the overall winner, although this is far from a complete picture -// - pebble has great real-life performance and scales much further than kvbtree -// the next1 oddball might be due to locking delays because of the testcase as next 1000x is faster. -// - setmultiple is slightly slower (due to building test keys) but it will much(!) faster than making N rpc calls -// -// table with data size to run the benchmark with -var DataSizeTable = []struct { - dataSize int - nrSteps int -}{ - {dataSize: 1000, nrSteps: 1}, - {dataSize: 1000, nrSteps: 1000}, - {dataSize: 100000, nrSteps: 1}, - {dataSize: 100000, nrSteps: 1000}, - //{dataSize: 1000000, textSize: 100}, - //{dataSize: 10000000, textSize: 100}, -} - -// Generate random test data used to set and set multiple -type TestEl struct { - key string - val []byte -} - -var testData = func() []TestEl { - count := 1000000 - keySize := 10 // 10 char string - textSize := 100 // 100 byte data - data := make([]TestEl, count) - for i := 0; i < count; i++ { - key := randstr.String(keySize) - val := randstr.Bytes(textSize) - data[i] = TestEl{key: key, val: val} - } - return data -}() - -func Benchmark_bucket(b *testing.B) { - logging.SetLogging("warning", "") - - for _, v := range DataSizeTable { - //setup - //testText := randstr.String(v.textSize) - store, _ := openNewStore() - err := addDocs(store, testBucketID, v.dataSize) - assert.NoError(b, err) - - // bucket.Set - b.Run(fmt.Sprintf("Bucket.Set datasize=%d;steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - - bucket := store.GetBucket(testBucketID) - - for i := 0; i < v.nrSteps; i++ { - td := testData[i] - err = bucket.Set(td.key, td.val) - assert.NoError(b, err) - } - err = bucket.Close() - assert.NoError(b, err) - } - }) - // bucket.SetMultiple - b.Run(fmt.Sprintf("Bucket.SetMultiple datasize=%d;steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - - bucket := store.GetBucket(testBucketID) - docs := make(map[string][]byte) - for i := 0; i < v.nrSteps; i++ { - td := testData[i] - docs[td.key] = td.val - } - err = bucket.SetMultiple(docs) - assert.NoError(b, err) - err = bucket.Close() - assert.NoError(b, err) - } - }) - // bucket.Get - b.Run(fmt.Sprintf("Bucket.Get datasize=%d,;steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - bucket := store.GetBucket(testBucketID) - for i := 0; i < v.nrSteps; i++ { - td := testData[i] - _, err := bucket.Get(td.key) - assert.NoError(b, err) - } - err = bucket.Close() - assert.NoError(b, err) - } - }) - // bucket.GetMultiple - b.Run(fmt.Sprintf("Bucket.GetMultiple datasize=%d,;steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - bucket := store.GetBucket(testBucketID) - keys := make([]string, v.nrSteps) - for i := 0; i < v.nrSteps; i++ { - td := testData[i] - keys[i] = td.key - } - docs, err := bucket.GetMultiple(keys) - assert.Equal(b, len(keys), len(docs)) - assert.NoError(b, err) - err = bucket.Close() - assert.NoError(b, err) - } - }) - // bucket.Seek - b.Run(fmt.Sprintf("Bucket.Seek datasize=%d,steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - bucket := store.GetBucket(testBucketID) - cursor, err := bucket.Cursor() - - // cursor based seek (find nearest) instead of a get - for i := 0; i < v.nrSteps; i++ { - td := testData[i] - key2, val2, valid := cursor.Seek(td.key) - _ = key2 - _ = val2 - assert.True(b, valid) - assert.NoError(b, err) - } - - cursor.Release() - err = bucket.Close() - assert.NoError(b, err) - } - }) - // bucket.Next (range) - b.Run(fmt.Sprintf("Bucket.Next datasize=%d,steps=%d", v.dataSize, v.nrSteps), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - bucket := store.GetBucket(testBucketID) - cursor, err := bucket.Cursor() - k0, v0, valid0 := cursor.First() - assert.True(b, valid0) - assert.NotEmpty(b, k0) - assert.NotEmpty(b, v0) - - // cursor based iteration - for i := 0; i < v.nrSteps; i++ { - k1, v1, valid1 := cursor.Next() - assert.True(b, valid1) - assert.NotEmpty(b, k1) - assert.NotEmpty(b, v1) - } - - cursor.Release() - err = bucket.Close() - assert.NoError(b, err) - } - }) - - err = store.Close() - assert.NoError(b, err) - } -} diff --git a/lib/buckets/BucketStore_test.go b/lib/buckets/BucketStore_test.go deleted file mode 100644 index fe48ab1c..00000000 --- a/lib/buckets/BucketStore_test.go +++ /dev/null @@ -1,624 +0,0 @@ -package buckets_test - -import ( - "encoding/json" - "fmt" - "log/slog" - "math/rand" - "os" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/bucketstore" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" -) - -var testBucketID = "default" - -//var testBackendType = buckets.BackendKVBTree - -var testBackendType = buckets.BackendPebble -var testBackendDirectory = "/tmp/test-bucketstore" - -const ( - doc1ID = "doc1" - doc2ID = "doc2" - deviceTypeSensor = "sensor" - propDeviceBattery = "battery" - propDeviceTitle = "tile" - propDeviceSoftwareVersion = "version" - propEnvTemperature = "temperature" - unitPercent = "%" -) - -var doc1 = []byte(`{ - "id": "doc1", - "title": "Title of doc 1", - "@type": "sensor", - "properties": - { "title": { - "name": "title1" - } - } -}`) -var doc2 = []byte(`{ - "id": "doc2", - "title": "Title of doc 2", - "properties": [ - { "title": "title2" } - ] -}`) - -// Create the bucket store using the backend -func openNewStore() (store buckets.IBucketStore, err error) { - _ = os.RemoveAll(testBackendDirectory) - store, err = bucketstore.NewBucketStore(testBackendDirectory, "test", testBackendType) - if err == nil { - err = store.Open() - } - return store, err -} - -// Create a TD document -func createTD(id string) *td.TD { - tdi := &td.TD{ - ID: id, - Title: fmt.Sprintf("test TD %s", id), - AtType: deviceTypeSensor, - Properties: make(map[string]*td.PropertyAffordance), - Events: make(map[string]*td.EventAffordance), - } - tdi.Properties[propDeviceTitle] = &td.PropertyAffordance{ - DataSchema: td.DataSchema{ - Title: "Sensor title", - Description: "This is a smart sensor", - Type: td.DataTypeString, - Default: "Default value", - }, - } - tdi.Properties[propDeviceSoftwareVersion] = &td.PropertyAffordance{ - DataSchema: td.DataSchema{ - Title: "Version", - Description: "Embedded firmware", - Type: td.DataTypeString, - Default: "Default value", - Const: "v1.0", - }, - } - tdi.Events[propEnvTemperature] = &td.EventAffordance{ - Title: "Event 1", - Description: "ID of this event", - Data: &td.DataSchema{ - Type: td.DataTypeString, - Const: "123", - Title: "Event name data", - Description: "String with friendly name of the event"}, - } - tdi.Events[propDeviceBattery] = &td.EventAffordance{ - Title: "Event 2", - Data: &td.DataSchema{ - Type: td.DataTypeInteger, - Title: "Battery level", - Unit: unitPercent, - Description: "Battery level update in % of device"}, - } - return tdi -} - -// AddDocs adds documents doc1, doc2 and given nr additional docs -func addDocs(store buckets.IBucketStore, bucketID string, count int) error { - slog.Info(fmt.Sprintf("Adding %d documents", count)) - const batchSize = 50000 - bucket := store.GetBucket(bucketID) - - // these docs have values used for testing - err := bucket.Set(doc1ID, doc1) - err = bucket.Set(doc2ID, doc2) - if err != nil { - return err - } - - // breakup in batches to limit the transaction size - // fill remainder with generated docs - // don't sort order of id - iBatch := 0 - docs := make(map[string][]byte) - for i := count; i > 2; i-- { - rn := rand.Intn(count * 33) // enough spread to avoid duplicates - id := fmt.Sprintf("addDocs-%6d", rn) - td := createTD(id) - _ = td - jsonDoc := []byte("hello world") - jsonDoc, _ = json.Marshal(td) // 900msec - docs[id] = jsonDoc - // restart the batch - iBatch++ - // close the bucket/transaction and reopen - if iBatch == batchSize { - err = bucket.SetMultiple(docs) - err = bucket.Close() - if err != nil { - panic(fmt.Sprintf("SetMultiple failed: %s", err)) - } - // next batch - docs = make(map[string][]byte) - bucket = store.GetBucket(bucketID) - iBatch = 0 - } - - } - // finish the remainder - _ = bucket.SetMultiple(docs) - err = bucket.Close() - slog.Info("Added records to the store", "count", count) - return err -} - -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - - res := m.Run() - os.Exit(res) -} - -func TestAllBackends(t *testing.T) { - backends := []string{buckets.BackendPebble, buckets.BackendKVBTree} - for _, backend := range backends { - testBackendType = backend - // Generic directory store testcases - t.Run("TestStartStop", TestStartStop) - t.Run("TestCreateStoreBadFolder", TestCreateStoreBadFolder) - t.Run("TestCreateStoreReadOnlyFolder", TestCreateStoreReadOnlyFolder) - t.Run("TestCreateStoreCantReadFile", TestCreateStoreCantReadFile) - t.Run("TestWriteRead", TestWriteRead) - t.Run("TestWriteBadData", TestWriteBadData) - t.Run("TestWriteReadMultiple", TestWriteReadMultiple) - t.Run("TestSeek", TestSeek) - t.Run("TestPrevNextN", TestPrevNextN) - } -} - -// Generic directory store testcases -func TestStartStop(t *testing.T) { - - store, err := openNewStore() - require.NoError(t, err) - err = store.Close() - assert.NoError(t, err) -} - -func TestCreateStoreBadFolder(t *testing.T) { - badDir := "/folder/does/not/exist/" - store, _ := bucketstore.NewBucketStore(badDir, "test", testBackendType) - err := store.Open() - assert.Error(t, err) -} - -func TestCreateStoreReadOnlyFolder(t *testing.T) { - badDir := "/var/" - store, _ := bucketstore.NewBucketStore(badDir, "test", testBackendType) - err := store.Open() - assert.Error(t, err) -} - -func TestCreateStoreCantReadFile(t *testing.T) { - badDir := "/bin" - store, _ := bucketstore.NewBucketStore(badDir, "test", testBackendType) - err := store.Open() - assert.Error(t, err) -} - -func TestWriteRead(t *testing.T) { - const id1 = "id1" - const id5 = "id5" - const id22 = "id22" - - store, err := openNewStore() - assert.NoError(t, err) - err = addDocs(store, testBucketID, 3) - - bucket := store.GetBucket(testBucketID) - assert.NotNil(t, bucket) - - require.NoError(t, err) - - // write docs - td1 := createTD(id1) - td1json, _ := json.Marshal(td1) - err = bucket.Set(id1, td1json) - assert.NoError(t, err) - td22 := createTD(id22) - td22json, _ := json.Marshal(td22) - err = bucket.Set(id22, td22json) - assert.NoError(t, err) - td5 := createTD(id5) - td5json, _ := json.Marshal(td5) - err = bucket.Set(id5, td5json) - assert.NoError(t, err) - - // kvstore flushes to file in autosave loop every 3 seconds - // needs to be tested - time.Sleep(time.Second * 4) - - err = bucket.Close() - assert.NoError(t, err) - err = store.Close() - assert.NoError(t, err) - time.Sleep(time.Second) - - // --- reopen --- - err = store.Open() // reopen - require.NoError(t, err) - bucket = store.GetBucket(testBucketID) - assert.NotNil(t, bucket) - - // Read and compare - resp, err := bucket.Get(id22) - if assert.NotNil(t, resp) { - assert.Equal(t, td22json, resp) - } - resp, err = bucket.Get(id1) - if assert.NotNil(t, resp) { - assert.Equal(t, td1json, resp) - } - resp, err = bucket.Get(id5) - if assert.NotNil(t, resp) { - assert.Equal(t, td5json, resp) - } - // Delete - err = bucket.Delete(id1) - assert.NoError(t, err) - time.Sleep(time.Millisecond) - resp, err = bucket.Get(id1) - assert.Error(t, err) - assert.Nil(t, resp) - - err = bucket.Close() - assert.NoError(t, err) - err = store.Close() - assert.NoError(t, err) - - // Read again should fail - // (pebble throws a panic :() - //_, err = store.Get(testBucketID, doc1ID) - //assert.Error(t, err) -} - -func TestWriteBadData(t *testing.T) { - store, err := openNewStore() - require.NoError(t, err) - defer store.Close() - bucket := store.GetBucket(testBucketID) - defer bucket.Close() - // not json - err = bucket.Set(doc1ID, []byte("not-json")) - assert.NoError(t, err) - // missing key - err = bucket.Set("", []byte("{}")) - assert.Error(t, err) - -} - -func TestWriteReadMultiple(t *testing.T) { - const id1 = "id1" - const id5 = "id5" - const id22 = "id22" - docs := make(map[string][]byte) - - store, err := openNewStore() - require.NoError(t, err) - err = addDocs(store, testBucketID, 3) - require.NoError(t, err) - - bucket := store.GetBucket(testBucketID) - assert.NotNil(t, bucket) - defer store.Close() - defer bucket.Close() // last defer completes first - - // write docs - docs[id1], _ = json.Marshal(createTD(id1)) - docs[id22], _ = json.Marshal(createTD(id22)) - docs[id5], _ = json.Marshal(createTD(id5)) - err = bucket.SetMultiple(docs) - assert.NoError(t, err) - - // Read and compare - - resp, err := bucket.GetMultiple([]string{id22, id1, id5}) - assert.NoError(t, err) - assert.Equal(t, docs[id1], resp[id1]) - assert.Equal(t, docs[id5], resp[id5]) - assert.Equal(t, docs[id22], resp[id22]) - - // Delete - err = bucket.Delete(id1) - assert.NoError(t, err) - resp2, err := bucket.GetMultiple([]string{id22, id1, id5}) - assert.NoError(t, err) - assert.Equal(t, 2, len(resp2)) -} - -func TestSeek(t *testing.T) { - const docsCount = 1000 - const seekCount = 200 - const base = 300 - - store, err := openNewStore() - require.NoError(t, err) - err = addDocs(store, testBucketID, docsCount) - require.NoError(t, err) - - bucket := store.GetBucket(testBucketID) - require.NotNil(t, bucket) - defer store.Close() - defer bucket.Close() - - // give this some time to settle so there isn't a modification during iteration - time.Sleep(time.Millisecond * 100) - - // set cursor 'base' records forward - cursor, err := bucket.Cursor() - require.NoError(t, err) - - //// last should work without seek - //k, v, valid := cursor.Last() - //_ = k - //_ = v - //assert.True(t, valid) - - k1, v1, valid := cursor.First() - assert.True(t, valid) - for i := 0; i < base; i++ { - k1, v1, valid = cursor.Next() - assert.True(t, valid) - assert.NotEmpty(t, k1) - assert.NotEmpty(t, v1) - } - // k1 now holds the key at the base Nth record - - // seek of the current key should bring us back here, at the base Nth record - k2, v2, valid2 := cursor.Seek(k1) - assert.True(t, valid2) - assert.Equal(t, k1, k2) - assert.Equal(t, string(v1), string(v2), "using backend: "+testBackendType) - - // test that keys increment - for i := 0; i < seekCount; i++ { - k, v, valid := cursor.Next() - require.GreaterOrEqual(t, k, k2) - assert.True(t, valid) - if !assert.NotEmpty(t, v) { - slog.Info("unexpected") - } - k2 = k - } - - // step seekCount nr backwards should lead us right back to k1 - k2, v2, valid2 = cursor.Prev() - assert.True(t, valid2) - for i := 1; i < seekCount; i++ { - k, v, valid := cursor.Prev() - assert.True(t, valid) - require.LessOrEqual(t, k, k2) - if !assert.NotEmpty(t, v) { - slog.Info("unexpected") - } - k2 = k - } - // how to test Last? - _, _, valid = cursor.Last() - assert.True(t, valid) - assert.Equal(t, k1, k2) - cursor.Release() -} - -func TestPrevNextN(t *testing.T) { - const count = 1000 - const seekCount = 200 - const base = 500 - - // setup - store, err := openNewStore() - require.NoError(t, err) - err = addDocs(store, testBucketID, count) - require.NoError(t, err) - bucket := store.GetBucket(testBucketID) - require.NotNil(t, bucket) - defer store.Close() - defer bucket.Close() - - // test NextN - cursor, err := bucket.Cursor() - require.NoError(t, err) - // FIXME: this sometimes returns a buffer filled with FF's. Can't reproduce. - k1, v1, valid := cursor.First() - assert.True(t, valid) - assert.NotEmpty(t, v1) - docs, itemsRemaining := cursor.NextN(seekCount) - assert.True(t, itemsRemaining) - assert.Equal(t, seekCount, len(docs)) - - docs2, itemsRemaining := cursor.PrevN(seekCount - 1) - assert.True(t, itemsRemaining) - assert.Equal(t, seekCount-1, len(docs2)) - - // one step further we're at the beginning again - k2, v2, valid2 := cursor.Prev() - assert.True(t, valid2) - assert.Equal(t, k1, k2) - assert.Equal(t, v1, v2, "cursor.First() failed with backend:"+testBackendType) - - cursor.Release() -} - -//func TestQuery(t *testing.T) { -// store, err := createNewStore() -// require.NoError(t, err) -// err = addDocs(store, 20) -// require.NoError(t, err) -// -// // filter on key 'id' == doc1 -// //args := &svc.Query_Args{JsonPathQuery: `$[?(@.id=="doc1")]`} -// jsonPath := `$[?(@.id=="doc1")]` -// resp, err := store.Query(jsonPath, 0, 0, nil) -// require.NoError(t, err) -// assert.NotEmpty(t, resp) -// -// // regular nested filter comparison. note that a TD does not hold values -// jsonPath = `$[?(@.properties.title.name=="title1")]` -// resp, err = store.Query(jsonPath, 0, 0, nil) -// require.NoError(t, err) -// assert.NotEmpty(t, resp) -// -// // filter with nested notation. some examples that return a list of TDs matching the filter -// //res, err = fileStore.Query(`$[?(@.properties.title.value=="title1")]`, 0, 0) -// // res, err = fileStore.Query(`$[?(@.*.title.value=="title1")]`, 0, 0) -// // res, err = fileStore.Query(`$[?(@['properties']['title']['value']=="title1")]`, 0, 0) -// jsonPath = `$[?(@..title.name=="title1")]` -// resp, err = store.Query(jsonPath, 0, 0, nil) -// assert.NoError(t, err) -// -// // these only return the properties - not good -// // res, err = fileStore.Query(`$.*.properties[?(@.value=="title1")]`, 0, 0) // returns list of props, not tds -// //res, err = fileStore.Query(`$.*.*[?(@.value=="title1")]`, 0, 0) // returns list of props, not tds -// // res, err = fileStore.Query(`$[?(@...value=="title1")]`, 0, 0) -// assert.NotEmpty(t, resp) -// -// // filter with bracket notation -// jsonPath = `$[?(@["id"]=="doc1")]` -// resp, err = store.Query(jsonPath, 0, 0, nil) -// require.NoError(t, err) -// assert.NotEmpty(t, resp) -// -// // filter with bracket notation and current object literal (for search @type) -// // only supported by: ohler55/ojg -// jsonPath = `$[?(@['@type']=="sensor")]` -// resp, err = store.Query(jsonPath, 0, 0, nil) -// assert.NoError(t, err) -// assert.Greater(t, len(resp), 1) -// -// // bad query expression -// jsonPath = `$[?(.id=="doc1")]` -// resp, err = store.Query(jsonPath, 0, 0, nil) -// assert.Error(t, err) -//} - -// tests to figure out how to use jp parse with bracket notation -//func TestQueryBracketNotationA(t *testing.T) { -// store := make(map[string]interface{}) -// query1 := `$[?(@['type']=="type1")]` -// query2 := `$[?(@['@type']=="sensor")]` -// -// jsonDoc := `{ -// "thing1": { -// "id": "thing1", -// "type": "type1", -// "@type": "sensor", -// "properties": { -// "title": "title1" -// } -// }, -// "thing2": { -// "id": "thing2", -// "type": "type2", -// "@type": "sensor", -// "properties": { -// "title": "title1" -// } -// } -// }` -// -// err := json.Decode([]byte(jsonDoc), &store) -// assert.NoError(t, err) -// -// jpExpr, err := jp.ParseString(query1) -// assert.NoError(t, err) -// result := jpExpr.Get(store) -// assert.NotEmpty(t, result) -// -// jpExpr, err = jp.ParseString(query2) -// assert.NoError(t, err) -// result = jpExpr.Get(store) -// assert.NotEmpty(t, result) -//} - -// tests to figure out how to use jp parse with bracket notation -//func TestQueryBracketNotationB(t *testing.T) { -// //store := make(map[string]interface{}) -// queryString := "$[?(@['@type']==\"sensor\")]" -// id1 := "thing1" -// id2 := "thing2" -// td1 := things.ThingDescription{ -// ID: id1, -// Title: "test TD 1", -// AtType: string(vocab.DeviceTypeSensor), -// Properties: make(map[string]*things.PropertyAffordance), -// } -// //td1 := things.CreateTD(id1, "test TD", vocab.DeviceTypeSensor) -// td1.Properties[vocab.PropNameTitle] = &things.PropertyAffordance{ -// DataSchema: things.DataSchema{ -// Title: "Sensor title", -// Type: td.DataTypeString, -// }, -// } -// td1.Properties[vocab.PropNameValue] = &things.PropertyAffordance{ -// DataSchema: things.DataSchema{ -// Title: "Sensor value", -// Type: td.DataTypeNumber, -// }, -// } -// -// td2 := things.ThingDescription{ -// ID: id2, -// Title: "test TD 2", -// AtType: string(vocab.DeviceTypeSensor), -// Properties: make(map[string]*things.PropertyAffordance), -// } -// td2.Properties[vocab.PropNameTitle] = &things.PropertyAffordance{ -// DataSchema: things.DataSchema{ -// Title: "The switch", -// Type: td.DataTypeBool, -// }, -// } -// -// store, err := createNewStore() -// require.NoError(t, err) -// -// //td1json, err := json.MarshalIndent(td1, "", "") -// td1json, err := json.Marshal(&td1) -// td2json, err := json.Marshal(&td2) -// _ = store.Write(id1, string(td1json)) -// err = store.Write(id2, string(td2json)) -// assert.NoError(t, err) -// -// // query returns 2 sensors. -// resp, err := store.Query(queryString, 0, 0, nil) -// require.NoError(t, err) -// require.Equal(t, 2, len(resp)) -// -// var readTD1 things.ThingDescription -// err = json.Decode([]byte(resp[0]), &readTD1) -// require.NoError(t, err) -// read1type := readTD1.AtType -// assert.Equal(t, string(vocab.DeviceTypeSensor), read1type) -//} - -// test query with reduced list of IDs -//func TestQueryFiltered(t *testing.T) { -// queryString := "$..id" -// -// store, err := createNewStore() -// require.NoError(t, err) -// _ = addDocs(store, 10) -// -// // result of a normal query -// resp, err := store.Query(queryString, 0, 0, nil) -// require.NoError(t, err) -// assert.Equal(t, 10, len(resp)) -//} diff --git a/lib/buckets/CursorCache.go b/lib/buckets/CursorCache.go deleted file mode 100644 index 27d577f8..00000000 --- a/lib/buckets/CursorCache.go +++ /dev/null @@ -1,212 +0,0 @@ -package buckets - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "sync" - "time" -) - -type ClientCursors []IBucketCursor - -type CursorInfo struct { - Key string - Filter string - // optional bucket instance this cursor operates on - // if provided it will be released with the cursor - Bucket IBucket - // the stored cursor - Cursor IBucketCursor - // clientID of the cursor owner - OwnerID string - // last use of the cursor - LastUsed time.Time - // lifespan of cursor after last use - Lifespan time.Duration -} - -// CursorCache manages a set of cursors that can be addressed remotely by key. -// Intended for servers that let remote clients iterate a cursor in the bucket store. -// -// Added cursors are stored in a map by generated key along with some metadata -// such as its expiry and optionally the bucket that was allocated to use the iterator. -// The key is passed back to the client for use during iterations. -// The client must release the cursor it when done. -// -// To prevent memory leaks due to not releasing a cursor, cursors are given a limited -// non-used lifespan, after which they are removed. The default is 1 minute. -// -// Cursors are linked to their owner to prevent 'accidental' use by others. Only -// if the client's ID matches that of the cursor owner, it can be used. -type CursorCache struct { - // lookup a cursor by key - cursorsByKey map[string]*CursorInfo - - // at 1000 cursors per sec this lasts 500M years between reboots ;) - cursorCounter uint64 - mux sync.RWMutex - // stop the background loop - stopCh chan bool -} - -// Add adds a cursor to the tracker and returns its key -// -// cursor is the object holding the cursor -// bucket instance created specifically for this cursor. optional. -// clientID of the owner -// lifespan of the cursor after last use -func (cc *CursorCache) Add( - cursor IBucketCursor, bucket IBucket, clientID string, filter string, lifespan time.Duration) string { - cc.mux.Lock() - defer cc.mux.Unlock() - - cc.cursorCounter++ - // the key is not a secret, only the owner can use it - key := strconv.FormatUint(cc.cursorCounter, 16) - ci := &CursorInfo{ - Key: key, - Filter: filter, - Bucket: bucket, - Cursor: cursor, - OwnerID: clientID, - Lifespan: lifespan, - } - cc.cursorsByKey[key] = ci - return key -} - -// Get returns the cursor with the given key. -// An error is returned if the cursor is not found, has expired, or belongs to a different owner. -// -// cursorKey obtained with Add() -// clientID requesting the cursor -// updateLastUsed resets the lifespan of the cursor to start now -func (cc *CursorCache) Get(cursorKey string, clientID string, updateLastUsed bool) ( - cursor IBucketCursor, ci *CursorInfo, err error) { - - cc.mux.Lock() - defer cc.mux.Unlock() - - ci, found := cc.cursorsByKey[cursorKey] - if !found { - slog.Warn("Cursor not found or expired", - slog.String("cursorKey", cursorKey), - slog.String("clientID", clientID)) - return nil, ci, fmt.Errorf("Cursor not found or expired") - } else if ci.OwnerID != clientID { - slog.Warn("Cursor belongs to different client", - slog.String("cursorKey", cursorKey), - slog.String("ownerID", ci.OwnerID), - slog.String("clientID", clientID)) - return nil, ci, fmt.Errorf("cursor doesn't belong to client '%s'", clientID) - } - if found && updateLastUsed { - ci.LastUsed = time.Now().UTC() - } - return ci.Cursor, ci, nil -} - -// GetExpiredCursors returns a list of cursors that have expired -// It is up to the user to remove and release the cursor -func (cc *CursorCache) GetExpiredCursors() []*CursorInfo { - expiredCursors := make([]*CursorInfo, 0) - cc.mux.RLock() - defer cc.mux.RUnlock() - - now := time.Now().UTC() - // rather brute force, might need a sorted list if heavily used - // however, it is not expected to have a lot of active cursors. - for _, ci := range cc.cursorsByKey { - // if cursor hasn't been used it is considered expired - if now.Sub(ci.LastUsed) > ci.Lifespan { - expiredCursors = append(expiredCursors, ci) - } - } - return expiredCursors -} - -// GetCursorsByOwner returns a list of cursors that are owned by a client. -// Intended to remove cursors whose owner has disconnected. -// It is up to the user to remove and release the cursor -func (cc *CursorCache) GetCursorsByOwner(ownerID string) []*CursorInfo { - ownedCursors := make([]*CursorInfo, 0) - cc.mux.RLock() - defer cc.mux.RUnlock() - // rather brute force, might need to switch this to a map if heavily used - for _, ci := range cc.cursorsByKey { - if ci.OwnerID == ownerID { - ownedCursors = append(ownedCursors, ci) - } - } - return ownedCursors -} - -// Release releases the cursor and removes the cursor from the tracker -// If a bucket was included it will be closed as well. -func (cc *CursorCache) Release(clientID string, cursorKey string) error { - slog.Info("Release", "cursorKey", cursorKey) - cc.mux.Lock() - defer cc.mux.Unlock() - ci, found := cc.cursorsByKey[cursorKey] - if !found { - slog.Error("Release. Cursor not found", "clientID", clientID, "cursorKey", cursorKey) - return nil - } - if ci.OwnerID != clientID { - slog.Warn("Release: Cursor belongs to different client", - slog.String("cursorKey", cursorKey), - slog.String("ownerID", ci.OwnerID), - slog.String("clientID", clientID)) - return fmt.Errorf("cursor doesn't belong to client '%s'", clientID) - } - - delete(cc.cursorsByKey, cursorKey) - ci.Cursor.Release() - if ci.Bucket != nil { - err := ci.Bucket.Close() - if err != nil { - slog.Error("failed closing bucket after releasing cursor", "err", err) - } - } - return nil -} - -// Start starts a background loop to remove expired cursors -func (cc *CursorCache) Start() { - go func() { - for { - ctx, cancelFn := context.WithTimeout(context.Background(), time.Minute) - select { - case <-ctx.Done(): - case <-cc.stopCh: - cancelFn() - break - } - cancelFn() - ciList := cc.GetExpiredCursors() - for _, ci := range ciList { - slog.Warn("Releasing expired cursor", - slog.String("agentID", ci.OwnerID), slog.String("key", ci.Key)) - // release the expired cursor and remove it from the cache - _ = cc.Release(ci.OwnerID, ci.Key) - } - } - }() -} - -// Stop the background auto-expiry loop if running -func (cc *CursorCache) Stop() { - cc.stopCh <- true -} - -func NewCursorCache() *CursorCache { - cc := CursorCache{ - cursorsByKey: make(map[string]*CursorInfo), - cursorCounter: 1, - mux: sync.RWMutex{}, - stopCh: make(chan bool), - } - return &cc -} diff --git a/lib/buckets/IBucketStore.go b/lib/buckets/IBucketStore.go deleted file mode 100644 index c88df594..00000000 --- a/lib/buckets/IBucketStore.go +++ /dev/null @@ -1,152 +0,0 @@ -// Package bucketstore is a storage library for use by the services. -// The bucket store is primarily used by the state service which provides this store as a service to multiple clients. -// This package defines an API to use the store with several implementations. -package buckets - -// Available embedded bucket store implementations with low memory overhead -const ( - BackendKVBTree = "kvbtree" // fastest and best for small to medium amounts of data (dependent on available memory) - BackendPebble = "pebble" // a good middle ground between performance and memory -) - -// BucketStoreInfo information of the bucket or the store -type BucketStoreInfo struct { - // DataSize contains the size of data in the store or bucket. - // -1 if not available. - DataSize int64 - - // Engine describes the storage engine of the store, eg kvbtree, pebble - Engine string - - // The store or bucket identifier, eg thingID, appID - Id string - - // NrRecords holds the number of records in the store or bucket. - // -1 if not available. - NrRecords int64 -} - -// IBucketStore defines the interface to a simple key-value embedded bucket store. -// - organizes data into buckets -// - open/close buckets as a transaction, if transactions are available -// - get/set single or multiple key/value pairs -// - delete key/value -// - cursor based seek and iteration -// Streaming data into a bucket is not supported -// Various implementations are available to the services to use. -// -// TODO: add refcount for multiple consumers of the store so it can be closed when done. -type IBucketStore interface { - // GetBucket opens and returns a bucket to use. - // This creates the bucket if it doesn't exist. - // Use bucket.Close() to close the bucket and release its resources. - GetBucket(bucketID string) (bucket IBucket) - - // Close the store and release its resources - Close() error - - // Open the store - Open() error - - // Info returns bucket store information - //Info() *BucketStoreInfo -} - -// IBucket defines the interface to a store key-value bucket -type IBucket interface { - - // Close the bucket and release its resources - Close() error - - // Cursor creates a new bucket cursor for iterating the bucket - // cursor.Remove must be called after use to release any read transactions - // The cursor points before the first item to iterate. - Cursor() (cursor IBucketCursor, err error) - - // Delete removes the key-value pair from the bucket store - // Returns nil if the key is deleted or doesn't exist. - // Returns an error if the database cannot be updated. - Delete(key string) (err error) - - // Get returns the document for the given key - // Returns an error if the key isn't found in the bucket or if the database cannot be read - Get(key string) (value []byte, err error) - - // GetMultiple returns a batch of documents with existing keys - // if a key does not exist it will not be included in the result. - // An error is return if the database cannot be read. - GetMultiple(keys []string) (keyValues map[string][]byte, err error) - - // ID returns the bucket's ID - ID() string - - // Info returns the bucket information, when available - Info() *BucketStoreInfo - - // Set sets a document with the given key - // This stores a copy of value. - // An error is returned if either the bucketID or the key is empty - Set(key string, value []byte) error - - // SetMultiple sets multiple documents in a batch update - // This stores a copy of docs. - // If the transaction fails an error is returned and no changes are made. - SetMultiple(docs map[string][]byte) (err error) - - // Status returns the bucket status - //Status() BucketStoreStatus -} - -// IBucketCursor provides the prev/next cursor based iterator on a range -type IBucketCursor interface { - // BucketID is the ID of the bucket this cursor iterates - BucketID() string - - // First positions the cursor at the first key in the ordered list - // valid is false if the bucket is empty - First() (key string, value []byte, valid bool) - - // Last positions the cursor at the last key in the ordered list - // valid is false if the bucket is empty - Last() (key string, value []byte, valid bool) - - // Next moves the cursor to the next key from the current cursor - // First() or Seek must have been called first. - // valid is false if the iterator has reached the end and no valid value is returned. - Next() (key string, value []byte, valid bool) - - // NextN moves the cursor to the next N places from the current cursor - // and return a map with the N key-value pairs. - // If the iterator reaches the end it returns the remaining items and itemsRemaining is false - // If the cursor is already at the end, the resulting map is empty and itemsRemaining is also false. - // Intended to speed up with batch iterations over rpc. - NextN(steps uint) (docs map[string][]byte, itemsRemaining bool) - - // Prev moves the cursor to the previous key from the current cursor - // Last() or Seek must have been called first. - // valid is false if the iterator has reached the beginning and no valid value is returned. - Prev() (key string, value []byte, valid bool) - - // PrevN moves the cursor back N places from the current cursor and returns a map with - // the N key-value pairs. - // Intended to speed up with batch iterations over rpc. - // If the iterator reaches the beginning it returns the remaining items and itemsRemaining is false - // If the cursor is already at the beginning, the resulting map is empty and itemsRemaining is also false. - PrevN(steps uint) (docs map[string][]byte, itemsRemaining bool) - - // Release close the cursor and release its resources. - // This invalidates all values obtained from the cursor - Release() - - // Seek positions the cursor at the given searchKey and corresponding value. - // If the key is not found, the next key is returned. - // valid is false if the iterator has reached the end and no valid value is returned. - Seek(searchKey string) (key string, value []byte, valid bool) - - // Skip N items without reading them - // If N is negative it skips backwards. - Skip(steps int) (itemsRemaining bool) - - // Stream the content of the cursor to the provided function - //Stream(cb func(key string, value []byte, done bool) error) -} diff --git a/lib/buckets/README.md b/lib/buckets/README.md deleted file mode 100644 index ac2efc2b..00000000 --- a/lib/buckets/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Bucket Store - -The bucketstore provides simple embedded and non-embedded key-value persistence for Hub services with the focus on simplicity. -It has a standardized key-value API and can support multiple backends. - -What the bucketstore is not, is a general purpose database. It is intended to be simple to use meet basic storage needs. If you need multiple indexes then this store is not the right choice. - -## Concept - -The bucket store concept provided through the API of the bucket store is: - - open store -> read/write bucket -> iterate using cursor - -Where: -* Store is a database instance per client. Client being a service that needs persistence. -* Bucket is a collection of key-value pairs in the store. Supported operations are get (multiple), set (multiple), and delete. -* Cursor is an iterator in a bucket to iterate to the first, last, next, previous and seek a specific key. - -That is all there is to it. No magic. - -## Backends - -Short description of the supported backends. - -Note that the implementation hasn't been optimized for performance and that the default settings are being used. Especially Pebble and BoltDB have many capabilities and tricks to might be able to significantly improve read/write performance. - -### kvbtree - -The kvbtree backend is an embedded in-memory store using a btree as a store per client. Data is serialized and persisted to one file per client. Data is periodically written to disk after modifications are made. - -This backend is exceptionally fast and the fastest of the available backends for both reading and writing. A read and a write takes less than 1 usec per record, so a speed of a million read/writes per second is possible. - -All the data is kept in memory, so the capacity depends on available memory. Kinda like Redis does. Data is not compressed. When the store is updated, a background process periodically takes a snapshot (shallow copy) and writes it to disk. In writing to disk it first writes to a temporary file and when successful, renames the temporary file. This avoids the risk of data corruption. - -This store is best suited for limited amount of data, based on memory, that is frequently read and updated. The recommended data limit is 100MB. Testing has shows - -### pebble - -The pebble backend is cockroachdb's persistence layer. It is all around awesome and probably a bit overkill. - -It is also an embedded database which explains is very high speed. While kvbtree is around 5-10 faster, it is still very fast with both reading and writing taking approx 1-2usec per record (see BucketBench_test.go for details.) - -Pebble's data size is pretty much limited to the available disk space. Got 1TB, well you can store 1TB without suffering too much of a performance penalty. (although this is only tested with about 10 million records). Data is compressed so actual disk space is likely to be less. - -This store is best suited for large amounts of data. For example, the time series data of the history store. - -### bolts - removed as overkill - -The boltDB (bbolt implementation) backend is a solid transactional embedded database. - -Its read speed is close to that of pebble. Read speed does tend to suffer for large amount of data, in the order of 1 million records or more. - -However, writing is rather slow with about 5msec per write transaction. Write speed can be greatly increase by using SetMultiple. For example, writing 1000 key-value pairs take less than twice as long as writing a single key-value pair. Just as with reading, writing gets noticeable slower when reaching a high number of records. - -BBolt's data size is also limited to available disk space. Data isn't compressed. - -This store is best suited for compatiblity with other BoltDB databases or tools. - - -### mongo - abandoned - -The mongoDB backend is not complete. One of the main stumbling blocks is that mongodb only has a forward iterator. In addition, mongodb is a standalone server while the other options are embedded databases. The performance of MongoDB will therefore not be able to compete with the others. - - -### redis - not planned -Redis is not an embedded store and requires external setup and maintenance. It is out of scope for this application. -That said, it has a well defined interface and superb performance so if a use-case comes up it can be considered. \ No newline at end of file diff --git a/lib/buckets/bucketstore/NewBucketStore.go b/lib/buckets/bucketstore/NewBucketStore.go deleted file mode 100644 index af7e9d9d..00000000 --- a/lib/buckets/bucketstore/NewBucketStore.go +++ /dev/null @@ -1,33 +0,0 @@ -package bucketstore - -import ( - "fmt" - "path" - - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/kvbtree" - "github.com/hiveot/hub/lib/buckets/pebble" -) - -// NewBucketStore creates a new bucket store of a given type -// The store will be created in the given directory using the -// backend as the name. The directory is typically the name of the service that -// uses the store. Different databases can co-exist. -// -// directory is the directory in which to create the store -// name of the store database file or folder without extension -// backend is the type of store to create: BackendKVBTree, BackendBBolt, BackendPebble -func NewBucketStore(directory, name string, backend string) (store buckets.IBucketStore, err error) { - if backend == buckets.BackendKVBTree { - // kvbtree stores data into a single file - storePath := path.Join(directory, name+".kvbtree") - store = kvbtree.NewKVStore(storePath) - } else if backend == buckets.BackendPebble { - // Pebbles stores data into a directory - storePath := path.Join(directory, name+".pebble") - store = pebble.NewPebbleStore(storePath) - } else { - err = fmt.Errorf("Unknown backend '%s'", backend) - } - return store, err -} diff --git a/lib/buckets/cmd/pebblecopy.go b/lib/buckets/cmd/pebblecopy.go deleted file mode 100644 index 6594d1b5..00000000 --- a/lib/buckets/cmd/pebblecopy.go +++ /dev/null @@ -1,156 +0,0 @@ -package main - -import ( - "fmt" - "github.com/cockroachdb/pebble" - "os" -) - -// temporary - testing of pebble DB iteration -// some of these might be useful to build into the bucketstore -func main() { - if len(os.Args) < 2 { - fmt.Println("Missing a pebble database path") - os.Exit(1) - } - dbPath := os.Args[1] - _, err := os.Stat(dbPath) - if err != nil { - fmt.Println("Not a valid path") - os.Exit(1) - } - - options := &pebble.Options{} - inDB, err := pebble.Open(dbPath, options) - if err != nil { - fmt.Println("Error opening database: ", err.Error()) - os.Exit(1) - } - defer inDB.Close() - - // only one input - if len(os.Args) == 2 { - metrics(inDB) - //iterkeys(inDB) - } else if len(os.Args) == 3 { - query(inDB, os.Args[2]) - //firstlast(inDB, os.Args[2]) - //copyTo(inDB,os.Args[2] - } -} - -func metrics(db *pebble.DB) { - m := db.Metrics() - fmt.Println("---") - fmt.Printf("diskspace: %d\n", m.DiskSpaceUsage()) - fmt.Println(m.String()) - fmt.Println("---") -} - -// print all keys in the db -func iterkeys(db *pebble.DB) error { - opts := pebble.IterOptions{} - iter, err := db.NewIter(&opts) - if err != nil { - return err - } - i := 0 - for iter.First(); iter.Valid(); iter.Next() { - fmt.Printf("key=%q value=%-20.20q\n", iter.Key(), iter.Value()) - i++ - } - fmt.Printf("Found '%d' keys\n", i) - return nil -} - -// copy the database keys to the destination -func copyTo(inDB *pebble.DB, dest string) { - _, err := os.Stat(dest) - if err == nil { - // todo use -f - fmt.Println("Value database already exists. Not copying keys.") - return - } - options := &pebble.Options{} - outDB, err := pebble.Open(dest, options) - if err != nil { - // todo use -f - fmt.Println("Error creating output database:", err.Error()) - return - } - defer outDB.Close() - copykeys(inDB, outDB) -} - -// copy the database keys to another database -func copykeys(dbIn *pebble.DB, dbOut *pebble.DB) error { - opts := pebble.IterOptions{} - iter, err := dbIn.NewIter(&opts) - if err != nil { - return err - } - writeOpts := pebble.WriteOptions{} - i := 0 - for iter.First(); iter.Valid(); iter.Next() { - k := iter.Key() - v, err := iter.ValueAndErr() - if err == nil { - err = dbOut.Set(k, v, &writeOpts) - } - if err != nil { - fmt.Printf("Copy error: %s", err.Error()) - return err - } - i++ - } - _ = dbOut.Flush() - fmt.Printf("Copied '%d' keys\n", i) - return nil -} - -// query all keys with the given prefix (bucket name/thingID) -func query(dbIn *pebble.DB, prefix string) { - opts := pebble.IterOptions{ - LowerBound: []byte(prefix + "$"), - UpperBound: []byte(prefix + "%"), // this key never exists - } - iter, err := dbIn.NewIter(&opts) - if err != nil { - fmt.Println("Key not found in DB: ", prefix) - return - } - i := 0 - valid := iter.SeekGE([]byte(prefix)) - for valid { - key := iter.Key() - //if !strings.HasPrefix(string(key), prefix) { - // break - //} - fmt.Printf("key=%s value=%-20.20s\n", key, iter.Value()) - valid = iter.Next() - i++ - } - fmt.Printf("Found '%d' keys\n", i) -} - -// return the last key with the given prefix (bucket name/thingID) -func firstlast(dbIn *pebble.DB, prefix string) { - opts := pebble.IterOptions{ - LowerBound: []byte(prefix + "$"), - UpperBound: []byte(prefix + "%"), // this key never exists - } - iter, err := dbIn.NewIter(&opts) - if err != nil { - fmt.Println("Key not found in DB: ", prefix) - return - } - - valid := iter.First() - if valid { - fmt.Printf("Frst: key=%s value=%-20.20s\n", iter.Key(), iter.Value()) - } - valid = iter.Last() - if valid { - fmt.Printf("Last: key=%s value=%-20.20s\n", iter.Key(), iter.Value()) - } -} diff --git a/lib/buckets/kvbtree/KVBTreeBucket.go b/lib/buckets/kvbtree/KVBTreeBucket.go deleted file mode 100644 index fee93d72..00000000 --- a/lib/buckets/kvbtree/KVBTreeBucket.go +++ /dev/null @@ -1,322 +0,0 @@ -// Package kvbtree -package kvbtree - -import ( - "bytes" - "fmt" - "log/slog" - "sync" - - "github.com/hiveot/hub/lib/buckets" - - "github.com/tidwall/btree" -) - -// Clone the given byte array. -// Use this is the source holds a transient value and its buffer can be reused elsewhere. -// Eg, all capnp byte arrays -// Golang will eventually get a bytes.clone() method but it isn't in go-19 -// -// returns nil if b is nil -func Clone(b []byte) (c []byte) { - if b != nil { - c = []byte(string(b)) - } - return c -} - -// KVBTreeBucket is an in-memory bucket for the KVBTreeBucket -type KVBTreeBucket struct { - BucketID string `json:"bucketID"` - refCount int // simple ref count for error detection - kvtree btree.Map[string, []byte] - - mutex sync.RWMutex - // cache for parsed json strings for faster query - //queryCache map[string]interface{} - - // update handler callback to notify bucket owner - updated func(bucket *KVBTreeBucket) -} - -// Close the bucket and release its resources -// commit is not used as this store doesn't handle transactions. -// This decreases the refCount and detects an error if below 0 -func (bucket *KVBTreeBucket) Close() (err error) { - - slog.Debug("closing bucket", "bucketID", bucket.BucketID) - // this just lowers the refCount to detect leaks - bucket.mutex.Lock() - defer bucket.mutex.Unlock() - - bucket.refCount-- - if bucket.refCount < 0 { - err = fmt.Errorf("bucket '%s' closed more often than opened", - bucket.BucketID) - } - return err -} - -// Cursor returns a new cursor for iterating the bucket. -// The cursor MUST be closed after use to release its memory. -// -// This should be fast enough for many use-cases. 100K records takes around 27msec on an i5@2.9GHz -// -// This returns a cursor with Next() and Prev() iterators -func (bucket *KVBTreeBucket) Cursor() (buckets.IBucketCursor, error) { - - bucket.mutex.RLock() - defer bucket.mutex.RUnlock() - - iter := bucket.kvtree.Iter() - cursor := NewKVCursor(bucket, iter) - return cursor, nil -} - -// Delete a document from the bucket -// Also succeeds if the document doesn't exist -func (bucket *KVBTreeBucket) Delete(key string) error { - bucket.mutex.Lock() - defer bucket.mutex.Unlock() - - slog.Info("Deleting key from bucket", "key", key, "bucketID", bucket.BucketID) - bucket.kvtree.Delete(key) - bucket.updated(bucket) - return nil -} - -// Export returns a shallow copy of the bucket content -func (bucket *KVBTreeBucket) Export() map[string][]byte { - bucket.mutex.RLock() - defer bucket.mutex.RUnlock() - - // shallow copy each bucket kv pairs as well - exportedCopy := make(map[string][]byte) - //exportedCopy := bucket.kvtree.Copy[string, []byte]() - iter := bucket.kvtree.Iter() - hasItem := iter.First() - for hasItem { - exportedCopy[iter.Key()] = iter.Value() - hasItem = iter.Next() - } - return exportedCopy -} - -// Get an object by its ID -// returns an error if the key does not exist. -func (bucket *KVBTreeBucket) Get(key string) (val []byte, err error) { - var found bool - bucket.mutex.RLock() - defer bucket.mutex.RUnlock() - - val, found = bucket.kvtree.Get(key) - if !found { - err = fmt.Errorf("key '%s' not found", key) - // return nil with no error - } - return val, err -} - -// GetMultiple returns a batch of documents for the given key -// The document can be any text. -func (bucket *KVBTreeBucket) GetMultiple(keys []string) (docs map[string][]byte, err error) { - - bucket.mutex.RLock() - defer bucket.mutex.RUnlock() - docs = make(map[string][]byte) - - for _, key := range keys { - val, found := bucket.kvtree.Get(key) - if found { - docs[key] = val - } - } - return docs, err -} - -// Query for documents using JSONPATH -// -// This returns a cursor for a set of parsed documents that match. -// Note that the orderedKeys of the cursor are index numbers, not actual document orderedKeys. -// -// This parses the value into a json document. The parsed document is cached so successive queries -// will be faster. -// -// Eg `$[? @.properties.deviceType=="sensor"]` -// -// jsonPath contains the query for each document. -// offset contains the offset in the list of results, sorted by ID -// limit contains the maximum or of responses, 0 for the default 100 -// orderedKeys can be used to limit the result to documents with the given orderedKeys. Use nil to ignore -//func (bucket *KVBTreeBucket) Query( -// BucketID string, jsonPath string, orderedKeys []string) (cursor bucketstore.IBucketCursor, err error) { -// -// // "github.com/PaesslerAG/jsonpath" - just works, amazing! -// // Unfortunately no filter with bracket notation $[? @.["title"]=="my title"] -// // res, err := jsonpath.Get(jsonPath, store.docs) -// // github.com/ohler55/ojg/jp - seems to work with in-mem maps, no @token in bracket notation -// //logrus.Infof("jsonPath='%s', limit=%d", args.JsonPathQuery, args.Limit) -// -// jpExpr, err := jp.ParseString(jsonPath) -// if err != nil { -// return nil, err -// } -// store.mutex.RLock() -// -// // build an object tree of potential documents to query -// var potentialDocs = make(map[string][]byte) -// -// // no use to continue if the bucket doesn't exist -// bucket, found := store.getBucket(BucketID, false) -// if !found { -// return cursor, fmt.Errorf("bucket '%s' not found", BucketID) -// } -// // when the list of orderedKeys is given, reduce to those that actually exist -// if orderedKeys != nil { -// for _, key := range orderedKeys { -// doc, exists := bucket[key] -// if exists { -// potentialDocs[key] = doc -// } -// } -// } else { -// // get all docs -// for key, docString := range bucket { -// potentialDocs[key] = docString -// } -// } -// -// // unlock now we have a copy of the document list -// store.mutex.RUnlock() -// -// // the query requires a parsed version of json docs -// var docsToQuery = make(map[string]interface{}) -// for key, jsonDoc := range potentialDocs { -// doc, found := store.jsonCache[key] -// if found { -// // use cached doc -// docsToQuery[key] = doc -// } else { -// // parse and store -// doc, err = oj.ParseString(string(jsonDoc)) -// if err == nil { -// docsToQuery[key] = doc -// store.jsonCache[key] = doc -// } -// } -// } -// // A big problem with jp.Get is that it returns an interface and we lose the orderedKeys. -// // The only option is to query each document in order to retain the orderedKeys. That however affects jsonPath formulation. -// validDocs := jpExpr.Get(docsToQuery) -// -// // return the json docs instead of the interface. -// // FIXME: Unfortunately that means marshalling again as we lost the orderedKeys... :( -// cursorMap := make(map[string][]byte, 0) -// cursorKeys := make([]string, len(validDocs)) -// for i, validDoc := range validDocs { -// key := strconv.Itoa(i) -// cursorKeys[i] = key -// jsonDoc, _ := oj.Marshal(validDoc) -// cursorMap[key] = jsonDoc -// } -// cursor = NewKVCursor(cursorMap, cursorKeys, 0) -// return cursor, err -//} - -//// Size returns the number of items in the store -//func (bucket *KVBTreeBucket) Size(context.Context, *emptypb.Empty) (*svc.SizeResult, error) { -// store.mutex.RLock() -// defer bucket.mutex.RUnlock() -// res := &svc.SizeResult{ -// Count: int32(len(bucket.kvPairs)), -// } -// return res, nil -//} - -func (bucket *KVBTreeBucket) ID() string { - return bucket.BucketID -} - -// increment the ref counter when a new bucket is requested -func (bucket *KVBTreeBucket) incrRefCounter() { - bucket.mutex.Lock() - defer bucket.mutex.Unlock() - bucket.refCount++ -} - -// Info returns the bucket info -func (bucket *KVBTreeBucket) Info() (info *buckets.BucketStoreInfo) { - info = &buckets.BucketStoreInfo{} - // are these are full store sizes - info.NrRecords = int64(bucket.kvtree.Len()) - info.DataSize = -1 - // - info.Engine = buckets.BackendKVBTree - info.Id = bucket.BucketID - return -} - -// Set writes a document to the store. If the document exists it is replaced. -// This will store a copy of doc -// -// A background process periodically checks the change count. When increased: -// 1. Lock the store while copying the index. Unlock when done. -// 2. Stream the in-memory json documents to a temp file. -// 3. If success, move the temp file to the store file using the OS atomic move operation. -func (bucket *KVBTreeBucket) Set(key string, doc []byte) error { - if key == "" { - return fmt.Errorf("missing key") - } - - //docCopy := bytes.NewBuffer(doc).Bytes() - //docCopy := []byte(string(doc)) - // store the document and object - bucket.mutex.Lock() - defer bucket.mutex.Unlock() - bucket.kvtree.Set(key, Clone(doc)) - bucket.updated(bucket) - return nil -} - -func (bucket *KVBTreeBucket) setUpdateHandler(handler func(bucket *KVBTreeBucket)) { - bucket.updated = handler -} - -// SetMultiple writes a batch of key-values -// Values are copied -func (bucket *KVBTreeBucket) SetMultiple(docs map[string][]byte) (err error) { - // store the document and object - bucket.mutex.Lock() - defer bucket.mutex.Unlock() - for k, v := range docs { - bucket.kvtree.Set(k, bytes.NewBuffer(v).Bytes()) - } - bucket.updated(bucket) - return nil -} - -func NewKVMemBucket(bucketID string) *KVBTreeBucket { - kvbucket := &KVBTreeBucket{ - BucketID: bucketID, - refCount: 0, - mutex: sync.RWMutex{}, - updated: nil, - } - return kvbucket -} - -// NewKVMemBucketFromMap creates a new KVBTreeBucket from a map with bucket data -// Intended for loading a saved store. -func NewKVMemBucketFromMap(bucketID string, data map[string][]byte) *KVBTreeBucket { - slog.Debug("creating bucket", "bucketID", bucketID) - kvbucket := &KVBTreeBucket{ - BucketID: bucketID, - refCount: 0, - mutex: sync.RWMutex{}, - updated: nil, - } - for k, v := range data { - kvbucket.kvtree.Set(k, v) - } - return kvbucket -} diff --git a/lib/buckets/kvbtree/KVBTreeCursor.go b/lib/buckets/kvbtree/KVBTreeCursor.go deleted file mode 100644 index 07cbb7f1..00000000 --- a/lib/buckets/kvbtree/KVBTreeCursor.go +++ /dev/null @@ -1,152 +0,0 @@ -package kvbtree - -import ( - "github.com/hiveot/hub/lib/buckets" - "github.com/tidwall/btree" -) - -type KVBTreeCursor struct { - bucket buckets.IBucket - kviter btree.MapIter[string, []byte] -} - -func (cursor *KVBTreeCursor) BucketID() string { - return cursor.bucket.ID() -} - -// First moves the cursor to the first item -func (cursor *KVBTreeCursor) First() (key string, value []byte, valid bool) { - valid = cursor.kviter.First() - if !valid { - return - } - key = cursor.kviter.Key() - value = cursor.kviter.Value() - return -} - -// Last moves the cursor to the last item -func (cursor *KVBTreeCursor) Last() (key string, value []byte, valid bool) { - valid = cursor.kviter.Last() - if !valid { - return - } - key = cursor.kviter.Key() - value = cursor.kviter.Value() - return -} - -// Next increases the cursor position and return the next key and value -// If the end is reached the returned key is empty -func (cursor *KVBTreeCursor) Next() (key string, value []byte, valid bool) { - valid = cursor.kviter.Next() - if !valid { - return - } - key = cursor.kviter.Key() - value = cursor.kviter.Value() - return -} - -// NextN increases the cursor position N times and return the encountered key-value pairs -func (cursor *KVBTreeCursor) NextN(steps uint) (docs map[string][]byte, itemsRemaining bool) { - docs = make(map[string][]byte) - for i := uint(0); i < steps; i++ { - itemsRemaining = cursor.kviter.Next() - if !itemsRemaining { - break - } - key := cursor.kviter.Key() - value := cursor.kviter.Value() - docs[key] = value - } - return -} - -// Prev decreases the cursor position and return the previous key and value -// If the head is reached the returned key is empty -func (cursor *KVBTreeCursor) Prev() (key string, value []byte, valid bool) { - valid = cursor.kviter.Prev() - if !valid { - return - } - key = cursor.kviter.Key() - value = cursor.kviter.Value() - return -} - -// PrevN decreases the cursor position N times and return the encountered key-value pairs -func (cursor *KVBTreeCursor) PrevN(steps uint) (docs map[string][]byte, itemsRemaining bool) { - docs = make(map[string][]byte) - for i := uint(0); i < steps; i++ { - itemsRemaining = cursor.kviter.Prev() - if !itemsRemaining { - break - } - key := cursor.kviter.Key() - value := cursor.kviter.Value() - docs[key] = value - } - return -} - -// Release the cursor capability -func (cursor *KVBTreeCursor) Release() { -} - -// Seek positions the cursor at the given searchKey. -// This implementation is brute force. It generates a sorted list of orderedKeys for use by the cursor. -// This should still be fast enough for most cases. (test shows around 500msec for 1 million orderedKeys). -// -// BucketID to seach for. Returns and error if the bucket is not found -// key is the starting point. If key doesn't exist, the next closest key will be used. -// -// This returns a cursor with Next() and Prev() iterators -func (cursor *KVBTreeCursor) Seek(searchKey string) (key string, value []byte, valid bool) { - //var err error - valid = cursor.kviter.Seek(searchKey) - if !valid { - return - } - key = cursor.kviter.Key() - value = cursor.kviter.Value() - return -} - -// Skip the cursor forward or backwards. Intended for reading large lists in chunks. -// -// This returns a cursor with Next() and Prev() iterators -func (cursor *KVBTreeCursor) Skip(steps int) (itemsRemaining bool) { - if steps > 0 { - for i := 0; i < steps; i++ { - itemsRemaining = cursor.kviter.Next() - if !itemsRemaining { - break - } - } - } else { - for i := 0; i > steps; i-- { - itemsRemaining = cursor.kviter.Prev() - if !itemsRemaining { - break - } - } - } - - return itemsRemaining -} - -// NewKVCursor create a new bucket cursor for the KV store. -// Cursor.Remove() must be called to release the resources. -// -// bucket is the bucket holding the data -// orderedKeys is a snapshot of the keys in ascending order -// -// func NewKVCursor(bucket bucketstore.IBucket, orderedKeys []string, kvbtree btree.Map[string, []byte]) *KVBTreeCursor { -func NewKVCursor(bucket buckets.IBucket, kvIter btree.MapIter[string, []byte]) *KVBTreeCursor { - cursor := &KVBTreeCursor{ - bucket: bucket, - kviter: kvIter, - } - return cursor -} diff --git a/lib/buckets/kvbtree/KVBTreeStore.go b/lib/buckets/kvbtree/KVBTreeStore.go deleted file mode 100644 index d92b3075..00000000 --- a/lib/buckets/kvbtree/KVBTreeStore.go +++ /dev/null @@ -1,338 +0,0 @@ -package kvbtree - -import ( - "encoding/json" - "fmt" - "log/slog" - "os" - "path" - "sync" - "sync/atomic" - "time" - - "github.com/hiveot/hub/lib/buckets" -) - -// KVBTreeStore is an embedded, file backed, in-memory, lightweight, and very fast key-value bucket -// store. -// This is intended for simple use-cases with up to 100K records. -// Interestingly, this simple brute-force store using maps is faster than anything else I've tested and even -// scales up to 1M records. Pretty much all you need for basic databases. -// -// Changes are periodically persisted to file in the background. -// -// Limitations: -// - No transaction support (basic usage, remember) -// - Changes are periodically (default 3 second) written to disk -// -// Improvements for future considerations: -// - Append-only to reduce disk writes for larger databases -// -// --- about jsonpath --- -// This was experimental because of the W3C WoT recommendation, and seems to work well. -// However this is shelved as the Hub has no use-case for it and the other stores don't support it. -// -// Query: jsonPath: `$[?(@.properties.title.name=="title1")]` -// -// Approx 1.8 msec with a dataset of 1K records (1 result) -// Approx 23 msec with a dataset of 10K records (1 result) -// Approx 260 msec with a dataset of 100K records (1 result) -// -// A good overview of jsonpath implementations can be found here: -// > https://cburgmer.github.io/json-path-comparison/ -// Two good options for jsonpath queries: -// -// > github.com/ohler55/ojg/jp -// > github.com/PaesslerAG/jsonpath -// -// Note that future implementations of this service can change the storage media used while -// maintaining API compatibility. -type KVBTreeStore struct { - // collection of buckets, one for each Thing, each being a map. - buckets map[string]*KVBTreeBucket - storePath string // for file backed storage or "" for in-memory only - mutex sync.RWMutex // simple locking is still fast enough - updateCount int32 // nr of updates since last save - backgroundLoopEnded chan bool - backgroundLoopEnding chan bool - writeDelay time.Duration // delay before writing changes - // cache for parsed json strings for faster query - //jsonCache map[string]interface{} -} - -// importStoreFile loads the store content into a map and converts it to a map of buckets -// returns an error if the file does not exist -// not concurrent safe -func importStoreFile(storePath string) (docs map[string]*KVBTreeBucket, err error) { - imported, err := readStoreFile(storePath) - docs = make(map[string]*KVBTreeBucket) - if err != nil { - return nil, err - } - for bucketID, bucketData := range imported { - bucket := NewKVMemBucketFromMap(bucketID, bucketData) - docs[bucketID] = bucket - } - // if the store didn't exist it must be writable successfully in order to continue - return docs, err -} - -// readStoreFile loads the store JSON content into a map -// Returns empty data if storePath is "" (eg an memory-only store) -// Returns the OS error if loading fails -// not concurrent safe -func readStoreFile(storePath string) (docs map[string]map[string][]byte, err error) { - //docs = make(map[string]*KVBTreeBucket) - docs = make(map[string]map[string][]byte) - if storePath == "" { - return docs, nil - } - var rawData []byte - rawData, err = os.ReadFile(storePath) - if err == nil { - if len(rawData) > 100000000 { - slog.Warn("KVBTreeStore size getting large.", - "file", storePath, "size in MB", len(rawData)/1024/1024) - } - err = json.Unmarshal(rawData, &docs) - - if err != nil { - // todo: chain errors - slog.Warn("failed read store. Recover with an empty store. Sorry.", "storePath", storePath, "err", err) - } - } - return docs, err -} - -// writeStoreFile writes the store to file. -// This creates the folder if it doesn't exist. (the parent must exist) -// Note this is not concurrent safe. Callers must lock or create a shallow copy of the buckets. -// -// storePath is the full path to the file or "" when ignored -// docs contains an object map of the store objects -func writeStoreFile(storePath string, docs map[string]map[string][]byte) error { - slog.Debug("writeStoreFile: Flush changes to json store", "storePath", storePath) - if storePath == "" { - return nil - } - - // create the folder if needed - storeFolder := path.Dir(storePath) - _, err := os.Stat(storeFolder) - if os.IsNotExist(err) { - // folder doesn't exist. Attempt to create it - slog.Info("Store folder does not exist. Creating it now.", "storeFolder", storeFolder) - err = os.Mkdir(storeFolder, 0700) - } - // If the folder can't be created we're dead in the water - if err != nil { - err = fmt.Errorf("unable to create the store folder at '%s': %s", storeFolder, err) - } - if err != nil { - return err - } - - // serialize the data to json for writing. Use indent for testing and debugging - //rawData, err := oj.Marshal(docs) - rawData, err := json.MarshalIndent(docs, " ", " ") - if err != nil { - // yeah this is pretty fatal too - err = fmt.Errorf("unable to marshal documents while saving store to %s: %w", storePath, err) - slog.Error(err.Error()) - return err - } - // First write content to temp file - // The temp file is opened with 0600 permissions - tmpName := storePath + ".tmp" - err = os.WriteFile(tmpName, rawData, 0600) - if err != nil { - // ouch, wth? - err = fmt.Errorf("error while creating tempfile for jsonstore: %w", err) - slog.Error(err.Error()) - return err - } - - // move the temp file to the final store file. - // this replaces the file if it already exists - err = os.Rename(tmpName, storePath) - if err != nil { - err = fmt.Errorf("error while moving tempfile to jsonstore '%s': %w", storePath, err) - slog.Error(err.Error()) - return err - } - return nil -} - -// autoSaveLoop periodically saves changes to the store -func (store *KVBTreeStore) autoSaveLoop() { - slog.Debug("auto-save loop started") - - defer close(store.backgroundLoopEnded) - - for { - select { - case <-store.backgroundLoopEnding: - slog.Debug("Autosave loop ended") - return - case <-time.After(store.writeDelay): - //store.mutex.Lock() - if atomic.LoadInt32(&store.updateCount) > int32(0) { - // make a shallow copy for writing to avoid a lock during write to disk - exportedCopy := store.Export() - atomic.StoreInt32(&store.updateCount, 0) - //store.mutex.Unlock() - - // nothing we can do here. error is already logged - // FIXME: use separate write lock - _ = writeStoreFile(store.storePath, exportedCopy) - } else { - //store.mutex.Unlock() - } - } - } -} - -// Close the store and stop the background update. -// If any changes are remaining then write to disk now. -func (store *KVBTreeStore) Close() error { - var err error - slog.Debug("closing store for client", "storePath", store.storePath) - - if store.buckets == nil || store.backgroundLoopEnding == nil { - return fmt.Errorf("store already closed") - } - - store.backgroundLoopEnding <- true - - // wait for the background loop to end - <-store.backgroundLoopEnded - - // flush any remaining changes - if atomic.LoadInt32(&store.updateCount) > int32(0) { - // note Export does an rlock - exportedCopy := store.Export() - err = writeStoreFile(store.storePath, exportedCopy) - } - store.buckets = nil - slog.Info("store close completed. Background loop ended", "storePath", store.storePath) - return err -} - -// export returns a map of the given bucket -// this copies the keys and values into a new map -func (store *KVBTreeStore) Export() map[string]map[string][]byte { - store.mutex.RLock() - defer store.mutex.RUnlock() - var exportedCopy = make(map[string]map[string][]byte) - - for bucketID, bucket := range store.buckets { - bucketExport := bucket.Export() - exportedCopy[bucketID] = bucketExport - } - return exportedCopy -} - -// GetBucket returns a bucket and creates it if it doesn't exist -func (store *KVBTreeStore) GetBucket(bucketID string) (bucket buckets.IBucket) { - - if store.buckets == nil { - panic("store is not open") - } - store.mutex.Lock() - kvBucket := store.buckets[bucketID] - if kvBucket == nil { - kvBucket = NewKVMemBucket(bucketID) - kvBucket.setUpdateHandler(store.onBucketUpdated) - store.buckets[bucketID] = kvBucket - bucket = kvBucket - } - if kvBucket != nil { - kvBucket.incrRefCounter() - } - store.mutex.Unlock() - return kvBucket -} - -// callback handler for notification that a bucket has been modified -func (store *KVBTreeStore) onBucketUpdated(bucket *KVBTreeBucket) { - // at this point we don't need the bucket but this might change with more fine grained update tracking - _ = bucket - atomic.AddInt32(&store.updateCount, 1) -} - -// Open the store and start the background loop for saving changes -func (store *KVBTreeStore) Open() error { - slog.Info("Opening store", "storePath", store.storePath) - var err error - store.mutex.Lock() - defer store.mutex.Unlock() - - if store.buckets != nil { - return fmt.Errorf("store already open") - } - store.buckets, err = importStoreFile(store.storePath) - // recover from bad file. Missing file is okay. - if err != nil { - if os.IsNotExist(err) { - // store doesn't yet exist. This is okay - } else { - return fmt.Errorf("unknown error reading store '%s': %w", store.storePath, err) - } - // write an empty store to make sure the location is writable - store.buckets = make(map[string]*KVBTreeBucket) - dummy := make(map[string]map[string][]byte) - err = writeStoreFile(store.storePath, dummy) - if err != nil { - // unable to recover. Hitting a dead end - return fmt.Errorf("failed creating store file: '%w'", err) - } - } - // after loading set the handler for all buckets - for _, kvBucket := range store.buckets { - kvBucket.setUpdateHandler(store.onBucketUpdated) - } - - store.backgroundLoopEnding = make(chan bool) - store.backgroundLoopEnded = make(chan bool) - if err == nil { - go store.autoSaveLoop() - } - // allow a context switch to start the autoSaveLoop to avoid problems - // if the store is closed immediately. - //time.Sleep(time.Millisecond) - return err -} - -//// Size returns the number of items in the store -//func (store *KVBTreeStore) Size(context.Context, *emptypb.Empty) (*svc.SizeResult, error) { -// store.mutex.RLock() -// defer store.mutex.RUnlock() -// res := &svc.SizeResult{ -// Count: int32(len(store.docs)), -// } -// return res, nil -//} - -// SetWriteDelay sets the delay for writing after a change -func (store *KVBTreeStore) SetWriteDelay(delay time.Duration) { - store.writeDelay = delay -} - -// NewKVStore creates a store instance and load it with saved documents. -// Run Connect to start the background loop and Stop to end it. -// -// storeFile path to storage file or "" for in-memory only -func NewKVStore(storePath string) (store *KVBTreeStore) { - writeDelay := time.Duration(3000) * time.Millisecond - store = &KVBTreeStore{ - //jsonDocs: make(map[string]string), - buckets: nil, // will be set after open - storePath: storePath, - backgroundLoopEnding: nil, - backgroundLoopEnded: nil, - mutex: sync.RWMutex{}, - writeDelay: writeDelay, - //jsonCache: make(map[string]interface{}), - } - return store -} diff --git a/lib/buckets/pebble/PebbleBucket.go b/lib/buckets/pebble/PebbleBucket.go deleted file mode 100644 index 5973c146..00000000 --- a/lib/buckets/pebble/PebbleBucket.go +++ /dev/null @@ -1,204 +0,0 @@ -package pebble - -import ( - "bytes" - "errors" - "fmt" - "log/slog" - - "github.com/hiveot/hub/lib/buckets" - - "github.com/cockroachdb/pebble" -) - -// PebbleBucket represents a transactional bucket using Pebble -// Buckets are not supported in Pebble so these are simulated by prefixing all keys with "{bucketID}$" -// Each write operation is its own transaction. -type PebbleBucket struct { - db *pebble.DB - // key range for this bucket - rangeStart string - rangeEnd string - bucketID string - closed bool -} - -// Close the bucket -func (bucket *PebbleBucket) Close() (err error) { - if bucket.closed { - err = fmt.Errorf("bucket '%s' is already closed", bucket.bucketID) - } - bucket.closed = true - return err -} - -// Commit changes to the bucket -//func (bucket *PebbleBucket) Commit() (err error) { -// // this is just for error detection -// if !bucket.writable { -// err = fmt.Errorf("cant commit as bucket '%s' is not writable",bucket.bucketID) -// } -// if bucket.closed { -// err = fmt.Errorf("bucket '%s' is already closed", bucket.bucketID) -// } -// return err -//} - -// Cursor provides an iterator for the bucket using a pebble iterator with prefix bounds -// -// optional name for use by application -func (bucket *PebbleBucket) Cursor() (buckets.IBucketCursor, error) { - // bucket prefix is {bucketID}$ - // range bounds end at {bucketID}@ - opts := &pebble.IterOptions{ - LowerBound: []byte(bucket.bucketID + "$"), - // a bucketID that is longer would be included when using @. Is this a bug? - //UpperBound: []byte(bucket.bucketID + "@"), // this key never exists - // FIXME: add testcase - UpperBound: []byte(bucket.bucketID + "%"), // this key never exists - TableFilter: nil, - PointKeyFilters: nil, - RangeKeyFilters: nil, - KeyTypes: 0, - RangeKeyMasking: pebble.RangeKeyMasking{ - Suffix: nil, - Filter: nil, - }, - OnlyReadGuaranteedDurable: false, - UseL6Filters: false, - } - bucketIterator, err := bucket.db.NewIter(opts) - if err != nil { - slog.Error("Error getting cursor", "err", err) - return nil, fmt.Errorf("failed getting cursor: %w", err) - } - cursor := NewPebbleCursor(bucket.bucketID, bucket.rangeStart, bucketIterator) - return cursor, nil -} - -// Delete removes the key-value pair from the bucket store -func (bucket *PebbleBucket) Delete(key string) (err error) { - bucketKey := bucket.rangeStart + key - opts := &pebble.WriteOptions{} - err = bucket.db.Delete([]byte(bucketKey), opts) - return err -} - -// Get returns the document for the given key -func (bucket *PebbleBucket) Get(key string) (doc []byte, err error) { - bucketKey := bucket.rangeStart + key - if bucket.closed || bucket.db == nil { - slog.Error("Bad state getting key. Bucket closed or DB nil.", "key", key, "isClosed", bucket.closed) - } - byteValue, closer, err := bucket.db.Get([]byte(bucketKey)) - if err == nil { - doc = bytes.NewBuffer(byteValue).Bytes() - err = closer.Close() - } else if errors.Is(err, pebble.ErrNotFound) { - // return error if not found - err = fmt.Errorf("key '%s' not found", key) - doc = nil - } - return doc, err -} - -// GetMultiple returns a batch of documents with existing keys -func (bucket *PebbleBucket) GetMultiple(keys []string) (docs map[string][]byte, err error) { - - docs = make(map[string][]byte) - batch := bucket.db.NewIndexedBatch() - for _, key := range keys { - bucketKey := bucket.rangeStart + key - value, closer, err2 := batch.Get([]byte(bucketKey)) - if err2 == nil { - docs[key] = bytes.NewBuffer(value).Bytes() - _ = closer.Close() - } - } - err = batch.Close() - return docs, err -} - -// ID returns the bucket ID -func (bucket *PebbleBucket) ID() string { - return bucket.bucketID -} - -// Info returns bucket information -// FIXME: Unable to determine the number of records in a bucket (or even in the DB) -func (bucket *PebbleBucket) Info() (info *buckets.BucketStoreInfo) { - - //metrics := bucket.db.Metrics() - // bucket key range - //size, _ := bucket.db.EstimateDiskUsage([]byte(bucket.rangeStart), []byte(bucket.rangeEnd)) - //size = uint64(0) - //sstables, err := bucket.db.SSTables() - //if err == nil { - // for _, tblList := range sstables { - // for _, tbl := range tblList { - // size += tbl.Size - // } - // } - //} - - info = &buckets.BucketStoreInfo{ - Id: bucket.bucketID, - Engine: buckets.BackendPebble, - // FIXME: get bucket metrics - DataSize: -1, //int64(metrics.WAL.Size), - NrRecords: -1, - //Size: size, - } - - return info -} - -// Set sets a document with the given key -func (bucket *PebbleBucket) Set(key string, doc []byte) error { - if key == "" { - err := fmt.Errorf("empty key '%s' for bucket '%s'", - key, bucket.bucketID) - return err - } - bucketKey := bucket.rangeStart + key - opts := &pebble.WriteOptions{} - err := bucket.db.Set([]byte(bucketKey), doc, opts) - return err - -} - -// SetMultiple sets multiple documents in a batch update -func (bucket *PebbleBucket) SetMultiple(docs map[string][]byte) (err error) { - - batch := bucket.db.NewBatch() - for key, value := range docs { - bucketKey := bucket.rangeStart + key - opts := &pebble.WriteOptions{} - err = batch.Set([]byte(bucketKey), value, opts) - if err != nil { - slog.Error("failed set multiple documents", "err", err) - _ = batch.Close() - return err - } - } - err = bucket.db.Apply(batch, &pebble.WriteOptions{}) - _ = batch.Close() - return err -} - -// NewPebbleBucket creates a new bucket -// -// bucketID identifies the bucket -// pebbleDB backend storage -func NewPebbleBucket(bucketID string, pebbleDB *pebble.DB) *PebbleBucket { - if pebbleDB == nil { - slog.Error("pebbleDB is nil", "bucketID", bucketID) - } - srv := &PebbleBucket{ - bucketID: bucketID, - db: pebbleDB, - rangeStart: bucketID + "$", - rangeEnd: bucketID + "@", - } - return srv -} diff --git a/lib/buckets/pebble/PebbleCursor.go b/lib/buckets/pebble/PebbleCursor.go deleted file mode 100644 index baf9352b..00000000 --- a/lib/buckets/pebble/PebbleCursor.go +++ /dev/null @@ -1,159 +0,0 @@ -package pebble - -import ( - "fmt" - "log/slog" - "strings" - - "github.com/cockroachdb/pebble" -) - -type PebbleCursor struct { - //db *pebble.DB - //bucket *PebbleBucket - bucketPrefix string // prefix to remove from keys returned by get/set/seek/first/last - bucketID string - iterator *pebble.Iterator -} - -func (cursor *PebbleCursor) BucketID() string { - return cursor.bucketID -} - -// First moves the cursor to the first item -func (cursor *PebbleCursor) First() (key string, value []byte, valid bool) { - valid = cursor.iterator.First() - if !valid { - return - } - return cursor.getKV() -} - -// Return the iterator current key and value -// This removes the bucket prefix -func (cursor *PebbleCursor) getKV() (key string, value []byte, valid bool) { - k := string(cursor.iterator.Key()) - v, err := cursor.iterator.ValueAndErr() - if strings.HasPrefix(k, cursor.bucketPrefix) { - key = k[len(cursor.bucketPrefix):] - valid = cursor.iterator.Valid() - } else { - err = fmt.Errorf("bucket key '%s' has no prefix '%s'", k, cursor.bucketPrefix) - valid = false - } - - // what to do in case of error? - _ = err - return key, v, valid -} - -// Last moves the cursor to the last item -func (cursor *PebbleCursor) Last() (key string, value []byte, valid bool) { - valid = cursor.iterator.Last() - if !valid { - return - } - return cursor.getKV() -} - -// Next iterates to the next key from the current cursor -func (cursor *PebbleCursor) Next() (key string, value []byte, valid bool) { - valid = cursor.iterator.Next() - if !valid { - return - } - return cursor.getKV() -} - -// NextN increases the cursor position N times and return the encountered key-value pairs -func (cursor *PebbleCursor) NextN(steps uint) (docs map[string][]byte, itemsRemaining bool) { - docs = make(map[string][]byte) - for i := uint(0); i < steps; i++ { - itemsRemaining = cursor.iterator.Next() - if !itemsRemaining { - break - } - key, value, _ := cursor.getKV() - docs[key] = value - } - return -} - -// Prev iterations to the previous key from the current cursor -func (cursor *PebbleCursor) Prev() (key string, value []byte, valid bool) { - valid = cursor.iterator.Prev() - if !valid { - return - } - return cursor.getKV() -} - -// PrevN decreases the cursor position N times and return the encountered key-value pairs -func (cursor *PebbleCursor) PrevN(steps uint) (docs map[string][]byte, itemsRemaining bool) { - docs = make(map[string][]byte) - for i := uint(0); i < steps; i++ { - itemsRemaining = cursor.iterator.Prev() - if !itemsRemaining { - break - } - key, value, _ := cursor.getKV() - docs[key] = value - } - return -} - -// Release the cursor -func (cursor *PebbleCursor) Release() { - err := cursor.iterator.Close() - if err != nil { - slog.Error("unexpected error releasing cursor", "err", err.Error()) - } -} - -// Seek returns a cursor with Next() and Prev() iterators -func (cursor *PebbleCursor) Seek(searchKey string) (key string, value []byte, valid bool) { - bucketKey := cursor.bucketPrefix + searchKey - valid = cursor.iterator.SeekGE([]byte(bucketKey)) - if !valid { - return - } - return cursor.getKV() -} - -// Skip the cursor forward or backwards. Intended for skipping to an offset when caller -// hasn't retained the curosr. -func (cursor *PebbleCursor) Skip(steps int) (itemsRemaining bool) { - if steps > 0 { - for i := 0; i < steps; i++ { - itemsRemaining = cursor.iterator.Next() - if !itemsRemaining { - break - } - } - } else { - for i := 0; i > steps; i-- { - itemsRemaining = cursor.iterator.Prev() - if !itemsRemaining { - break - } - } - } - - return itemsRemaining -} - -// NewPebbleCursor create a pebble storage iterator cursor for a pebble bucket -// -// bucketID the cursor iterates -// bucketPrefix -// iterator is pebble's iterator -func NewPebbleCursor(bucketID string, bucketPrefix string, iterator *pebble.Iterator) *PebbleCursor { - - // TBD: use pebble range keys instead of bucket prefix - cursor := &PebbleCursor{ - bucketPrefix: bucketPrefix, - bucketID: bucketID, - iterator: iterator, - } - return cursor -} diff --git a/lib/buckets/pebble/PebbleStore.go b/lib/buckets/pebble/PebbleStore.go deleted file mode 100644 index 7a76213c..00000000 --- a/lib/buckets/pebble/PebbleStore.go +++ /dev/null @@ -1,122 +0,0 @@ -package pebble - -import ( - "errors" - "fmt" - "log/slog" - "os" - - "github.com/hiveot/hub/lib/buckets" - - "github.com/cockroachdb/pebble" - "golang.org/x/sys/unix" -) - -// PebbleStore implements the IBucketStore API using the embedded CockroachDB pebble database -// -// The following benchmark are made using BucketBench_test.go -// Performance is stellar! Fast, efficient data storage and low memory usage compared to the others. -// Estimates are made using a i5-4570S @2.90GHz cpu. Document size is 100 bytes. -// -// Create&commit bucket, no data changes (fast since pebbles doesn't use transactions for this) -// -// Dataset 1K, 0.1 us/op -// Dataset 10K, 0.1 us/op -// Dataset 100K 0.1 us/op -// Dataset 1M 0.1 us/op -// -// Get bucket 1 record -// -// Dataset 1K, 1.0 us/op -// Dataset 10K, 1.6 us/op -// Dataset 100K 1.6 us/op -// Dataset 1M 3.2 us/op -// -// Set bucket 1 record -// -// Dataset 1K, 2.2 us/op -// Dataset 10K, 2.2 us/op -// Dataset 100K 2.5 us/op -// Dataset 1M 3.0 us/op -// Dataset 10M 40 us/op -// -// Seek, 1 record -// -// Dataset 1K, 5 us/op -// Dataset 10K, 3 us/op -// Dataset 100K 3 us/op -// Dataset 1M 14 us/op -// Dataset 10M 144 us/op -// -// See https://pkg.go.dev/github.com/cockroachdb/pebble for Pebble's documentation. -type PebbleStore struct { - storeDirectory string - db *pebble.DB -} - -func (store *PebbleStore) Close() error { - err := store.db.Close() - return err -} - -// GetBucket returns a bucket with the given ID. -// If the bucket doesn't yet exist it will be created. -func (store *PebbleStore) GetBucket(bucketID string) (bucket buckets.IBucket) { - pb := NewPebbleBucket(bucketID, store.db) - return pb -} - -// Open the store -func (store *PebbleStore) Open() (err error) { - options := &pebble.Options{} - // pebble.AddSession will panic if the store directory is readonly, so check ahead to return an error - stat, err := os.Stat(store.storeDirectory) - // if the path exists, it must be a directory - if err == nil { - if !stat.IsDir() { - err = fmt.Errorf("can't open store. '%s' is not a directory", store.storeDirectory) - } - } else if errors.Is(err, os.ErrNotExist) { - // if the path doesn't exist, create a directory with mode 0700 - err = os.MkdirAll(store.storeDirectory, 0700) - } - // path must be writable to avoid a panic - if err == nil { - err = unix.Access(store.storeDirectory, unix.W_OK) - } - if err == nil { - store.db, err = pebble.Open(store.storeDirectory, options) - } - if err != nil { - slog.Error("failed to open bucket store", "directory", store.storeDirectory, "err", err) - } else { - version := store.db.FormatMajorVersion() - metrics := store.db.Metrics() - stats := pebble.CheckLevelsStats{} - err = store.db.CheckLevels(&stats) - if err != nil { - slog.Error("PebbleStore.open DB.CheckLevels failed: ", "err", err.Error()) - } - _ = err - slog.Info("pebble bucket store opened", - slog.String("path", store.storeDirectory), - slog.Uint64("FormatMajorVersion", uint64(version)), - slog.Uint64("memtables size", metrics.MemTable.Size), - slog.Uint64("data size", metrics.WAL.Size), - ) - - // auto upgrade the database - //store.db.RatchetFormatMajorVersion() - } - return err -} - -// NewPebbleStore creates a storage database with bucket support. -// -// storeDirectory is the directory (not file) holding the database -func NewPebbleStore(storeDirectory string) *PebbleStore { - srv := &PebbleStore{ - storeDirectory: storeDirectory, - } - return srv -} diff --git a/lib/certs/CreateCA.go b/lib/certs/CreateCA.go deleted file mode 100644 index a9449d96..00000000 --- a/lib/certs/CreateCA.go +++ /dev/null @@ -1,69 +0,0 @@ -package certs - -import ( - "crypto/ecdsa" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "log/slog" - "math/big" - "time" - - "github.com/hiveot/hub/lib/keys" -) - -const CertOrgName = "HiveOT" -const CertOrgLocality = "HiveOT zone" - -// CreateCA creates a CA certificate with an private key for self-signed server certificates. -// Browsers don't support ed25519 keys so use ecdsa for the certificate. -// Source: https://shaneutt.com/blog/golang-ca-and-signed-cert-go/ -func CreateCA(cn string, validityDays int) (cert *x509.Certificate, key keys.IHiveKey, err error) { - - // set up our CA certificate - // see also: https://superuser.com/questions/738612/openssl-ca-keyusage-extension - // firefox complains if serial is the same as that of the CA. So generate a unique one based on timestamp. - serial := time.Now().Unix() - 1 // prevent duplicate timestamp with server cert - rootTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{ - Country: []string{"CA"}, - Province: []string{"BC"}, - Locality: []string{CertOrgLocality}, - Organization: []string{CertOrgName}, - CommonName: cn, - }, - NotBefore: time.Now().Add(-3 * time.Second), - NotAfter: time.Now().AddDate(0, 0, validityDays), - // CA cert can be used to sign certificate and revocation lists - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment, - - // firefox seems to consider a CA invalid if extended key usage is combined with regular (critical) key usage??? - // certificate.Verify however fails if ext key usage is just the OCSPSigning. - //ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageOCSPSigning}, - //ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, - // https://github.com/hashicorp/vault/issues/846 suggests no ext key usage for CA's - ExtKeyUsage: []x509.ExtKeyUsage{}, - - // This hub cert is the only CA. Not using intermediate CAs - BasicConstraintsValid: true, - IsCA: true, - MaxPathLen: 0, - MaxPathLenZero: true, - } - - // Create the CA private key. Browsers don't support ed25519 (2024) so use ecdsa - caKey := keys.NewKey(keys.KeyTypeECDSA) - privKey := caKey.PrivateKey().(*ecdsa.PrivateKey) - pubKey := caKey.PublicKey().(*ecdsa.PublicKey) - - // create the CA - caCertDer, err := x509.CreateCertificate( - rand.Reader, rootTemplate, rootTemplate, pubKey, privKey) - if err != nil { - // normally this never happens - slog.Error("unable to create CA cert", "err", err) - } - caCert, _ := x509.ParseCertificate(caCertDer) - return caCert, caKey, nil -} diff --git a/lib/certs/CreateCertBundle.go b/lib/certs/CreateCertBundle.go deleted file mode 100644 index 82be8035..00000000 --- a/lib/certs/CreateCertBundle.go +++ /dev/null @@ -1,67 +0,0 @@ -// Package certs with managing certificates for testing -package certs - -import ( - "crypto/tls" - "crypto/x509" - - "github.com/hiveot/hub/lib/keys" -) - -const ServerAddress = "127.0.0.1" -const TestServerID = "server1" -const TestClientID = "client1" - -// TestCertBundle creates a set of CA, server and client certificates intended for testing -type TestCertBundle struct { - CaCert *x509.Certificate - CaKey keys.IHiveKey - - // server certificate - ServerAddr string - ServerKey keys.IHiveKey - ServerCert *tls.Certificate - - // client cert auth - ClientKey keys.IHiveKey - ClientCert *tls.Certificate -} - -// CreateTestCertBundle creates a bundle of ca, server certificates and keys for testing. -// The server cert is valid for the 127.0.0.1, localhost and os.hostname. -func CreateTestCertBundle() TestCertBundle { - var err error - certBundle := TestCertBundle{ - ServerAddr: ServerAddress, - } - // Setup CA and server TLS certificates - certBundle.CaCert, certBundle.CaKey, err = CreateCA("testing", 1) - if err != nil { - panic("CreateCertBundler failed: " + err.Error()) - } - // browsers don't support certifate with ed25519 keys so use ecdsa - certBundle.ServerKey = keys.NewKey(keys.KeyTypeECDSA) - certBundle.ClientKey = keys.NewKey(keys.KeyTypeECDSA) - - names := []string{ServerAddress, "localhost"} - serverCert, err := CreateServerCert( - TestServerID, "server", 1, - certBundle.ServerKey, - names, - certBundle.CaCert, certBundle.CaKey) - if err != nil { - panic("unable to create server cert: " + err.Error()) - } - certBundle.ServerCert = X509CertToTLS(serverCert, certBundle.ServerKey) - - clientCert, err := CreateClientCert(TestClientID, "service", 1, - certBundle.ClientKey, - certBundle.CaCert, - certBundle.CaKey) - if err != nil { - panic("unable to create client cert: " + err.Error()) - } - certBundle.ClientCert = X509CertToTLS(clientCert, certBundle.ClientKey) - - return certBundle -} diff --git a/lib/certs/CreateCerts_test.go b/lib/certs/CreateCerts_test.go deleted file mode 100644 index a15aa808..00000000 --- a/lib/certs/CreateCerts_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package certs_test - -import ( - "testing" - - "github.com/hiveot/hub/lib/keys" - - "github.com/hiveot/hub/lib/certs" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateCerts(t *testing.T) { - // test creating hub certificate - const serverID = "testService" - const clientID = "testClient" - names := []string{"127.0.0.1", "localhost"} - - caCert, caKey, _ := certs.CreateCA("testca", 1) - - serverKey := keys.NewKey(keys.KeyTypeECDSA) - serverCert, err := certs.CreateServerCert( - serverID, "myou", 0, serverKey, names, caCert, caKey) - - serverCertPEM := certs.X509CertToPEM(serverCert) - // verify service certificate against CA - _, err = certs.VerifyCert(serverCertPEM, caCert) - assert.NoError(t, err) - - // create a server TLS cert - tlsCert := certs.X509CertToTLS(serverCert, serverKey) - assert.NotEmpty(t, tlsCert) - - // create a client cert - clientKey := keys.NewKey(keys.KeyTypeEd25519) - clientCert, err := certs.CreateClientCert(clientID, "", 0, clientKey, caCert, caKey) - assert.NoError(t, err) - assert.NotEmpty(t, clientCert) -} - -// test with bad parameters -func TestServerCertBadParms(t *testing.T) { - const serverID = "testService" - names := []string{"127.0.0.1", "localhost"} - - caCert, caKey, _ := certs.CreateCA("testca", 1) - serverKey := keys.NewKey(keys.KeyTypeECDSA) - - // Missing CA certificate - assert.Panics(t, func() { - _, _ = certs.CreateServerCert( - serverID, "myou", 0, serverKey, names, nil, caKey) - }) - - // missing CA private key - assert.Panics(t, func() { - _, _ = certs.CreateServerCert( - serverID, "myou", 0, serverKey, names, caCert, nil) - }) - - // missing service ID - serverCert, err := certs.CreateServerCert( - "", "myou", 0, serverKey, names, caCert, caKey) - _ = serverCert - require.Error(t, err) - require.Empty(t, serverCert) - - // missing public key - serverCert, err = certs.CreateServerCert( - serverID, "myou", 0, nil, names, caCert, caKey) - require.Error(t, err) - require.Empty(t, serverCert) - -} diff --git a/lib/certs/CreateClientCert.go b/lib/certs/CreateClientCert.go deleted file mode 100644 index 23d89270..00000000 --- a/lib/certs/CreateClientCert.go +++ /dev/null @@ -1,63 +0,0 @@ -package certs - -import ( - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "math/big" - "time" - - "github.com/hiveot/hub/lib/keys" -) - -// DefaultClientCertValidityDays with validity of generated service certificates -const DefaultClientCertValidityDays = 366 - -// CreateClientCert generates a x509 client certificate with keys, signed by the CA -// intended for testing, not for production -// -// cn is the certificate common name, usually the client ID -// ou the organization. -// pubKey is the owner public key for this certificate -// caKeys is the signing CA's key pair -// validityDays -func CreateClientCert(cn string, ou string, validityDays int, pubKey keys.IHiveKey, - caCert *x509.Certificate, caKeys keys.IHiveKey) (cert *x509.Certificate, err error) { - validity := time.Hour * time.Duration(24*validityDays) - - if validityDays == 0 { - validityDays = DefaultClientCertValidityDays - } - - extkeyUsage := x509.ExtKeyUsageClientAuth - keyUsage := x509.KeyUsageDigitalSignature - serial := time.Now().Unix() - 2 - - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{ - Country: []string{"CA"}, - Organization: []string{"hiveot"}, - Province: []string{"BC"}, - Locality: []string{"local"}, - CommonName: cn, - OrganizationalUnit: []string{ou}, - Names: make([]pkix.AttributeTypeAndValue, 0), - }, - NotBefore: time.Now().Add(-10 * time.Second), - NotAfter: time.Now().Add(validity), - KeyUsage: keyUsage, - ExtKeyUsage: []x509.ExtKeyUsage{extkeyUsage}, - - BasicConstraintsValid: true, - IsCA: false, - } - - certDerBytes, err := x509.CreateCertificate(rand.Reader, template, caCert, pubKey.PublicKey(), caKeys.PrivateKey()) - if err == nil { - //certPEMBuffer := new(bytes.Buffer) - //_ = pem.Encode(certPEMBuffer, &pem.Block{Type: "CERTIFICATE", Bytes: certDerBytes}) - cert, err = x509.ParseCertificate(certDerBytes) - } - return cert, err -} diff --git a/lib/certs/CreateServerCert.go b/lib/certs/CreateServerCert.go deleted file mode 100644 index 12a22c2c..00000000 --- a/lib/certs/CreateServerCert.go +++ /dev/null @@ -1,105 +0,0 @@ -package certs - -import ( - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "log/slog" - "math/big" - "net" - "time" - - "github.com/hiveot/hub/lib/keys" -) - -// DefaultServerCertValidityDays with validity of generated service certificates -const DefaultServerCertValidityDays = 100 - -// CreateServerCert create a server certificate, signed by the given CA, for use in hiveot services. -// -// The provided x509 certificate can be converted to a PEM text with: -// -// certPEM = certs.X509CertToPEM(cert) -// -// * serviceID is the unique service ID used as the CN. for example hostname-serviceName -// * ou is the organizational unit of the certificate -// * validityDays is the duration the cert is valid for. Use 0 for default. -// * serverKey contains the server's public key (use ecdsa keys for browser certificates) -// * names are the SAN names to include with the certificate, localhost and 127.0.0.1 are always added -// * caCert is the CA certificate used to sign the certificate -// * caKey is the CA private key used to sign certificate -func CreateServerCert( - serverID string, ou string, validityDays int, - serverKey keys.IHiveKey, names []string, - caCert *x509.Certificate, caKey keys.IHiveKey) ( - x509Cert *x509.Certificate, err error) { - - if serverID == "" || serverKey == nil { - err := fmt.Errorf("missing argument serviceID, servicePubKey") - slog.Error(err.Error()) - return nil, err - } - if validityDays == 0 { - validityDays = DefaultServerCertValidityDays - } - if names == nil { - names = []string{} - } - names = append(names, "127.0.0.1") - names = append(names, "localhost") - - // firefox complains if serial is the same as that of the CA. So generate a unique one based on timestamp. - serial := time.Now().Unix() - 3 - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{ - Country: []string{"CA"}, - Province: []string{"BC"}, - Locality: []string{"local"}, - Organization: []string{"hiveot"}, - OrganizationalUnit: []string{ou}, - CommonName: serverID, - }, - NotBefore: time.Now().Add(-time.Second), - NotAfter: time.Now().AddDate(0, 0, validityDays), - //NotBefore: time.Now(), - //NotAfter: time.Now().AddDate(0, 0, config.DefaultServiceCertDurationDays), - - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment, - // allow use as both server and client cert - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - - // TBD: for NATS client cert the clientID should be added to the DNS name or Email address - // TODO test this: Add the clientID to the SAN for mapping to a user in NATS - // source: https://stackoverflow.com/questions/26441547/go-how-do-i-add-an-extension-subjectaltname-to-a-x509-certificate - // and: https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/tls_mutual_auth - // one of these solutions: - //DNSNames: []string{clientID}, - //EmailAddresses: []string{serverID}, - - IsCA: false, - MaxPathLenZero: true, - // BasicConstraintsValid: true, - // IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - IPAddresses: []net.IP{}, - } - // determine the hosts for this hub - for _, h := range names { - if ip := net.ParseIP(h); ip != nil { - template.IPAddresses = append(template.IPAddresses, ip) - } else { - template.DNSNames = append(template.DNSNames, h) - } - } - // Create the service private key - - // and the certificate itself - pubKey := serverKey.PublicKey() - certDerBytes, err := x509.CreateCertificate( - rand.Reader, template, caCert, pubKey, caKey.PrivateKey()) - if err == nil { - x509Cert, err = x509.ParseCertificate(certDerBytes) - } - return x509Cert, err -} diff --git a/lib/certs/VerifyCert.go b/lib/certs/VerifyCert.go deleted file mode 100644 index 2e281f2a..00000000 --- a/lib/certs/VerifyCert.go +++ /dev/null @@ -1,33 +0,0 @@ -package certs - -import ( - "crypto/x509" - "fmt" -) - -// VerifyCert verifies whether the given certificate is a valid client certificate -// This returns the certificate CN as the clientID -func VerifyCert(certPEM string, caCert *x509.Certificate) (string, error) { - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert) - - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - cert, err := X509CertFromPEM(certPEM) - if err == nil { - if cert.Subject.CommonName == "" { - err = fmt.Errorf("cert has no CommonName") - } - } - //if err == nil { - // x509Cert, err := x509.ParseCertificate(clientCert.Certificate[0]) - //} - if err == nil { - // FIXME: TestCertAuth: certificate specifies incompatible key usage - // why? Is the certpool invalid? Yet the test succeeds - _, err = cert.Verify(opts) - } - return cert.Subject.CommonName, err -} diff --git a/lib/certs/certs.go b/lib/certs/certs.go deleted file mode 100644 index 3009ae5b..00000000 --- a/lib/certs/certs.go +++ /dev/null @@ -1,176 +0,0 @@ -// Package certs with functions to load CA and client certificates for use -// by the protocol binding in the Consumed Thing factory or other clients. -package certs - -import ( - "crypto/ecdsa" - "crypto/ed25519" - "crypto/tls" - "crypto/x509" - "encoding/pem" - "errors" - "log/slog" - "os" - - "github.com/hiveot/hub/lib/keys" -) - -const DefaultCaCertFile = "caCert.pem" -const DefaultCaKeyFile = "caKey.pem" - -// Certificate Organization Unit for client certificate based authorization -const ( - //OUAdmin lets a client approve things provisioning (postOOB), add and remove users - // Provision API permissions: GetDirectory, ProvisionRequest, GetStatus, PostOOB - OUAdmin = "admin" - - // OUNone is the default OU with no API access permissions - OUNone = "unauth" - - // OUUser for consumers with mutual authentication - OUUser = "user" - - // OUIoTDevice for IoT devices with mutual authentication - OUIoTDevice = "device" - - // OUService for Hub services with mutual authentication - // By default, services have access to other services - // Provision API permissions: Any - OUService = "service" -) - -// LoadX509CertFromPEM loads the x509 certificate from a PEM file format. -// -// Intended to load the CA certificate to validate server and broker. -// -// pemPath is the full path to the X509 PEM file. -func LoadX509CertFromPEM(pemPath string) (cert *x509.Certificate, err error) { - pemEncoded, err := os.ReadFile(pemPath) - if err != nil { - return nil, err - } - return X509CertFromPEM(string(pemEncoded)) -} - -// LoadTLSCertFromPEM loads the TLS certificate from PEM formatted file. -// TLS certificates are a container for both X509 certificate and private key. -// -// Intended to load the certificate and key for servers, or for clients such as IoT devices -// that use client certificate authentication. The idprov service issues this type of -// certificate during IoT device provisioning. -// -// This is simply a wrapper around tls.LoadX509KeyPair. See also SaveTLSCertToPEM. -// -// If loading fails, this returns nil as certificate pointer -func LoadTLSCertFromPEM(certPEMPath, keyPEMPath string) (cert *tls.Certificate, err error) { - // FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key - tlsCert, err := tls.LoadX509KeyPair(certPEMPath, keyPEMPath) - if err != nil { - return nil, err - } - return &tlsCert, err -} - -// PublicKeyFromCert extracts an ECDSA public key from x509 certificate -// Returns nil if certificate doesn't hold a ECDSA public key -func PublicKeyFromCert(cert *x509.Certificate) *ecdsa.PublicKey { - var pubKey *ecdsa.PublicKey - switch pub := cert.PublicKey.(type) { - case *ecdsa.PublicKey: - pubKey = pub - } - return pubKey -} - -// SaveTLSCertToPEM saves the x509 certificate and private key to separate files in PEM format -// -// Intended for saving a certificate received from provisioning or created for testing. -// -// cert is the obtained TLS certificate whose parts to save -// certPEMPath the file to save the X509 certificate to in PEM format -// keyPEMPath the file to save the private key to in PEM format -func SaveTLSCertToPEM(cert *tls.Certificate, certPEMPath, keyPEMPath string) error { - //slog.Info("Saving TLS cert to " + certPEMPath) - b := pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]} - certPEM := pem.EncodeToMemory(&b) - // remove existing cert since perm 0444 doesn't allow overwriting it - _ = os.Remove(certPEMPath) - _ = os.Remove(keyPEMPath) - err := os.WriteFile(certPEMPath, certPEM, 0444) - if err != nil { - slog.Error("Failed writing server cert to file", "err", err) - return err - } - ecdsaPK, found := cert.PrivateKey.(*ecdsa.PrivateKey) - if found { - k := keys.NewEcdsaKeyFromPrivate(ecdsaPK) - err = k.ExportPrivateToFile(keyPEMPath) - return err - } - ed25519PK, found := cert.PrivateKey.(ed25519.PrivateKey) - if found { - //raw, err := x509.MarshalPKCS8PrivateKey(ed25519PK) - //block := &pem.Block{ - // Type: "PRIVATE KEY", - // Bytes: raw, - //} - //err = os.WriteFile(keyPEMPath, pem.EncodeToMemory(block), 0600) - k := keys.NewEd25519KeyFromPrivate(ed25519PK) - err = k.ExportPrivateToFile(keyPEMPath) - return err - } - - ////cert.PrivateKey. - //privKey := cert.PrivateKey - //seed := privKey.Seed() - //pemEnc = pem.EncodeToMemory( - // &pem.Block{Type: "PRIVATE KEY", - // Bytes: seed, - // }) - //return string(pemEnc) - - //k, err := keys.NewKeyFromEnc(ed25519PK) - if err == nil { - //err = k.ExportPrivateToFile(keyPEMPath) - } - return err -} - -// SaveX509CertToPEM saves the x509 certificate to file in PEM format. -// Clients that receive a client certificate from provisioning can use this -// to save the provided certificate to file. -func SaveX509CertToPEM(cert *x509.Certificate, pemPath string) error { - b := pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw} - certPEM := pem.EncodeToMemory(&b) - // remove existing cert since perm 0444 doesn't allow overwriting it - _ = os.Remove(pemPath) - err := os.WriteFile(pemPath, certPEM, 0444) - return err -} - -// X509CertFromPEM converts a X509 certificate in PEM format to an X509 instance -func X509CertFromPEM(certPEM string) (*x509.Certificate, error) { - caCertBlock, _ := pem.Decode([]byte(certPEM)) - if caCertBlock == nil { - return nil, errors.New("pem.Decode failed") - } - caCert, err := x509.ParseCertificate(caCertBlock.Bytes) - return caCert, err -} - -// X509CertToPEM converts the x509 certificate to PEM format -func X509CertToPEM(cert *x509.Certificate) string { - b := pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw} - certPEM := pem.EncodeToMemory(&b) - return string(certPEM) -} - -// X509CertToTLS combines a x509 certificate and private key into a TLS certificate -func X509CertToTLS(cert *x509.Certificate, privKey keys.IHiveKey) *tls.Certificate { - // A TLS certificate is a wrapper around x509 with private key - tlsCert := &tls.Certificate{} - tlsCert.Certificate = append(tlsCert.Certificate, cert.Raw) - tlsCert.PrivateKey = privKey.PrivateKey() - - return tlsCert -} diff --git a/lib/certs/certs_test.go b/lib/certs/certs_test.go deleted file mode 100644 index 8e678f21..00000000 --- a/lib/certs/certs_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package certs_test - -import ( - "path" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/hiveot/hub/lib/certs" -) - -// ! These tests use the TestMain of main_test.go ! - -func TestX509ToFromPem(t *testing.T) { - testCerts := certs.CreateTestCertBundle() - asPem := certs.X509CertToPEM(testCerts.CaCert) - assert.NotEmpty(t, asPem) - asX509, err := certs.X509CertFromPEM(asPem) - assert.NoError(t, err) - assert.NotEmpty(t, asX509) -} - -func TestSaveLoadX509Cert(t *testing.T) { - // hostnames := []string{"localhost"} - caPemFile := path.Join(TestCertFolder, "caCert.pem") - - testCerts := certs.CreateTestCertBundle() - - // save the test x509 cert - err := certs.SaveX509CertToPEM(testCerts.CaCert, caPemFile) - assert.NoError(t, err) - - cert, err := certs.LoadX509CertFromPEM(caPemFile) - assert.NoError(t, err) - assert.NotNil(t, cert) -} - -func TestPublicKeyFromCert(t *testing.T) { - testCerts := certs.CreateTestCertBundle() - pubKey := certs.PublicKeyFromCert(testCerts.CaCert) - assert.NotEmpty(t, pubKey) -} - -func TestSaveLoadTLSCert(t *testing.T) { - // hostnames := []string{"localhost"} - certFile := path.Join(TestCertFolder, "x509cert.pem") - keyFile := path.Join(TestCertFolder, "tlskey.pem") - - testCerts := certs.CreateTestCertBundle() - - // save the test x509 part of the TLS cert - err := certs.SaveTLSCertToPEM(testCerts.ServerCert, certFile, keyFile) - assert.NoError(t, err) - - // load back the x509 part of the TLS cert - cert, err := certs.LoadTLSCertFromPEM(certFile, keyFile) - assert.NoError(t, err) - assert.NotNil(t, cert) -} - -//func TestSaveLoadCertNoFile(t *testing.T) { -// certFile := "/root/notavalidcert.pem" -// keyFile := "/root/notavalidkey.pem" -// testCerts := certs.CreateTestCertBundle() -// // save the test x509 cert -// err := certs.SaveX509CertToPEM(testCerts.CaCert, certFile) -// assert.Error(t, err) -// -// _, err = certs.LoadX509CertFromPEM(certFile) -// assert.Error(t, err) -// -// // save the test x509 part of the TLS cert -// err = certs.SaveTLSCertToPEM(testCerts.ServerCert, certFile, keyFile) -// assert.Error(t, err) -// -// // load back the x509 part of the TLS cert -// _, err = certs.LoadTLSCertFromPEM(certFile, keyFile) -// assert.Error(t, err) -// -//} diff --git a/lib/certs/main_test.go b/lib/certs/main_test.go deleted file mode 100644 index f1369e32..00000000 --- a/lib/certs/main_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package certs_test - -import ( - "os" - "path" - "testing" - - "github.com/hiveot/hub/lib/logging" -) - -var TestCertFolder string -var testPrivKeyPemFile string -var testPubKeyPemFile string - -// TestMain create a test folder for certificates and private key -func TestMain(m *testing.M) { - TestCertFolder, _ = os.MkdirTemp("", "hiveot-go-") - - testPrivKeyPemFile = path.Join(TestCertFolder, "privKey.pem") - testPubKeyPemFile = path.Join(TestCertFolder, "pubKey.pem") - logging.SetLogging("info", "") - - result := m.Run() - if result != 0 { - println("Test failed with code:", result) - println("Find test files in:", TestCertFolder) - } else { - // comment out the next line to be able to inspect results - _ = os.RemoveAll(TestCertFolder) - } - - os.Exit(result) -} diff --git a/lib/clients/ConnectClient.go b/lib/clients/ConnectClient.go deleted file mode 100644 index 7a4d41bf..00000000 --- a/lib/clients/ConnectClient.go +++ /dev/null @@ -1,267 +0,0 @@ -package clients - -import ( - "crypto/x509" - "fmt" - "net/url" - "os" - "path" - "strings" - "time" - - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/clients/authclient" - "github.com/hiveot/hub/lib/clients/discoclient" - "github.com/hiveot/hub/lib/clients/hiveotsseclient" - "github.com/hiveot/hub/lib/clients/httpclient" - "github.com/hiveot/hub/lib/clients/wssclient" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/discoserver" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - "github.com/hiveot/hub/lib/servers/wssserver" -) - -// TokenFileExt defines the filename extension under which client tokens are stored -// in the keys directory. -const TokenFileExt = ".token" - -var DefaultTimeout = time.Second * 3 - -// ConnectWithPassword is a convenience function to authenticate with a password -// and create a secured client connection using a CA from the directory. -// -// This discovers the server if no URL is provided or the url is not able to authenticate. -// -// loginID is the clientID to login as -// password to authenticate with -// caCert is the server CA. See also LoadCA() -// connectURL of the server to connect to, or "" to use discovery -// authURL of the authentication server, or "" to use connectURL -// -// This returns the client connection with the authentication token used or an error if invalid -func ConnectWithPassword( - loginID string, password string, caCert *x509.Certificate, connectURL string, authURL string, timeout time.Duration) ( - cc messaging.IClientConnection, token string, err error) { - - // 1. obtain the CA public cert to verify the server - //caCert, err := LoadCA(caDir) - //if err != nil { - // return nil, "", err - //} - - // 2. discover the server - if connectURL == "" { - var records []*discoclient.DiscoveryResult - - records, err = discoclient.DiscoverWithDnsSD( - discoserver.DefaultServiceName, DefaultTimeout, true) - if err == nil && len(records) > 0 { - rec0 := records[0] - // attempt hiveot websocket - connectURL = rec0.WSSEndpoint - if connectURL == "" { - // fall back to SSE - connectURL = rec0.SSEEndpoint - } - if authURL == "" { - authURL = rec0.AuthEndpoint - } - } - } - if authURL == "" { - authURL = connectURL - } - - if err != nil { - return nil, "", err - } else if connectURL == "" { - return nil, "", fmt.Errorf("WoT Server discovery failed") - } - - // 3. authenticate. A cid is required to link the authenticated session with - // the connections using it. - parts, _ := url.Parse(authURL) - authCl := authclient.NewAuthClient(parts.Host, caCert, timeout) - //cl := tlsclient.NewTLSClient(parts.Host, nil, caCert, timeout) - //token, err = authenticator.LoginWithPassword( - // cl, "", loginID, password) - token, err = authCl.LoginWithPassword(loginID, password) - if err != nil { - return nil, "", err - } - - // 4. create protocol client and connect with token - cc, err = ConnectWithToken(loginID, token, caCert, connectURL, timeout) - return cc, token, err -} - -// ConnectWithToken is a convenience function to create a client and connect with a token -// and CA certificate. -// -// clientID to identify as -// token to authenticate with -// caCert of the server for validating the SSL connect -// connectURL is the optional URL of the server. Leave empty to auto-discover -// timeout is optional connection timeout or 0 for default -// -// This returns the client connection or an error if invalid -func ConnectWithToken( - clientID string, token string, caCert *x509.Certificate, connectURL string, timeout time.Duration) ( - cc messaging.IClientConnection, err error) { - - cc, err = NewClient(clientID, caCert, connectURL, timeout) - if err == nil { - err = cc.ConnectWithToken(token) - } - return cc, err -} - -// ConnectWithTokenFile is a convenience function to create a connection using -// a saved token and optional CA file. -// This is similar to ConnectWithToken but reads a token and CA from file. -// -// clientID to identify as -// keysDir is the directory with the {clientID}.key, {clientID}.token and caCert.pem files. -// connectURL is the full connection URL of the server to connect to. -// timeout is optional connection timeout or 0 for default -// -// This returns the connection, token and CaCert used or an error if invalid -func ConnectWithTokenFile(clientID string, keysDir string, connectURL string, timeout time.Duration) ( - cc messaging.IClientConnection, token string, caCert *x509.Certificate, err error) { - - caCert, err = LoadCA(keysDir) - if err == nil { - token, err = LoadToken(clientID, keysDir) - } - if err == nil { - cc, err = ConnectWithToken(clientID, token, caCert, connectURL, timeout) - } - return cc, token, caCert, err -} - -// GetProtocolFromURL determines which transport protocol type the URL represents -// This returns an empty string if the protocol cannot be determined -// -// fullURL contains the full server address as provided by discovery: -// -// WARNING: hiveot can use a different protocol for the sse and wss schemas. -// In case of doubt the hiveot protocol is used. -// -// https://addr:port/ for http-basic -// sse://addr:port/hiveot/sse for hiveot sse-sc subprotocol binding -// sse://addr:port/... default WoT SSE (not supported) -// wss://addr:port/wss path for websocket direct messaging -// mqtts://addr:port/ for mqtt over websocket over TLS -func GetProtocolFromURL(fullURL string) string { - // determine the protocol to use from the URL - protocolType := "" - - parts, err := url.Parse(fullURL) - if err != nil { - return "" - } - - if parts.Scheme == "" { - // without a schema pick the basic - protocolType = messaging.ProtocolTypeHTTPBasic - } else if parts.Scheme == "https" { - protocolType = messaging.ProtocolTypeHTTPBasic - } else if parts.Scheme == wssserver.WssSchema { - // websocket protocol can use either WoT or hiveot message envelopes - protocolType = messaging.ProtocolTypeWSS - } else if parts.Scheme == hiveotsseserver.HiveotSSESchema { - // wot SSE is not supported - protocolType = messaging.ProtocolTypeHiveotSSE - } else if strings.HasPrefix(fullURL, "mqtts") { - protocolType = messaging.ProtocolTypeWotMQTTWSS - } else { - // fall back to use https basic - protocolType = messaging.ProtocolTypeHTTPBasic - } - return protocolType -} - -// GetProtocolFromForm determine the protocol type from a WoT form. -// FIXME: forms can contain relative paths instead of full URL. The TD is needed -// -// base is the TD base URI used for all relative URI references. -// form is the form whose (sub)protocol to determine. -//func GetProtocolFromForm(base string, form *td.Form) string { -// subProto, _ := form.GetSubprotocol() -// if subProto != "" { -// return subProto -// } -// href, _ := form.GetHRef() -// return GetProtocolFromURL(href) -//} - -// LoadCA is a simple helper to load the default CA from file -func LoadCA(caDir string) (*x509.Certificate, error) { - caCertFile := path.Join(caDir, certs.DefaultCaCertFile) - caCert, err := certs.LoadX509CertFromPEM(caCertFile) - return caCert, err -} - -// LoadToken is a simple helper to load a saved auth token from file -func LoadToken(clientID string, keysDir string) (string, error) { - //keyFile := path.Join(keysDir, clientID+keys.KPFileExt) - tokenFile := path.Join(keysDir, clientID+TokenFileExt) - token, err := os.ReadFile(tokenFile) - return string(token), err -} - -// NewClient returns a new unconnected client ready for connecting to a -// hiveot server. -// Intended for use with the hiveot hub, but should be usable with other hiveot servers. -// -// Note 1: This doesnt use forms as request/response envelopes dont need it. -// -// clientID is the ID to authenticate as when using one of the Connect... methods -// caCert is the server's CA certificate to verify the connection. -// connectURL contains the connection URL for the protocol. -// timeout is optional maximum wait time for connecting or waiting for responses. -// -// If no connectURL is specified then this uses discovery to determine the best protocol. -func NewClient( - clientID string, caCert *x509.Certificate, connectURL string, timeout time.Duration) ( - cc messaging.IClientConnection, err error) { - - // 1. determine the connection address - if connectURL == "" { - // use the first hiveot instance to connect to - discoList, err := discoclient.DiscoverWithDnsSD( - discoserver.DefaultServiceName, timeout, true) - if err != nil || len(discoList) == 0 { - return nil, fmt.Errorf("hub not found") - } - connectURL = discoList[0].WSSEndpoint - if connectURL == "" { - connectURL = discoList[0].SSEEndpoint - } - } - if timeout <= 0 { - timeout = DefaultTimeout - } - parts, err := url.Parse(connectURL) - if err != nil { - return nil, err - } - - // Create the client for the protocol - if parts.Scheme == "wss" { - cc = wssclient.NewHiveotWssClient(connectURL, clientID, caCert, timeout) - } else if parts.Scheme == "sse" { - cc = hiveotsseclient.NewHiveotSseClient( - connectURL, clientID, nil, caCert, nil, timeout) - } else if parts.Scheme == "https" { - cc = httpclient.NewHttpBasicClient( - connectURL, clientID, nil, caCert, nil, timeout) - } else if parts.Scheme == "mqtts" { - // bc = mqttclient.NewMqttAgentClient( - // fullURL, clientID, nil, caCert, timeout) - err = fmt.Errorf("NewHiveotClient: mqtt client is not yet supported for '%s'", connectURL) - } else { - err = fmt.Errorf("NewHiveotClient: Server URL '%s' does not have a valid schema (wss://.. or sse://...", connectURL) - } - return cc, err -} diff --git a/lib/clients/authclient/AuthenticateWithPassword.go b/lib/clients/authclient/AuthenticateWithPassword.go deleted file mode 100644 index 61733a4c..00000000 --- a/lib/clients/authclient/AuthenticateWithPassword.go +++ /dev/null @@ -1,129 +0,0 @@ -package authclient - -import ( - "crypto/x509" - "log/slog" - "net/http" - "net/url" - "time" - - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" - jsoniter "github.com/json-iterator/go" -) - -// AuthClient is a client for handling authentication using http endpoints -// This creates a http TLS client. -type AuthClient struct { - tlsClient *tlsclient.TLSClient -} - -// LoginWithPassword invokes the remote password authenticator. -// -// authURL is the http address to invoke. If a path is omitted, the default path -// (HttpPostLoginPath) defined in the http server will be used. -// -// This creates a temporary TLS client identified with the loginID and cid. -// The resulting authentication is linked to an internal session. -// -// tlsClient is an unauthenticated client initialized with a URL, CA and cid -// loginPath the path to post the login request. Default is HttpPostLoginPath -// loginID to login as -// password to use with digest auth -// -// This returns an authentication token for connecting with any of the protocols, or an error -func (cl *AuthClient) LoginWithPassword(loginID string, password string) (newToken string, err error) { - - // FIXME: support for http digest auth? - loginMessage := map[string]string{ - "login": loginID, - "password": password, - } - - loginPath := httpbasic.HttpPostLoginPath - - dataJSON, _ := jsoniter.Marshal(loginMessage) - outputRaw, status, err := cl.tlsClient.Post(loginPath, dataJSON) - - if err == nil && status == http.StatusOK { - err = jsoniter.Unmarshal(outputRaw, &newToken) - } - if err != nil { - slog.Warn("AuthenticateWithPassword failed: " + err.Error()) - } - return newToken, err -} - -// Logout requests invalidating all client sessions. -// tlsClient is a client with an existing authenticated connection -// logoutPath is the http address to invoke. If a path is omitted, the default path -func (cl *AuthClient) Logout() (err error) { - logoutPath := httpbasic.HttpPostLogoutPath - - _, _, err = cl.tlsClient.Post(logoutPath, nil) - cl.tlsClient.Close() - return err -} - -// RefreshToken invokes the hub's authenticator to refresh the token. -// -// tlsClient is a client with an existing authenticated connection -// refreshPath is the http address to invoke. If a path is omitted, the default path -// (HttpPostRefreshPath) defined in the http server will be used. -// -// This returns a new authentication token, or an error -func (cl *AuthClient) RefreshToken(oldToken string) (newToken string, err error) { - - refreshPath := httpbasic.HttpPostRefreshPath - dataJSON, _ := jsoniter.Marshal(oldToken) - cl.tlsClient.SetAuthToken(oldToken) - outputRaw, status, err := cl.tlsClient.Post(refreshPath, dataJSON) - - if err == nil && status == http.StatusOK { - err = jsoniter.Unmarshal(outputRaw, &newToken) - } - - if err != nil { - slog.Warn("RefreshToken failed: " + err.Error()) - } else { - cl.tlsClient.SetAuthToken(newToken) - } - return newToken, err -} - -// Close the client connection if connected -func (cl *AuthClient) Close() { - if cl.tlsClient != nil { - cl.tlsClient.Close() - } -} - -// NewAuthClient for authentication and token refresh -// -// hostPort is the address of the authentication server -// caCert is the CA of the server -// timeout of requests. 0 for default. -func NewAuthClient(hostPort string, caCert *x509.Certificate, timeout time.Duration) *AuthClient { - - tlsClient := tlsclient.NewTLSClient(hostPort, nil, caCert, timeout) - ac := &AuthClient{ - tlsClient: tlsClient, - } - return ac -} - -// NewAuthClientFromConnection for authentication using an exising connection -// the auth server must be reachable on the same address. -// bearerToken as used by the client connection. -func NewAuthClientFromConnection(cl messaging.IClientConnection, bearerToken string) *AuthClient { - - cinfo := cl.GetConnectionInfo() - parts, _ := url.Parse(cinfo.ConnectURL) - tlsClient := tlsclient.NewTLSClient(parts.Host, nil, cinfo.CaCert, cinfo.Timeout) - tlsClient.SetAuthToken(bearerToken) - ac := &AuthClient{ - tlsClient: tlsClient, - } - return ac -} diff --git a/lib/clients/discoclient/DirectoryClient.go b/lib/clients/discoclient/DirectoryClient.go deleted file mode 100644 index 980a4fae..00000000 --- a/lib/clients/discoclient/DirectoryClient.go +++ /dev/null @@ -1,197 +0,0 @@ -package discoclient - -import ( - "crypto/x509" - "fmt" - "net/url" - "sync" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/clients/tlsclient" - jsoniter "github.com/json-iterator/go" -) - -// The DirectoryClient holds discovered TDs from a directory service. -type DirectoryClient struct { - mux sync.RWMutex - - // auth token to read the TD - authToken string - - caCert *x509.Certificate - - // The connecting client - clientID string - - // directory of TD documents - directory map[string]*td.TD - - // The TD of the directory itself - directoryTD *td.TD - // Full URL to wget the directory TD - //directoryTDURL string - - // connection pool by connection URL to access things as per TD form. - // The url is protocol specific. - //connectPool map[string]transports.IClientConnection - - // Connection to the directory for reading or update - tlsClient *tlsclient.TLSClient - - timeout time.Duration - // client for talking to the directory using http, wss or sse - //httpClient *tlsclient.TLSClient -} - -// NewClient creates a client for connecting to access a Thing based on the -// given TD. If the thingID is not known then this returns nil -//func (cl *DirectoryClient) NewClient(tdi *td.TD) transports.IClientConnection { -// base := td.Base -// // how to determine the protocol from the base URL??? -// // Option 1: see below -// // https://host:port/ -// // wss://host:port/wss <- not base scheme -// // sse://host:port/sse <- not base scheme -// // mqtt://host:port/... -// // Option 2: use form -// // which one? -// // wait with creating client until invoking an operation? -// // implies invoking operation on the directory -// // directory.Invoke(thingID, op, ...) -// protocol := td.Protocol -// cc, err := clients.NewClient(base, protocol, cl.clientID, cl.caCert, cl.GetForm, cl.timeout) -// return cc -//} - -// GetForms provides the forms for invoking an operation on a thing -// This returns nil if the thing is unknown -func (cl *DirectoryClient) GetForms(thingID string, operation string) []td.Form { - cl.mux.RLock() - tdi, found := cl.directory[thingID] - defer cl.mux.RUnlock() - if !found { - return nil - } - f := tdi.GetForms(operation, "") - return f -} - -// Connect connects to the directory, reads the directory's TD. -// If successful, call List to read the content. -// -// tdURL is the URL of the directory service TD. -func (cl *DirectoryClient) Connect(tdURL string) error { - cl.mux.RLock() - defer cl.mux.RUnlock() - - // 1: connect to the directory and read its TD - parts, _ := url.Parse(tdURL) - tlsClient := tlsclient.NewTLSClient(parts.Host, nil, cl.caCert, cl.timeout) - tlsClient.SetAuthToken(cl.authToken) - cl.tlsClient = tlsClient - - // 2: read its TD - resp, status, err := tlsClient.Get(parts.Path) - _ = status - if err != nil { - return err - } - tdi := td.TD{} - err = jsoniter.Unmarshal(resp, &tdi) - if err != nil { - return err - } - cl.directoryTD = &tdi - return nil -} - -// ListTD pages through a list of things to update the local directory -// This follows: https://w3c.github.io/wot-discovery/#exploration-directory-api-things-listing -// which requires the http get at /things?limit=... -func (cl *DirectoryClient) ListTD(limit int) error { - - listPath := fmt.Sprintf("/things?limit=%d", limit) - raw, stat, err := cl.tlsClient.Get(listPath) - _ = stat - if err != nil { - return err - } - tdList := []*td.TD{} - err = jsoniter.Unmarshal(raw, &tdList) - if err != nil { - return err - } - // - cl.mux.Lock() - for _, tdi := range tdList { - cl.directory[tdi.ID] = tdi - } - cl.mux.Unlock() - return nil -} - -// ReadTD refreshes the cached TD from the directory -// This follows: https://w3c.github.io/wot-discovery/#exploration-directory-api -// which requires the http get at /things/{id} -func (cl *DirectoryClient) ReadTD(thingID string) (*td.TD, error) { - - raw, stat, err := cl.tlsClient.Get("/things/" + thingID) - _ = stat - if err != nil { - return nil, err - } - tdi := &td.TD{} - err = jsoniter.Unmarshal(raw, &tdi) - if err != nil { - return nil, err - } - // - cl.mux.Lock() - cl.directory[thingID] = tdi - cl.mux.Unlock() - return tdi, err -} - -// UpdateThing updates the TD document in the remote directory. -// This does not update the local directory. -// Intended for use by Thing agents to publish their TD. Regular consumers -// are not allowed to do this. -// -// tdjson is the TD document in JSON format -func (cl *DirectoryClient) UpdateThing(tdi *td.TD) error { - - tdJSON, _ := jsoniter.Marshal(&tdi) - // FIXME: use the directory forms - updatePath := fmt.Sprintf("/things/%s", tdi.ID) - data, status, err := cl.tlsClient.Put(updatePath, tdJSON) - _ = status - _ = data - return err -} - -// NewDirectoryClient creates a new client for reading and holding the content -// of a directory. -// -// This first reads the directory's TD using https and the given auth token. -// Call ReadDirectory to establish a connection to the server using the protocol -// provided in the TD and reads the directory content. The connection is added -// to the connection pool for clients to access TDs. -// -// In Hiveot this pool is just the single connection as all Things are access via -// the digital twin on the Hub. -// -// Intended for consumers to hold the TD's they have access to. -// -// tdURL is the introduction address that provides the directory TD itself -func NewDirectoryClient(clientID string, token string, caCert *x509.Certificate, timeout time.Duration) *DirectoryClient { - cl := &DirectoryClient{ - caCert: caCert, - clientID: clientID, - authToken: token, - timeout: timeout, - directory: make(map[string]*td.TD), - } - - return cl -} diff --git a/lib/clients/discoclient/DiscoverTDD.go b/lib/clients/discoclient/DiscoverTDD.go deleted file mode 100644 index 5f764c90..00000000 --- a/lib/clients/discoclient/DiscoverTDD.go +++ /dev/null @@ -1,147 +0,0 @@ -package discoclient - -import ( - "fmt" - "log/slog" - "strings" - "time" - - "github.com/hiveot/hub/lib/servers/discoserver" -) - -const HIVEOT_TDD_DNSSD_TYPE = "_hiveot._wot._tcp" -const WOT_DNSSD_TYPE = "_wot._tcp" -const WOT_TDD_DNSSD_TYPE = "_directory._sub._wot._tcp" - -//const WOT_UDP_DNSSD_TYPE = "_wot._udp" - -type DiscoveryResult struct { - Addr string // IP or hostname of the server - Port int // port the server listens on - IsDirectory bool // URL is that of a Thing Directory - IsThing bool // URL is of a Thing - Instance string - // predefined WoT discovery parameters - Scheme string // Scheme part of the URL - Type string // Thing or Directory - TD string // absolute pathname of the TD or TDD - // hiveot connection endpoints - AuthEndpoint string // authentication service endpoint - SSEEndpoint string // Http/SSE-SC transport protocol - WSSEndpoint string // Websocket transport - Params map[string]string // optional parameters -} - -// DiscoverTDD supports introduction mechanisms to bootstrap the WoT discovery -// process and returns a list of discovered directory TD URLs. -// -// This supports multiple discovery methods and merges the result. -// -// This supports an optional filter on the instance. Intended to filter for a -// specific type of directory, for example a "hiveot" hub directory. -// -// serviceName of the service to discover. Default is "wot" -// waitTime is the duration to wait for the results -// firstResult returns immediately with the first result instead of waiting the full waitTime -func DiscoverTDD(serviceName string, waitTime time.Duration, firstResult bool) []*DiscoveryResult { - - drList := make([]*DiscoveryResult, 0) - - if serviceName == "" { - serviceName = discoserver.DefaultServiceName - } - - // query well-known thing TD or TDD - drList2, err := DiscoverWithDnsSD(serviceName, waitTime, firstResult) - if err == nil && len(drList2) > 0 { - drList = append(drList, drList2...) - } - - // remove duplicates - return drList -} - -// DiscoverWithDnsSD searches for services with the given instance or service name. -// -// [_{instanceName}.]_{serviceName}._tcp service type -// -// instanceName is optional to search for a particular implementation such as hiveot -func DiscoverWithDnsSD( - serviceName string, waitTime time.Duration, firstResult bool) ([]*DiscoveryResult, error) { - - drList := make([]*DiscoveryResult, 0) - - serviceType := fmt.Sprintf("_%s._tcp", serviceName) - if waitTime == 0 { - waitTime = time.Second * 3 - } - records, err := DnsSDScan(serviceType, waitTime, firstResult) - if err != nil { - return drList, err - } - if len(records) == 0 { - err = fmt.Errorf("DiscoverService: no service of type '%s' found after %d seconds", - serviceType, int(waitTime/time.Second)) - return drList, err - } - for _, rec := range records { - discoResult := DiscoveryResult{ - Params: make(map[string]string), - Instance: rec.Instance, - Port: rec.Port, - } - - // determine the address string - // use the local IP if provided - if len(rec.AddrIPv4) > 0 { - discoResult.Addr = rec.AddrIPv4[0].String() - } else if len(rec.AddrIPv6) > 0 { - discoResult.Addr = rec.AddrIPv6[0].String() - } else { - // fall back to use host.domainname - discoResult.Addr = rec.HostName - } - // https://w3c.github.io/wot-discovery/#introduction-dns-sd-sec - if rec.ServiceName() == WOT_TDD_DNSSD_TYPE || rec.ServiceName() == HIVEOT_TDD_DNSSD_TYPE { - discoResult.IsDirectory = true - } else if rec.ServiceName() == WOT_DNSSD_TYPE { - //this is a thing - discoResult.IsThing = true - } else { - // not sure what this is - } - - // For TCP-based services, the following information MUST be included in the - // TXT record that is pointed to by the Service Instance Name: - for _, txtRecord := range rec.Text { - kv := strings.Split(txtRecord, "=") - if len(kv) != 2 { - slog.Info("DiscoverService: Ignoring non key-value in TXT record", "key", txtRecord) - continue - } - key := kv[0] - val := kv[1] - if key == "td" { - discoResult.TD = val // Absolute pathname of the TD/TDD - } else if key == "type" { - discoResult.Type = val // Type of TD, "Thing" or "Directory" or "Hiveot" - discoResult.IsDirectory = val == "Directory" - discoResult.IsThing = val == "Thing" - } else if key == "scheme" { - // http (default), https, coap+tcp, coaps+tcp - discoResult.Scheme = val // Scheme part of URL - } else if key == discoserver.WSSEndpoint { - // 'base' is specific to hiveot to provide a default connection URL - discoResult.WSSEndpoint = val - } else if key == discoserver.SSEEndpoint { - // 'base' is specific to hiveot to provide a default connection URL - discoResult.SSEEndpoint = val - } else if key == discoserver.AuthEndpoint { - discoResult.AuthEndpoint = val - } - discoResult.Params[key] = val - } - drList = append(drList, &discoResult) - } - return drList, nil -} diff --git a/lib/clients/discoclient/DnsSDScan.go b/lib/clients/discoclient/DnsSDScan.go deleted file mode 100644 index 39ae2eb8..00000000 --- a/lib/clients/discoclient/DnsSDScan.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package discodiscoclientvery with client for DNS-SD service discovery -package discoclient - -import ( - "context" - "log/slog" - "sync" - "time" - - "github.com/grandcat/zeroconf" -) - -// DnsSDScan scans zeroconf publications on local domain -// The zeroconf library does not support browsing of all services, but a workaround is -// to search the service types with "_services._dns-sd._udp" then query each of the service types. -// -// serviceType to look for in format "_{serviceType}._tcp", or "" to discover all service types (not all services) -// waitTime with duration to wait while collecting results. 0 means exit on the first result. -// firstResult return immediately -func DnsSDScan(serviceType string, waitTime time.Duration, firstResult bool) ([]*zeroconf.ServiceEntry, error) { - sdDomain := "local" - mu := &sync.Mutex{} - - if serviceType == "" { - // https://github.com/grandcat/zeroconf/pull/15 - serviceType = "_services._dns-sd._udp" - } - if waitTime == 0 { - waitTime = time.Second * 3 - } - records := make([]*zeroconf.ServiceEntry, 0) - resolver, err := zeroconf.NewResolver(nil) - if err != nil { - slog.Error("Failed to create DNS-SD resolver", "err", err) - return nil, err - } - ctx, cancel := context.WithTimeout(context.Background(), waitTime) - defer cancel() - - // 'records' channel captures the result - entries := make(chan *zeroconf.ServiceEntry) - go func(results <-chan *zeroconf.ServiceEntry) { - for entry := range results { - rec := entry.ServiceRecord - slog.Info("DnsSDScan: Found service", - "instance", rec.Instance, - "service", rec.Service, - "domain", rec.Domain, - "ip4", entry.AddrIPv4, - slog.Int("port", entry.Port)) - - mu.Lock() - records = append(records, entry) - mu.Unlock() - if firstResult { - cancel() - } - } - slog.Debug("DnsSDScan: No more entries.") - }(entries) - - err = resolver.Browse(ctx, serviceType, sdDomain, entries) - if err != nil { - slog.Error("DnsSDScan: Failed to browse", "err", err) - } - <-ctx.Done() - mu.Lock() - results := records - mu.Unlock() - - return results, err -} diff --git a/lib/clients/hiveotsseclient/ConnectSSE.go b/lib/clients/hiveotsseclient/ConnectSSE.go deleted file mode 100644 index f1166c92..00000000 --- a/lib/clients/hiveotsseclient/ConnectSSE.go +++ /dev/null @@ -1,263 +0,0 @@ -package hiveotsseclient - -import ( - "bytes" - "context" - "errors" - "fmt" - "log/slog" - "net/http" - "net/url" - "sync/atomic" - "time" - - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - "github.com/hiveot/hub/lib/servers/httpbasic" - sse "github.com/tmaxmax/go-sse" -) - -// maxSSEMessageSize allow this maximum size of an SSE message -// go-sse allows this increase of allocation size on receiving messages -const maxSSEMessageSize = 1024 * 1024 * 10 - -// ConnectSSE establishes a new sse connection using the given http client. -// -// If the connection is interrupted, the sse connection retries with backoff period. -// If an authentication error occurs then the onDisconnect handler is invoked with an error. -// If the connection is cancelled then the onDisconnect is invoked without error -// -// This invokes onConnect when the connection is lost. The caller must handle the -// connection established when the first ping is received after successful connection. -func ConnectSSE(cinfo messaging.ConnectionInfo, - bearerToken string, - httcl *http.Client, - onConnect func(bool, error), - onMessage func(event sse.Event), -) (cancelFn func(), err error) { - - // use context to disconnect the client on Close - sseCtx, sseCancelFn := context.WithCancel(context.Background()) - bodyReader := bytes.NewReader([]byte{}) - parts, _ := url.Parse(cinfo.ConnectURL) // the sse: schema isn't recognized. Use https - parts.Scheme = "https" - connectURL := parts.String() - req, err := http.NewRequestWithContext(sseCtx, http.MethodGet, connectURL, bodyReader) - if err != nil { - sseCancelFn() - return nil, err - } - req.Header.Add(httpbasic.ConnectionIDHeader, cinfo.ConnectionID) - req.Header.Add("Authorization", "bearer "+bearerToken) - origin := fmt.Sprintf("https://%s", parts.Host) - req.Header.Add("Origin", origin) - - sseClient := &sse.Client{ - //HTTPClient: httpClient, - HTTPClient: httcl, - OnRetry: func(err error, backoff time.Duration) { - slog.Warn("SSE Connection retry", - "err", err, "clientID", cinfo.ClientID, - "backoff", backoff) - // TODO: how to be notified if the connection is restored? - // workaround: in handleSSEEvent, update the connection status - onConnect(false, err) - }, - } - conn := sseClient.NewConnection(req) - - // increase the maximum buffer size to 1M (_maxSSEMessageSize) - // note this requires go-sse v0.9.0-pre.2 as a minimum. - //https://github.com/tmaxmax/go-sse/issues/32 - newBuf := make([]byte, 0, 1024*65) - // TODO: make limit configurable - conn.Buffer(newBuf, maxSSEMessageSize) - remover := conn.SubscribeToAll(onMessage) - - // Wait for max 3 seconds to detect a connection - waitConnectCtx, waitConnectCancelFn := context.WithTimeout(context.Background(), cinfo.Timeout) - conn.SubscribeEvent(hiveotsseserver.SSEPingEvent, func(event sse.Event) { - // WORKAROUND since go-sse has no callback for a successful (re)connect, simulate one here. - // As soon as a connection is established the server could send a 'ping' event. - // success! - slog.Info("handleSSEEvent: connection (re)established; setting connected to true") - onConnect(true, nil) - waitConnectCancelFn() - }) - var sseConnErr atomic.Pointer[sse.ConnectionError] - go func() { - // connect and wait until the connection ends - // and report an error if connection ends due to reason other than context cancelled - // onConnect will be called on receiving the first (ping) message - //onConnect(true, nil) - err := conn.Connect() - // FIXME: pass 401 unauthorized to caller - if connError, ok := err.(*sse.ConnectionError); ok { - // since sse retries, this is likely an authentication error - slog.Error("SSE connection failed (server shutdown or connection interrupted)", - "clientID", cinfo.ClientID, - "err", err.Error()) - sseConnErr.Store(connError) - //err = fmt.Errorf("connect Failed: %w", connError.Err) //connError.Err - waitConnectCancelFn() - } else if errors.Is(err, context.Canceled) { - // context was cancelled. no error - err = nil - } - onConnect(false, err) - _ = remover - }() - - // wait for the SSE connection to be established - <-waitConnectCtx.Done() - e := waitConnectCtx.Err() - if errors.Is(e, context.DeadlineExceeded) { - err = fmt.Errorf("ConnectSSE: Timeout connecting to the server") - slog.Warn(err.Error()) - sseCancelFn() - } else if sseConnErr.Load() != nil { - err = sseConnErr.Load() - // something else went wrong - slog.Warn("ConnectSSE: error" + err.Error()) - } - closeSSEFn := func() { - // any other cleanup? - sseCancelFn() - } - return closeSSEFn, err -} - -// ConnectSSE establishes the sse connection using the given bearer token -// cl.handleSseEvent will set 'connected' status when the first ping event is -// received from the server. (go-sse doesn't have a connected callback) -func (cc *HiveotSseClient) ConnectSSE(token string) (err error) { - if cc.ssePath == "" { - return fmt.Errorf("connectSSE: Missing SSE path") - } - // establish the SSE connection for the return channel - //sseURL := fmt.Sprintf("https://%s%s", cc.hostPort, cc.ssePath) - cinfo := cc.GetConnectionInfo() - cc.sseCancelFn, err = ConnectSSE(cinfo, token, - // use the same http client for both http requests and sse connection - cc.GetTlsClient(), - cc.handleSSEConnect, - cc.handleSseEvent) - - return err -} - -// handler when the SSE connection is established or fails. -// This invokes the connectHandler callback if provided. -func (cc *HiveotSseClient) handleSSEConnect(connected bool, err error) { - errMsg := "" - cinfo := cc.GetConnectionInfo() - - // if the context is cancelled this is not an error - if err != nil { - errMsg = err.Error() - } - slog.Info("handleSSEConnect", - slog.String("clientID", cinfo.ClientID), - slog.String("connectionID", cinfo.ConnectionID), - slog.Bool("connected", connected), - slog.String("err", errMsg)) - - var connectionChanged bool = false - if cc.IsConnected() != connected { - connectionChanged = true - } - cc.SetConnected(connected) - if err != nil { - cc.mux.Lock() - cc.lastError.Store(&err) - cc.mux.Unlock() - } - handler := cc.GetAppConnectHandler() - - // Note: this callback can send notifications to the client, - // so prevent deadlock by running in the background. - // (caught by readhistory failing for unknown reason) - if connectionChanged && handler != nil { - go func() { - handler(connected, err, cc) - }() - } -} - -// handleSSEEvent processes the push-event received from the hub. -// This splits the message into notification, response and request -// requests have an operation and correlationID -// responses have no operations and a correlationID -// notifications have an operations and no correlationID -func (cc *HiveotSseClient) handleSseEvent(event sse.Event) { - - // no further processing of a ping needed - if event.Type == hiveotsseserver.SSEPingEvent { - return - } - - // Use the hiveot message envelopes for request, response and notification - switch event.Type { - case messaging.MessageTypeNotification: - notif := cc.converter.DecodeNotification([]byte(event.Data)) - if notif == nil { - return - } - // don't block the receiver flow - go func() { - h := cc.GetAppNotificationHandler() - if h == nil { - slog.Error("appNotificationHandler is nil") - } else { - h(notif) - } - }() - case messaging.MessageTypeRequest: - req := cc.converter.DecodeRequest([]byte(event.Data)) - if req == nil { - return - } - go func() { - h := cc.GetAppRequestHandler() - if h == nil { - slog.Error("appRequestHandler is nil") - } else { - resp := h(req, cc) - if resp != nil { - _ = cc.SendResponse(resp) - } - } - }() - case messaging.MessageTypeResponse: - resp := cc.converter.DecodeResponse([]byte(event.Data)) - if resp == nil { - return - } - // don't block the receiver flow - go func() { - h := cc.GetAppResponseHandler() - if h == nil { - slog.Error("appResponseHandler is nil") - } else { - _ = h(resp) - } - }() - default: - // all other events are intended for other use-cases such as the UI, - // and can have a formats of event/{dThingID}/{name} - // Attempt to deliver this for compatibility with other protocols (such has hiveoview test client) - notif := messaging.NotificationMessage{} - notif.MessageType = messaging.MessageTypeNotification - notif.Value = event.Data - notif.Operation = event.Type - // don't block the receiver flow - go func() { - h := cc.GetAppNotificationHandler() - if h == nil { - slog.Error("appNotificationHandler is nil") - } else { - h(¬if) - } - }() - } -} diff --git a/lib/clients/hiveotsseclient/HiveotSseClient.go b/lib/clients/hiveotsseclient/HiveotSseClient.go deleted file mode 100644 index 8d0697fc..00000000 --- a/lib/clients/hiveotsseclient/HiveotSseClient.go +++ /dev/null @@ -1,280 +0,0 @@ -package hiveotsseclient - -import ( - "context" - "crypto/tls" - "crypto/x509" - "log/slog" - "net/http" - "net/url" - "sync" - "sync/atomic" - "time" - - "github.com/hiveot/hub/lib/clients/httpclient" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/messaging/converters" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - jsoniter "github.com/json-iterator/go" -) - -// HiveotSseClient is the http/2 client for connecting a WoT client to a -// WoT server using the HiveOT http and sse sub-protocol. -// This implements the IClientConnection interface. -// -// This can be used by both consumers and agents. -// This is intended to be used together with an SSE return channel. -// -// The Forms needed to invoke an operations are obtained using the 'getForm' -// callback, which can be tied to a store of TD documents. The form contains the -// hiveot RequestMessage and ResponseMessage endpoints. If no form is available -// then use the default hiveot endpoints that are defined with this protocol binding. -type HiveotSseClient struct { - httpclient.HttpBasicClient - - // converter between sse messages and hiveot envelopes - converter messaging.IMessageConverter - - // The full server's base URL sse://host:port/path - //fullURL string - // The server host:port - hostPort string - - // the sse connection path - ssePath string - sseRetryOnDisconnect atomic.Bool - // handler for closing the sse connection - sseCancelFn context.CancelFunc - - //isConnected atomic.Bool - - // RPC timeout - //timeout time.Duration - - // sse variables access - mux sync.RWMutex - - // http2 client for posting messages - //httpClient *http.Client - // authentication bearer token if authenticated - //bearerToken string - - // getForm obtains the form for sending a request or notification - // if nil, then the hiveot protocol envelope and URL are used as fallback - //getForm messaging.GetFormHandler - - // custom headers to include in each request - //headers map[string]string - - lastError atomic.Pointer[error] -} - -// ConnectWithToken sets the bearer token to use with requests and establishes -// an SSE connection. -// If a connection exists it is closed first. -func (cc *HiveotSseClient) ConnectWithToken(token string) error { - - // ensure disconnected (note that this resets retryOnDisconnect) - cc.Disconnect() - - err := cc.SetBearerToken(token) - if err != nil { - return err - } - // connectSSE will set 'isConnected' on success - err = cc.ConnectSSE(token) - if err != nil { - cc.HttpBasicClient.SetConnected(false) - return err - } - return err -} - -// Disconnect from the server -func (cc *HiveotSseClient) Disconnect() { - slog.Debug("HiveotSseClient.Disconnect", - slog.String("clientID", cc.GetConnectionInfo().ClientID), - ) - cc.mux.Lock() - cb := cc.sseCancelFn - cc.sseCancelFn = nil - cc.mux.Unlock() - - // the connection status will update, if changed, through the sse callback - if cb != nil { - cb() - } - - cc.mux.Lock() - defer cc.mux.Unlock() - if cc.IsConnected() { - cc.GetTlsClient().CloseIdleConnections() - } -} - -// SendNotification Agent posts a notification using the hiveot protocol. -// -// This provides a way for sending responses over plain http to the Hub, which -// will forward it to the connected client. The client must use a sse, wss or other -// connection based protocol. -// -// This passes the notification as-its payload. -// -// This posts the JSON-encoded NotificationMessage on the well-known hiveot notification href. -// In WoT Agents are typically a server, not a client, so this is intended for -// agents that use connection-reversal. -// -// This returns an error if the notification could not be delivered to the server -func (cc *HiveotSseClient) SendNotification(msg *messaging.NotificationMessage) error { - outputJSON, _ := jsoniter.MarshalToString(msg) - _, _, _, err := cc.Send(http.MethodPost, - hiveotsseserver.DefaultHiveotPostNotificationHRef, []byte(outputJSON)) - if err != nil { - slog.Warn("SendNotification failed", - "clientID", cc.GetConnectionInfo().ClientID, - "err", err.Error()) - } - return err -} - -// SendRequest sends the RequestMessage envelope to the server, which the SSE server -// listens on. -func (cc *HiveotSseClient) SendRequest(req *messaging.RequestMessage) error { - outputJSON, _ := jsoniter.MarshalToString(req) - outputRaw, headers, code, err := cc.Send(http.MethodPost, - hiveotsseserver.DefaultHiveotPostRequestHRef, []byte(outputJSON)) - _ = headers - - if code == http.StatusOK { - resp := req.CreateResponse(nil, nil) - // unmarshal output. This is either the json encoded output or the ResponseMessage envelope - if len(outputRaw) == 0 { - // nothing to unmarshal - } else { - err = jsoniter.UnmarshalFromString(string(outputRaw), resp) - } - if err != nil { - resp.Error = messaging.ErrorValueFromError(err) - resp.Error.Status = 500 // internal error - } - // pass a direct response to the application handler - h := cc.GetAppResponseHandler() - if h == nil { - slog.Error("AppResponseHandler is nil") - } else { - go func() { - _ = h(resp) - }() - } - } else if code > 200 && code < 300 { - // http servers/things might respond with 201 for pending as per spec - // pending results are passed as a notification. - var notif *messaging.NotificationMessage - if len(outputRaw) == 0 { - // no response yet. do not send process a notification - } else { - // pass the notification to the application handler - notif = req.CreateNotification() - err = jsoniter.Unmarshal(outputRaw, notif) - h := cc.GetAppNotificationHandler() - if h == nil { - slog.Error("AppNotificationHandler is nil") - } else { - go func() { - h(notif) - }() - } - } - } else { - // error response - resp := req.CreateResponse(nil, nil) - httpProblemDetail := map[string]string{} - if len(outputRaw) > 0 { - err = jsoniter.Unmarshal(outputRaw, &httpProblemDetail) - resp.Error = &messaging.ErrorValue{ - Status: code, - Title: httpProblemDetail["title"], - Detail: httpProblemDetail["detail"], - } - } else { - resp.Error = &messaging.ErrorValue{ - Status: code, - Title: "request failed", - } - } - // pass a direct response to the application handler - h := cc.GetAppResponseHandler() - if h == nil { - slog.Error("AppResponseHandler is nil") - } else { - go func() { - _ = h(resp) - }() - } - } - return err -} - -// SendResponse Agent posts a response using the hiveot protocol. -// -// This provides a way for sending responses over plain http to the Hub, which -// will forward it to the connected client. The client must use a sse, wss or other -// connection based protocol. -// -// This passes the ResponseMessage as its payload as this isn't defined in WoT. -// -// This posts the JSON-encoded ResponseMessage on the well-known hiveot response href. -// In WoT Agents are typically a server, not a client, so this is intended for -// agents that use connection-reversal. -func (cc *HiveotSseClient) SendResponse(resp *messaging.ResponseMessage) error { - outputJSON, _ := jsoniter.MarshalToString(resp) - _, _, _, err := cc.Send(http.MethodPost, - hiveotsseserver.DefaultHiveotPostResponseHRef, []byte(outputJSON)) - return err -} - -// NewHiveotSseClient creates a new instance of the http-basic protocol binding client. -// This uses TD forms to perform an operation. -// -// sseURL of the http and sse server to connect to, including the schema -// clientID to identify as. Must match the auth token -// clientCert optional client certificate to connect with -// caCert of the server to validate the server or nil to not check the server cert -// getForm is the handler for return a form for invoking an operation. nil for default -// timeout for waiting for response. 0 to use the default. -func NewHiveotSseClient( - sseURL string, clientID string, clientCert *tls.Certificate, caCert *x509.Certificate, - getForm messaging.GetFormHandler, - timeout time.Duration) *HiveotSseClient { - - urlParts, err := url.Parse(sseURL) - if err != nil { - slog.Error("Invalid URL") - return nil - } - hostPort := urlParts.Host - ssePath := urlParts.Path - - //cinfo := messaging.ConnectionInfo{ - // //CaCert: caCert, - // //ClientID: clientID, - // //ConnectionID: "http-" + shortid.MustGenerate(), - // //ConnectURL: sseURL, - // ProtocolType: messaging.ProtocolTypeHiveotSSE, - // //Timeout: timeout, - //} - cl := HiveotSseClient{ - HttpBasicClient: *httpclient.NewHttpBasicClient( - sseURL, clientID, clientCert, caCert, getForm, timeout), - converter: converters.NewPassthroughMessageConverter(), - //cinfo: cinfo, - //clientID: clientID, - //caCert: caCert, - //cid: "http-" + shortid.MustGenerate(), - //fullURL: sseURL, - ssePath: ssePath, - hostPort: hostPort, - //timeout: timeout, - } - return &cl -} diff --git a/lib/clients/httpclient/HttpBasicClient.go b/lib/clients/httpclient/HttpBasicClient.go deleted file mode 100644 index 359f0f37..00000000 --- a/lib/clients/httpclient/HttpBasicClient.go +++ /dev/null @@ -1,642 +0,0 @@ -package httpclient - -import ( - "bytes" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "sync" - "sync/atomic" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" - jsoniter "github.com/json-iterator/go" - "github.com/teris-io/shortid" -) - -// HttpBasicClient is the http/2 client for connecting a WoT client to a -// WoT server using the http-basic protocol profile. -// This implements the IClientConnection interface. -// -// This can be used alone or with the hiveotsseclient which provides an SSE return channel. -// This provides authentication methods. -// -// The Forms needed to invoke an operations are obtained using the 'getForm' -// callback, which can be tied to a store of TD documents. The form contains the -// hiveot RequestMessage and ResponseMessage endpoints. If no form is available -// then use the default hiveot endpoints that are defined with this protocol binding. -type HttpBasicClient struct { - - // handler for requests send by clients - appConnectHandler messaging.ConnectionHandler - - // handler for notifications sent by agents - appNotificationHandler messaging.NotificationHandler - // handler for requests send by clients - appRequestHandler messaging.RequestHandler - // handler for responses sent by agents - appResponseHandler messaging.ResponseHandler - - //clientID string - // Connection information such as clientID, cid, address, protocol etc - cinfo messaging.ConnectionInfo - - // CA certificate to verify the server with - //caCert *x509.Certificate - - // This client's connection ID - //cid string - - // The full server's base URL https://host:port/path - //fullURL string - - // The server host:port - hostPort string - - isConnected atomic.Bool - - // RPC timeout - //timeout time.Duration - // protected operations - mux sync.RWMutex - // http2 client for posting messages - httpClient *http.Client - // authentication bearer token if authenticated - bearerToken string - - // getForm obtains the form for sending a request or notification - // if nil, then the hiveot protocol envelope and URL are used as fallback - getForm messaging.GetFormHandler - - // custom headers to include in each request - headers map[string]string - - //lastError atomic.Pointer[error] -} - -// ConnectWithClientCert creates a connection with the server using a client certificate for mutual authentication. -// The provided certificate must be signed by the server's CA. -// -// kp is the key-pair used to the certificate validation -// clientCert client tls certificate containing x509 cert and private key -// -// Returns nil if successful, or an error if connection failed -// -// func (cl *HiveotSseClient) ConnectWithClientCert(kp keys.IHiveKey, clientCert *tls.Certificate) (err error) { -// cl.mux.RLock() -// defer cl.mux.RUnlock() -// _ = kp -// cl.tlsClient = tlsclient.NewTLSClient(cl.hostPort, clientCert, cl.caCert, cl.timeout) -// return err -// } - -// ConnectWithToken sets the bearer token to use with requests. -func (cc *HttpBasicClient) ConnectWithToken(token string) error { - - // ensure disconnected (note that this resets retryOnDisconnect) - cc.Disconnect() - - err := cc.SetBearerToken(token) - if err != nil { - return err - } - - return err -} - -// Disconnect from the server -func (cc *HttpBasicClient) Disconnect() { - slog.Debug("HiveotSseClient.Disconnect", - slog.String("clientID", cc.cinfo.ClientID), - ) - - cc.mux.Lock() - defer cc.mux.Unlock() - if cc.isConnected.Load() { - cc.httpClient.CloseIdleConnections() - } -} - -// GetAppConnectHandler returns the application handler for connection status updates -func (cc *HttpBasicClient) GetAppConnectHandler() messaging.ConnectionHandler { - cc.mux.RLock() - handler := cc.appConnectHandler - cc.mux.RUnlock() - return handler -} - -// GetAppNotificationHandler returns the application handler for received notifications -func (cc *HttpBasicClient) GetAppNotificationHandler() messaging.NotificationHandler { - cc.mux.RLock() - handler := cc.appNotificationHandler - cc.mux.RUnlock() - return handler -} - -// GetAppRequestHandler returns the application handler for incoming requests -func (cc *HttpBasicClient) GetAppRequestHandler() messaging.RequestHandler { - cc.mux.RLock() - handler := cc.appRequestHandler - cc.mux.RUnlock() - return handler -} - -// GetAppResponseHandler set the application handler for received responses -func (cc *HttpBasicClient) GetAppResponseHandler() messaging.ResponseHandler { - cc.mux.RLock() - handler := cc.appResponseHandler - cc.mux.RUnlock() - return handler -} - -// GetConnectionInfo returns the client's connection details -func (cc *HttpBasicClient) GetConnectionInfo() messaging.ConnectionInfo { - return cc.cinfo -} - -// GetDefaultForm return the default http form for the operation -// This simply returns nil for anything else than login, logout, ping or refresh. -func (cc *HttpBasicClient) GetDefaultForm(op, thingID, name string) (f *td.Form) { - // login has its own URL as it is unauthenticated - if op == td.HTOpPing { - href := httpbasic.HttpGetPingPath - nf := td.NewForm(op, href) - nf.SetMethodName(http.MethodGet) - f = &nf - //} else if op == td.HTOpLogin { - // href := httpserver.HttpPostLoginPath - // nf := td.NewForm(op, href) - // nf.SetMethodName(http.MethodPost) - // f = &nf - //} else if op == td.HTOpLogout { - // href := httpserver.HttpPostLogoutPath - // nf := td.NewForm(op, href) - // nf.SetMethodName(http.MethodPost) - // f = &nf - //} else if op == td.HTOpRefresh { - // href := httpserver.HttpPostRefreshPath - // nf := td.NewForm(op, href) - // nf.SetMethodName(http.MethodPost) - // f = &nf - } - // everything else has no default form, so falls back to hiveot protocol endpoints - return f -} - -func (cc *HttpBasicClient) GetTlsClient() *http.Client { - cc.mux.RLock() - defer cc.mux.RUnlock() - return cc.httpClient -} - -// IsConnected return whether the return channel is connection, eg can receive data -func (cc *HttpBasicClient) IsConnected() bool { - return cc.isConnected.Load() -} - -// LoginWithForm invokes login using a form - temporary helper -// intended for testing a connection to a web server. -// -// This sets the bearer token for further requests. It requires the server -// to set a session cookie in response to the login. -//func (cl *HiveotSseClient) LoginWithForm( -// password string) (newToken string, err error) { -// -// // FIXME: does this client need a cookie jar??? -// formMock := url.Values{} -// formMock.Add("loginID", cl.GetClientID()) -// formMock.Add("password", password) -// -// var loginHRef string -// f := cl.getForm(td.HTOpLoginWithForm, "", "") -// if f != nil { -// loginHRef, _ = f.GetHRef() -// } -// loginURL, err := url.Parse(loginHRef) -// if err != nil { -// return "", err -// } -// if loginURL.Host == "" { -// loginHRef = cl.fullURL + loginHRef -// } -// -// //PostForm should return a cookie that should be used in the http connection -// if loginHRef == "" { -// return "", errors.New("Login path not found in getForm") -// } -// resp, err := cl.httpClient.PostForm(loginHRef, formMock) -// if err != nil { -// return "", err -// } -// -// // get the session token from the cookie -// //cookie := resp.Request.Header.Get("cookie") -// cookie := resp.Header.Get("cookie") -// kvList := strings.Split(cookie, ",") -// -// for _, kv := range kvList { -// kvParts := strings.SplitN(kv, "=", 2) -// if kvParts[0] == "session" { -// cl.bearerToken = kvParts[1] -// break -// } -// } -// if cl.bearerToken == "" { -// slog.Error("No session cookie was received on login") -// } -// return cl.bearerToken, err -//} - -// LoginWithPassword posts a login request to the TLS server using a login ID and -// password and obtain an auth token for use with SetBearerToken. -// -// FIXME: use a WoT standardized auth method -// -// If the connection fails then any existing connection is cancelled. -//func (cl *HiveotSseClient) LoginWithPassword(password string) (newToken string, err error) { -// -// slog.Info("ConnectWithPassword", -// "clientID", cl.GetClientID(), "connectionID", cl.GetConnectionID()) -// -// // FIXME: figure out how a standard login method is used to obtain an auth token -// loginMessage := map[string]string{ -// "login": cl.GetClientID(), -// "password": password, -// } -// f := cl.getForm(td.HTOpLogin, "", "") -// if f == nil { -// err = fmt.Errorf("missing form for login operation") -// slog.Error(err.Error()) -// return "", err -// } -// method, _ := f.GetMethodName() -// href, _ := f.GetHRef() -// -// dataJSON, _ := jsoniter.Marshal(loginMessage) -// outputRaw, _, _, err := cl.Send(method, href, dataJSON) -// -// if err == nil { -// err = jsoniter.Unmarshal(outputRaw, &newToken) -// } -// // store the bearer token further requests -// // when login fails this clears the existing token. Someone else -// // logging in cannot continue on a previously valid token. -// cl.mux.Lock() -// cl.bearerToken = newToken -// cl.mux.Unlock() -// //cl.BaseIsConnected.Store(true) -// if err != nil { -// slog.Warn("connectWithPassword failed: " + err.Error()) -// } -// -// return newToken, err -//} - -// Send a HTTPS method and return the http response. -// -// If token authentication is enabled then add the bearer token to the header -// -// method: GET, PUT, POST, ... -// reqPath: path to invoke -// contentType of the payload or "" for default (application/json) -// thingID optional path URI variable -// name optional path URI variable containing affordance name -// body contains the serialized payload -// correlationID: optional correlationID header value -// -// This returns the raw serialized response data, a response message ID, return status code or an error -func (cc *HttpBasicClient) Send( - method string, methodPath string, body []byte) ( - resp []byte, headers http.Header, code int, err error) { - - if cc.httpClient == nil { - err = fmt.Errorf("Send: '%s'. Client is not started", methodPath) - return nil, nil, 0, err - } - // Caution! a double // in the path causes a 301 and changes post to get - bodyReader := bytes.NewReader(body) - serverURL := cc.cinfo.ConnectURL - parts, _ := url.Parse(serverURL) - parts.Scheme = "https" - parts.Path = methodPath - fullURL := parts.String() - //fullURL := parts.cc.GetServerURL() + reqPath - req, err := http.NewRequest(method, fullURL, bodyReader) - if err != nil { - err = fmt.Errorf("Send %s %s failed: %w", method, fullURL, err) - return nil, nil, 0, err - } - - // set the origin header to the intended destination without the path - //parts, err := url.Parse(fullURL) - origin := fmt.Sprintf("https://%s", parts.Host) - req.Header.Set("Origin", origin) - - // set the authorization header - if cc.bearerToken != "" { - req.Header.Add("Authorization", "bearer "+cc.bearerToken) - } - - // set other headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set(httpbasic.ConnectionIDHeader, cc.cinfo.ConnectionID) - //if correlationID != "" { - // req.Header.Set(httpserver.CorrelationIDHeader, correlationID) - //} - for k, v := range cc.headers { - req.Header.Set(k, v) - } - - httpResp, err := cc.httpClient.Do(req) - if err != nil { - slog.Error(err.Error()) - return nil, nil, 0, err - } - - respBody, err := io.ReadAll(httpResp.Body) - // response body MUST be closed for clients - _ = httpResp.Body.Close() - httpStatus := httpResp.StatusCode - - if httpStatus == 401 { - err = fmt.Errorf("%s", httpResp.Status) - } else if httpStatus >= 400 && httpStatus < 500 { - if respBody != nil { - err = fmt.Errorf("%d (%s): %s", httpResp.StatusCode, httpResp.Status, respBody) - } else { - err = fmt.Errorf("%d (%s): Request failed", httpResp.StatusCode, httpResp.Status) - } - } else if httpStatus >= 500 { - err = fmt.Errorf("Error %d (%s): %s", httpStatus, httpResp.Status, respBody) - slog.Error("Send returned internal server error", "reqPath", methodPath, "err", err.Error()) - } else if err != nil { - err = fmt.Errorf("Send: Error %s %s: %w", method, methodPath, err) - } - return respBody, httpResp.Header, httpStatus, err -} - -// pass the result of a http request to the registered response handler in the -// ResponseMessage envelope. -func (cc *HttpBasicClient) handleRequestResult() { - -} - -// SendRequest sends a request over http message using the form based path and passes -// the result as a response to the registered response handler. -// -// This locates the form for the operation using 'getForm' and uses the result -// to determine the URL to publish the request to and if the hiveot RequestMessage -// envelope is used. -// -// If no form is found then fall back to the hiveot default paths. -// The request input, if any, is json encoded into the body of the request. -// This does not use a RequestMessage envelope to remain http-basic compatible. -// -// The response follows the http-basic specification: -// * code 200: completed; body is output -// * code 201: pending; body is http action status message -// * code 40x: failed ; body is error payload, if present -// * code 50x: failed ; body is error payload, if present -// -// The result is passed to the BaseRnR channel associated with the request just -// like it is done with an async response. -func (cc *HttpBasicClient) SendRequest(req *messaging.RequestMessage) error { - - var inputJSON string - var method string - var href string - var thingID = req.ThingID - var name = req.Name - - if req.Operation == "" && req.CorrelationID == "" { - err := fmt.Errorf("SendMessage: missing both operation and correlationID") - slog.Error(err.Error()) - return err - } - - // the getForm callback provides the method and URL to invoke for this operation. - // use the hiveot fallback if not available - // If a form is provided and it doesn't use the hiveot subprotocol then fall - // back to invoking using http basic using the form href. - f := cc.getForm(req.Operation, req.ThingID, req.Name) - if f != nil { - method, _ = f.GetMethodName() - href = f.GetHRef() - } - - if f == nil { - // fall back to the 'well known' hiveot request URL using uri variables - // eg: /things/{operation}/{thingID}/{name} or /hiveot/request - method = http.MethodPost - href = httpbasic.HttpBasicAffordanceOperationPath - inputJSON, _ = jsoniter.MarshalToString(req.Input) - } - - // Inject URI variables for hrefs that use them: - // use + as wildcard for thingID to avoid a 404 - // while not recommended, it is allowed to subscribe/observe all things - if thingID == "" { - thingID = "+" - } - // use + as wildcard for affordance name to avoid a 404 - // this should not happen very often but it is allowed - if name == "" { - name = "+" - } - // substitute URI variables in the path, if any. - // intended for use with http-basic forms. - vars := map[string]string{ - httpbasic.HttpBasicThingIDURIVar: thingID, - httpbasic.HttpBasicNameURIVar: name, - httpbasic.HttpBasicOperationURIVar: req.Operation} - reqPath := utils.Substitute(href, vars) - - // send the request - outputRaw, headers, code, err := cc.Send(method, reqPath, []byte(inputJSON)) - _ = headers - - // 1. error response - if err != nil { - return err - } - // follow the HTTP Basic specification - if code == http.StatusOK { - resp := req.CreateResponse(nil, nil) - // unmarshal output. This is either the json encoded output or the ResponseMessage envelope - if len(outputRaw) == 0 { - // nothing to unmarshal - } else { - err = jsoniter.UnmarshalFromString(string(outputRaw), &resp.Value) - } - if err != nil { - resp.Error = messaging.ErrorValueFromError(err) - resp.Error.Status = 500 // decode error - } - - // pass a direct response to the application handler - h := cc.GetAppResponseHandler() - go func() { - _ = h(resp) - }() - } else if code > 200 && code < 300 { - // httpbasic servers/things might respond with 201 for pending as per spec - // this is a response message. - var resp *messaging.ResponseMessage - if len(outputRaw) == 0 { - // no response yet. do not send process a notification - } else { - // standard http response payload - var tmp any - err = jsoniter.Unmarshal(outputRaw, &tmp) - resp = req.CreateResponse(tmp, err) - } - - // pass a direct response to the application handler - if resp != nil { - h := cc.GetAppResponseHandler() - go func() { - _ = h(resp) - }() - } - } else { - // unknown response, create an error response - resp := req.CreateResponse(nil, nil) - // unmarshal output. This is either the json encoded output or the ResponseMessage envelope - if outputRaw == nil { - // nothing to unmarshal - } else { - err = jsoniter.UnmarshalFromString(string(outputRaw), &resp.Value) - } - httpProblemDetail := map[string]string{} - if len(outputRaw) > 0 { - err = jsoniter.Unmarshal(outputRaw, &httpProblemDetail) - statusCode := utils.DecodeAsInt(httpProblemDetail["status"]) - resp.Error = &messaging.ErrorValue{ - Status: statusCode, - Title: httpProblemDetail["title"], - Detail: httpProblemDetail["detail"], - } - } else if err != nil { - resp.Error = messaging.ErrorValueFromError(err) - } else { - resp.Error = &messaging.ErrorValue{ - Status: code, - Title: "request failed", - } - - } - - // pass a direct response to the application handler - h := cc.GetAppResponseHandler() - go func() { - _ = h(resp) - }() - } - return err -} - -// SendResponse is not supported in http-basic -func (cc *HttpBasicClient) SendResponse(resp *messaging.ResponseMessage) error { - return errors.New("HttpBasic doesn't support sending async responses") -} - -// SendNotification is not supported in http-basic -func (cc *HttpBasicClient) SendNotification(msg *messaging.NotificationMessage) error { - return errors.New("HttpBasic doesn't support sending notifications") -} - -// SetBearerToken sets the authentication bearer token to authenticate http requests. -func (cc *HttpBasicClient) SetBearerToken(token string) error { - cc.mux.Lock() - cc.bearerToken = token - cc.mux.Unlock() - return nil -} - -// SetConnected sets the sub-protocol connection status -func (cc *HttpBasicClient) SetConnected(isConnected bool) { - cc.isConnected.Store(isConnected) -} - -// SetConnectHandler set the application handler for connection status updates -func (cc *HttpBasicClient) SetConnectHandler(cb messaging.ConnectionHandler) { - cc.mux.Lock() - cc.appConnectHandler = cb - cc.mux.Unlock() -} - -// SetNotificationHandler set the application handler for received notifications -func (cc *HttpBasicClient) SetNotificationHandler(cb messaging.NotificationHandler) { - cc.mux.Lock() - cc.appNotificationHandler = cb - cc.mux.Unlock() -} - -// SetRequestHandler set the application handler for incoming requests -func (cc *HttpBasicClient) SetRequestHandler(cb messaging.RequestHandler) { - cc.mux.Lock() - cc.appRequestHandler = cb - cc.mux.Unlock() -} - -// SetResponseHandler set the application handler for received responses -func (cc *HttpBasicClient) SetResponseHandler(cb messaging.ResponseHandler) { - cc.mux.Lock() - cc.appResponseHandler = cb - cc.mux.Unlock() -} - -// NewHttpBasicClient creates a new instance of the http-basic protocol binding client. -// -// This uses TD forms to perform an operation. -// -// baseURL of the http server. Used as the base for all further requests. -// clientID to identify as. Must match the authentication information. -// clientCert optional client certificate to connect with, avoiding the need for login. -// caCert of the server to validate the server or nil to not check the server cert -// getForm is the handler for return a form for invoking an operation. nil for default -// timeout for waiting for response. 0 to use the default. -func NewHttpBasicClient( - baseURL string, clientID string, clientCert *tls.Certificate, caCert *x509.Certificate, - getForm messaging.GetFormHandler, timeout time.Duration) *HttpBasicClient { - - urlParts, err := url.Parse(baseURL) - if err != nil { - slog.Error("Invalid URL") - return nil - } - hostPort := urlParts.Host - - cinfo := messaging.ConnectionInfo{ - CaCert: caCert, - ClientID: clientID, - ConnectionID: "http-" + shortid.MustGenerate(), - ConnectURL: baseURL, - //ProtocolType: messaging.ProtocolTypeHTTPBasic, - Timeout: timeout, - } - cl := HttpBasicClient{ - cinfo: cinfo, - //clientID: clientID, - //caCert: caCert, - //cid: "http-" + shortid.MustGenerate(), - hostPort: hostPort, - //timeout: timeout, - getForm: getForm, - headers: make(map[string]string), - } - if cl.getForm == nil { - cl.getForm = cl.GetDefaultForm - } - cl.httpClient = tlsclient.NewHttp2TLSClient(caCert, clientCert, timeout) - return &cl -} diff --git a/lib/clients/mqttclient/MqttAgentClient.go.old b/lib/clients/mqttclient/MqttAgentClient.go.old deleted file mode 100644 index 7056c870..00000000 --- a/lib/clients/mqttclient/MqttAgentClient.go.old +++ /dev/null @@ -1,210 +0,0 @@ -package mqttclient - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "github.com/eclipse/paho.golang/paho" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hivekit/go/wot" - "github.com/hiveot/hivekit/go/api/td" - jsoniter "github.com/json-iterator/go" - "log/slog" - "time" -) - -// MqttAgentClient provides WoT protocol binding for the MQTT protocol -// This implements the IAgentTransport interface. -type MqttAgentClient struct { - MqttConsumerClient - - // the application's request handler set with SetRequestHandler - appRequestHandler func(msg transports.RequestMessage) (response transports.ResponseMessage) -} - -// handleMessage handles incoming MQTT messages from the agent -// FIXME: rework for hiveot -//func (cl *MqttAgentClient) HandleAgentMessage(msg *transports.ThingMessage) { -// slog.Debug("handleMessage", slog.String("op", msg.Operation)) -// cl.HandleConsumerMessage(msg) -//} - -// PubEvent helper for agents to publish an event -// This is short for SendNotification( ... td.OpEvent ...) -func (cl *MqttAgentClient) PubEvent(thingID string, name string, value any) error { - notif := transports.NewNotificationResponse(td.HTOpEvent, thingID, name, value) - return cl.SendNotification(notif) -} - -// PubProperty helper for agents to publish a property value update -// This is short for SendNotification( ... td.OpProperty ...) -func (cl *MqttAgentClient) PubProperty(thingID string, name string, value any) error { - - notif := transports.NewNotificationResponse(td.HTOpUpdateProperty, thingID, name, value) - return cl.SendNotification(notif) -} - -// PubProperties helper for agents to publish a map of property values -func (cl *MqttAgentClient) PubProperties(thingID string, propMap map[string]any) error { - - notif := transports.NewNotificationResponse(td.HTOpUpdateMultipleProperties, thingID, "", propMap) - err := cl.SendNotification(notif) - return err -} - -// UpdateThing helper for agents to publish a TD update -// This is short for SendNotification( ... td.HTOpTD ...) -func (cl *MqttAgentClient) UpdateThing(td *td.TD) error { - tdJson, _ := jsoniter.MarshalToJSON(td) - notif := transports.NewNotificationResponse(td.HTOpUpdateTD, td.ID, "", tdJson) - return cl.SendNotification(notif) -} - -//// PubEvent publishes a message and returns -//func (cl *MqttAgentClient) PubEvent(topic string, payload []byte) (err error) { -// slog.Debug("PubEvent", "topic", topic) -// ctx, cancelFn := context.WithTimeout(context.Background(), cl.BaseTimeout) -// defer cancelFn() -// pubMsg := &paho.Publish{ -// QoS: 0, //withQos, -// Retain: false, -// Topic: topic, -// Payload: payload, -// } -// cl.BaseMux.RLock() -// pcl := cl.pahoClient -// cl.BaseMux.RUnlock() -// if pcl != nil { -// _, err = pcl.Publish(ctx, pubMsg) -// } else { -// err = errors.New("no connection with the hub") -// } -// return err -//} - -// SendResponse sends the action response message -func (cl *MqttAgentClient) SendResponse(resp transports.ResponseMessage) error { - // FIXME: in paho, the response needs a reply-to address from the request - // option 1: include this in the correlationID - // option 2: use context to pass it - //err = cl._send(resp) - //return err - return fmt.Errorf("Not implemented") -} - -// sendReply sends a reply on the response topic of the request -// This uses the same QoS as the request, without retain. -// -// req is the request to reply to -// optionally include a payload in the reply -// optionally include an error message in the reply -func (cl *MqttAgentClient) sendReply(req *paho.Publish, payload []byte, errResp error) (err error) { - - slog.Debug("sendReply", - slog.String("topic", req.Topic), - slog.String("responseTopic", req.Properties.ResponseTopic)) - - responseTopic := req.Properties.ResponseTopic - if responseTopic == "" { - err2 := fmt.Errorf("sendReply. No response topic. Not sending a reply") - slog.Error(err2.Error()) - } - replyMsg := &paho.Publish{ - QoS: req.QoS, - Retain: false, - Topic: responseTopic, - Properties: &paho.PublishProperties{ - CorrelationData: req.Properties.CorrelationData, - User: req.Properties.User, - PayloadFormat: req.Properties.PayloadFormat, - ContentType: req.Properties.ContentType, - }, - Payload: payload, - } - if errResp != nil { - replyMsg.Properties.ContentType = "error" // payload is an error message - replyMsg.Properties.User.Add("error", errResp.Error()) - // for testing, somehow properties.user is not transferred - replyMsg.Payload = []byte(errResp.Error()) - } - cl.BaseMux.RLock() - pcl := cl.pahoClient - cl.BaseMux.RUnlock() - if pcl == nil { - err = errors.New("connection lost") - } else { - ctx, cancelFn := context.WithTimeout(context.Background(), time.Second) - defer cancelFn() - _, err = pcl.Publish(ctx, replyMsg) - - if err != nil { - slog.Warn("sendReply. Error publishing response", - slog.String("err", err.Error())) - } - } - return err -} - -// SendNotification sends the operation as a notification and returns immediately. -func (cl *MqttAgentClient) SendNotification( - notif transports.NotificationMessage) (err error) { - - topic := "hiveot/notification" - payload, _ := jsoniter.Marshal(notif) - cl._send(topic, payload, "") - slog.Debug("SendNotification", "topic", topic) - ctx, cancelFn := context.WithTimeout(context.Background(), cl.BaseTimeout) - defer cancelFn() - pubMsg := &paho.Publish{ - QoS: 0, //withQos, - Retain: false, - Topic: topic, - Payload: payload, - } - cl.BaseMux.RLock() - pcl := cl.pahoClient - cl.BaseMux.RUnlock() - if pcl != nil { - _, err = pcl.Publish(ctx, pubMsg) - } else { - err = errors.New("no connection with the hub") - } - return err -} - -// SetRequestHandler set the application handler for incoming requests -func (cl *MqttAgentClient) SetRequestHandler(cb transports.RequestHandler) { - cl.mux.Lock() - cl.appRequestHandler = cb - cl.mux.Unlock() -} - -// Initialize the client -func (cl *MqttAgentClient) Init(fullURL string, clientID string, - clientCert *tls.Certificate, caCert *x509.Certificate, - timeout time.Duration) { - - cl.MqttConsumerClient.Init( - fullURL, clientID, clientCert, caCert, nil, timeout) - - //cl.BaseHandleMessage = cl.HandleAgentMessage -} - -// NewMqttAgentClient creates a new instance of the mqtt binding client -// -// fullURL of broker to connect to, including the schema -// clientID to connect as -// clientCert optional client certificate to connect with -// caCert of the server to validate the server or nil to not check the server cert -// timeout for waiting for response. 0 to use the default. -func NewMqttAgentClient(fullURL string, clientID string, - clientCert *tls.Certificate, caCert *x509.Certificate, - timeout time.Duration) *MqttAgentClient { - - cl := MqttAgentClient{} - cl.Init(fullURL, clientID, clientCert, caCert, timeout) - - return &cl -} diff --git a/lib/clients/mqttclient/MqttClientConnection.go b/lib/clients/mqttclient/MqttClientConnection.go deleted file mode 100644 index f9815261..00000000 --- a/lib/clients/mqttclient/MqttClientConnection.go +++ /dev/null @@ -1,722 +0,0 @@ -package mqttclient - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log" - "log/slog" - "net/url" - "os" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/eclipse/paho.golang/autopaho" - "github.com/eclipse/paho.golang/paho" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" - "github.com/teris-io/shortid" -) - -// Connecting with UDS for local services. Might not work with autopaho -// FIXME: UDS isn't supported by autopaho -const ( - MqttInMemUDSProd = "@/MqttInMemUDSProd" // production UDS name - MqttInMemUDSTest = "@/MqttInMemUDSTest" // test server UDS name -) - -const ( - ConnStatConnected = "connected" - ConnStatConnecting = "connecting" - ConnStatUnauthorized = "unauthorized" - ConnStatDisconnected = "disconnected" -) - -// InboxTopicFormat is the INBOX subscription topic used by the client and RPC calls -// _INBOX/{clientID} (clientID is the unique session clientID, not per-se the loginID) -const INBOX_PREFIX = "INBOX" -const InboxTopicFormat = INBOX_PREFIX + "/%s" -const keepAliveInterval = 30 // seconds -const withQos = 1 - -// MqttClientConnection provides WoT protocol binding for the MQTT protocol -// This implements the IClientConnection interface. -type MqttClientConnection struct { - - // handler for requests send by clients - appConnectHandlerPtr atomic.Pointer[messaging.ConnectionHandler] - - // handler for notifications sent by agents - appNotificationHandler messaging.NotificationHandler - // handler for requests send by clients - appRequestHandlerPtr atomic.Pointer[messaging.RequestHandler] - // handler for responses sent by agents - appResponseHandlerPtr atomic.Pointer[messaging.ResponseHandler] - - cinfo messaging.ConnectionInfo - - authToken string - - // paho mqtt client - pahoClient *autopaho.ConnectionManager - // enable debug logging in the paho client - logDebug bool - // handler to obtain a form for the operation - getForm func(op, thingID, name string) *td.Form - - // - inboxTopic string // set on init - connectionID string // unique connection ID - connectionStatus string - isConnected atomic.Bool - lastError atomic.Pointer[error] - // - correlData map[string]chan *paho.Publish - subscriptions map[string]bool - - // mutex for controlling writing and closing - mux sync.RWMutex - - // request timeout - timeout time.Duration - - // the agent handles requests - agentRequestHandler func(m *paho.Publish) -} - -// ConnectWithToken establishes a connection to the MQTT broker using the paho client -func (cl *MqttClientConnection) ConnectWithToken(token string) (newToken string, err error) { - // setup TLS - caCertPool := x509.NewCertPool() - if cl.cinfo.CaCert == nil { - slog.Info("NewTLSClient: No CA certificate. InsecureSkipVerify used", - slog.String("destination", cl.cinfo.ConnectURL)) - } else { - caCertPool.AddCert(cl.cinfo.CaCert) - } - tlsCfg := &tls.Config{ - RootCAs: caCertPool, - //Certificates: clientCertList, - InsecureSkipVerify: cl.cinfo.CaCert == nil, - } - - //safeConn := packets.NewThreadSafeConn(conn) - // Setup the Paho client configuration - hostName, _ := os.Hostname() - connectID := fmt.Sprintf("%s-%s-%s", cl.cinfo.ClientID, hostName, time.Now().Format("20060102150405.000")) - logger := log.Default() - u, err := url.Parse(cl.cinfo.ConnectURL) - autoCfg := autopaho.ClientConfig{ - BrokerUrls: []*url.URL{u}, - PahoErrors: logger, - ClientConfig: paho.ClientConfig{ - ClientID: connectID, // instance ID, not the clientID - //Conn: safeConn, // autopaho ignores this :( - PacketTimeout: cl.timeout, - Router: paho.NewSingleHandlerRouter(func(m *paho.Publish) { - cl.handlePahoMessage(m) - }), - }, - TlsCfg: tlsCfg, - // CleanStartOnInitialConnection defaults to false. - // Setting this to true will clear the session on the first connection. - //CleanStartOnInitialConnection: true, - KeepAlive: 20, // Keepalive message should be sent every 20 seconds - } - autoCfg.ConnectUsername = cl.cinfo.ClientID - autoCfg.ConnectPassword = []byte(cl.authToken) - autoCfg.OnConnectError = cl.onPahoConnectionError - autoCfg.OnConnectionUp = cl.onPahoConnect - autoCfg.OnServerDisconnect = func(disconnect *paho.Disconnect) { - cl.isConnected.Store(false) - hPtr := cl.appConnectHandlerPtr.Load() - if hPtr != nil { - go (*hPtr)(false, nil, cl) - } - } - - if cl.logDebug { - autoCfg.PahoDebug = logger - } - - // Warning, can't use WithTimeout as it will disconnect the perfectly good - // connection after the timeout has passed. - ctx := context.Background() - - pcl, err := autopaho.NewConnection(ctx, autoCfg) - - cl.mux.Lock() - cl.connectionID = connectID - cl.pahoClient = pcl - cl.mux.Unlock() - - // Wait for the connection to come up - ctx, cancelFn := context.WithTimeout(ctx, time.Second*1) - err = pcl.AwaitConnection(ctx) - cancelFn() - if err != nil { - // provide a more meaningful error, the actual error is not returned by paho - cl.mux.RLock() - errptr := cl.lastError.Load() - err = *errptr - cl.mux.RUnlock() - } - // the onPahoConnect handler (re)subscribes to the inbox topic - return token, err -} - -// CreateKeyPair returns a new set of serialized public/private key pair -//func (cl *MqttTransportClient) CreateKeyPair() (cryptoKeys keys.IHiveKey) { -// k := keys.NewKey(keys.KeyTypeECDSA) -// return k -//} - -// Disconnect from the MQTT broker and unsubscribe from all topics and set -// device state to disconnected -func (cl *MqttClientConnection) Disconnect() { - cl.mux.Lock() - pcl := cl.pahoClient - connectID := cl.connectionID - - cl.pahoClient = nil - cl.isConnected.Store(false) - err := errors.New("disconnected by user") - cl.lastError.Store(&err) - cl.mux.Unlock() - - slog.Info("Disconnecting", "cid", connectID) - if pcl != nil { - //time.Sleep(time.Millisecond * 10) // Disconnect doesn't seem to wait for all messages. A small delay ahead helps - err := pcl.Disconnect(context.Background()) - if err != nil { - slog.Error("disconnect error", "err", err) - } - } -} - -// GetConnectionInfo returns the connection information -func (cl *MqttClientConnection) GetConnectionInfo() messaging.ConnectionInfo { - return cl.cinfo -} - -//// handle receiving an action status update. -//// This can be a response to a non-rpc request, or an update to a prior RPC -//// request that already received a response. -//// This is passed to the client as a notification. -//func (cl *MqttClientConnection) handleActionStatus(msg *transports.ThingMessage) { -// // todo -//} - -// HandleResponseMessage handles received consumer message -//func (cl *MqttClientConnection) HandleMqttMessage(rxMsg *transports.ThingMessage) { -// -// switch rxMsg.Operation { -// case td.HTOpActionStatus: -// // the consumer received an async update to the action request -// // this client is receiving a status update from a previously sent action. -// cl.handleActionStatus(rxMsg) -// case ssescserver.SSEOpPong: -// cl.handlePongMessage(rxMsg) -// default: -// // response to a read operation or notification of property or events -// // no correlationID means this is just a notification without a response expected -// cl.handleNotificationMessage(rxMsg) -// } -//} - -// Anything that isn't a request or response is passed up as a notification -//func (cl *MqttClientConnection) handleNotificationMessage(msg *transports.ThingMessage) { -// -// // pass everything else to the message handler. No reply is sent. -// // Eg" consumer receive event, property and TD updates -// if cl.BaseNotificationHandler == nil { -// slog.Warn("handleSseEvent, no message handler registered. Message ignored.", -// slog.String("operation", msg.Operation), -// slog.String("thingID", msg.ThingID), -// slog.String("name", msg.Name), -// slog.String("clientID", cl.GetClientID())) -// return -// } -// cl.BaseNotificationHandler(msg) -//} - -// handlePahoMessage handles incoming mqtt messages. -// This converts it to the standard request,response or notification envelope and -// passes it to the registered handler. -func (cl *MqttClientConnection) handlePahoMessage(m *paho.Publish) { - slog.Debug("handlePahoMessage", slog.String("topic", m.Topic)) - - // run this in the background to allow for reentrancy - go func() { - correlationID := "" - if m.Properties.CorrelationData != nil { - correlationID = string(m.Properties.CorrelationData) - } - - // handle the response to a request from this consumer - // responses have topic starting with the inbox prefix - if strings.HasPrefix(m.Topic, INBOX_PREFIX) && correlationID != "" { - resp := messaging.ResponseMessage{} - resp.MessageType = messaging.MessageTypeResponse - // mqtt payload are straight hiveot messages (for now) - err := jsoniter.Unmarshal(m.Payload, &resp) - if err != nil { - slog.Warn("handlePahoMessage. Payload unmarshal failed", - "topic", m.Topic, - "correlationID", correlationID) - } else { - hPtr := cl.appResponseHandlerPtr.Load() - if hPtr != nil { - _ = (*hPtr)(&resp) - } - } - return - } - // handle request message from consumer (move to agent) - replyTo := m.Properties.ResponseTopic - if replyTo != "" && correlationID != "" { - if cl.agentRequestHandler == nil { - slog.Error("handlePahoMessage: received request but this is a consumer") - } else { - cl.agentRequestHandler(m) - } - return - } - - // this is a notification message with an event, property, TD update - notif := messaging.ResponseMessage{} - notif.MessageType = messaging.MessageTypeResponse - err := jsoniter.Unmarshal(m.Payload, ¬if) - if err != nil { - slog.Warn("handlePahoMessage. Notification unmarshal failed", - "topic", m.Topic, - "correlationID", correlationID) - } else { - hPtr := cl.appResponseHandlerPtr.Load() - if hPtr != nil { - _ = (*hPtr)(¬if) - } - } - }() -} - -// HandlePongMessage handles the response to ping -//func (cl *MqttClientConnection) handlePongMessage(msg *transports.ThingMessage) { -// cl.BaseRnrChan.HandleResponse(msg) -//} - -// InvokeAction invokes an action on a thing and wait for the response -//func (cl *MqttClientConnection) InvokeAction(dThingID, name string, input any, output any) error { -// return cl.SendRequest(td.OpInvokeAction, dThingID, name, input, output) -//} - -// IsConnected return whether the return channel is connection, eg can receive data -func (cl *MqttClientConnection) IsConnected() bool { - return cl.isConnected.Load() -} - -// Logout from the server and end the session. -// This is specific to the Hiveot Hub. -func (cl *MqttClientConnection) Logout() error { - err := fmt.Errorf("Not implemented") - return err -} - -// once paho is connected -func (cl *MqttClientConnection) onPahoConnect(cm *autopaho.ConnectionManager, connAck *paho.Connack) { - cl.mux.Lock() - defer cl.mux.Unlock() - - cl.isConnected.Store(true) - cl.lastError.Store(nil) - // resubscribe previous subscriptions - subList := make([]string, 0, len(cl.subscriptions)) - for topic := range cl.subscriptions { - subList = append(subList, topic) - } - hPtr := cl.appConnectHandlerPtr.Load() - - go func() { - // (re)subscribe all subscriptions - for _, topic := range subList { - err := cl.sub(topic) - if err != nil { - slog.Error("onConnect. resubscribe failed", "topic", topic) - } - } - // last but not least, subscribe the inbox to receive responses to requests - err := cl.SubscribeToTopic(cl.inboxTopic) - if err != nil { - slog.Error("Failed inbox subscription. Requests will not receive a response", - "err", err, "inboxTopic", cl.inboxTopic) - } - - // now subscriptions have been restored, inform subscriber - if hPtr != nil { - (*hPtr)(true, nil, cl) - } - }() -} - -// paho reports an error but will keep trying until disconnect is called -func (cl *MqttClientConnection) onPahoConnectionError(err error) { - go func() { - connStatus := ConnStatConnecting - connErr := err - // possible causes: - // 1. wrong credentials - inform user, dont repeat or do repeat? - // 2. connection is interrupted - inform user/log, keep repeating - // 3. server disconnects - inform user/log, keep repeating - // 4. client disconnects - terminate - switch et := err.(type) { - case *autopaho.ConnackError: - if et.ReasonCode == 134 { - connStatus = ConnStatUnauthorized - connErr = fmt.Errorf("Unauthorized: %s", et.Reason) - } else { - connStatus = ConnStatConnecting - connErr = fmt.Errorf("%s: %w", et.Reason, err) - //connErr = fmt.Errorf("disconnected user '%s': %s", cl.BaseClientID, err.Error()) - } - default: - connStatus = ConnStatConnecting - connErr = fmt.Errorf("disconnected: %w", err) - slog.Error("connection error", "clientID", cl.cinfo.ClientID, "err", err) - } - // notify on change - cl.mux.RLock() - oldStatus := cl.connectionStatus - oldErrPtr := cl.lastError.Load() - cl.mux.RUnlock() - if connStatus != oldStatus || connErr != *oldErrPtr { - cl.mux.Lock() - //cl.connectionStatus = connStatus - cl.lastError.Store(&connErr) - cl.isConnected.Store(false) - hPtr := cl.appConnectHandlerPtr.Load() - cl.mux.Unlock() - if hPtr != nil { - (*hPtr)(false, connErr, cl) - } - } - slog.Info("onPahoConnectionError", "err", connErr.Error()) - // don't retry on authentication error - cl.mux.RLock() - pcl := cl.pahoClient - cl.mux.RUnlock() - if connStatus == ConnStatUnauthorized && pcl != nil { - _ = pcl.Disconnect(context.Background()) - } - }() -} - -// ParseResponse helper message to parse response and check for errors -func (cl *MqttClientConnection) _parseResponse(data []byte, resp interface{}) error { - var err error - if data == nil || len(data) == 0 { - if resp != nil { - err = fmt.Errorf("ParseResponse: client '%s', expected a response but none received", - cl.cinfo.ClientID) - } else { - err = nil // all good - } - } else { - if resp == nil { - err = fmt.Errorf("ParseResponse: client '%s', received response but none was expected. data=%s", - cl.cinfo.ClientID, data) - } else { - err = jsoniter.Unmarshal(data, resp) - } - } - return err -} - -// _pub publishes a message and waits for an answer or until timeout -// In order to receive replies, an inbox subscription is added on the first request. -//func (cl *MqttClientConnection) _pub(topic string, payload []byte) (resp []byte, err error) { -// slog.Debug("SendRequest", "topic", topic) -// -// ctx, cancelFn := context.WithTimeout(context.BackgroundImage(), cl.BaseTimeout) -// defer cancelFn() -// // -// //// FIXME! a deadlock can occur here -// //cl.mux.RLock() -// //pcl := cl.pahoClient -// //cl.mux.RUnlock() -// -// err = cl._send(topic, payload, cid) -// -// //pubMsg := &paho.Publish{ -// // QoS: withQos, -// // Retain: false, -// // Topic: topic, -// // Payload: payload, -// // Properties: &paho.PublishProperties{ -// // CorrelationData: []byte(cid), -// // ResponseTopic: inboxTopic, -// // ContentType: "json", -// // User: paho.UserProperties{{ -// // Key: "test", -// // Value: "test", -// // }}, -// // }, -// //} -// //_, err = pcl.Publish(ctx, pubMsg) -// if err != nil { -// return nil, err -// } -// -// // wait for response -// var respMsg *paho.Publish -// select { -// case <-ctx.Done(): -// err = fmt.Errorf("timeout waiting for response") -// break -// case respMsg = <-rChan: -// break -// } -// if err != nil { -// return nil, err -// } -// -// // test alternative to handling errors since User properties aren't -// // passed through for some reason. -// if respMsg.Properties.ContentType == "error" { -// err = errors.New(string(respMsg.Payload)) -// return nil, err -// } -// -// slog.Debug("SendRequest end:", -// slog.String("topic", topic), -// slog.String("ContentType (if any)", respMsg.Properties.ContentType), -// ) -// return respMsg.Payload, err -//} - -//// RefreshToken refreshes the authentication token -//// The resulting token can be used with 'SetBearerToken' -//// This is specific to the Hiveot Hub. -//func (cl *MqttClientConnection) RefreshToken(oldToken string) (newToken string, err error) { -// return oldToken, fmt.Errorf("not implemented") -//} - -// Send a request message to a topic -func (cl *MqttClientConnection) _send(topic string, msg any, correlationID string) error { - - slog.Info("_send", slog.String("topic", topic)) - - ctx, cancelFn := context.WithTimeout(context.Background(), cl.timeout) - payload, _ := jsoniter.Marshal(msg) - pahoMsg := paho.Publish{ - QoS: 1, - Retain: false, - Topic: topic, - Payload: payload, - Properties: &paho.PublishProperties{ - CorrelationData: []byte(correlationID), - ResponseTopic: cl.inboxTopic, - ContentType: "json", - User: paho.UserProperties{{ - Key: "test", - Value: "test", - }}, - }, - } - cl.mux.RLock() - pcl := cl.pahoClient - cl.mux.RUnlock() - resp, err := pcl.Publish(ctx, &pahoMsg) - _ = resp - cancelFn() - return err -} - -// SendNotification send a notification message -func (cl *MqttClientConnection) SendNotification(resp *messaging.NotificationMessage) error { - panic("todo: implement") -} - -// SendRequest send a request message over websockets -// This transforms the request to the protocol message and sends it to the server. -func (cl *MqttClientConnection) SendRequest(req *messaging.RequestMessage) error { - panic("todo: implement") -} - -// SendResponse send a response message -// This transforms the response to the protocol message and sends it to the server. -// Responses without correlationID are subscription notifications. -func (cl *MqttClientConnection) SendResponse(resp *messaging.ResponseMessage) error { - panic("todo: implement") -} - -// SetConnectHandler set the application handler for connection status updates -func (cl *MqttClientConnection) SetConnectHandler(cb messaging.ConnectionHandler) { - if cb == nil { - cl.appConnectHandlerPtr.Store(nil) - } else { - cl.appConnectHandlerPtr.Store(&cb) - } -} - -// SetNotificationHandler set the application handler for received notifications -func (cc *MqttClientConnection) SetNotificationHandler(cb messaging.NotificationHandler) { - cc.mux.Lock() - cc.appNotificationHandler = cb - cc.mux.Unlock() -} - -// SetRequestHandler set the application handler for incoming requests -func (cl *MqttClientConnection) SetRequestHandler(cb messaging.RequestHandler) { - if cb == nil { - cl.appRequestHandlerPtr.Store(nil) - } else { - cl.appRequestHandlerPtr.Store(&cb) - } -} - -// SetResponseHandler set the application handler for received responses -func (cl *MqttClientConnection) SetResponseHandler(cb messaging.ResponseHandler) { - if cb == nil { - cl.appResponseHandlerPtr.Store(nil) - } else { - cl.appResponseHandlerPtr.Store(&cb) - } -} - -// SubscribeToTopic subscribes to a topic. -// Incoming messages are passed to the event or request handler, depending on whether -// a reply-to address and correlation-ID is set. -func (cl *MqttClientConnection) SubscribeToTopic(topic string) error { - slog.Debug("SubscribeToTopic", "topic", topic) - err := cl.sub(topic) - if err != nil { - return err - } - cl.mux.Lock() - cl.subscriptions[topic] = true - cl.mux.Unlock() - return err -} -func (cl *MqttClientConnection) UnsubscribeFromTopic(topic string) { - packet := &paho.Unsubscribe{ - Topics: []string{topic}, - } - cl.mux.RLock() - pcl := cl.pahoClient - cl.mux.RUnlock() - - ack, err := pcl.Unsubscribe(context.Background(), packet) - _ = ack - if err != nil { - slog.Error("Unable to unsubscribe from topic", "topic", topic) - return - } - cl.mux.Lock() - delete(cl.subscriptions, topic) - cl.mux.Unlock() -} - -// -//// SendRequest sends a request and waits for a result -//// The operation is used to retrieve the form of the Thing whose operation to -//// send and determine the endpoint. If no form can be retrieved this falls -//// back to the hub known endpoint. -//func (cl *MqttClientConnection) SendRequest( -// operation string, thingID, name string, input interface{}, output interface{}) error { -// return fmt.Errorf("not implemented") -//} - -// -//// SendResponse [agent] sends a operation response to the server. -//// (todo) -//func (cl *MqttTransportClient) SendResponse(correlationID string, data any) { -// topic := "" -// payload, _ := jsoniter.Marshal(data) -// cl._send(topic, payload) -//} - -// sub builds a subscribe packet and submits it -func (cl *MqttClientConnection) sub(topic string) error { - packet := &paho.Subscribe{ - Properties: nil, - Subscriptions: []paho.SubscribeOptions{ - { - Topic: topic, - QoS: withQos, - }, - }, - } - cl.mux.RLock() - pcl := cl.pahoClient - cl.mux.RUnlock() - suback, err := pcl.Subscribe(context.Background(), packet) - _ = suback - return err -} - -// NewMqttConsumerClient creates a new instance of the mqtt binding client -// -// fullURL of broker to connect to, including the schema -// clientID to connect as -// clientCert optional client certificate to connect with -// caCert of the server to validate the server or nil to not check the server cert -// getForm is the handler that provides a form for the given operation -// timeout for waiting for response. 0 to use the default. -func NewMqttConsumerClient(fullURL string, clientID string, - clientCert *tls.Certificate, caCert *x509.Certificate, - getForm messaging.GetFormHandler, - timeout time.Duration) *MqttClientConnection { - - cl := MqttClientConnection{} - - caCertPool := x509.NewCertPool() - - // Use CA certificate for server authentication if it exists - if caCert == nil { - slog.Info("NewMqttTransportClient: No CA certificate. InsecureSkipVerify used", - slog.String("destination", fullURL)) - } else { - slog.Debug("NewHttpSSEClient: CA certificate", - slog.String("destination", fullURL), - slog.String("caCert CN", caCert.Subject.CommonName)) - caCertPool.AddCert(caCert) - } - if timeout == 0 { - timeout = time.Second * 3 - } - - cl.cinfo.CaCert = caCert - cl.cinfo.ClientID = clientID - cl.connectionID = "mqtt-" + shortid.MustGenerate() - cl.timeout = timeout - //cl.rnrChan = NewRnRChan() - - // max delay 3 seconds before a response is expected - cl.cinfo.ConnectURL = fullURL - - cl.getForm = getForm - cl.correlData = make(map[string]chan *paho.Publish) - cl.subscriptions = make(map[string]bool) - - // setup an inbox to reply to - hostName, _ := os.Hostname() - cl.connectionID = fmt.Sprintf("%s-%s-%s", - cl.cinfo.ClientID, hostName, time.Now().Format("20060102150405.000")) - cl.inboxTopic = fmt.Sprintf(InboxTopicFormat, cl.connectionID) - - var onConnection messaging.ConnectionHandler = func(connected bool, err error, c messaging.IConnection) { - slog.Info("connection status change", - "newStatus", connected, - "lastError", err, - "clientID", clientID) - } - cl.appConnectHandlerPtr.Store(&onConnection) - //err = cl.pahoConnect() - - return &cl -} diff --git a/lib/clients/mqttclient/MqttMessages.go.old b/lib/clients/mqttclient/MqttMessages.go.old deleted file mode 100644 index 71d3c9a5..00000000 --- a/lib/clients/mqttclient/MqttMessages.go.old +++ /dev/null @@ -1,193 +0,0 @@ -package mqttclient - -import ( - "fmt" - "github.com/eclipse/paho.golang/paho" - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" - "log/slog" - "strings" -) - -// handleMessage handles incoming MQTT messages from the agent -// FIXME: rework for hiveot -func (cl *MqttConsumerTransport) handlePahoMessage(m *paho.Publish) { - slog.Debug("handleMessage", slog.String("topic", m.Topic)) - // run this in the background to allow for reentrancy - go func() { - // handle reply message - if strings.HasPrefix(m.Topic, INBOX_PREFIX) && m.Properties.CorrelationData != nil { - // Pass replies to their waiting channel - cID := string(m.Properties.CorrelationData) - cl.BaseMux.RLock() - rChan, _ := cl.correlData[cID] - cl.BaseMux.RUnlock() - if rChan == nil { - slog.Warn("Received reply without matching correlation ID", "corrID", cID) - } else { - cl.BaseMux.Lock() - delete(cl.correlData, cID) - cl.BaseMux.Unlock() - - rChan <- m - } - return - } - - // handle request message - replyTo := m.Properties.ResponseTopic - if replyTo != "" && m.Properties.CorrelationData != nil { - var reply []byte - var err error - var donotreply bool - // get a reply from the single request handler - cl.BaseMux.RLock() - reqHandler := cl.AppRequestHandler - cl.BaseMux.RUnlock() - - if reqHandler != nil { - //reply, err, donotreply = reqHandler(m.Topic, m.Payload) - if err != nil { - slog.Warn("SubRequest: handle request failed.", - slog.String("err", err.Error()), - slog.String("topic", m.Topic)) - } - } else { - slog.Error("Received request message but no request handler is set.", - slog.String("clientID", cl.BaseClientID), - slog.String("topic", m.Topic), - slog.String("replyTo", replyTo)) - err = errors.New("Cannot handle request. No handler is set") - } - if !donotreply { - err = cl.sendReply(m, reply, err) - - if err != nil { - slog.Error("SubRequest. Sending reply failed", "err", err) - } - } - } else { - // this is en event message - cl.BaseMux.RLock() - evHandler := cl.BaseNotificationHandler - cl.BaseMux.RUnlock() - if evHandler != nil { - //evHandler(m.Topic, m.Payload) - op := "" - thingID := "" - name := "" - data := m.Payload - senderID := "" - tm := transports.NewThingMessage(op, thingID, name, data, senderID) - evHandler(tm) - } - } - }() -} - -// ParseResponse helper message to parse response and check for errors -func (cl *MqttConsumerTransport) ParseResponse(data []byte, resp interface{}) error { - var err error - if data == nil || len(data) == 0 { - if resp != nil { - err = fmt.Errorf("ParseResponse: client '%s', expected a response but none received", - cl.BaseClientID) - } else { - err = nil // all good - } - } else { - if resp == nil { - err = fmt.Errorf("ParseResponse: client '%s', received response but none was expected. data=%s", - cl.BaseClientID, data) - } else { - err = jsoniter.Unmarshal(data, resp) - } - } - return err -} - -// sendReply sends a reply on the response topic of the request -// This uses the same QoS as the request, without retain. -// -// req is the request to reply to -// optionally include a payload in the reply -// optionally include an error message in the reply -func (cl *MqttConsumerTransport) sendReply(req *paho.Publish, payload []byte, errResp error) (err error) { - - slog.Debug("sendReply", - slog.String("topic", req.Topic), - slog.String("responseTopic", req.Properties.ResponseTopic)) - - responseTopic := req.Properties.ResponseTopic - if responseTopic == "" { - err2 := fmt.Errorf("sendReply. No response topic. Not sending a reply") - slog.Error(err2.Error()) - } - replyMsg := &paho.Publish{ - QoS: req.QoS, - Retain: false, - Topic: responseTopic, - Properties: &paho.PublishProperties{ - CorrelationData: req.Properties.CorrelationData, - User: req.Properties.User, - PayloadFormat: req.Properties.PayloadFormat, - ContentType: req.Properties.ContentType, - }, - Payload: payload, - } - if errResp != nil { - replyMsg.Properties.ContentType = "error" // payload is an error message - replyMsg.Properties.User.Add("error", errResp.Error()) - // for testing, somehow properties.user is not transferred - replyMsg.Payload = []byte(errResp.Error()) - } - cl.BaseMux.RLock() - pcl := cl.pahoClient - cl.BaseMux.RUnlock() - if pcl == nil { - err = errors.New("connection lost") - } else { - ctx, cancelFn := context.WithTimeout(context.Background(), time.Second) - defer cancelFn() - _, err = pcl.Publish(ctx, replyMsg) - - if err != nil { - slog.Warn("sendReply. Error publishing response", - slog.String("err", err.Error())) - } - } - return err -} - -// SubscribeToTopic subscribes to a topic. -// Incoming messages are passed to the event or request handler, depending on whether -// a reply-to address and correlation-ID is set. -func (cl *MqttConsumerTransport) SubscribeToTopic(topic string) error { - slog.Debug("SubscribeToTopic", "topic", topic) - err := cl.sub(topic) - if err != nil { - return err - } - cl.BaseMux.Lock() - cl.subscriptions[topic] = true - cl.BaseMux.Unlock() - return err -} -func (cl *MqttConsumerTransport) UnsubscribeFromTopic(topic string) { - packet := &paho.Unsubscribe{ - Topics: []string{topic}, - } - cl.BaseMux.RLock() - pcl := cl.pahoClient - cl.BaseMux.RUnlock() - - ack, err := pcl.Unsubscribe(context.Background(), packet) - _ = ack - if err != nil { - slog.Error("Unable to unsubscribe from topic", "topic", topic) - return - } - cl.BaseMux.Lock() - delete(cl.subscriptions, topic) - cl.BaseMux.Unlock() -} diff --git a/lib/clients/tlsclient/ConnectTLS.go b/lib/clients/tlsclient/ConnectTLS.go deleted file mode 100644 index 94112934..00000000 --- a/lib/clients/tlsclient/ConnectTLS.go +++ /dev/null @@ -1,66 +0,0 @@ -package tlsclient - -import ( - "crypto/tls" - "crypto/x509" - "net/url" - "strings" -) - -// ConnectTLS creates a TLS connection to a server, optionally using a client certificate. -// -// serverURL full URL: tls/tcp/tcps://host:8883, wss://host:9001 -// clientCert to login with. Nil to not use client certs -// caCert of the server to setup to (recommended). Nil to not verify the server connection. -func ConnectTLS(serverURL string, clientCert *tls.Certificate, caCert *x509.Certificate) ( - *tls.Conn, error) { - - // setup always uses TLS - caCertPool := x509.NewCertPool() - if caCert != nil { - caCertPool.AddCert(caCert) - } - tlsOpts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - // if a client certificate is given then include it - clientCertList := make([]tls.Certificate, 0) - if clientCert != nil { - x509Cert, _ := x509.ParseCertificate(clientCert.Certificate[0]) - clientCertList = append(clientCertList, *clientCert) - _, err := x509Cert.Verify(tlsOpts) - if err != nil { - return nil, err - } - } - - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - Certificates: clientCertList, - InsecureSkipVerify: caCert == nil, - } - // url.Parse doesn't handle in memory UDS address starting with 'unix://@/path' - broker, err := url.Parse(serverURL) - if strings.HasPrefix(serverURL, "unix://@") { - broker.Scheme = "unix" - broker.Path = serverURL[7:] - } - - if err != nil { - return nil, err - } - // if no scheme is given then its a unix path - if broker.Scheme == "" { - broker.Scheme = "unix" - } - if broker.Host == "" { - broker.Host = broker.Path - } - // dial doesn't support tcps or mqtts - if broker.Scheme == "tcps" || broker.Scheme == "mqtts" || broker.Scheme == "tls" { - broker.Scheme = "tcp" - } - conn, err := tls.Dial(broker.Scheme, broker.Host, tlsConfig) - return conn, err -} diff --git a/lib/clients/tlsclient/CreateHttp2TLSClient.go b/lib/clients/tlsclient/CreateHttp2TLSClient.go deleted file mode 100644 index f5260c06..00000000 --- a/lib/clients/tlsclient/CreateHttp2TLSClient.go +++ /dev/null @@ -1,59 +0,0 @@ -package tlsclient - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "log/slog" - "net" - "net/http" - "net/http/cookiejar" - "time" - - "golang.org/x/net/http2" - "golang.org/x/net/publicsuffix" -) - -// NewHttp2TLSClient creates a http client setup for http/2 -func NewHttp2TLSClient(caCert *x509.Certificate, clientCert *tls.Certificate, timeout time.Duration) *http.Client { - var clientCertList []tls.Certificate - - // the CA certificate is set in NewTLSClient - caCertPool := x509.NewCertPool() - if caCert != nil { - caCertPool.AddCert(caCert) - } - if clientCert != nil { - clientCertList = []tls.Certificate{*clientCert} - } - - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - InsecureSkipVerify: caCert == nil, - Certificates: clientCertList, - } - //tlsTransport := http.DefaultTransport.(*http.Transport) - tlsTransport := &http2.Transport{ - AllowHTTP: true, - DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) { - c, err := tls.Dial(network, addr, cfg) - return c, err - }, - TLSClientConfig: tlsConfig, - } - // add a cookie jar for storing cookies - cjarOpts := &cookiejar.Options{PublicSuffixList: publicsuffix.List} - cjar, err := cookiejar.New(cjarOpts) - if err != nil { - err = fmt.Errorf("NewHttp2TLSClient: error creating cookiejar. Continuing anyways: %w", err) - slog.Error(err.Error()) - err = nil - } - // Dont set a timeout here as it will end the connection - httpClient := &http.Client{ - Transport: tlsTransport, - Jar: cjar, - } - return httpClient -} diff --git a/lib/clients/tlsclient/NewRequest.go b/lib/clients/tlsclient/NewRequest.go deleted file mode 100644 index b668c1a6..00000000 --- a/lib/clients/tlsclient/NewRequest.go +++ /dev/null @@ -1,33 +0,0 @@ -package tlsclient - -import ( - "bytes" - "fmt" - "net/http" - "net/url" - "strings" -) - -// NewRequest creates a request object containing a bearer token if available -func NewRequest(method string, fullURL string, bearerToken string, body []byte) (*http.Request, error) { - method = strings.ToUpper(method) - bodyReader := bytes.NewReader(body) - req, err := http.NewRequest(method, fullURL, bodyReader) - - if err != nil { - return nil, err - } - - // set the intended destination - // in web browser this is the origin that provided the web page, - // here it means the server that we'd like to talk to. - parts, err := url.Parse(fullURL) - origin := fmt.Sprintf("%s://%s", parts.Scheme, parts.Host) - req.Header.Set("Origin", origin) - if bearerToken != "" { - req.Header.Add("Authorization", "bearer "+bearerToken) - } else { - // no authentication - } - return req, nil -} diff --git a/lib/clients/tlsclient/TLSClient.go b/lib/clients/tlsclient/TLSClient.go deleted file mode 100644 index 74207a34..00000000 --- a/lib/clients/tlsclient/TLSClient.go +++ /dev/null @@ -1,317 +0,0 @@ -// Package tlsclient with a TLS client helper supporting certificate, JWT or Basic authentication -package tlsclient - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "io" - "log/slog" - "net/http" - "net/url" - "time" -) - -const DefaultClientTimeout = time.Second * 30 - -// TLSClient is a simple TLS Client with authentication using certificates or JWT authentication with login/pw -type TLSClient struct { - // host and port of the server to setup to - hostPort string - caCert *x509.Certificate - httpClient *http.Client - timeout time.Duration - - // client certificate mutual authentication - clientCert *tls.Certificate - - // JWT bearer token after login, refresh, or external source - // Invoke will use this if set. - bearerToken string - - // headers to include in each request - headers map[string]string -} - -// Certificate returns the client auth certificate or nil if none is used -func (cl *TLSClient) Certificate() *tls.Certificate { - return cl.clientCert -} - -// Close the connection with the server -func (cl *TLSClient) Close() { - slog.Debug("TLSClient.Remove: Closing client connection") - - if cl.httpClient != nil { - cl.httpClient.CloseIdleConnections() - //cl.httpClient = nil - } -} - -// Delete sends a delete message -// Note that delete methods do not allow a body, or a 405 is returned. -// -// path to invoke -func (cl *TLSClient) Delete(path string) (resp []byte, httpStatus int, err error) { - // careful, a double // in the path causes a 301 and changes POST to GET - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - resp, httpStatus, _, err = cl.Send("DELETE", serverURL, nil, "", nil) - return resp, httpStatus, err - -} - -// Get is a convenience function to send a request -// This returns the response data, the http status code and an error of delivery failed -// -// path to invoke -func (cl *TLSClient) Get(path string) (resp []byte, httpStatus int, err error) { - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - resp, httpStatus, _, err = cl.Send("GET", serverURL, nil, "", nil) - return resp, httpStatus, err -} - -// GetHttpClient returns the underlying HTTP client -func (cl *TLSClient) GetHttpClient() *http.Client { - return cl.httpClient -} - -// Send a HTTPS method and read response. -// -// If a JWT authentication is enabled then add the bearer token to the header -// If msg is a string then it is considered to be already serialized. -// If msg is not a string then it will be json encoded. -// -// method: GET, PUT, POST, ... -// url: full URL to invoke -// body contains the serialized request body -// contentType: default is "application/json" -// qParams: optional map with query parameters -// -// This returns the serialized response data, a response message ID, return status code or an error -func (cl *TLSClient) Send( - method string, requrl string, body []byte, contentType string, qParams map[string]string) ( - resp []byte, httpStatus int, headers http.Header, err error) { - - var req *http.Request - if contentType == "" { - contentType = "application/json" - } - if cl == nil || cl.httpClient == nil { - err = fmt.Errorf("_send: '%s'. Client is not started", requrl) - return nil, http.StatusInternalServerError, nil, err - } - slog.Debug("TLSClient.Send", "method", method, "requrl", requrl) - - // Caution! a double // in the path causes a 301 and changes post to get - req, err = NewRequest(method, requrl, cl.bearerToken, body) - // optional query parameters - if err == nil && qParams != nil { - qValues := req.URL.Query() - for k, v := range qParams { - qValues.Add(k, v) - } - req.URL.RawQuery = qValues.Encode() - } - if err != nil { - return nil, http.StatusInternalServerError, nil, err - } - - // set headers - req.Header.Set("Content-Type", contentType) - for k, v := range cl.headers { - req.Header.Set(k, v) - } - - httpResp, err := cl.httpClient.Do(req) - if err != nil { - err = fmt.Errorf("Send: %s %s: %w", method, requrl, err) - slog.Error(err.Error()) - return nil, 500, nil, err - } else if httpResp.StatusCode >= 300 { - err = fmt.Errorf("Send: %s %s: failed with (%d) %s", - method, requrl, httpResp.StatusCode, httpResp.Status) - slog.Error(err.Error()) - return nil, httpResp.StatusCode, nil, err - } - respBody, err := io.ReadAll(httpResp.Body) - // response body MUST be closed - _ = httpResp.Body.Close() - httpStatus = httpResp.StatusCode - - if httpStatus == 401 { - err = fmt.Errorf("%s", httpResp.Status) - } else if httpStatus >= 400 && httpStatus < 500 { - err = fmt.Errorf("%s: %s", httpResp.Status, respBody) - if httpResp.Status == "" { - err = fmt.Errorf("%d (%s): %s", httpResp.StatusCode, httpResp.Status, respBody) - } - } else if httpStatus >= 500 { - err = fmt.Errorf("Error %d (%s): %s", httpStatus, httpResp.Status, respBody) - slog.Error("Send returned internal server error", "requrl", requrl, "err", err.Error()) - } else if err != nil { - err = fmt.Errorf("Send: Error %s %s: %w", method, requrl, err) - } - return respBody, httpStatus, httpResp.Header, err -} - -//// Logout from the server and end the session -//func (cl *TLSClient) Logout() error { -// serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, vocab.PostLogoutPath) -// _, err := cl._send("POST", serverURL, http.NoBody, nil) -// return err -//} - -// Patch sends a patch message with json payload -// If msg is a string then it is considered to be already serialized. -// If msg is not a string then it will be json encoded. -// -// path to invoke -// body contains the serialized body -func (cl *TLSClient) Patch( - path string, body []byte) (resp []byte, statusCode int, err error) { - - // careful, a double // in the path causes a 301 and changes POST to GET - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - resp, statusCode, _, err = cl.Send(http.MethodPatch, serverURL, body, "", nil) - return resp, statusCode, err -} - -// Post a message. -// If msg is a string then it is considered to be already serialized. -// If msg is not a string then it will be json encoded. -// -// path to invoke -// body contains the serialized request body -func (cl *TLSClient) Post(path string, body []byte) ( - resp []byte, statusCode int, err error) { - - // careful, a double // in the path causes a 301 and changes POST to GET - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - resp, statusCode, _, err = cl.Send( - http.MethodPost, serverURL, body, "", nil) - return resp, statusCode, err -} - -// PostForm posts a form message. -func (cl *TLSClient) PostForm(path string, formData map[string]string) ( - resp []byte, statusCode int, err error) { - - // careful, a double // in the path causes a 301 and changes POST to GET - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - form := url.Values{} - for k, v := range formData { - form.Add(k, v) - } - body := form.Encode() - resp, statusCode, _, err = cl.Send( - http.MethodPost, serverURL, []byte(body), "application/x-www-form-urlencoded", nil) - return resp, statusCode, err -} - -// Put a message with json payload -// If msg is a string then it is considered to be already serialized. -// If msg is not a string then it will be json encoded. -// -// path to invoke -// body contains the serialized request body -// correlationID optional field to link async requests and responses -func (cl *TLSClient) Put(path string, body []byte) ( - resp []byte, statusCode int, err error) { - - // careful, a double // in the path causes a 301 and changes POST to GET - serverURL := fmt.Sprintf("https://%s%s", cl.hostPort, path) - resp, statusCode, _, err = cl.Send(http.MethodPut, serverURL, body, "", nil) - return resp, statusCode, err -} - -// SetAuthToken Sets login ID and secret for bearer token authentication using a -// token obtained at login or elsewhere. -// -// No error is returned as this just sets up the token for future use. No messages are send yet. -func (cl *TLSClient) SetAuthToken(token string) { - cl.bearerToken = token -} - -// SetHeader sets a header to include in each request -// use an empty value to remove the header -func (cl *TLSClient) SetHeader(name string, val string) { - if val == "" { - delete(cl.headers, name) - } else { - cl.headers[name] = val - } -} - -//// SetBearerToken sets the authentication token for the http header -//func (cl *TLSClient) SetBearerToken(token string) { -// cl.bearerToken = token -//} - -// NewTLSClient creates a new TLS Client instance. -// Use setup/Remove to open and close connections -// -// hostPort is the server hostname or IP address and port to setup to -// clientCert is an option client certificate used to connect -// caCert with the x509 CA certificate, nil if not available -// timeout duration of the request or 0 for default -// -// returns TLS client for submitting requests -func NewTLSClient(hostPort string, clientCert *tls.Certificate, caCert *x509.Certificate, - timeout time.Duration) *TLSClient { - - if timeout == 0 { - timeout = DefaultClientTimeout - } - - // Use CA certificate for server authentication if it exists - if caCert == nil { - slog.Info("NewTLSClient: No CA certificate. InsecureSkipVerify used", - slog.String("destination", hostPort)) - } - - // cert verification - // if a client cert is given then test if it is valid for our CA. - // this detects problems with certs that can be hard to track down - if caCert != nil && clientCert != nil { - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert) - - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - x509Cert, err := x509.ParseCertificate(clientCert.Certificate[0]) - if err == nil { - // FIXME: TestCertAuth: certificate specifies incompatible key usage - // why? Is the certpool invalid? Yet the test succeeds - _, err = x509Cert.Verify(opts) - } - if err != nil { - err = fmt.Errorf("NewTLSClient: certificate verfication failed: %w. Continuing for now.", err) - slog.Error(err.Error()) - } - } - // create the client - httpClient := NewHttp2TLSClient(caCert, clientCert, timeout) - // this has moved to NewHttp2TlsClient - //// add a cookie jar for storing cookies - //cjarOpts := &cookiejar.Options{PublicSuffixList: publicsuffix.List} - //cjar, err := cookiejar.New(cjarOpts) - //if err != nil { - // err = fmt.Errorf("NewTLSClient: error setting cookiejar. The use of auth cookie might not persist. Continuing: %w", err) - // slog.Error(err.Error()) - // err = nil - //} - //httpClient.Jar = cjar - - cl := &TLSClient{ - hostPort: hostPort, - httpClient: httpClient, - timeout: timeout, - clientCert: clientCert, - caCert: caCert, - headers: make(map[string]string), - } - - return cl -} diff --git a/lib/clients/tlsclient/TLSClient_test.go b/lib/clients/tlsclient/TLSClient_test.go deleted file mode 100644 index b06d3e06..00000000 --- a/lib/clients/tlsclient/TLSClient_test.go +++ /dev/null @@ -1,315 +0,0 @@ -package tlsclient_test - -import ( - "crypto/rand" - "crypto/tls" - "crypto/x509" - "encoding/json" - "io" - "log/slog" - "net/http" - "os" - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/stretchr/testify/require" - - "github.com/stretchr/testify/assert" -) - -// test hostname and port -var testAddress string - -// CA, server and plugin test certificate -var authBundle certs.TestCertBundle -var serverTLSConf *tls.Config - -func startTestServer(mux *http.ServeMux) (*http.Server, error) { - var err error - httpServer := &http.Server{ - Addr: testAddress, - // ReadTimeout: 5 * time.Minute, // 5 min to allow for delays when testing - // WriteTimeout: 10 * time.Second, - // Handler: srv.router, - TLSConfig: serverTLSConf, - Handler: mux, - //ErrorLog: log.Default(), - } - go func() { - err = httpServer.ListenAndServeTLS("", "") - }() - // Catch any startup errors - time.Sleep(100 * time.Millisecond) - return httpServer, err -} - -// TestMain runs a http server -// Used for all test cases in this package -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - slog.Info("------ TestMain of httpauthhandler ------") - testAddress = "127.0.0.1:9888" - // hostnames := []string{testAddress} - - authBundle = certs.CreateTestCertBundle() - - caCertPool := x509.NewCertPool() - caCertPool.AddCert(authBundle.CaCert) - - // serverTLSCert := testenv.X509ToTLS(certsclient.ServerCert, nil) - serverTLSConf = &tls.Config{ - Certificates: []tls.Certificate{*authBundle.ServerCert}, - ClientAuth: tls.VerifyClientCertIfGiven, - ClientCAs: caCertPool, - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: false, - } - - res := m.Run() - - time.Sleep(time.Second) - os.Exit(res) -} - -func TestNoCA(t *testing.T) { - path1 := "/hello" - path1Hit := 0 - - // setup server and client environment - mux := http.NewServeMux() - srv, err := startTestServer(mux) - mux.HandleFunc(path1, func(http.ResponseWriter, *http.Request) { - slog.Info("TestAuthCert: path1 hit") - path1Hit++ - }) - assert.NoError(t, err) - - // certificate authentication but no CA - cl := tlsclient.NewTLSClient(testAddress, authBundle.ClientCert, nil, 0) - assert.NoError(t, err) - - _, _, err = cl.Get(path1) - assert.NoError(t, err) - assert.Equal(t, 1, path1Hit) - cl.Close() - - // No authentication - cl = tlsclient.NewTLSClient(testAddress, nil, nil, 0) - - _, _, err = cl.Get(path1) - assert.NoError(t, err) - assert.Equal(t, 2, path1Hit) - - cl.Close() - _ = srv.Close() -} - -// Test certificate based authentication -func TestAuthClientCert(t *testing.T) { - path1 := "/test1" - path1Hit := 0 - - // setup server and client environment - mux := http.NewServeMux() - srv, err := startTestServer(mux) - assert.NoError(t, err) - // - mux.HandleFunc(path1, func(http.ResponseWriter, *http.Request) { - slog.Info("TestAuthClientCert: path1 hit") - path1Hit++ - }) - // - cl := tlsclient.NewTLSClient(testAddress, authBundle.ClientCert, authBundle.CaCert, 0) - assert.NoError(t, err) - - clientCert := cl.Certificate() - assert.NotNil(t, clientCert) - - // verify service certificate against CA - caCertPool := x509.NewCertPool() - caCertPool.AddCert(authBundle.CaCert) - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - cert, err := x509.ParseCertificate(clientCert.Certificate[0]) - if err == nil { - _, err = cert.Verify(opts) - } - assert.NoError(t, err) - - // - _, _, err = cl.Get(path1) - assert.NoError(t, err) - _, _, err = cl.Post(path1, nil) - assert.NoError(t, err) - _, _, err = cl.Put(path1, nil) - assert.NoError(t, err) - _, _, err = cl.Delete(path1) - assert.NoError(t, err) - _, _, err = cl.Patch(path1, nil) - assert.NoError(t, err) - assert.Equal(t, 5, path1Hit) - - cl.Close() - _ = srv.Close() -} - -func TestNotStarted(t *testing.T) { - cl := tlsclient.NewTLSClient(testAddress, nil, authBundle.CaCert, 0) - _, _, err := cl.Get("/notstarted") - assert.Error(t, err) - cl.Close() -} -func TestNoClientCert(t *testing.T) { - cl := tlsclient.NewTLSClient(testAddress, nil, authBundle.CaCert, 3) - cl.Close() -} - -func TestBadClientCert(t *testing.T) { - // use cert not signed by the CA - otherCA, otherKey, err := certs.CreateCA("test", 1) - otherCert, err := certs.CreateClientCert("name", "ou", 1, - authBundle.ClientKey, otherCA, otherKey) - otherTLS := certs.X509CertToTLS(otherCert, authBundle.ClientKey) - assert.NoError(t, err) - - cl := tlsclient.NewTLSClient(testAddress, otherTLS, authBundle.CaCert, 0) - // this should produce an error in the log - //assert.Error(t, err) - cl.Close() -} - -func TestNoServer(t *testing.T) { - // setup server and client environm - // - cl := tlsclient.NewTLSClient(testAddress, authBundle.ClientCert, authBundle.CaCert, 0) - _, _, err := cl.Get("/noserver") - assert.Error(t, err) - cl.Close() -} -func TestCert404(t *testing.T) { - mux := http.NewServeMux() - srv, err := startTestServer(mux) - assert.NoError(t, err) - - cl := tlsclient.NewTLSClient(testAddress, authBundle.ClientCert, authBundle.CaCert, 0) - - _, _, err = cl.Get("/pathnotfound") - assert.Error(t, err) - - cl.Close() - _ = srv.Close() -} - -func TestAuthJWT(t *testing.T) { - pathLogin1 := httpbasic.HttpPostLoginPath // this doesn't belong here - pathLogin2 := "/login2" - path3 := "/test3" - path3Hit := 0 - user1 := "user1" - password1 := "password1" - secret := make([]byte, 64) - _, _ = rand.Read(secret) - - // setup server and client environment - mux := http.NewServeMux() - // Handle a jwt login - mux.HandleFunc(pathLogin1, func(resp http.ResponseWriter, req *http.Request) { - // FIXME: remove dependency on authn - // Is the login API a transport feature? Look into the WoT specification. - authMsg := messaging.UserLoginArgs{} - slog.Info("TestAuthJWT: login") - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - err = json.Unmarshal(body, &authMsg) - - // expect a correlationID - //msgID := req.Header.Get(tlsclient.HTTPCorrelationIDHeader) - assert.NoError(t, err) - assert.Equal(t, user1, authMsg.ClientID) - assert.Equal(t, password1, authMsg.Password) - - if authMsg.ClientID == user1 { - claims := jwt.RegisteredClaims{ - ID: user1, - Issuer: "me", - Subject: "accessToken", - // In JWT, the expiry time is expressed as unix milliseconds - IssuedAt: jwt.NewNumericDate(time.Now()), - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second)), - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - newToken, err := token.SignedString(secret) - assert.NoError(t, err) - //resp.Header().Set(tlsclient.HTTPCorrelationIDHeader, msgID) - data, _ := json.Marshal(newToken) - _, _ = resp.Write(data) - } else { - // write nothing - _ = err - } - path3Hit++ - }) - // a second login function that returns nothing - mux.HandleFunc(pathLogin2, func(resp http.ResponseWriter, req *http.Request) { - }) - - mux.HandleFunc(path3, func(http.ResponseWriter, *http.Request) { - slog.Info("TestAuthJWT: path3 hit") - path3Hit++ - }) - srv, err := startTestServer(mux) - assert.NoError(t, err) - // - loginMessage := messaging.UserLoginArgs{ - ClientID: user1, - Password: password1, - } - cl := tlsclient.NewTLSClient(testAddress, nil, authBundle.CaCert, 0) - jsonArgs, _ := json.Marshal(loginMessage) - resp, _, err := cl.Post(httpbasic.HttpPostLoginPath, jsonArgs) - require.NoError(t, err) - reply := "" - err = json.Unmarshal(resp, &reply) - - // reconnect using the given token - cl.SetAuthToken(reply) - _, _, err = cl.Get(path3) - assert.NoError(t, err) - assert.Equal(t, 2, path3Hit) - - cl.Close() - _ = srv.Close() -} - -func TestAuthJWTFail(t *testing.T) { - pathHello1 := "/hello" - - // setup server and client environment - mux := http.NewServeMux() - srv, err := startTestServer(mux) - assert.NoError(t, err) - // - mux.HandleFunc(pathHello1, func(resp http.ResponseWriter, req *http.Request) { - slog.Info("TestAuthJWTFail: login") - //_, _ = resp.Write([]byte("invalid token")) - resp.WriteHeader(http.StatusUnauthorized) - }) - // - cl := tlsclient.NewTLSClient(testAddress, nil, authBundle.CaCert, 0) - cl.SetAuthToken("badtoken") - resp, _, err := cl.Post(pathHello1, []byte("test")) - assert.Empty(t, resp) - // unauthorized - assert.Error(t, err) - - cl.Close() - _ = srv.Close() -} diff --git a/lib/clients/wssclient/ConnectWss.go b/lib/clients/wssclient/ConnectWss.go deleted file mode 100644 index 9ef1cb5d..00000000 --- a/lib/clients/wssclient/ConnectWss.go +++ /dev/null @@ -1,157 +0,0 @@ -package wssclient - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "log/slog" - "net" - "net/http" - "net/url" - "sync/atomic" - - "github.com/gorilla/websocket" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" -) - -// ConnectWSS establishes a websocket session with the server -func ConnectWSS( - cinfo messaging.ConnectionInfo, - bearerToken string, - onConnect func(bool, error), - onMessage func(raw []byte), -) (cancelFn func(), conn *websocket.Conn, err error) { - var clientCertList []tls.Certificate - - slog.Info("ConnectWSS (to hub) - establishing Websocket connection to server", - slog.String("URL", cinfo.ConnectURL), - slog.String("clientID", cinfo.ClientID), - ) - - // use context to disconnect the client - wssCtx, wssCancelFn := context.WithCancel(context.Background()) - - // the CA certificate is set in NewTLSClient - caCertPool := x509.NewCertPool() - if cinfo.CaCert != nil { - caCertPool.AddCert(cinfo.CaCert) - } - //if clientCert != nil { - // clientCertList = []tls.Certificate{*clientCert} - //} - wssURLParsed, _ := url.Parse(cinfo.ConnectURL) - tlsConfig := &tls.Config{ - RootCAs: caCertPool, - // ServerName is required with InsecureSkipVerify disabled - ServerName: wssURLParsed.Hostname(), // how to know? - InsecureSkipVerify: cinfo.CaCert == nil, - //InsecureSkipVerify: true, - Certificates: clientCertList, - } - - wssHeader := http.Header{} - wssHeader.Add("Authorization", "bearer "+bearerToken) - wssHeader.Add(httpbasic.ConnectionIDHeader, cinfo.ConnectionID) - //parts, _ := url.Parse(hostPort) - //origin := fmt.Sprintf("%s://%s", parts.Scheme, parts.Host) - //opts.HTTPHeader.Add("Origin", origin) - - dialer := *websocket.DefaultDialer // run a copy - //dialer := websocket.DefaultDialer - dialer.TLSClientConfig = tlsConfig - dialer.NetDialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - netConn, err := net.Dial(network, addr) - if err != nil { - return nil, err - } - - // 'NetDialTLSContext' also gets called during the proxy CONNECT for some reason (at this point 'network' equals "TCP" and 'addr' equals "127.0.0.1:8888") - // The HTTP proxy doesn't support HTTPS however, so I return the established TCP connection early. - // If I don't do this check, the connection hangs forever (tested with several proxies). - // This feels kinda hacky though, not sure if this is the correct approach... - //if p.Host == addr { - // return netConn, err - //} - - // Example TLS handshake - tlsConn := tls.Client(netConn, tlsConfig) - if err = tlsConn.Handshake(); err != nil { - return nil, err - } - - return tlsConn, nil - } - // FIXME: use http/2 - //httpParts, _ := url.Parse(wssURL) - //httpParts.Scheme = "https" - //httpsURL := httpParts.String() - - wssConn, r, err := dialer.Dial(cinfo.ConnectURL, wssHeader) - if err != nil { - // FIXME: when unauthorized, don't retry. A new token is needed. (session ended). - if r != nil && r.StatusCode == http.StatusUnauthorized { - msg := fmt.Sprintf("Unauthorized: Connection as '%s' to '%s' failed: %s", - cinfo.ClientID, cinfo.ConnectURL, err.Error()) - slog.Warn(msg) - err = messaging.UnauthorizedError - } - wssCancelFn() - return nil, nil, err - } - - closeWSSFn := func() { - err = wssConn.Close() - - // is this needed after close above? - wssCancelFn() - } - // notify the world we're connected - if onConnect != nil { - onConnect(true, nil) - } - // last, start handling incoming messages - go func() { - WSSReadLoop(wssCtx, wssConn, onMessage) - if onConnect != nil { - onConnect(false, nil) - } - }() - - return closeWSSFn, wssConn, nil -} - -// WSSReadLoop reads incoming websocket messages in a loop, until connection closes or context is cancelled -func WSSReadLoop(ctx context.Context, - wssConn *websocket.Conn, onMessage func(raw []byte)) { - - var readLoop atomic.Bool - readLoop.Store(true) - - // close the client when the context ends drops - go func() { - select { - case <-ctx.Done(): // remote client connection closed - slog.Debug("WSSReadLoop: Remote client disconnected") - // close channel when no-one is writing - // in the meantime keep reading to prevent deadlock - _ = wssConn.Close() - readLoop.Store(false) - } - }() - - // read messages from the client until the connection closes - for readLoop.Load() { // sseMsg := range sseChan { - _, raw, err := wssConn.ReadMessage() - if err != nil { - // avoid further writes - readLoop.Store(false) - // ending the read loop and returning will close the connection - break - } - // process in the background - go onMessage(raw) - } - -} diff --git a/lib/clients/wssclient/WssClient.go b/lib/clients/wssclient/WssClient.go deleted file mode 100644 index 7b1e0e69..00000000 --- a/lib/clients/wssclient/WssClient.go +++ /dev/null @@ -1,406 +0,0 @@ -package wssclient - -import ( - "context" - "crypto/x509" - "errors" - "fmt" - "log/slog" - "math/rand/v2" - "net/url" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/messaging/converters" - "github.com/hiveot/hub/lib/servers" - "github.com/teris-io/shortid" -) - -// WssClient manages the connection to a websocket server. -// This implements the IConnection interface. -// -// This supports multiple message formats using a 'messageConverter'. The hiveot -// converts is a straight passthrough of RequestMessage and ResponseMessage, while -// the wotwssConverter maps the messages to the WoT websocket specification. -type WssClient struct { - - // handler for requests send by clients - appConnectHandlerPtr atomic.Pointer[messaging.ConnectionHandler] - - // handler for notifications sent by agents - appNotificationHandlerPtr atomic.Pointer[messaging.NotificationHandler] - // handler for requests send by clients - appRequestHandlerPtr atomic.Pointer[messaging.RequestHandler] - // handler for responses sent by agents - appResponseHandlerPtr atomic.Pointer[messaging.ResponseHandler] - - //clientID string - // Connection information such as clientID, cid, address, protocol etc - cinfo messaging.ConnectionInfo - - // authentication token - authToken string - //caCert *x509.Certificate - - // clientID is the account ID of the agent or consumer - //clientID string - //connectionID string - //fullURL string - - isConnected atomic.Bool - // lastError atomic.Pointer[error] - - maxReconnectAttempts int // 0 for indefinite - - // convert the request/response to the wss messaging protocol used - messageConverter messaging.IMessageConverter - - // mutex for controlling writing and closing - mux sync.RWMutex - - retryOnDisconnect atomic.Bool - - // request timeout - //timeout time.Duration - - // underlying websocket connection - wssConn *websocket.Conn - wssCancelFn context.CancelFunc -} - -// websocket connection status handler -func (cc *WssClient) _onConnectionChanged(connected bool, err error) { - - hPtr := cc.appConnectHandlerPtr.Load() - - cc.isConnected.Store(connected) - if hPtr != nil { - (*hPtr)(connected, err, cc) - } - // if retrying is enabled then try on disconnect - if !connected && cc.retryOnDisconnect.Load() { - cc.Reconnect() - } -} - -// _send publishes a message over websockets -func (cc *WssClient) _send(wssMsg any) (err error) { - if !cc.isConnected.Load() { - // note, it might be trying to reconnect in the background - err := fmt.Errorf("_send: Not connected to the hub") - return err - } - // websockets do not allow concurrent writes - cc.mux.Lock() - err = cc.wssConn.WriteJSON(wssMsg) - cc.mux.Unlock() - return err -} - -// ConnectWithToken attempts to establish a websocket connection using a valid auth token -// If a connection exists it is closed first. -func (cc *WssClient) ConnectWithToken(token string) error { - - // ensure disconnected (note that this resets retryOnDisconnect) - cc.Disconnect() - - cc.authToken = token - wssCancelFn, wssConn, err := ConnectWSS(cc.cinfo, token, - cc._onConnectionChanged, cc.HandleWssMessage) - - cc.mux.Lock() - cc.wssCancelFn = wssCancelFn - cc.wssConn = wssConn - cc.mux.Unlock() - - // even if connection failed right now, enable retry - cc.retryOnDisconnect.Store(true) - - return err -} - -// Disconnect from the server -func (cc *WssClient) Disconnect() { - slog.Debug("Disconnect", - slog.String("clientID", cc.cinfo.ClientID), - ) - // dont try to reconnect - cc.retryOnDisconnect.Store(false) - - cc.mux.Lock() - defer cc.mux.Unlock() - if cc.wssCancelFn != nil { - cc.wssCancelFn() - cc.wssCancelFn = nil - } -} - -// GetConnectionInfo returns the client's connection details -func (cc *WssClient) GetConnectionInfo() messaging.ConnectionInfo { - return cc.cinfo -} - -// HandleWssMessage processes the websocket message received from the server. -// This decodes the message into a request or response message and passes -// it to the application handler. -func (cc *WssClient) HandleWssMessage(raw []byte) { - var notif *messaging.NotificationMessage - var req *messaging.RequestMessage - var resp *messaging.ResponseMessage - - // // for testing: - // var jsonObj any - // err := jsoniter.Unmarshal(raw, &jsonObj) - // if err != nil { - // slog.Error("HandleWssMessage: failed to decode JSON", - // "clientID", cc.cinfo.ClientID, - // "err", err.Error(), - // "raw", string(raw)) - // return - // } - - // try to decode as notification first, then response, then request as - - // both non-agents and agents receive responses - notif = cc.messageConverter.DecodeNotification(raw) - if notif == nil { - resp = cc.messageConverter.DecodeResponse(raw) - if resp == nil { - req = cc.messageConverter.DecodeRequest(raw) - } - } - if notif != nil { - hPtr := cc.appNotificationHandlerPtr.Load() - if hPtr == nil { - slog.Error("HandleWssMessage: no notification handler set", - "clientID", cc.cinfo.ClientID, - "operation", notif.Operation, - ) - return - } - // pass the response to the registered handler - (*hPtr)(notif) - } else if resp != nil { - hPtr := cc.appResponseHandlerPtr.Load() - if hPtr == nil { - slog.Error("HandleWssMessage: no response handler set", - "clientID", cc.cinfo.ClientID, - "operation", resp.Operation, - ) - return - } - // pass the response to the registered handler - _ = (*hPtr)(resp) - } else if req != nil { - hPtr := cc.appRequestHandlerPtr.Load() - if hPtr == nil { - slog.Error("HandleWssMessage: no request handler set", - "clientID", cc.cinfo.ClientID, - "operation", req.Operation, - ) - return - } - // return the response to the caller - resp = (*hPtr)(req, cc) - // responses are optional - if resp != nil { - _ = cc.SendResponse(resp) - } - } else { - slog.Warn("HandleWssMessage: Message is not a valid notification, request or response", - "raw", string(raw)) - return - } -} - -// IsConnected return whether the return channel is connection, eg can receive data -func (cc *WssClient) IsConnected() bool { - return cc.isConnected.Load() -} - -// Reconnect attempts to re-establish a dropped connection using the last token -// This uses an increasing backoff period up to 15 seconds, starting random between 0-2 seconds -func (cc *WssClient) Reconnect() { - var err error - var backoffDuration time.Duration = time.Duration(rand.Uint64N(uint64(time.Second * 2))) - - for i := 0; cc.maxReconnectAttempts == 0 || i < cc.maxReconnectAttempts; i++ { - slog.Warn("Reconnecting attempt", - slog.String("clientID", cc.cinfo.ClientID), - slog.Int("i", i)) - err = cc.ConnectWithToken(cc.authToken) - if err == nil { - break - } - // retry until max repeat is reached, disconnect is called or authorization failed - if !cc.retryOnDisconnect.Load() { - break - } - if errors.Is(err, messaging.UnauthorizedError) { - break - } - // the connection timeout doesn't seem to work for some reason - // - time.Sleep(backoffDuration) - // slowly wait longer until 10 sec. FIXME: use random - if backoffDuration < time.Second*15 { - backoffDuration += time.Second - } - } - if err != nil { - slog.Warn("Reconnect failed: ", "err", err.Error()) - } -} - -// SendNotification Agent posts a notification over websockets -// This passes the notification as-is as a payload. -// -// This posts the JSON-encoded NotificationMessage on the well-known hiveot notification href. -// In WoT Agents are typically a server, not a client, so this is intended for -// agents that use connection-reversal. -func (cc *WssClient) SendNotification(notif *messaging.NotificationMessage) error { - - slog.Debug("SendNotification", - slog.String("clientID", cc.cinfo.ClientID), - slog.String("correlationID", notif.CorrelationID), - slog.String("operation", notif.Operation), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - // convert the operation into a protocol message - wssMsg, err := cc.messageConverter.EncodeNotification(notif) - if err != nil { - slog.Error("SendNotification: unknown request", "op", notif.Operation) - return err - } - err = cc._send(wssMsg) - if err != nil { - slog.Warn("SendNotification failed", - "clientID", cc.cinfo.ClientID, - "err", err.Error()) - } - return err -} - -// SendRequest send a request message over websockets -// This transforms the request to the protocol message and sends it to the server. -func (cc *WssClient) SendRequest(req *messaging.RequestMessage) error { - - slog.Debug("SendRequest", - slog.String("clientID", cc.cinfo.ClientID), - slog.String("correlationID", req.CorrelationID), - slog.String("operation", req.Operation), - slog.String("thingID", req.ThingID), - slog.String("name", req.Name), - ) - - // convert the operation into a protocol message - wssMsg, err := cc.messageConverter.EncodeRequest(req) - if err != nil { - slog.Error("SendRequest: unknown request", "op", req.Operation) - return err - } - err = cc._send(wssMsg) - return err -} - -// SendResponse send a response message over websockets -// This transforms the response to the protocol message and sends it to the server. -// Responses without correlationID are subscription notifications. -func (cc *WssClient) SendResponse(resp *messaging.ResponseMessage) error { - errMsg := "" - if resp.Error != nil { - errMsg = resp.Error.String() - } - slog.Debug("SendResponse", - slog.String("operation", resp.Operation), - slog.String("clientID", cc.cinfo.ClientID), - slog.String("thingID", resp.ThingID), - slog.String("name", resp.Name), - slog.String("error", errMsg), - slog.String("correlationID", resp.CorrelationID), - ) - - // convert the operation into a protocol message - wssMsg := cc.messageConverter.EncodeResponse(resp) - err := cc._send(wssMsg) - return err -} - -// SetConnectHandler set the application handler for connection status updates -func (cc *WssClient) SetConnectHandler(cb messaging.ConnectionHandler) { - if cb == nil { - cc.appConnectHandlerPtr.Store(nil) - } else { - cc.appConnectHandlerPtr.Store(&cb) - } -} - -// SetNotificationHandler set the application handler for received notifications -func (cc *WssClient) SetNotificationHandler(cb messaging.NotificationHandler) { - if cb == nil { - cc.appNotificationHandlerPtr.Store(nil) - } else { - cc.appNotificationHandlerPtr.Store(&cb) - } -} - -// SetRequestHandler set the application handler for incoming requests -func (cc *WssClient) SetRequestHandler(cb messaging.RequestHandler) { - if cb == nil { - cc.appRequestHandlerPtr.Store(nil) - } else { - cc.appRequestHandlerPtr.Store(&cb) - } -} - -// SetResponseHandler set the application handler for received responses -func (cc *WssClient) SetResponseHandler(cb messaging.ResponseHandler) { - if cb == nil { - cc.appResponseHandlerPtr.Store(nil) - } else { - cc.appResponseHandlerPtr.Store(&cb) - } -} - -// NewHiveotWssClient creates a new instance of the websocket client. -// -// messageConverter offers the ability to use any websocket message format that -// can be mapped to a RequestMessage and ResponseMessage. It is used to support -// both hiveot and WoT websocket message formats. -// -// wssURL is the full websocket connection URL -// clientID is the authentication ID of the consumer or agent -// caCert is the server CA for TLS connection validation -// protocol is the protocol ID of the websocket messages -// timeout is the maximum connection wait time -func NewHiveotWssClient( - wssURL string, clientID string, caCert *x509.Certificate, - timeout time.Duration) *WssClient { - - // ensure the URL has port as 443 is not valid for this - parts, _ := url.Parse(wssURL) - if parts.Port() == "" { - parts.Host = fmt.Sprintf("%s:%d", parts.Hostname(), servers.DefaultHttpsPort) - wssURL = parts.String() - } - - cinfo := messaging.ConnectionInfo{ - CaCert: caCert, - ClientID: clientID, - ConnectionID: "wss-" + shortid.MustGenerate(), - ConnectURL: wssURL, - //ProtocolType: converter.GetProtocolType(), - Timeout: timeout, - } - cl := WssClient{ - cinfo: cinfo, - maxReconnectAttempts: 0, - messageConverter: converters.NewWssMessageConverter(), - } - //cl.Init(fullURL, clientID, clientCert, caCert, getForm, timeout) - return &cl -} diff --git a/lib/consumedthing/ConsumedThing.go b/lib/consumedthing/ConsumedThing.go deleted file mode 100644 index 29fe546c..00000000 --- a/lib/consumedthing/ConsumedThing.go +++ /dev/null @@ -1,516 +0,0 @@ -package consumedthing - -import ( - "fmt" - "log/slog" - "sync" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" -) - -// InteractionListener is the handler that receives updates to interaction -// requests, eg write property, invoke action or subscribe to events. -type InteractionListener func(*InteractionOutput) - -// ConsumedThing implements the ConsumedThing interface for accessing a Thing's -// schema and values roughly in line with the WoT scripting API. -// -// However, since the scripting API is based on Javascript some differences are -// made to accommodate the different environment. -// -// This keeps a copy of the Thing's property and event values and updates on changes. -type ConsumedThing struct { - // The consumer instance this uses for invoking actions - co *consumer.Consumer - - // ID of this Thing for use by consumers - ThingID string - // The Thing title or property 'title' - Title string - // The Thing description or property 'description' - Description string - - // tdi is the immutable associated TD - tdi *td.TD - // observer of property value changes by property name - observers map[string]InteractionListener - // subscribers to events by eventName - subscribers map[string]InteractionListener - - // action status input values - actionInputs map[string]*InteractionInput - // action status output values - actionOutputs map[string]*InteractionOutput - // prop values - propValues map[string]*InteractionOutput - // event values - eventValues map[string]*InteractionOutput - - mux sync.RWMutex -} - -// build a map of interaction outputs for the given values -//func (ct *ConsumedThing) buildInteractionOutputMap( -// tmm map[string]*transports.ResponseMessage) map[string]*InteractionOutput { -// -// outMap := make(map[string]*InteractionOutput) -// for key, msg := range tmm { -// iout := NewInteractionOutputFromResponse(msg, ct.tdi) -// outMap[key] = iout -// } -// return outMap -//} - -// Create an interactionOutput for the given thing message -//func (ct *ConsumedThing) buildInteractionOutput(msg *transports.ResponseMessage) *InteractionOutput { -// iout := NewInteractionOutputFromResponse(msg, ct.tdi) -// return iout -//} - -// GetActionAff returns the action affordance or nil if not found -func (ct *ConsumedThing) GetActionAff(name string) *td.ActionAffordance { - aff, found := ct.tdi.Actions[name] - _ = found - return aff -} - -// GetActionInputFromStatus returns the action input value of the given action, if available -func (ct *ConsumedThing) GetActionInputFromStatus(as messaging.ActionStatus) *InteractionInput { - iin := NewInteractionInput(ct.tdi, as.Name, as.Input) - return iin -} - -// GetActionOutputFromStatus returns the interaction output of the given -// action status. -// -// If as.name is not a known action then fallback to property and event schema. -// -// See also GetValue that always return an iout (for rendering purpose) -// -// This returns nil if as.name is not a known action -func (ct *ConsumedThing) GetActionOutputFromStatus(as messaging.ActionStatus) (iout *InteractionOutput) { - - iout = NewInteractionOutput(ct, - messaging.AffordanceTypeAction, as.Name, as.Output, as.TimeUpdated) - - // graceful fallback. - // If no output schema use property or event with the same name - if iout.Schema.Type == "" { - fallbackOutput := ct.GetPropertyOutput(as.Name) - if fallbackOutput == nil { - fallbackOutput = ct.GetEventOutput(as.Name) - } - if fallbackOutput != nil { - iout.Schema = fallbackOutput.Schema - iout.Value = fallbackOutput.Value - } - } - return iout -} - -// GetActionOutput returns the cached interaction output of the given action affordance -// -// This returns nil if no cached value is available -func (ct *ConsumedThing) GetActionOutput(name string) (iout *InteractionOutput) { - - ct.mux.RLock() - iout, _ = ct.actionOutputs[name] - ct.mux.RUnlock() - return iout -} - -// GetActionStatus returns the ActionStatus object of the latest action value. -// -// This returns nil if not found -//func (ct *ConsumedThing) GetActionStatus(name string) (as *transports.ActionStatus) { -// ct.mux.RLock() -// as, _ = ct.actionStatus[name] -// ct.mux.RUnlock() -// return as -//} - -// GetAtTypeTitle return the Thing @type field as a human readable text -// If @type contains an array then the title of the first value is returned. -func (ct *ConsumedThing) GetAtTypeTitle() string { - atTypeValue := "" - switch t := ct.tdi.AtType.(type) { - case string: - atTypeValue = t - case []string: - if len(t) > 0 { - atTypeValue = t[0] - } - } - // FIXME: read from file to support different vocabularies? - atTypeVocab, found := vocab.DeviceClassesMap[atTypeValue] - if !found { - return atTypeValue - } - return atTypeVocab.Title -} - -// GetConsumer returns the consumer instance this ConsumedT hing uses to communicate with -// the server. -func (ct *ConsumedThing) GetConsumer() (co *consumer.Consumer) { - return ct.co -} - -// GetEventAff returns the event affordance or nil if not found -func (ct *ConsumedThing) GetEventAff(name string) *td.EventAffordance { - aff, found := ct.tdi.Events[name] - _ = found - return aff -} - -// GetEventOutput returns the cached interaction output of the latest value of an event -// See also GetValue that always return an iout (for rendering purpose) -// -// This returns nil if not found -func (ct *ConsumedThing) GetEventOutput(name string) (iout *InteractionOutput) { - ct.mux.RLock() - iout, _ = ct.eventValues[name] - ct.mux.RUnlock() - return iout -} - -// GetPropertyAff returns the cached property affordance or nil if not found -func (ct *ConsumedThing) GetPropertyAff(name string) *td.PropertyAffordance { - aff, found := ct.tdi.Properties[name] - _ = found - return aff -} - -// GetPropertyInput returns the interaction input for a property -// This populates the input with the last known cached value. -func (ct *ConsumedThing) GetPropertyInput(name string) *InteractionInput { - ct.mux.RLock() - iout, _ := ct.propValues[name] - ct.mux.RUnlock() - if iout == nil { - return nil - } - iin := NewInteractionInput(ct.tdi, name, iout.Value.Raw) - return iin -} - -// GetPropertyOutput returns the interaction output of the latest property value. -// See also GetValue that always return an iout (for rendering purpose) -// -// # This returns nil if not found -// -// FIXME: this returns an old cached value. -// Instead it should show an updated iout -func (ct *ConsumedThing) GetPropertyOutput(name string) (iout *InteractionOutput) { - ct.mux.RLock() - iout, _ = ct.propValues[name] - ct.mux.RUnlock() - return iout -} - -// GetValue returns the interaction output of the latest received event or property value. -// To refresh the value use ReadValue() instead. -// Note: This returns an NoValue() InteractionOutput if name is not known -func (ct *ConsumedThing) GetValue(affType messaging.AffordanceType, name string) (iout *InteractionOutput) { - var found bool - - // FIXME: should name be matched against the affordances TD instead of cached values? - if affType == "action" { - iout = ct.GetActionOutput(name) - } else if affType == "event" { - iout = ct.GetEventOutput(name) - } else { - // must be a property - iout = ct.GetPropertyOutput(name) - } - - _ = found - if iout == nil { - // not a known value so create an empty io with NoSchema - iout = &InteractionOutput{ - ThingID: ct.tdi.ID, - Name: name, - Value: DataSchemaValue{Raw: "ConsumedThing is missing data"}, - Schema: td.NoSchema(), - } - } - return iout -} - -// GetAllActionOutputs returns all Thing action status values and returns them in a -// map of InteractionOutputs. -func (ct *ConsumedThing) GetAllActionOutputs() map[string]*InteractionOutput { - return ct.actionOutputs -} - -// GetAllEvents returns all Thing event values and returns them in a -// map of InteractionOutputs. -func (ct *ConsumedThing) GetAllEvents() map[string]*InteractionOutput { - return ct.eventValues -} - -// GetAllProperties returns all Thing property values and returns them in a -// map of InteractionOutputs. -func (ct *ConsumedThing) GetAllProperties() map[string]*InteractionOutput { - return ct.propValues -} - -// InvokeAction requests an action on the Thing -// This updates the action status with the response. For async actions the owner -// of the Consumer must invoke OnResponse to update the action status. -func (ct *ConsumedThing) InvokeAction(name string, iin InteractionInput) (*InteractionOutput, error) { - - req := messaging.NewRequestMessage( - td.OpInvokeAction, ct.ThingID, name, iin.Value.Raw, "") - resp, err := ct.co.SendRequest(req, true) - if err != nil { - return nil, err - } - // update the - iout := NewInteractionOutput(ct, - messaging.AffordanceTypeAction, name, resp.Value, resp.Timestamp) - ct.mux.Lock() - ct.actionOutputs[name] = iout - ct.mux.Unlock() - return iout, nil -} - -// ObserveProperty registers a callback for updates to property values. -// Only a single subscription per property is allowed. This returns an error -// if an existing observer is already registered. -func (ct *ConsumedThing) ObserveProperty(name string, listener InteractionListener) error { - if _, found := ct.observers[name]; found { - return fmt.Errorf("A property observer is already registered") - } - ct.observers[name] = listener - return nil -} - -// OnNotification handles receiving a Thing event or property update. -// To be called by the manager of this ConsumedThing, the one that receives -// all subscribed events from the hub client. -// This updates the latest event value and invokes the registered event subscriber, if any. -// -// msg is the notification message received. -func (ct *ConsumedThing) OnNotification(notif *messaging.NotificationMessage) { - - if notif.Operation == td.OpSubscribeEvent && - notif.ThingID == digitwin.ThingDirectoryDThingID && - notif.Name == digitwin.ThingDirectoryEventThingUpdated { - // decode the TD - tdi := &td.TD{} - err := jsoniter.UnmarshalFromString(notif.ToString(0), &tdi) - if err != nil { - slog.Error("invalid payload for TD event. Ignored", - "thingID", notif.ThingID) - return - } - // update consumed thing, if existing - ct.mux.Lock() - ct.tdi = tdi - ct.mux.Unlock() - } else if notif.Operation == td.OpObserveProperty { - // update value - iout := NewInteractionOutputFromNotification( - ct, messaging.AffordanceTypeProperty, notif) - ct.mux.Lock() - ct.propValues[notif.Name] = iout - // the consumed thing title and description are updated with corresponding properties - if notif.Name == td.WoTTitle { - ct.Title = iout.Value.Text() - } else if notif.Name == td.WoTDescription { - ct.Description = iout.Value.Text() - } - ct.mux.Unlock() - - subscr, _ := ct.subscribers[notif.Name] - if subscr != nil { - subscr(iout) - } - } else if notif.Operation == td.OpSubscribeEvent { - iout := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeEvent, notif) - // this is a regular value event - ct.mux.Lock() - ct.eventValues[notif.Name] = iout - ct.mux.Unlock() - subscr, _ := ct.subscribers[notif.Name] - if subscr != nil { - subscr(iout) - } - } else if notif.Operation == td.OpInvokeAction { - iout := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeAction, notif) - // this is a regular action progress event - ct.mux.Lock() - ct.actionOutputs[notif.Name] = iout - ct.mux.Unlock() - subscr, _ := ct.subscribers[notif.Name] - if subscr != nil { - subscr(iout) - } - } -} - -// QueryAction queries the action status record from the hub and update the cache. -// If no action exists then actionstatus will be mostly empty -// -// This returns an empty ActionStatus if not found -func (ct *ConsumedThing) QueryAction(name string) messaging.ActionStatus { - as, _ := ct.co.QueryAction(ct.ThingID, name) - iin := ct.GetActionInputFromStatus(as) - iout := ct.GetActionOutputFromStatus(as) - ct.actionOutputs[name] = iout - ct.actionInputs[name] = iin - return as -} - -// ReadEvent refreshes the last event value by reading it from the hub -// TODO: subscribing to events should send the last one -func (ct *ConsumedThing) ReadEvent(name string) *InteractionOutput { - - tv, err := digitwin.ThingValuesReadEvent(ct.co, name, ct.ThingID) - if err != nil { - return nil - } - iout := NewInteractionOutput(ct, messaging.AffordanceTypeEvent, name, tv.Data, tv.Timestamp) - //iout.setSchemaFromTD(ct.tdi) - ct.mux.Lock() - ct.eventValues[name] = iout - ct.mux.Unlock() - return iout -} - -// ReadProperty reads the Thing property value from the Thing and updates its cache. -// Call GetPropertyValue to get the cached value. -func (ct *ConsumedThing) ReadProperty(name string) *InteractionOutput { - - resp, err := ct.co.ReadProperty(ct.ThingID, name) - if err != nil { - return nil - } - iout := NewInteractionOutput(ct, messaging.AffordanceTypeProperty, name, resp.Data, resp.Timestamp) - ct.mux.Lock() - ct.propValues[name] = iout - ct.mux.Unlock() - return iout -} - -// Refresh reloads all property, event and action values from the Hub and updates -// the cache. -// This also updates the Thing title and description if they have corresponding -// properties. -func (ct *ConsumedThing) Refresh() error { - var iout *InteractionOutput - // refresh events - valueMap, err := digitwin.ThingValuesReadAllEvents(ct.co, ct.ThingID) - if err != nil { - return err - } - for name, _ := range ct.tdi.Events { - tv, found := valueMap[name] - if found { - iout = NewInteractionOutputFromValue(ct, messaging.AffordanceTypeEvent, tv) - } else { - iout = NewInteractionOutput(ct, messaging.AffordanceTypeEvent, name, nil, "") - } - ct.eventValues[name] = iout - } - // refresh properties - valueMap, err = digitwin.ThingValuesReadAllProperties(ct.co, ct.ThingID) - //valueMap, err = ct.co.ReadAllProperties(ct.ThingID) - if err != nil { - return err - } - for name, _ := range ct.tdi.Properties { - tv, found := valueMap[name] - if found { - iout = NewInteractionOutputFromValue(ct, messaging.AffordanceTypeProperty, tv) - // the consumed thing title and description can be modified with corresponding properties - if name == td.WoTTitle { - ct.Title = iout.Value.Text() - } else if name == td.WoTDescription { - ct.Description = iout.Value.Text() - } - } else { - iout = NewInteractionOutput( - ct, messaging.AffordanceTypeProperty, name, nil, "") - } - ct.propValues[name] = iout - } - // refresh action status - actionStatusMap, err := ct.co.QueryAllActions(ct.ThingID) - if err != nil { - return err - } - // update the ActionStatus for each of the Thing's actions - for name, _ := range ct.tdi.Actions { - as, found := actionStatusMap[name] - if !found { - // the server doesn't have a status, create an empty - // TODO: server should always return a status - as = messaging.ActionStatus{ - Name: name, - State: "n/a", - ThingID: ct.ThingID, - } - } - iout := NewInteractionOutputFromActionStatus(ct, as) - ct.actionOutputs[name] = iout - } - return nil -} - -// SubscribeEvent sets the handler to invoke when event with the name is received -// This returns an error if an existing subscriber already exists -func (ct *ConsumedThing) SubscribeEvent(name string, listener InteractionListener) error { - if _, found := ct.subscribers[name]; found { - return fmt.Errorf("An event subscriber is already registered") - } - ct.subscribers[name] = listener - return nil -} - -// TD return the TD document that is represented here. -func (ct *ConsumedThing) TD() *td.TD { - return ct.tdi -} - -// WriteProperty requests a change to a property value. -// -// Note that writing a property can take some time, especially if the device is asleep. -// run as a go-routing if measures are taken to receive async updates. -// -// Note that WoT does not specify a mechanism to confirm the success or failure of -// delivery and applying the value so this might timeout on incompatible Things. -// -// This returns an error if the request wasnt be completed. -// -// FIXME: provide a way to handle timeouts more gracefully. Maybe this should -// use async? -func (ct *ConsumedThing) WriteProperty(name string, ii InteractionInput) (err error) { - err = ct.co.WriteProperty(ct.ThingID, name, ii.Value.Raw, true) - return err -} - -// NewConsumedThing creates a new instance of a Thing -// Call Stop() when done -func NewConsumedThing(tdi *td.TD, co *consumer.Consumer) *ConsumedThing { - ct := ConsumedThing{ - ThingID: tdi.ID, - // title and description are updated with corresponding properties if they exist - Title: tdi.Title, - Description: tdi.Description, - tdi: tdi, - co: co, - observers: make(map[string]InteractionListener), - subscribers: make(map[string]InteractionListener), - actionInputs: make(map[string]*InteractionInput), - actionOutputs: make(map[string]*InteractionOutput), - eventValues: make(map[string]*InteractionOutput), - propValues: make(map[string]*InteractionOutput), - } - return &ct -} diff --git a/lib/consumedthing/ConsumedThingsDirectory.go b/lib/consumedthing/ConsumedThingsDirectory.go deleted file mode 100644 index 675a2f1b..00000000 --- a/lib/consumedthing/ConsumedThingsDirectory.go +++ /dev/null @@ -1,215 +0,0 @@ -package consumedthing - -import ( - "log/slog" - "sync" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" -) - -// ReadDirLimit is the maximum amount of TDs to read in one call -const ReadDirLimit = 1000 - -// ConsumedThingsDirectory manages a directory of consumed Things. -// -// The cache must be updated by an external consumer that receives update events. -type ConsumedThingsDirectory struct { - // the consumer connection for sending requests - co *consumer.Consumer - // Things used by the client - consumedThings map[string]*ConsumedThing - // directory of TD documents - directory map[string]*td.TD - // the full directory has been read in this session - fullDirectoryRead bool - mux sync.RWMutex -} - -// Consume creates a ConsumedThing instance for reading and operating a Thing. -// -// If an instance already exists it is returned, otherwise one is created using -// the ThingDescription. If the TD is unknown then request it from the directory -// This returns nil with an error if a valid Thing cannot be found. -func (cts *ConsumedThingsDirectory) Consume(thingID string) (ct *ConsumedThing, err error) { - cts.mux.RLock() - ct, found := cts.consumedThings[thingID] - cts.mux.RUnlock() - if !found { - // create a new instance using the known TD, or request a TD from the server - cts.mux.RLock() - tdi, found2 := cts.directory[thingID] - cts.mux.RUnlock() - if !found2 { - // request the TD from the Hub - tdi, err = cts.RetrieveThing(thingID) - if err != nil { - return nil, err - } - } - ct = NewConsumedThing(tdi, cts.co) - err = ct.Refresh() - if err != nil { - slog.Error("Consume failed", "ThingID", thingID, "err", err.Error()) - } else { - cts.mux.Lock() - cts.consumedThings[thingID] = ct - cts.mux.Unlock() - } - } - return ct, nil -} - -// GetForm returns the form for an operation on a Thing -//func (cts *ConsumedThingsDirectory) GetForm(op, thingID, name string) (f *td.Form) { -// tdi := cts.GetTD(thingID) -// if tdi != nil { -// f2 := tdi.GetForm(op, name, "") -// f = &f2 -// } -// return f -//} - -// IsActive returns whether the session has a connection to the Hub or is in the process of connecting. -//func (cs *ConsumedThingsDirectory) IsActive() bool { -// status := cs.co.GetStatus() -// return status.ConnectionStatus == hubclient.Connected || -// status.ConnectionStatus == hubclient.Connecting -//} - -// GetTD returns the TD in the session or nil if thingID isn't found -func (cts *ConsumedThingsDirectory) GetTD(thingID string) *td.TD { - cts.mux.RLock() - defer cts.mux.RUnlock() - // FIXME: get the TD from the server if it isn't in the cache - tdi := cts.directory[thingID] - return tdi -} - -// OnNotification updates the consumed things from subscriptions. -// To be invoked by the owner of the consumer when an async notification has -// been received. -// Any updates from things not in the directory are ignored. -func (cts *ConsumedThingsDirectory) OnNotification(notif *messaging.NotificationMessage) error { - - slog.Debug("ConsumedThingsDirectory.OnResponse", - slog.String("senderID", notif.SenderID), - slog.String("operation", notif.Operation), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - slog.String("clientID (me)", cts.co.GetClientID()), - ) - cts.mux.RLock() - ct, _ := cts.consumedThings[notif.ThingID] - cts.mux.RUnlock() - if ct != nil { - ct.OnNotification(notif) - } - return nil -} - -// ReadDirectory loads and decodes Thing Description documents from the directory. -// These are used by Consume() -// This currently limits the nr of things to ReadDirLimit. -// -// Set force to force a reload of the directory instead of using any cached TD documents. -// TODO: reload when things were added or removed -func (cts *ConsumedThingsDirectory) ReadDirectory(force bool) (map[string]*td.TD, error) { - cts.mux.RLock() - fullDirectoryRead := cts.fullDirectoryRead - currentDir := cts.directory - cts.mux.RUnlock() - - // return the cached version - if !force && fullDirectoryRead { - return currentDir, nil - } - newDir := make(map[string]*td.TD) - - // TODO: support for reading in pages - // TODO: use a WoT discovery/directory API instead of a digitwin api - thingsList, err := digitwin.ThingDirectoryRetrieveAllThings(cts.co, ReadDirLimit, 0) - if err != nil { - return newDir, err - } - for _, tdJson := range thingsList { - tdi := td.TD{} - err = jsoniter.UnmarshalFromString(tdJson, &tdi) - if err == nil { - newDir[tdi.ID] = &tdi - } - } - cts.mux.Lock() - defer cts.mux.Unlock() - cts.fullDirectoryRead = true - cts.directory = newDir - return newDir, nil -} - -// RetrieveThing requests the TD from the remote directory -func (cts *ConsumedThingsDirectory) RetrieveThing(thingID string) (*td.TD, error) { - // request the TD from the Hub - tdi := &td.TD{} - tdJson, err := digitwin.ThingDirectoryRetrieveThing(cts.co, thingID) - if err == nil { - err = jsoniter.UnmarshalFromString(tdJson, &tdi) - } - if err != nil { - return nil, err - } - cts.mux.Lock() - defer cts.mux.Unlock() - cts.directory[thingID] = tdi - return tdi, err -} - -//// SetResponseHandler registers a handler to receive async responses including events and property updates -//func (cts *ConsumedThingsDirectory) SetResponseHandler(handler func(message *transports.ResponseMessage)) { -// cts.mux.Lock() -// defer cts.mux.Unlock() -// cts.responseHandler = handler -//} - -// UpdateTD updates the TD document of a consumed thing. -// This returns the new consumed thing. -// If the consumed thing doesn't exist then ignore this and return nil as -// it looks like it isn't used. RetrieveThing will read it if requested. -// -// tdjson is the TD document in JSON format -func (cts *ConsumedThingsDirectory) UpdateTD(tdJSON string) *ConsumedThing { - // convert the TD - var tdi td.TD - err := jsoniter.UnmarshalFromString(tdJSON, &tdi) - if err != nil { - return nil - } - cts.mux.Lock() - defer cts.mux.Unlock() - // Replace the TD in the consumed thing - ct := cts.consumedThings[tdi.ID] - if ct == nil { - return nil - } - ct.tdi = &tdi - return ct -} - -// NewConsumedThingsDirectory returns a new instance of a directory for managing -// consumed Things for a client. -// -// This uses the consumer to send requests. -// The owner must call OnResponse() if an update is received. -// -// A consumed thing contains a Thing TD, with its property and event values. The -// values are updated when consumer events are received. -func NewConsumedThingsDirectory(co *consumer.Consumer) *ConsumedThingsDirectory { - ctm := ConsumedThingsDirectory{ - co: co, - consumedThings: make(map[string]*ConsumedThing), - directory: make(map[string]*td.TD), - } - return &ctm -} diff --git a/lib/consumedthing/DataSchemaValue.go b/lib/consumedthing/DataSchemaValue.go deleted file mode 100644 index 456d8918..00000000 --- a/lib/consumedthing/DataSchemaValue.go +++ /dev/null @@ -1,72 +0,0 @@ -package consumedthing - -import ( - "log/slog" - - "github.com/hiveot/hivekit/go/utils" -) - -// DataSchemaValue represents a value provided by an InteractionOutput. -// This differs from the WoT definition in that it includes type conversions. -type DataSchemaValue struct { - Raw any -} - -// Array returns the value as an array -// The result depends on the Schema type -// -// array: returns array of values as describe ni the Schema -// boolean: returns a single element true/false -// bytes: return an array of bytes -// int: returns a single element with integer -// object: returns a single element with object -// string: returns a single element with string -func (v DataSchemaValue) Array() []interface{} { - objArr := make([]interface{}, 0) - err := utils.DecodeAsObject(v.Raw, &objArr) - _ = err - return objArr -} - -func (v DataSchemaValue) Boolean() bool { - return utils.DecodeAsBool(v.Raw) -} -func (v DataSchemaValue) Integer() int { - return utils.DecodeAsInt(v.Raw) -} - -// Map returns the value as a name-value map -// Returns nil if no data was provided. -func (v DataSchemaValue) Map() map[string]interface{} { - o := make(map[string]interface{}) - err := utils.DecodeAsObject(v.Raw, &o) - if err != nil { - slog.Error("Can't convert value to a map", "value", v.Raw) - } - return o -} - -// Number returns the value as a float32 -func (v DataSchemaValue) Number() float32 { - return utils.DecodeAsNumber(v.Raw) -} - -// Object decodes the value to the given object. -func (v DataSchemaValue) Object(output interface{}) error { - return utils.DecodeAsObject(v.Raw, output) -} - -// Text returns the text representation of the value -func (v DataSchemaValue) Text() string { - return utils.DecodeAsString(v.Raw, 0) -} - -// ToString returns the text representation of the value with a size limit -func (v DataSchemaValue) ToString(maxlen int) string { - return utils.DecodeAsString(v.Raw, maxlen) -} - -// NewDataSchemaValue implements a dataschema value -func NewDataSchemaValue(value any) DataSchemaValue { - return DataSchemaValue{Raw: value} -} diff --git a/lib/consumedthing/InteractionInput.go b/lib/consumedthing/InteractionInput.go deleted file mode 100644 index d5bc16ad..00000000 --- a/lib/consumedthing/InteractionInput.go +++ /dev/null @@ -1,54 +0,0 @@ -package consumedthing - -import ( - "log/slog" - - "github.com/hiveot/hivekit/go/api/td" -) - -// InteractionInput contains the last value and data schema of an input -// Use NewInteractionInput to initialize -type InteractionInput struct { - // The property, event or action name - Name string - // Title with the human name provided by the interaction affordance - Title string - // Schema describing the data from property, event or action affordance - Schema *td.DataSchema - // decoded data in its native format as described by the schema - // eg string, int, array, object - Value DataSchemaValue -} - -// NewInteractionInput creates a new interaction input for property or action -// -// tdi Thing Description instance with schemas for the value. Use nil if schema is unknown. -// name of the input property or action -func NewInteractionInput(td *td.TD, name string, defaultValue any) *InteractionInput { - ii := &InteractionInput{ - Name: name, - Value: NewDataSchemaValue(defaultValue), - } - if td == nil { - return ii - } - - actionAff, found := td.Actions[name] - if found { - if actionAff.Input != nil { - ii.Schema = actionAff.Input - } - ii.Title = actionAff.Title - return ii - } - propAff, found := td.Properties[name] - if found { - ii.Schema = &propAff.DataSchema - ii.Title = propAff.Title - return ii - } - - slog.Warn("NewInteractionInput: name not a property or action input in TD", - "thingID", td.ID, "name", name) - return ii -} diff --git a/lib/consumedthing/InteractionOutput.go b/lib/consumedthing/InteractionOutput.go deleted file mode 100644 index b8a362c8..00000000 --- a/lib/consumedthing/InteractionOutput.go +++ /dev/null @@ -1,212 +0,0 @@ -package consumedthing - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -type InteractionOutputMap map[string]*InteractionOutput - -// NoValue contains the empty interaction output -// Intended to use when an instance is needed but not known. -// This should be treated as immutable and can be compared using pointers. -var noValue = &InteractionOutput{Schema: td.NoSchema()} - -func NoValue() *InteractionOutput { return noValue } - -// InteractionOutput contains the schema and value returned from WoT Interactions. -// Use NewInteractionOutput to initialize -// -// TODO: this seems like a whole lot of processing just to get a value... -// might want to do some performance and resource benchmarking. What is the cost -// and efficiency of using this io in an application vs getting raw data from a server? -type InteractionOutput struct { - // ID of the interaction output in a format that is internally used to identify - // properties, events and actions: {affordanceType}/{ThingID}/{Name} - // (Also used by the hiveoview server to notify of updates using SSE) - ID string - - // ID of the Thing whose output is exposed - ThingID string - // Name of the property, event or action affordance name - Name string - // Title with the human name provided by the interaction affordance - Title string - - // Schema describing the data from property, event or action affordance - // This is an empty schema without type, if none is known - Schema *td.DataSchema - - // decoded data in its native format as described by the schema - // eg string, int, array, object - Value DataSchemaValue - - // RFC822 timestamp this was last updated. - // Use utils.FormatDateTime(Timestamp,format) to format. - Timestamp string - - // Type of affordance: "property", "action", "event" - AffordanceType messaging.AffordanceType - - // Error value in case reading the value failed - Err error - - // senderID of last update (action, write property) - SenderID string -} - -//// FormatTime is a helper function to return the formatted time of the given ISO timestamp -//// The default format is RFC822 ("02 Jan 06 15:04 MST") -//func (iout *InteractionOutput) FormatTime(stamp string) (formattedTime string) { -// formattedTime = utils.FormatAuto(stamp) -// return formattedTime -//} - -// Substr is a simple helper that returns a substring of the given input -func (iout *InteractionOutput) Substr(data any, maxLen int) string { - text := utils.DecodeAsString(data, maxLen) - return text -} - -// ToString returns the value and unit as text for presentation purposes -// This includes the unit symbol if schema has it defined. -func (iout *InteractionOutput) ToString() string { - text := iout.Value.Text() - if iout.Schema.Unit != "" { - text = text + " " + iout.UnitSymbol() - } - return text -} - -// UnitSymbol returns the symbol of the unit of this schema using the vocabulary unit map -func (iout *InteractionOutput) UnitSymbol() string { - if iout.Schema.Unit == "" { - return "" - } - unit, found := vocab.UnitClassesMap[iout.Schema.Unit] - if !found { - return iout.Schema.Unit - } - return unit.Symbol -} - -// NewInteractionOutput creates a new immutable interaction output from the -// affordance type and raw value. -// -// ct Consumed Thing instance whose output this belongs to -// affType is one of AffordanceTypeAction, event or property -// name is the interaction affordance name the output belongs to -// raw is the raw data -// updated is the timestamp the data is last updated -func NewInteractionOutput(ct *ConsumedThing, - affType messaging.AffordanceType, name string, raw any, updated string) *InteractionOutput { - - var schema *td.DataSchema - var title string - - switch affType { - case messaging.AffordanceTypeAction: - aff := ct.GetActionAff(name) - - if aff == nil { - // fall back to properties that contain the action output - break - } - title = aff.Title - schema = aff.Output - case messaging.AffordanceTypeEvent: - aff := ct.GetEventAff(name) - if aff == nil { - break - } - title = aff.Title - schema = aff.Data - case messaging.AffordanceTypeProperty: - aff := ct.GetPropertyAff(name) - if aff == nil { - break - } - title = aff.Title - schema = &aff.DataSchema - } - if schema == nil { - schema = &td.DataSchema{ - Title: "NewInteractionOutput: tdi has no " + string(affType) + " output schema: " + name, - } - schema.ReadOnly = true // default unless proven otherwise - raw = "" - - } - if title == "" { - title = schema.Title - } - io := &InteractionOutput{ - ID: fmt.Sprintf("%s/%s/%s", affType, ct.ThingID, name), - AffordanceType: affType, - ThingID: ct.ThingID, - Name: name, - Title: title, - Timestamp: updated, - Schema: schema, - Value: NewDataSchemaValue(raw), - } - - return io -} - -func NewInteractionOutputFromValueList(ct *ConsumedThing, affType messaging.AffordanceType, values []digitwin.ThingValue) InteractionOutputMap { - ioMap := make(map[string]*InteractionOutput) - for _, tv := range values { - iout := NewInteractionOutput(ct, affType, tv.Name, tv.Data, tv.Timestamp) - ioMap[tv.Name] = iout - } - return ioMap -} - -func NewInteractionOutputFromValue(ct *ConsumedThing, affType messaging.AffordanceType, tv digitwin.ThingValue) *InteractionOutput { - iout := NewInteractionOutput(ct, affType, tv.Name, tv.Data, tv.Timestamp) - return iout -} - -// NewInteractionOutputFromNotification creates a new immutable interaction output from -// a NotificationMessage (event,property) and optionally its associated TD. -// -// If no td is available, this value conversion will still be usable but it won't -// contain any schema information. -// -// Intended for use when a property, or event value update is received from -// a subscription. -// -// See also the digitwin Values Service, which provides a ThingValue result that -// includes metadata such as a timestamp when it was last updated and who updated it. -// -// tm contains the received ThingMessage data -// tdi is the associated thing description -func NewInteractionOutputFromNotification( - ct *ConsumedThing, affType messaging.AffordanceType, notif *messaging.NotificationMessage) *InteractionOutput { - - iout := NewInteractionOutput(ct, affType, notif.Name, notif.Value, notif.Timestamp) - iout.SenderID = notif.SenderID - return iout -} - -// NewInteractionOutputFromActionStatus creates a new immutable interaction output from -// an action status and optionally its associated TD. -// -// If no td is available, this value conversion will still be usable but it won't -// contain any schema information. -// -// tm contains the received ThingMessage data -// tdi is the associated thing description -func NewInteractionOutputFromActionStatus( - ct *ConsumedThing, as messaging.ActionStatus) *InteractionOutput { - - iout := NewInteractionOutput(ct, messaging.AffordanceTypeAction, as.Name, as.Output, as.TimeUpdated) - iout.SenderID = as.SenderID - return iout -} diff --git a/lib/consumedthing/InteractionOutput_test.go b/lib/consumedthing/InteractionOutput_test.go deleted file mode 100644 index 045837d7..00000000 --- a/lib/consumedthing/InteractionOutput_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package consumedthing - -import ( - "log/slog" - "testing" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - - "github.com/stretchr/testify/assert" -) - -const key1ID = "key1" - -func MakeTD() *td.TD { - tdi := td.NewTD("thingID", "test Thing", "devicetype") - tdi.AddProperty(key1ID, "property 1", "test property", td.DataTypeString) - return tdi -} - -func TestNilSchema(t *testing.T) { - logging.SetLogging("", "") - slog.Info("--- TestNilSchema ---") - data1 := "text" - - notif := &messaging.NotificationMessage{Name: key1ID, Value: data1} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, notif) - - asValue := io.Value.Text() - assert.Equal(t, data1, asValue) - -} - -func TestArray(t *testing.T) { - data1 := []string{"item 1", "item 2"} - tv := &messaging.NotificationMessage{Name: key1ID, Value: data1} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, tv) - asArray := io.Value.Array() - assert.Len(t, asArray, 2) -} - -func TestBool(t *testing.T) { - - tv := &messaging.NotificationMessage{Name: key1ID, Value: true} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, tv) - asBool := io.Value.Boolean() - assert.Equal(t, true, asBool) - asString := io.Value.Text() - assert.Equal(t, "true", asString) - asInt := io.Value.Integer() - assert.Equal(t, 1, asInt) -} - -func TestInt(t *testing.T) { - data1 := 42 - tv := &messaging.NotificationMessage{Name: key1ID, Value: data1} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, tv) - asInt := io.Value.Integer() - assert.Equal(t, 42, asInt) - asString := io.Value.Text() - assert.Equal(t, "42", asString) -} - -func TestString(t *testing.T) { - data1 := "Hello world" - tv := &messaging.NotificationMessage{Name: key1ID, Value: data1} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, tv) - asString := io.Value.Text() - assert.Equal(t, data1, asString) -} - -func TestObject(t *testing.T) { - //schema := tdd.DataSchema{Type: vocab.DataTypeObject} - type User struct { - Name string - Age int - Active bool - LastLoginAt string - } - data1 := User{Name: "Bob", Age: 10, Active: true, LastLoginAt: "today"} - notif := &messaging.NotificationMessage{Name: key1ID, Value: data1} - tdi := MakeTD() - ct := NewConsumedThing(tdi, nil) - io := NewInteractionOutputFromNotification(ct, messaging.AffordanceTypeProperty, notif) - asMap := io.Value.Map() - assert.Equal(t, data1.Name, asMap["Name"]) -} diff --git a/lib/consumedthing/Subscription.go b/lib/consumedthing/Subscription.go deleted file mode 100644 index 51f9ea38..00000000 --- a/lib/consumedthing/Subscription.go +++ /dev/null @@ -1,8 +0,0 @@ -package consumedthing - -type Subscription interface { - // Active returns the subscription active status - Active() bool - // Stop the Subscription - Stop() -} diff --git a/lib/consumer/Consumer.go b/lib/consumer/Consumer.go deleted file mode 100644 index 2085e14e..00000000 --- a/lib/consumer/Consumer.go +++ /dev/null @@ -1,588 +0,0 @@ -package consumer - -import ( - "errors" - "fmt" - "log/slog" - "sync" - "sync/atomic" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/teris-io/shortid" -) - -const DefaultRpcTimeout = time.Second * 60 // 60 for testing; 3 seconds - -// Consumer provides the messaging functionality for consumers -// This provides a golang API to consumer operations. -type Consumer struct { - // application callback for reporting connection status change - appConnectHandlerPtr atomic.Pointer[messaging.ConnectionHandler] - - // application callback that handles asynchronous responses - appResponseHandlerPtr atomic.Pointer[messaging.ResponseHandler] - - // application callback that handles notifications - appNotificationHandlerPtr atomic.Pointer[messaging.NotificationHandler] - - // The underlying transport connection for delivering and receiving requests and responses - cc messaging.IConnection - - mux sync.RWMutex - - // The timeout to use when waiting for a response - rpcTimeout time.Duration - - // Request and Response channel helper - rnrChan *RnRChan -} - -// Disconnect the client connection. -// Do not use this consumer after disconnect. -func (co *Consumer) Disconnect() { - co.cc.Disconnect() - // the connect callback is still needed to notify the client of a disconnect -} - -// GetClientID returns the client's account ID -func (co *Consumer) GetClientID() string { - cinfo := co.cc.GetConnectionInfo() - return cinfo.ClientID -} - -// GetConnection returns the underlying connection of this consumer -func (co *Consumer) GetConnection() messaging.IConnection { - return co.cc -} - -// InvokeAction invokes an action on a thing and wait for the response -// If the response type is known then provide it with output, otherwise use interface{} -func (co *Consumer) InvokeAction( - dThingID, name string, input any, output any) error { - - req := messaging.NewRequestMessage(td.OpInvokeAction, dThingID, name, input, "") - resp, err := co.SendRequest(req, true) - - if err != nil { - return err - } else if resp.Error != nil { - return resp.Error.AsError() - } - err = resp.Decode(output) - return err -} - -// IsConnected returns true if the consumer has a connection -func (co *Consumer) IsConnected() bool { - return co.cc.IsConnected() -} - -// Logout requests invalidating all client sessions. -//func (co *Consumer) Logout() (err error) { -// -// slog.Info("Logout", -// slog.String("clientID", co.GetClientID())) -// -// req := transports.NewRequestMessage(td.HTOpLogout, "", "", nil, "") -// _, err = co.SendRequest(req, true) -// return err -//} - -// ObserveProperty sends a request to observe one or all properties -// -// thingID is empty for all things -// name is empty for all properties of the selected things -func (co *Consumer) ObserveProperty(thingID string, name string) error { - op := td.OpObserveProperty - if name == "" { - op = td.OpObserveAllProperties - } - req := messaging.NewRequestMessage(op, thingID, name, nil, "") - resp, err := co.SendRequest(req, true) - _ = resp - return err -} - -// connection status handler -func (co *Consumer) onConnect(connected bool, err error, c messaging.IConnection) { - hPtr := co.appConnectHandlerPtr.Load() - if hPtr != nil { - (*hPtr)(connected, err, c) - } -} - -// onNotification passes a response to the RnR response channel and falls back to pass -// it to the registered application response handler. If neither is available -// then turn the response in a notification and pass it to the notification handler. -func (co *Consumer) onNotification(notif *messaging.NotificationMessage) { - - hPtr := co.appNotificationHandlerPtr.Load() - if hPtr == nil { - if notif.Operation == td.OpInvokeAction { - // not everyone is interested in action progress updates - slog.Info("onNotification: Action progress received. No handler registered", - "operation", notif.Operation, - "clientID", co.GetClientID(), - "thingID", notif.ThingID, - "name", notif.Name, - ) - } else { - // When subscribing then a handler is expected - slog.Error("onNotification: Notification received but no handler registered", - "correlationID", notif.CorrelationID, - "operation", notif.Operation, - "clientID", co.GetClientID(), - "thingID", notif.ThingID, - "name", notif.Name, - ) - } - return - } - // pass the response to the registered handler - slog.Info("onNotification", - "operation", notif.Operation, - "clientID", co.GetClientID(), - "thingID", notif.ThingID, - "name", notif.Name, - "value", notif.ToString(50), - ) - (*hPtr)(notif) -} - -// onResponse passes a response to the RnR response channel and falls back to pass -// it to the registered application response handler. If neither is available -// then turn the response in a notification and pass it to the notification handler. -func (co *Consumer) onResponse(resp *messaging.ResponseMessage) error { - - handled := co.rnrChan.HandleResponse(resp) - if handled { - return nil - } - - // handle the response as an async response with no wait handler registered - hPtr := co.appResponseHandlerPtr.Load() - if hPtr == nil { - // at least one of the handlers should be registered - slog.Error("Response received but no handler registered", - "correlationID", resp.CorrelationID, - "operation", resp.Operation, - "clientID", co.GetClientID(), - "thingID", resp.ThingID, - "name", resp.Name, - ) - err := fmt.Errorf("response received but no handler registered") - return err - } - // pass the response to the registered handler - slog.Info("onResponse (async)", - "operation", resp.Operation, - "clientID", co.GetClientID(), - "thingID", resp.ThingID, - "name", resp.Name, - "value", resp.ToString(50), - ) - return (*hPtr)(resp) -} - -// Ping the server and wait for a pong response -// This uses the underlying transport native method of ping-pong. -func (co *Consumer) Ping() error { - correlationID := shortid.MustGenerate() - req := messaging.NewRequestMessage(td.HTOpPing, "", "", nil, correlationID) - resp, err := co.SendRequest(req, true) - if err != nil { - return err - } - if resp.Value == nil { - return errors.New("ping returned successfully but received no data") - } - return nil -} - -// QueryAction obtains the status of an action -// -// Q: http-basic protocol returns an array per action in QueryAllActions but only -// -// a single action in QueryAction. This is inconsistent. -// -// The underlying protocol binding constructs the ActionStatus from the -// protocol specific messages. -// The hiveot protocol passes this as-is as the output. -func (co *Consumer) QueryAction(thingID, name string) ( - value messaging.ActionStatus, err error) { - - err = co.Rpc(td.OpQueryAction, thingID, name, nil, &value) - // if state is empty then this action has not run before - if err == nil && value.State == "" { - value.ThingID = thingID - value.Name = name - } - return value, err -} - -// QueryAllActions returns a map of action status for all actions of a thing. -// -// This returns a map of actionName and the last known action status. -// -// Q: http-basic protocol returns an array for each action. What is the use-case? -// -// that can have multiple concurrent actions? An actuator can only move in -// one direction at the same time. -// Maybe the array only applies to stateless actions? -// -// This depends on the underlying protocol binding to construct appropriate -// ActionStatus message. All hiveot protocols include full information. -// WoT bindings might not include update timestamp and such. -func (co *Consumer) QueryAllActions(thingID string) ( - values map[string]messaging.ActionStatus, err error) { - - err = co.Rpc(td.OpQueryAllActions, thingID, "", nil, &values) - return values, err -} - -// ReadAllEvents sends a request to read all Thing event values from the hub. -// -// This returns a map of eventName and the last received event message. -// -// TODO: maybe better to send the last events on subscription... -//func (co *Consumer) ReadAllEvents(thingID string) ( -// values map[string]transports.ThingValue, err error) { -// -// err = co.Rpc(td.HTOpReadAllEvents, thingID, "", nil, &values) -// return values, err -//} - -// ReadAllProperties sends a request to read all Thing property values. -// -// This depends on the underlying protocol binding to construct appropriate -// ResponseMessages and include information such as Timestamp. All hiveot protocols -// include full information. WoT bindings might be more limited. -func (co *Consumer) ReadAllProperties(thingID string) ( - values map[string]messaging.ThingValue, err error) { - - err = co.Rpc(td.OpReadAllProperties, thingID, "", nil, &values) - return values, err -} - -// ReadAllTDs sends a request to read all TDs from an agent -// This returns an array of TDs in JSON format -// This is not a WoT operation (but maybe it should be) -//func (co *Consumer) ReadAllTDs() (tdJSONs []string, err error) { -// err = co.Rpc(td.HTOpReadAllTDs, "", "", nil, &tdJSONs) -// return tdJSONs, err -//} - -// ReadEvent sends a request to read a Thing event value. -// -// This returns a ResponseMessage containing the value as described in the TD -// event affordance schema. -// -// TODO: maybe better to send the last events on subscription... -//func (co *Consumer) ReadEvent(thingID, name string) ( -// value transports.ThingValue, err error) { -// -// err = co.Rpc(td.HTOpReadEvent, thingID, name, nil, &value) -// return value, err -//} - -// ReadProperty sends a request to read a Thing property value. -// -// This depends on the underlying protocol binding to construct appropriate -// ResponseMessages and include information such as Timestamp. All hiveot protocols -// include full information. WoT bindings might be too limited. -func (co *Consumer) ReadProperty(thingID, name string) ( - value messaging.ThingValue, err error) { - - err = co.Rpc(td.OpReadProperty, thingID, name, nil, &value) - return value, err -} - -// RetrieveThing sends a request to read the latest Thing TD -// This returns the TD in JSON format. -// This is not a WoT operation (but maybe it should be) -//func (co *Consumer) RetrieveThing(thingID string) (tdJSON string, err error) { -// err = co.Rpc(td.HTOpReadTD, thingID, "", nil, &tdJSON) -// return tdJSON, err -//} - -// RefreshToken refreshes the authentication token -// The resulting token can be used with 'SetBearerToken' -// This is specific to the Hiveot Hub. -//func (co *Consumer) RefreshToken(oldToken string) (newToken string, err error) { -// -// // FIXME: what is the WoT standard for refreshing a token using http? -// slog.Info("RefreshToken", -// slog.String("clientID", co.GetClientID())) -// -// req := transports.NewRequestMessage(td.HTOpRefresh, "", "", oldToken, "") -// resp, err := co.SendRequest(req, true) -// -// // set the new token as the bearer token -// if err == nil { -// newToken = tputils.DecodeAsString(resp.Value, 0) -// } -// return newToken, err -//} - -// Rpc sends a request message and waits for a response. -// This returns an error if the request fails or if the response contains an error -func (co *Consumer) Rpc(operation, thingID, name string, input any, output any) error { - correlationID := shortid.MustGenerate() - req := messaging.NewRequestMessage(operation, thingID, name, input, correlationID) - resp, err := co.SendRequest(req, true) - if err == nil { - if resp.Error != nil { - err = resp.Error.AsError() - } else { - err = resp.Decode(output) - } - } - return err -} - -// SendRequest sends an operation request and optionally waits for completion or timeout. -// If waitForCompletion is true and no correlationID is provided then a correlationID will -// be generated to wait for completion. -// -// If waitForCompletion is false then any response will go to the async response -// handler and this returns nil response. -// If waitForCompletion is true this will wait until a response is received with -// a matching correlationID, or until a timeout occurs. -// -// If the request has no correlation ID, one will be generated. -func (co *Consumer) SendRequest(req *messaging.RequestMessage, waitForCompletion bool) ( - resp *messaging.ResponseMessage, err error) { - - t0 := time.Now() - slog.Info("SendRequest: ->", - slog.String("op", req.Operation), - slog.String("dThingID", req.ThingID), - slog.String("name", req.Name), - slog.String("correlationID", req.CorrelationID), - slog.String("input", req.ToString(30)), - ) - // if not waiting then return asap with a pending response - if !waitForCompletion { - err = co.cc.SendRequest(req) - return nil, err - } - - if req.CorrelationID == "" { - req.CorrelationID = shortid.MustGenerate() - } - // open a return channel for the response - rChan := co.rnrChan.Open(req.CorrelationID) - - err = co.cc.SendRequest(req) - - if err != nil { - slog.Warn("SendRequest ->: error in sending request", - "dThingID", req.ThingID, - "name", req.Name, - "correlationID", req.CorrelationID, - "err", err.Error()) - co.rnrChan.Close(req.CorrelationID) - return resp, err - } - // hmm, not pretty but during login the connection status can be ignored - // the alternative is not to use SendRequest but plain TLS post - //ignoreDisconnect := req.Operation == td.HTOpLogin || req.Operation == td.HTOpRefresh - ignoreDisconnect := false - resp, err = co.WaitForCompletion(rChan, req.Operation, req.CorrelationID, ignoreDisconnect) - - t1 := time.Now() - duration := t1.Sub(t0) - if err != nil { - slog.Info("SendRequest: <- failed", - slog.String("op", req.Operation), - slog.Int64("duration msec", duration.Milliseconds()), - slog.String("correlationID", req.CorrelationID), - slog.String("error", err.Error())) - } else { - errMsg := "" - if resp.Error != nil { - errMsg = resp.Error.String() - } - slog.Info("SendRequest: <-", - slog.String("op", req.Operation), - slog.Float64("duration msec", float64(duration.Microseconds())/1000), - slog.String("correlationID", req.CorrelationID), - slog.String("err", errMsg), - slog.String("output", resp.ToString(30)), - ) - } - return resp, err -} - -// SetConnectHandler sets the notification handler of changes to this consumer connection -// Intended to notify the client that a reconnect or relogin is needed. -// Only a single handler is supported. This replaces the previously set callback. -func (co *Consumer) SetConnectHandler(cb messaging.ConnectionHandler) { - if cb == nil { - co.appConnectHandlerPtr.Store(nil) - } else { - co.appConnectHandlerPtr.Store(&cb) - } -} - -// SetNotificationHandler sets the notification handler of events for this consumer -// Only a single handler is supported. This replaces the previously set callback. -func (co *Consumer) SetNotificationHandler(cb messaging.NotificationHandler) { - if cb == nil { - co.appNotificationHandlerPtr.Store(nil) - } else { - co.appNotificationHandlerPtr.Store(&cb) - } -} - -// SetResponseHandler set the handler that receives asynchronous responses -// Those are response to requests that are not waited for using the baseRnR handler. -func (co *Consumer) SetResponseHandler(cb messaging.ResponseHandler) { - if cb == nil { - co.appResponseHandlerPtr.Store(nil) - } else { - co.appResponseHandlerPtr.Store(&cb) - } -} - -// Subscribe to one or all events of a thing -// name is the event to subscribe to or "" for all events -func (co *Consumer) Subscribe(thingID string, name string) error { - op := td.OpSubscribeEvent - if name == "" { - op = td.OpSubscribeAllEvents - } - req := messaging.NewRequestMessage(op, thingID, name, nil, "") - resp, err := co.SendRequest(req, true) - _ = resp - return err -} - -// UnobserveProperty a previous observed property or all properties -func (co *Consumer) UnobserveProperty(thingID string, name string) error { - op := td.OpUnobserveProperty - if name == "" { - op = td.OpUnobserveAllProperties - } - req := messaging.NewRequestMessage(op, thingID, name, nil, "") - resp, err := co.SendRequest(req, true) - _ = resp - return err -} - -// Unsubscribe is a helper for sending an unsubscribe request -func (co *Consumer) Unsubscribe(thingID string, name string) error { - op := td.OpUnsubscribeEvent - if name == "" { - op = td.OpUnsubscribeAllEvents - } - req := messaging.NewRequestMessage(op, thingID, name, nil, "") - resp, err := co.SendRequest(req, true) - _ = resp - return err -} - -// WaitForCompletion waits for a completed or failed response message on the -// given correlationID channel, or until N seconds passed, or the connection drops. -// -// If a proper response is received it is written to the given output and nil -// (no error) is returned. -// If anything goes wrong, an error is returned -func (co *Consumer) WaitForCompletion( - rChan chan *messaging.ResponseMessage, operation, correlationID string, ignoreDisconnect bool) ( - resp *messaging.ResponseMessage, err error) { - - waitCount := 0 - var completed bool - - for !completed { - // If the server connection no longer exists then don't wait any longer. - // The problem with this is that a response can already be available before - // a disconnect occurred, which we'll miss here. - // Especially in case of login or token refresh isconnected check should - // not be used. - if !co.cc.IsConnected() && !ignoreDisconnect { - err = errors.New("connection lost") - break - } - - // wait at most co.timeout or until delivery completes or fails - // if the connection breaks while waiting then tlsClient will be nil. - if time.Duration(waitCount)*time.Second > co.rpcTimeout { - err = errors.New("timeout. No response") - break - } - if waitCount > 0 { - slog.Info("WaitForCompletion (wait)", - slog.Int("count", waitCount), - slog.String("clientID", co.GetClientID()), - slog.String("operation", operation), - slog.String("correlationID", correlationID), - ) - } - completed, resp = co.rnrChan.WaitForResponse(rChan, time.Second) - waitCount++ - } - - // ending the wait - co.rnrChan.Close(correlationID) - slog.Debug("WaitForCompletion (result)", - slog.String("clientID", co.GetClientID()), - slog.String("operation", operation), - slog.String("correlationID", correlationID), - ) - - // check for errors - if err != nil { - slog.Warn("WaitForCompletion failed", "err", err.Error()) - } else if resp == nil { - err = fmt.Errorf("no response received on request '%s'", operation) - } else if resp.Error != nil { - // if response data holds an error type then return that as the error - err = resp.Error.AsError() - } - return resp, err -} - -// WriteProperty is a helper to send a write property request -func (co *Consumer) WriteProperty(thingID string, name string, input any, wait bool) error { - correlationID := shortid.MustGenerate() - req := messaging.NewRequestMessage(td.OpWriteProperty, thingID, name, input, correlationID) - resp, err := co.SendRequest(req, wait) - _ = resp - return err -} - -// NewConsumer returns a new instance of the WoT consumer for use with the given -// connection. The connection should not be used by others as this consumer takes -// possession by registering connection callbacks. -// -// This provides the API for common WoT operations such as invoking actions and -// supports RPC calls by waiting for a response. -// -// Use SetNotificationHandler to set the callback to receive async notifications. -// Use SetResponseHandler to set the callback to receive async responses. -// Use SetConnectHandler to set the callback to be notified of connection changes. -// -// cc the client connection to use for sending requests and receiving responses. -// timeout of the rpc connections or 0 for default (3 sec) -func NewConsumer(cc messaging.IConnection, rpcTimeout time.Duration) *Consumer { - if rpcTimeout == 0 { - rpcTimeout = DefaultRpcTimeout - } - consumer := Consumer{ - cc: cc, - rnrChan: NewRnRChan(), - rpcTimeout: rpcTimeout, - } - consumer.SetNotificationHandler(nil) - consumer.SetConnectHandler(nil) - consumer.SetResponseHandler(nil) - // set the connection callbacks to this consumer - cc.SetNotificationHandler(consumer.onNotification) - cc.SetResponseHandler(consumer.onResponse) - cc.SetConnectHandler(consumer.onConnect) - return &consumer -} diff --git a/lib/consumer/RnR.go b/lib/consumer/RnR.go deleted file mode 100644 index 361f11f0..00000000 --- a/lib/consumer/RnR.go +++ /dev/null @@ -1,144 +0,0 @@ -package consumer - -import ( - "context" - "log/slog" - "sync" - "time" - - "github.com/hiveot/hub/lib/messaging" -) - -// RnRChan is a helper for Request 'n Response message handling using channels. -// Intended to link responses in asynchronous request-response communication. -// -// Usage: -// 1. create a request ID: shortid.MustGenerate() -// 2. register the request ID: c := Open(correlationID) -// 3. Send the request message in the client, passing the correlationID -// 4. Wait for a response: completed, data := WaitForResponse(c, timeout) -// 5. Handle response message (in client callback): HandleResponse(correlationID,data) -type RnRChan struct { - mux sync.RWMutex - - // map of correlationID to delivery status update channel - correlData map[string]chan *messaging.ResponseMessage - - //timeout write to a response channel - writeTimeout time.Duration -} - -// Close removes the request channel -func (rnr *RnRChan) Close(correlationID string) { - rnr.mux.Lock() - defer rnr.mux.Unlock() - - //slog.Info("closing channel. ", "correlationID", correlationID) - rChan, found := rnr.correlData[correlationID] - if found { - delete(rnr.correlData, correlationID) - close(rChan) - } -} - -// CloseAll force closes all channels, ending all waits for RPC responses. -func (rnr *RnRChan) CloseAll() { - rnr.mux.Lock() - defer rnr.mux.Unlock() - for _, rChan := range rnr.correlData { - close(rChan) - } - rnr.correlData = make(map[string]chan *messaging.ResponseMessage) - -} - -// HandleResponse writes a reply to the request channel. -// -// This returns true on success or false if correlationID is unknown (no-one is waiting) -// It is up to the handler of this response to close the channel when done. -// -// If a timeout passes while writing is block the write is released. -func (rnr *RnRChan) HandleResponse(msg *messaging.ResponseMessage) bool { - // Note: avoid a race between closing the channel and writing multiple responses. - // This would happen if a 'pending' response arrives after a 'completed' response, - // and 'wait-for-response' closes the channel while the second result is written. - // This would panic, so lock the lookup and writing of the response channel. - rnr.mux.Lock() - rChan, isRPC := rnr.correlData[msg.CorrelationID] - defer rnr.mux.Unlock() - if isRPC { - slog.Debug("HandleResponse: writing response to RPC go channel. ", - slog.String("correlationID", msg.CorrelationID), - slog.String("operation", msg.Operation), - ) - ctx, cancelFn := context.WithTimeout(context.Background(), rnr.writeTimeout) - select { - case rChan <- msg: - case <-ctx.Done(): - // this should never happen - slog.Error("Response RPC go channel is full. Is no-one listening?") - // recover - isRPC = false - } - cancelFn() - } else { - //slog.Info("HandleResponse: not an RPC call (subscription).", - // slog.String("correlationID", msg.CorrelationID), - // slog.String("operation", msg.Operation)) - } - return isRPC -} - -func (rnr *RnRChan) Len() int { - rnr.mux.Lock() - defer rnr.mux.Unlock() - return len(rnr.correlData) -} - -// Open a new channel for receiving response to a request -// Call Close when done. -// -// This returns a reply channel on which the data is received. Use -// WaitForResponse(rChan) -func (rnr *RnRChan) Open(correlationID string) chan *messaging.ResponseMessage { - //slog.Info("opening channel. ", "correlationID", correlationID) - // this needs to be able to buffer 1 response in case completed and pending - // are received out of order. - rChan := make(chan *messaging.ResponseMessage, 1) - rnr.mux.Lock() - rnr.correlData[correlationID] = rChan - rnr.mux.Unlock() - return rChan -} - -// WaitForResponse waits for an answer received on the reply channel. -// After timeout without response this returns with completed is false. -// -// Use 'autoclose' to immediately close the channel when no further responses are -// expected. (they would not be read and thus lost) -// -// If the channel was closed this returns hasResponse with no reply -func (rnr *RnRChan) WaitForResponse( - replyChan chan *messaging.ResponseMessage, timeout time.Duration) ( - hasResponse bool, resp *messaging.ResponseMessage) { - - ctx, cancelFunc := context.WithTimeout(context.Background(), timeout) - defer cancelFunc() - select { - case rData := <-replyChan: - resp = rData - hasResponse = true - break - case <-ctx.Done(): - hasResponse = false - } - return hasResponse, resp -} - -func NewRnRChan() *RnRChan { - r := &RnRChan{ - correlData: make(map[string]chan *messaging.ResponseMessage), - writeTimeout: time.Second * 300, // default 3 - } - return r -} diff --git a/lib/exposedthing/HandleRequest.go b/lib/exposedthing/HandleRequest.go deleted file mode 100644 index b0d1e360..00000000 --- a/lib/exposedthing/HandleRequest.go +++ /dev/null @@ -1,13 +0,0 @@ -package exposedthing - -import ( - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" -) - -type ThingAction struct { - Name string - Handler func(message *messaging.RequestMessage) *messaging.ResponseMessage - Input *td.DataSchema // reuse common schemas - Output *td.DataSchema -} diff --git a/lib/exposedthing/ThingValues.go b/lib/exposedthing/ThingValues.go deleted file mode 100644 index e368d37b..00000000 --- a/lib/exposedthing/ThingValues.go +++ /dev/null @@ -1,109 +0,0 @@ -package exposedthing - -import ( - "strconv" - "sync" -) - -// TODO: merge with digital twin as part of a 'wot-kernel' - -// ThingValues is a helper type for managing property and event values and their changes. -// This is concurrent safe. -type ThingValues struct { - latest map[string]any - changed map[string]any - mux sync.RWMutex -} - -// GetValue returns the string representation of the latest value for the given name -// If the name doesn't exist this returns an empty string -func (pv *ThingValues) GetValue(name string) (value any, found bool) { - pv.mux.RLock() - defer pv.mux.RUnlock() - value, found = pv.latest[name] - return value, found -} - -// GetValues returns the latest or changed values and reset the changed values -// If onlyChanges is set then only return the changes -func (pv *ThingValues) GetValues(onlyChanges bool) (v map[string]any) { - if onlyChanges { - v = pv.GetChanged(true) - } else { - v = pv.GetLatest() - } - pv.changed = make(map[string]any) - return v -} - -// GetLatest returns the latest map of properties -// This does not reset the 'changed' values so it can be called as often as needed. -func (pv *ThingValues) GetLatest() map[string]any { - pv.mux.RLock() - defer pv.mux.RUnlock() - latestValues := make(map[string]any) - for k, v := range pv.latest { - latestValues[k] = v - } - return latestValues -} - -// HasChanges returns whether there are changed properties -func (pv *ThingValues) HasChanges() bool { - pv.mux.RLock() - defer pv.mux.RUnlock() - return len(pv.changed) > 0 -} - -// GetChanged returns a copy of the map of changed property values -// clear will clear the changed values -func (pv *ThingValues) GetChanged(clear bool) map[string]any { - pv.mux.RLock() - defer pv.mux.RUnlock() - changedValues := pv.changed - if clear { - pv.changed = make(map[string]any) - } else { - changedValues = make(map[string]any) - for k, v := range pv.changed { - changedValues[k] = v - } - } - return changedValues -} - -// SetValue update the property 'name' with a string value -// Returns true if changed. -func (pv *ThingValues) SetValue(name string, newValue any) { - pv.mux.Lock() - defer pv.mux.Unlock() - oldValue, found := pv.latest[name] - if !found || oldValue != newValue { - pv.latest[name] = newValue - pv.changed[name] = newValue - } -} - -// SetValueBool sets the boolean true/false value in the property map -func (pv *ThingValues) SetValueBool(name string, newValue bool) { - valueString := "false" - if newValue { - valueString = "true" - } - pv.SetValue(name, valueString) -} - -// SetValueInt sets the integer value in the property map -func (pv *ThingValues) SetValueInt(name string, newValue int) { - valueString := strconv.FormatInt(int64(newValue), 10) - pv.SetValue(name, valueString) -} - -// NewThingValues creates a new set of maps for storing and tracking changes to -// property or event values -func NewThingValues() *ThingValues { - return &ThingValues{ - latest: make(map[string]any), - changed: make(map[string]any), - } -} diff --git a/lib/hubagent/AgentHelper.go b/lib/hubagent/AgentHelper.go deleted file mode 100644 index 7dbcaccc..00000000 --- a/lib/hubagent/AgentHelper.go +++ /dev/null @@ -1,62 +0,0 @@ -package hubagent - -import ( - "fmt" - - "github.com/hiveot/hub/lib/messaging" -) - -// Deprecated: This is a reflection based request handler that invokes a service -// native API with the request parameters. Intended to eliminate boilerplate code. -// While clever it is hard to debug and trace and reflection isnt super fast. -// Replaced with the tm2go generator that generates easy to read boilerplate code. -// Services are expected to define a TD and generate their API from the TD. -// This file is kept for a while to not lose the reflection magic. - -// AgentHandler is a helper that maps messages to a Thing (service) invocation -// -// On receiving a message for a Thing: -// 1. looks-up the method name and obtains the registered method -// 2a. if the method has an argument (args struct) then -// 2.1 Instantiate the args struct -// 2.2 Decode the message request data into the arguments struct -// 2.3 invoke the method with the argument -// 2b. if the method doesn't have an argument -// 2.4 invoke the method without an argument -// -// Replace with generated clients using tm2go, that doesn't use reflection. -type AgentHandler struct { - // the thing this agent is a handler for - thingID string - methods map[string]interface{} -} - -func (agent *AgentHandler) InvokeMethod( - method interface{}, msg *messaging.RequestMessage) (output any, err error) { - - respData, err := HandleRequestMessage(msg.SenderID, method, msg.Input) - return respData, err -} - -func (agent *AgentHandler) HandleRequest( - req *messaging.RequestMessage, _ messaging.IConnection) *messaging.ResponseMessage { - - if req.ThingID == agent.thingID { - method, found := agent.methods[req.Name] - if found { - output, err := agent.InvokeMethod(method, req) - return req.CreateResponse(output, err) - } - } - err := fmt.Errorf( - "agent for service '%s' does not have method '%s'", agent.thingID, req.Name) - return req.CreateResponse(nil, err) -} - -func NewAgentHandler(thingID string, methods map[string]interface{}) *AgentHandler { - agent := AgentHandler{ - thingID: thingID, - methods: methods, - } - return &agent -} diff --git a/lib/hubagent/HandleRequestMessage.go b/lib/hubagent/HandleRequestMessage.go deleted file mode 100644 index 45eccc0d..00000000 --- a/lib/hubagent/HandleRequestMessage.go +++ /dev/null @@ -1,150 +0,0 @@ -package hubagent - -import ( - "errors" - "fmt" - jsoniter "github.com/json-iterator/go" - "reflect" -) - -// ServiceContext with context provided to services -//type ServiceContext struct { -// context.Context -// // SenderID of the caller -// SenderID string -//} - -// HandleRequestMessage unmarshal an agent request message parameters, passes it to the associated method, -// and marshals the result. Intended to remove boilerplate from RPC service request handlers. -// -// The first argument can optionally be the senderID of the clientID invoking the request. -// The second argument and results can be a struct value or reference. -// -// Since the arguments are JSON serialized, the wire protocol expects -// a single (type1) struct that holds the parameters. If the handler -// implements multiple arguments, they all receive the same payload if they -// are of the same type. -// -// Supported method types are: -// -// - func([string,] type1) (type2,error) -// - func([string,] type1) (error) -// - func([string,] type1) () -// - func([string]) (type,error) -// - func([string]) (error) -// - func([string]) () -// -// where type1 and type2 can be a struct or native type, or a pointer to a struct or native type. -func HandleRequestMessage(senderID string, method interface{}, data any) (respData any, err error) { - - // magic spells found at: https://github.com/a8m/reflect-examples#call-function-with-list-of-arguments-and-validate-return-values - // and here: https://stackoverflow.com/questions/45679408/unmarshal-json-to-reflected-struct - // First determine the type of argument of the method and whether it is passed by value or reference - methodValue := reflect.ValueOf(method) - methodType := methodValue.Type() - argv := make([]reflect.Value, methodType.NumIn()) - nrArgs := methodType.NumIn() - - // TODO: IMPROVE ON THIS or replace it with tdd2go generated code - // this uses reflection and argument marshal/unmarshal to convert to the proper type - payload, _ := jsoniter.Marshal(data) - - for i := 0; i < nrArgs; i++ { - // determine the type of argument, expect the senderID string as first arg - argType := methodType.In(i) - argKind := argType.Name() - argIsSenderID := i == 0 && argKind == "string" - if argIsSenderID { - // first argument is the sender clientID - argv[i] = reflect.ValueOf(senderID) - } else { - // the argument is not a senderID string - argIsRef := (argType.Kind() == reflect.Ptr) - n1 := reflect.New(argType) // pointer to a new zero value of type - n1El := n1.Elem() - // n1El now contains the value of the argument type. - // ? Would it not contain a pointer if passed by value ? apparently not ??? - if argIsRef { - // n1El is a struct pointer value? - // for some reason, unmarshall still needs to receive the address of it - err = jsoniter.Unmarshal(payload, n1El.Addr().Interface()) - argv[i] = reflect.ValueOf(n1El.Interface()) - } else { - // n1El is the value, unmarshal to its address - err = jsoniter.Unmarshal(payload, n1El.Addr().Interface()) - argv[i] = reflect.ValueOf(n1El.Interface()) - } - if err != nil { - return "", fmt.Errorf("failed unmarshal request: %s", err) - } - } - - } - - resValues := methodValue.Call(argv) - var errResp interface{} - var resp interface{} - if len(resValues) == 0 { - errResp = nil - } else if len(resValues) == 1 { - // only returns an error value - resp = nil - v0 := resValues[0] - errResp = v0.Interface() - } else if len(resValues) == 2 { - // returns a struct result to be marshalled and an error value - v0 := resValues[0] - resp = v0.Interface() - v1 := resValues[1] - errResp = v1.Interface() - } else { - return "", - errors.New("method has more than 2 result params. This is not supported") - } - if errResp == nil { - err = nil - } else { - err = errResp.(error) - } - - return resp, err -} - -// RegisterRPCCapability is a helper to easily subscribe capability methods with their handler. -// The handler must have signature func(clientID string, args interface{})(interface{},error) -// -// hc is the agent connection to the message bus -// capID is the capability to register -// capMethods maps method names to their implementation. Supported formats: -// 1: no context, args or result: func()(error) -// 2: context, args, and result : func(*ServiceContext,args interface{})(interface{},error) -// 3: context, args, no result : func(*ServiceContext,args interface{})(error) -// 4: context, no args, result : func(*ServiceContext)(interface{}, error) -//func RegisterRPCCapability(hc IHubTransport, capID string, capMethods map[string]interface{}) (ISubscription, error) { -// // subscribe to the capability with our own handler. -// // the handler invokes the method registered with the capability map, -// // after unmarshalling the request argument. -// sub, err := hc.SubRequest(capID, func(msg *RequestMessage) error { -// var err error -// var respData []byte -// -// capMethod, found := capMethods[msg.Name] -// if !found { -// return fmt.Errorf("method '%s' not part of capability '%s'", msg.Name, capID) -// } -// ctx := ServiceContext{ -// Context: context.Background(), -// SenderID: msg.SenderID, -// } -// respData, err = HandleRequestMessage(ctx, capMethod, msg.Payload) -// if err == nil { -// if respData == nil { -// err = msg.SendAck() -// } else { -// err = msg.SendReply(respData, err) -// } -// } -// return err -// }) -// return sub, err -//} diff --git a/lib/hubagent/HandleRequestMessage_test.go b/lib/hubagent/HandleRequestMessage_test.go deleted file mode 100644 index cf8a8998..00000000 --- a/lib/hubagent/HandleRequestMessage_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package hubagent_test - -import ( - "fmt" - "log/slog" - "testing" - "time" - - "github.com/hiveot/hub/lib/hubagent" - jsoniter "github.com/json-iterator/go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type M1Args struct { - P1 string - P2 int -} -type M1Res struct { - R1 string -} - -const senderID = "sender1" - -func Method1Ref(args *M1Args) (*M1Res, error) { - //slog.Info("Method1 called.", "p1", args.P1) - res := M1Res{R1: args.P1} - return &res, nil -} -func Method1Val(args M1Args) (M1Res, error) { - slog.Info("Method1 called.", "p1", args.P1) - res := M1Res{R1: args.P1} - return res, nil -} - -func Method2NoArgs() { - slog.Info("Method2 called.") -} -func Method3ErrorResult() error { - slog.Info("Method3 called, returning error") - return fmt.Errorf("method3 returns an error") -} -func Method4DataAndErrorResult() (M1Res, error) { - res := M1Res{R1: "data and err"} - return res, fmt.Errorf("data and error") -} -func Method5StringArg(sender string, arg1 string) (string, error) { - slog.Info("received string arg", "arg1", arg1) - return arg1, nil -} -func Method6IntArg(sender string, arg1 int) (int, error) { - slog.Info("received int arg", "arg1", arg1) - return arg1, nil -} -func Method7ByteArrayArg(sender string, arg1 []byte) ([]byte, error) { - slog.Info("received array arg", "arg1", arg1) - return arg1, nil -} -func Method8TwoArgs(sender string, arg1 string, arg2 int) (string, error) { - // this fails as arg2 is an int - slog.Info("received 2 args", "arg1", arg1, "arg2", arg2) - return arg1, nil -} -func Method9ThreeRes(sender string, arg1 string) (string, string, error) { - slog.Info("returning 3 results should fail", "arg1", arg1) - return arg1, arg1, nil -} - -//--- - -func TestHandleRequestMessage(t *testing.T) { - // pass args by reference - args := M1Args{ - P1: "agent1", - P2: 5, - } - data, err := hubagent.HandleRequestMessage(senderID, Method1Ref, args) - require.NoError(t, err) - m1resp, ok := data.(*M1Res) - require.True(t, ok) - - require.Equal(t, args.P1, m1resp.R1) - - // pass args and response by value - args = M1Args{ - P1: "agent2", - P2: 6, - } - data, err = hubagent.HandleRequestMessage(senderID, Method1Val, args) - require.NoError(t, err) - m1resVal, ok := data.(M1Res) - require.True(t, ok) - require.NoError(t, err) - assert.Equal(t, args.P1, m1resVal.R1) -} - -func TestHandleRequestNoArgs(t *testing.T) { - // pass args by reference - data, err := hubagent.HandleRequestMessage(senderID, Method2NoArgs, "") - require.NoError(t, err) - assert.Empty(t, data) -} - -func TestErrorResult(t *testing.T) { - // pass args by reference - data, err := hubagent.HandleRequestMessage(senderID, Method3ErrorResult, "") - require.Error(t, err) - assert.Empty(t, data) -} - -func TestDataAndErrorResult(t *testing.T) { - // check this doesnt fail somehow - _, err := hubagent.HandleRequestMessage(senderID, Method4DataAndErrorResult, "") - require.Error(t, err) -} - -func TestStringArgs(t *testing.T) { - // check this doesn't fail somehow - sargs := "Hello world" - - data, err := hubagent.HandleRequestMessage(senderID, Method5StringArg, sargs) - require.NoError(t, err) - result := data.(string) - - require.Equal(t, "Hello world", result) - require.NoError(t, err) -} - -func TestIntArgs(t *testing.T) { - // check this doesnt fail somehow - sarg := 25 - data, err := hubagent.HandleRequestMessage(senderID, Method6IntArg, sarg) - require.NoError(t, err) - result := data.(int) - - require.Equal(t, 25, result) - require.NoError(t, err) -} -func TestByteArrayArgs(t *testing.T) { - args := []byte{1, 2, 3} - data, err := hubagent.HandleRequestMessage(senderID, Method7ByteArrayArg, args) - require.NoError(t, err) - - result := data.([]byte) - require.Equal(t, args, result) - require.NoError(t, err) -} -func TestTwoArgsFail(t *testing.T) { - sargJson, _ := jsoniter.MarshalToString("Hello world") - // this method has 2 args, we only pass 1. Does it blow up? - data, err := hubagent.HandleRequestMessage(senderID, Method8TwoArgs, sargJson) - assert.Error(t, err) - assert.Empty(t, data) -} -func TestThreeResFail(t *testing.T) { - sarg := "Hello world" - // this method has 3 results. Does it blow up? - data, err := hubagent.HandleRequestMessage(senderID, Method9ThreeRes, sarg) - assert.Error(t, err) - assert.Empty(t, data) -} -func Benchmark_Overhead(b *testing.B) { - m1args := M1Args{ - P1: "agent1", - P2: 5, - } - count1 := uint64(0) - count2 := uint64(0) - // 0.32 ns/op - b.Run("direct call, no marshalling", - func(b *testing.B) { - for n := 0; n < b.N; n++ { - // pass args by reference - m1res, err := Method1Ref(&m1args) - _ = err - _ = m1res - } - }) - t1 := time.Now() - // 2673 ns/op (2.5 usec with json, 1.1 usec with jsoniter) - b.Run("direct call, with marshalling", - func(b *testing.B) { - for n := 0; n < b.N; n++ { - count1++ - // a remote call would marshal and unmarshal the request parameters - argsJson, _ := jsoniter.Marshal(m1args) - m1args2 := M1Args{} - _ = jsoniter.Unmarshal(argsJson, &m1args2) - m1res, err := Method1Ref(&m1args) - // a remote call would marshal and unmarshal the result - data, err := jsoniter.Marshal(m1res) - _ = err - _ = m1res - _ = jsoniter.Unmarshal(data, &m1res) - } - }) - t2 := time.Now() - // 3545 ns/op (3.5 usec for reflection w json, 2.0 usec jsoniter) - b.Run("indirect call", - func(b *testing.B) { - for n := 0; n < b.N; n++ { - count2++ - // pass args by reference - data, err := hubagent.HandleRequestMessage(senderID, Method1Ref, &m1args) - _ = err - m1res, ok := data.(*M1Res) - require.True(b, ok) - require.NotEmpty(b, m1res) - } - }) - t3 := time.Now() - d1 := uint64(t2.Sub(t1)) / count1 - d2 := uint64(t3.Sub(t2)) / count2 - overhead := (d2 - d1) - fmt.Printf("HandleRequestMessage overhead: %d nsec per call; marshalling/unmarshalling: %d nsec/call\n", overhead, d1) -} diff --git a/lib/keys/EcdsaKey.go b/lib/keys/EcdsaKey.go deleted file mode 100644 index a9afef87..00000000 --- a/lib/keys/EcdsaKey.go +++ /dev/null @@ -1,137 +0,0 @@ -package keys - -import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/sha256" - "crypto/x509" - "fmt" - "os" - "reflect" -) - -// EcdsaKey implements the secp256k ECDSA key. -// This implements the IHiveKeys interface. -type EcdsaKey struct { - // Reuse RSA functionality - RsaKey -} - -// ImportPrivate reads the key-pair from the PEM private key -// and determines its key type. -// This returns an error if the PEM is not a valid key. -func (k *EcdsaKey) ImportPrivate(privatePEM string) (err error) { - derBytes, err := k._importDer(privatePEM) - _ = err - rawPrivateKey, err := x509.ParsePKCS8PrivateKey(derBytes) - if err != nil { - return err - } - - privKey, valid := rawPrivateKey.(*ecdsa.PrivateKey) - if !valid { - keyType := reflect.TypeOf(k.pubKeyPtr) - return fmt.Errorf("not an ECDSA private key. It looks to be a '%s'", keyType) - } - k.privKeyPtr = privKey - k.pubKeyPtr = &privKey.PublicKey - - return err -} - -// ImportPrivateFromFile loads public/private key pair from PEM file -// and determines its key type. -func (k *EcdsaKey) ImportPrivateFromFile(pemPath string) (err error) { - pemEncodedPriv, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPrivate(string(pemEncodedPriv)) - return err -} - -// ImportPublic reads the public key from the PEM data. -// This returns an error if the PEM is not a valid public key -// -// publicPEM must contain either a PEM encoded string, or its base64 encoded content -func (k *EcdsaKey) ImportPublic(publicPEM string) (err error) { - derBytes, err := k._importDer(publicPEM) - _ = err - k.pubKeyPtr, err = x509.ParsePKIXPublicKey(derBytes) - k.privKeyPtr = nil - _, valid := k.pubKeyPtr.(*ecdsa.PublicKey) - if !valid { - keyType := reflect.TypeOf(k.pubKeyPtr) - return fmt.Errorf("not an ECDSA public key. It looks to be a '%s'", keyType) - } - return err -} - -// ImportPublicFromFile loads ECDSA public key from PEM file -func (k *EcdsaKey) ImportPublicFromFile(pemPath string) (err error) { - pemEncodedPub, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPublic(string(pemEncodedPub)) - return err -} - -// Initialize generates a new key -func (k *EcdsaKey) Initialize() IHiveKey { - curve := elliptic.P256() - privKey, err := ecdsa.GenerateKey(curve, rand.Reader) - if err != nil { - panic("unable to create ECDSA key") - } - k.privKeyPtr = privKey - k.pubKeyPtr = &privKey.PublicKey - return k -} - -// KeyType returns this key's type, eg ecdsa -func (k *EcdsaKey) KeyType() KeyType { - return KeyTypeECDSA -} - -// Sign returns the signature of a message signed using this key -// This signs the SHA256 hash of the message -// this requires a private key to be created or imported -func (k *EcdsaKey) Sign(msg []byte) (signature []byte, err error) { - msgHash := sha256.Sum256(msg) - privKey := k.privKeyPtr.(*ecdsa.PrivateKey) - - signature, err = ecdsa.SignASN1(rand.Reader, privKey, msgHash[:]) - return signature, err -} - -// Verify the signature of a message using this key's public key -// This verifies using the SHA256 hash of the message. -// this requires a public key to be created or imported -// returns true if the signature is valid for the message -func (k *EcdsaKey) Verify(msg []byte, signature []byte) (valid bool) { - msgHash := sha256.Sum256(msg) - pubKey := k.pubKeyPtr.(*ecdsa.PublicKey) - valid = ecdsa.VerifyASN1(pubKey, msgHash[:], signature) - return valid -} - -// NewEcdsaKey creates and initialize a ECDSA key -func NewEcdsaKey() IHiveKey { - nk := &EcdsaKey{} - nk.Initialize() - return nk -} - -// NewEcdsaKeyFromPrivate creates and initialize a IHiveKey object from an existing ECDSA private key. -func NewEcdsaKeyFromPrivate(privKey *ecdsa.PrivateKey) IHiveKey { - k := &EcdsaKey{ - // reuse RSA - RsaKey{ - privKeyPtr: privKey, - pubKeyPtr: &privKey.PublicKey, - }, - } - return k -} diff --git a/lib/keys/Ed25519Key.go b/lib/keys/Ed25519Key.go deleted file mode 100644 index 5afba429..00000000 --- a/lib/keys/Ed25519Key.go +++ /dev/null @@ -1,215 +0,0 @@ -package keys - -import ( - "crypto" - "crypto/ed25519" - "crypto/rand" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "os" -) - -// Ed25519Key contains the ED25519 cryptographic key set for signing and authentication. -// This implements the IHiveKeys interface. -type Ed25519Key struct { - privKey ed25519.PrivateKey // []byte key type - pubKey ed25519.PublicKey // []byte ley type -} - -// ExportPrivate returns the PEM encoded private key -func (k *Ed25519Key) ExportPrivate() string { - var pemEnc []byte - if k.privKey == nil { - panic("private key not initialized") - } - raw, err := x509.MarshalPKCS8PrivateKey(k.privKey) - block := &pem.Block{ - Type: "PRIVATE KEY", - Bytes: raw, - } - pemEnc = pem.EncodeToMemory(block) - _ = err - return string(pemEnc) -} - -// ExportPrivateToFile saves the private key set to file in PEM format. -// The file permissions are set to 0400, current user only, read-write permissions. -// -// Returns error in case the key is invalid or file cannot be written. -func (k *Ed25519Key) ExportPrivateToFile(pemPath string) error { - privPEM := k.ExportPrivate() - // remove existing key since perm 0400 doesn't allow overwriting it - _ = os.Remove(pemPath) - err := os.WriteFile(pemPath, []byte(privPEM), 0400) - return err -} - -// ExportPublicToFile saves the public key to file in PEM format. -// The file permissions are set to 0644, current user can write, rest can read. -// -// Returns error in case the public key is invalid or file cannot be written. -func (k *Ed25519Key) ExportPublicToFile(pemPath string) error { - pemEncoded := k.ExportPublic() - err := os.WriteFile(pemPath, []byte(pemEncoded), 0644) - return err -} - -// ExportPublic returns the PEM encoded public key if available -func (k *Ed25519Key) ExportPublic() (pemKey string) { - var pemData []byte - if k.pubKey == nil { - panic("public key not initialized") - } - - x509EncodedPub, err := x509.MarshalPKIXPublicKey(k.pubKey) - if err != nil { - panic("ED25519 public key can't be marshalled") - } - pemData = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub}) - return string(pemData) -} - -// ImportPrivateFromFile loads public/private key pair from PEM file -// and determines its key type. -func (k *Ed25519Key) ImportPrivateFromFile(pemPath string) (err error) { - pemEncodedPriv, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPrivate(string(pemEncodedPriv)) - return err -} - -// ImportPrivate reads the key-pair from PEM format. -// This returns an error if the PEM is not a valid key. -func (k *Ed25519Key) ImportPrivate(privatePEM string) (err error) { - var derBytes []byte - blockPub, _ := pem.Decode([]byte(privatePEM)) - if blockPub == nil { - // not pem encoded. try base64 - derBytes, err = base64.StdEncoding.DecodeString(privatePEM) - } else { - derBytes = blockPub.Bytes - } - if err != nil { - err = fmt.Errorf("not a PEM or base64 encoded format") - return err - } - // try PKCS8 encoding - rawPrivateKey, err := x509.ParsePKCS8PrivateKey(derBytes) - ed25519PK, found := rawPrivateKey.(ed25519.PrivateKey) - if found { - k.privKey = ed25519PK - k.pubKey = k.privKey.Public().(ed25519.PublicKey) - return nil - } - - // is it a ed25519 seed? - if len(derBytes) != ed25519.SeedSize { - err = fmt.Errorf("not a ED25519 seed") - return err - } - k.privKey = ed25519.NewKeyFromSeed(derBytes) - k.pubKey = k.privKey.Public().(ed25519.PublicKey) - return err -} - -// ImportPublic reads the public key from the PEM data. -// This returns an error if the PEM is not a valid public key -// -// publicPEM must contain either a PEM encoded string, or its base64 encoded content -func (k *Ed25519Key) ImportPublic(publicPEM string) (err error) { - var x509EncodedPub []byte - blockPub, _ := pem.Decode([]byte(publicPEM)) - if blockPub == nil { - // try just base64 decoding - x509EncodedPub, err = base64.StdEncoding.DecodeString(publicPEM) - } else { - x509EncodedPub = blockPub.Bytes - } - if err != nil { - err = fmt.Errorf("ImportPublic: not an ED25519 public key format") - return err - } - genericPublicKey, err := x509.ParsePKIXPublicKey(x509EncodedPub) - if err != nil { - return err - } - k.pubKey = genericPublicKey.(ed25519.PublicKey) - k.privKey = nil - return err -} - -// ImportPublicFromFile loads ED25519 public key from PEM file -func (k *Ed25519Key) ImportPublicFromFile(pemPath string) (err error) { - pemEncodedPub, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPublic(string(pemEncodedPub)) - return err -} - -// Initialize generates a new key -func (k *Ed25519Key) Initialize() IHiveKey { - var err error - k.pubKey, k.privKey, err = ed25519.GenerateKey(rand.Reader) - if err != nil { - panic(err.Error()) - } - return k -} - -// KeyType returns this key's type, eg ED25519 -func (k *Ed25519Key) KeyType() KeyType { - return KeyTypeEd25519 -} - -// PrivateKey returns the native private key pointer -func (k *Ed25519Key) PrivateKey() crypto.PrivateKey { - return k.privKey -} - -// PublicKey returns the native public key pointer -func (k *Ed25519Key) PublicKey() crypto.PublicKey { - return k.pubKey -} - -// Sign returns the signature of a message signed using this key -// This signs the SHA256 hash of the message -// this requires a private key to be created or imported -func (k *Ed25519Key) Sign(msg []byte) (signature []byte, err error) { - msgHash := sha256.Sum256(msg) - signature = ed25519.Sign(k.privKey, msgHash[:]) - return signature, nil -} - -// Verify the signature of a message using this key's public key. -// This verifies using the SHA256 hash of the message. -// this requires a public key to be created or imported -// returns true if the signature is valid for the message -func (k *Ed25519Key) Verify(msg []byte, signature []byte) (valid bool) { - msgHash := sha256.Sum256(msg) - valid = ed25519.Verify(k.pubKey, msgHash[:], signature) - return valid -} - -// NewEd25519Key creates and initialize a ED25519 key -func NewEd25519Key() IHiveKey { - k := &Ed25519Key{} - k.Initialize() - return k -} - -// NewEd25519KeyFromPrivate creates and initialize a IHiveKey object from an existing private key. -func NewEd25519KeyFromPrivate(privKey ed25519.PrivateKey) IHiveKey { - pubKey := privKey.Public() - k := &Ed25519Key{ - privKey: privKey, - pubKey: pubKey.(ed25519.PublicKey), - } - return k -} diff --git a/lib/keys/IHiveKey.go b/lib/keys/IHiveKey.go deleted file mode 100644 index a9cd2b78..00000000 --- a/lib/keys/IHiveKey.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package keys with key generation -package keys - -import "crypto" - -type KeyType string - -const ( - KeyTypeECDSA KeyType = "ecdsa" - KeyTypeEd25519 KeyType = "ed25519" - KeyTypeRSA KeyType = "rsa" - KeyTypeNKey KeyType = "nkey" - KeyTypeUnknown = "" -) - -// KPFileExt defines the filename extension under which public/private keys are stored -// in the keys directory. -const KPFileExt = ".key" - -// PubKeyFileExt defines the filename extension under which public key is stored -// in the keys directory. -const PubKeyFileExt = ".pub" - -// IHiveKey defines the standard interface for various key types used for signing and authentication -// -// ... because we don't care about all these keys, just that it works and is secure... -type IHiveKey interface { - - // ExportPrivate returns the serialized private key if available - // This defaults to PEM encoding unless the key type doesn't support it. - // key type ecdsa, rsa use PEM encoding - // key type ed25519 encodes it to base64 - // key type nkeys encodes when generating its seed - ExportPrivate() string - - // ExportPrivateToFile saves the private/public key to a key file - ExportPrivateToFile(keyPath string) error - - // ExportPublic returns the serialized public key if available - // This defaults to PEM encoding unless the key type doesn't support it. - ExportPublic() string - - // ExportPublicToFile exports the public key and write to file. - // This defaults to PEM encoding unless the key type doesn't support it. - ExportPublicToFile(pemPath string) error - - // ImportPrivate decodes the key-pair from the serialized private key - // This returns an error if the encoding can't be determined - ImportPrivate(privateEnc string) error - - // ImportPrivateFromFile private/public key from a PEM file - ImportPrivateFromFile(pemPath string) error - - // ImportPublic reads the public key from the given encoded data. - // Intended for verifying signatures using the public key. - // This returns an error if the encoding can't be determined - ImportPublic(publicEnc string) error - - // ImportPublicFromFile reads the public key from file. - // The encoding depends on the key type. ed25519, ecdsa and rsa uses pem format. - // Intended for verifying signatures using the public key. - // This returns an error if the file cannot be read or is not a valid public key - // Note that after ImportPublicFrom...(), the private key is not available. - ImportPublicFromFile(pemPath string) error - - // Initialize generates a new key set using its curve algorithm - Initialize() IHiveKey - - // KeyType returns the key's type - KeyType() KeyType - - // PrivateKey returns the native private key - // ECDSA: *ecdsa.PrivateKey - // RSA: *rsa.PrivateKey - // ED25519: ed25519.PrivateKey (not a pointer) - // nkeys: nkeys.KeyPair - PrivateKey() crypto.PrivateKey - - // PublicKey returns the native public key - // ECDSA: *ecdsa.PublicKey - // RSA: *rsa.PublicKey - // ED25519: ed25519.PublicKey (not a pointer) - // nkeys: nkeys.KeyPair - PublicKey() crypto.PublicKey - - // Sign returns the signature of a message signed using this key - // this requires a private key to be created or imported - Sign(message []byte) ([]byte, error) - - // Verify the message signature using this key's public key - // this requires a public key to be created or imported - // returns true if the signature is valid for the message - Verify(message []byte, signature []byte) bool -} diff --git a/lib/keys/RsaKey.go b/lib/keys/RsaKey.go deleted file mode 100644 index 5bb0cb78..00000000 --- a/lib/keys/RsaKey.go +++ /dev/null @@ -1,224 +0,0 @@ -package keys - -import ( - "crypto" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "log" - "os" - "reflect" -) - -// RsaKey implements the IHiveKeys interface to a RSA key. -type RsaKey struct { - privKeyPtr crypto.PrivateKey // RSA native key type stored as pointer - pubKeyPtr crypto.PublicKey // RSA native key type stored as pointer -} - -// ExportPrivate returns the PEM encoded private key -func (k *RsaKey) ExportPrivate() string { - var err error - var pemEnc []byte - var keyBytes []byte - if k.privKeyPtr == nil { - panic("private key not initialized") - } - - keyBytes, err = x509.MarshalPKCS8PrivateKey(k.privKeyPtr) - pemEnc = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) - if err != nil { - panic("private key can't be marshalled: " + err.Error()) - } - return string(pemEnc) -} - -// ExportPrivateToFile saves the private key set to file in PEM format. -// The file permissions are set to 0400, current user only, read-write permissions. -// -// Returns error in case the key is invalid or file cannot be written. -func (k *RsaKey) ExportPrivateToFile(pemPath string) error { - privPEM := k.ExportPrivate() - // remove existing key since perm 0400 doesn't allow overwriting it - _ = os.Remove(pemPath) - err := os.WriteFile(pemPath, []byte(privPEM), 0400) - return err -} - -// ExportPublic returns the PEM encoded public key if available -func (k *RsaKey) ExportPublic() (pemKey string) { - var pemData []byte - if k.pubKeyPtr == nil { - panic("public key not initialized") - } - - x509EncodedPub, err := x509.MarshalPKIXPublicKey(k.pubKeyPtr) - if err != nil { - panic("public key can't be marshalled") - } - pemData = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub}) - if pemData == nil { - panic("public key can't be marshalled") - } - return string(pemData) -} - -// ExportPublicToFile saves the public key to file in PEM format. -// The file permissions are set to 0644, current user can write, rest can read. -// -// Returns error in case the public key is invalid or file cannot be written. -func (k *RsaKey) ExportPublicToFile(pemPath string) error { - pemEncoded := k.ExportPublic() - err := os.WriteFile(pemPath, []byte(pemEncoded), 0644) - return err -} - -func (k *RsaKey) _importDer(privateEnc string) ([]byte, error) { - var derBytes []byte - var err error - blockPub, _ := pem.Decode([]byte(privateEnc)) - if blockPub == nil { - // not pem encoded. try base64 - //return fmt.Errorf("not a valid private key PEM string") - derBytes, err = base64.StdEncoding.DecodeString(privateEnc) - } else { - derBytes = blockPub.Bytes - } - if err != nil { - err = fmt.Errorf("ImportPrivate: unknown key format") - return nil, err - } - return derBytes, nil -} - -// ImportPrivate reads the key-pair from the PEM private key -// and determines its key type. -// This returns an error if the PEM is not a valid key. -func (k *RsaKey) ImportPrivate(privatePEM string) (err error) { - derBytes, err := k._importDer(privatePEM) - - // this decodes RSA, ECDSA, ED25519 or ECDH key - rawPrivateKey, err := x509.ParsePKCS8PrivateKey(derBytes) - if err != nil { - return err - } - privKey, valid := rawPrivateKey.(*rsa.PrivateKey) - if !valid { - keyType := reflect.TypeOf(k.pubKeyPtr) - return fmt.Errorf("not an RSA private key. It looks to be a '%s'", keyType) - } - k.privKeyPtr = privKey - k.pubKeyPtr = &privKey.PublicKey - return err -} - -// ImportPrivateFromFile loads public/private key pair from PEM file -// and determines its key type. -func (k *RsaKey) ImportPrivateFromFile(pemPath string) (err error) { - pemEncodedPriv, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPrivate(string(pemEncodedPriv)) - return err -} - -// ImportPublic reads the public key from the PEM data. -// This returns an error if the PEM is not a valid public key -// -// publicPEM must contain either a PEM encoded string, or its base64 encoded content -func (k *RsaKey) ImportPublic(publicPEM string) (err error) { - derBytes, err := k._importDer(publicPEM) - - k.pubKeyPtr, err = x509.ParsePKIXPublicKey(derBytes) - k.privKeyPtr = nil - _, valid := k.pubKeyPtr.(*rsa.PublicKey) - if !valid { - keyType := reflect.TypeOf(k.pubKeyPtr) - return fmt.Errorf("not an RSA public key. It looks to be a '%s'", keyType) - } - return err -} - -// ImportPublicFromFile loads ECDSA public key from PEM file -func (k *RsaKey) ImportPublicFromFile(pemPath string) (err error) { - pemEncodedPub, err := os.ReadFile(pemPath) - if err != nil { - return err - } - err = k.ImportPublic(string(pemEncodedPub)) - return err -} - -// Initialize generates a new key -func (k *RsaKey) Initialize() IHiveKey { - privKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - panic(err.Error()) - } - k.privKeyPtr = privKey - k.pubKeyPtr = &privKey.PublicKey - return k -} - -// KeyType returns this key's type, eg ecdsa -func (k *RsaKey) KeyType() KeyType { - return KeyTypeRSA -} - -// PrivateKey returns the native *rsa.PrivateKey -func (k *RsaKey) PrivateKey() crypto.PrivateKey { - return k.privKeyPtr -} - -// PublicKey returns the native *rsa.PublicKey -func (k *RsaKey) PublicKey() crypto.PublicKey { - return k.pubKeyPtr -} - -// Sign returns the signature of a message signed using this key -// this requires a private key to be created or imported -func (k *RsaKey) Sign(msg []byte) (signature []byte, err error) { - - // https://www.sohamkamani.com/golang/rsa-encryption/ - // Before signing, we need to hash our message - // The hash is what we actually sign - msgHash := sha256.Sum256(msg) - privKey := k.privKeyPtr.(*rsa.PrivateKey) - signature, err = rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, msgHash[:]) - if err != nil { - log.Fatalf("Error signing message: %v", err) - } - return signature, err -} - -// Verify the signature of a message using this key's public key -// this requires a public key to be created or imported -// returns true if the signature is valid for the message -func (k *RsaKey) Verify(msg []byte, signature []byte) (valid bool) { - msgHash := sha256.Sum256(msg) - pubKey := k.pubKeyPtr.(*rsa.PublicKey) - err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA256, msgHash[:], signature) - valid = err == nil - return valid -} - -// NewRsaKey generates a RSA key with IHiveKey interface -func NewRsaKey() IHiveKey { - k := &RsaKey{} - k.Initialize() - return k -} - -// NewRsaKeyFromPrivate creates and initialize a IHiveKey object from an existing RSA private key. -func NewRsaKeyFromPrivate(privKey *rsa.PrivateKey) IHiveKey { - k := &RsaKey{ - privKeyPtr: privKey, - pubKeyPtr: &privKey.PublicKey, - } - return k -} diff --git a/lib/keys/keys.go b/lib/keys/keys.go deleted file mode 100644 index 47e30bb3..00000000 --- a/lib/keys/keys.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package keys with key management for certificates and JWT -package keys - -import ( - "crypto/ecdsa" - "crypto/ed25519" - "crypto/rsa" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "os" - "path" -) - -// DetermineKeyType returns the type of key -func DetermineKeyType(encKey string) KeyType { - var derBytes []byte - var err error - blockPub, _ := pem.Decode([]byte(encKey)) - if blockPub == nil { - //// is this an nkey seed? - //_, err = nkeys.FromSeed([]byte(encKey)) - //if err == nil { - // return KeyTypeNKey - //} - // no nkey, try base64 decoding. Eg PEM content - derBytes, err = base64.StdEncoding.DecodeString(encKey) - - // todo: support for hex format? - } else { - derBytes = blockPub.Bytes - } - // first check the public key type - genericPublicKey, err := x509.ParsePKIXPublicKey(derBytes) - if err == nil { - switch genericPublicKey.(type) { - case *ecdsa.PublicKey: - return KeyTypeECDSA - case ed25519.PublicKey: // note: <-- not a pointer - return KeyTypeEd25519 - case *rsa.PublicKey: - return KeyTypeRSA - } - } - // no luck yet, check private - // PKCS1 is RSA - _, err = x509.ParsePKCS1PrivateKey(derBytes) - if err == nil { - return KeyTypeRSA - } - // try PKCS8 encoding - rawPrivateKey, err := x509.ParsePKCS8PrivateKey(derBytes) - if err == nil { - switch rawPrivateKey.(type) { - case *ecdsa.PrivateKey: - return KeyTypeECDSA - case ed25519.PrivateKey: - return KeyTypeEd25519 - case *rsa.PrivateKey: - return KeyTypeRSA - default: - return KeyTypeUnknown - } - } - // is it a ed25519 seed? - if len(derBytes) == ed25519.SeedSize { - privKey := ed25519.NewKeyFromSeed(derBytes) - _ = privKey - return KeyTypeEd25519 - } - return KeyTypeUnknown -} - -// LoadCreateKeyPair loads a public/private key pair from file or create it if it doesn't exist -// This will load or create a file .key and .pub from the keysDir. -// -// clientID is the client to create the keys for -// keysDir is the location of the key file -// keyType is the type of key to create (see IHiveKey) -func LoadCreateKeyPair(clientID string, keysDir string, keyType KeyType) (kp IHiveKey, err error) { - if keysDir == "" { - return nil, fmt.Errorf("keys directory must be provided") - } - - keyFile := path.Join(keysDir, clientID+KPFileExt) - pubFile := path.Join(keysDir, clientID+PubKeyFileExt) - - // load key from file - kp, err = NewKeyFromFile(keyFile) - - if err != nil { - // no keyfile, create the key - kp = NewKey(keyType) - - // save the key for future use - err = kp.ExportPrivateToFile(keyFile) - if err == nil { - err = kp.ExportPublicToFile(pubFile) - } - } - return kp, err -} - -// NewKey creates a new key of the given type -func NewKey(keyType KeyType) IHiveKey { - switch keyType { - case KeyTypeECDSA: - return NewEcdsaKey() - case KeyTypeEd25519: - return NewEd25519Key() - //case KeyTypeNKey: - // return NewNkeysKey() - case KeyTypeRSA: - return NewRsaKey() - default: - return nil - } -} - -// NewKeyFromEnc helper creates a HiveKey instance from an encoded private key. -// This returns nil if the key type cannot be determined -// -// privEnc is the encoded private key -func NewKeyFromEnc(privEnc string) IHiveKey { - keyType := DetermineKeyType(privEnc) - if keyType == KeyTypeUnknown { - return nil - } - key := NewKey(keyType) - _ = key.ImportPrivate(privEnc) - return key -} - -// NewKeyFromFile helper to load a public/private key pair from file -// This returns nil if the key type cannot be determined -// -// keyPath is the path to the file containing the key -func NewKeyFromFile(keyPath string) (IHiveKey, error) { - keyEnc, err := os.ReadFile(keyPath) - if err != nil { - return nil, err - } - k := NewKeyFromEnc(string(keyEnc)) - if k == nil { - err = fmt.Errorf("Unknown key format") - } - return k, err -} diff --git a/lib/keys/keys_test.go b/lib/keys/keys_test.go deleted file mode 100644 index a4e11da5..00000000 --- a/lib/keys/keys_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package keys_test - -import ( - "os" - "path" - "testing" - - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/logging" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const keyType = keys.KeyTypeEd25519 - -// set in TestMain -var TestKeysFolder string -var testPrivKeyPemFile string -var testPubKeyPemFile string - -// TestMain create a test folder for keys -func TestMain(m *testing.M) { - TestKeysFolder, _ = os.MkdirTemp("", "hiveot-keys-") - - testPrivKeyPemFile = path.Join(TestKeysFolder, "privKey.pem") - testPubKeyPemFile = path.Join(TestKeysFolder, "pubKey.pem") - logging.SetLogging("info", "") - - result := m.Run() - if result != 0 { - println("Test failed with code:", result) - println("Find test files in:", TestKeysFolder) - } else { - // comment out the next line to be able to inspect results - _ = os.RemoveAll(TestKeysFolder) - } - - os.Exit(result) -} - -func TestSaveLoadPrivKey(t *testing.T) { - k1 := keys.NewKey(keyType) - err := k1.ExportPrivateToFile(testPrivKeyPemFile) - assert.NoError(t, err) - - k2, err := keys.NewKeyFromFile(testPrivKeyPemFile) - assert.NoError(t, err) - require.NotNil(t, k2) - require.Equal(t, keyType, k2.KeyType()) - - pem1 := k1.ExportPublic() - pem2 := k2.ExportPublic() - - msg := []byte("hello world") - signature, err := k1.Sign(msg) - require.NoError(t, err) - valid := k2.Verify(msg, signature) - assert.True(t, valid) - - assert.NoError(t, err) - assert.NotEmpty(t, pem1) - assert.Equal(t, pem1, pem2) -} - -func TestSaveLoadPubkey(t *testing.T) { - k1 := keys.NewKey(keyType) - err := k1.ExportPublicToFile(testPubKeyPemFile) - assert.NoError(t, err) - - k2, err := keys.NewKeyFromFile(testPubKeyPemFile) - require.NoError(t, err) - require.NotEmpty(t, k2) - pubEnc := k2.ExportPublic() - assert.NotEmpty(t, pubEnc) -} - -func TestSaveLoadPrivKeyNotFound(t *testing.T) { - k1 := keys.NewKey(keyType) - // no access - err := k1.ExportPrivateToFile("/root") - assert.Error(t, err) - - // - err = k1.ImportPrivateFromFile("/filedoesnotexist.pem") - assert.Error(t, err) -} - -func TestSaveLoadPubKeyNotFound(t *testing.T) { - k1 := keys.NewKey(keyType) - err := k1.ImportPublicFromFile("/filedoesnotexist.pem") - assert.Error(t, err) -} - -func TestPublicKeyPEM(t *testing.T) { - // golang generated public key (91 bytes after base64 decode) - THIS WORKS - const TestKeyPub2 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFFPcFfnGQr8/t2ZzWFYg/ZFLAkT0z/EYlC1RED4iot367KRNwZlilogTGHzi3HjH6NnL14d/DQHxAInctEeqxw==" - const TestKeyPubPEM2 = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEaejQVxAbrUiN41Nqjgw8HG8q5OQM\nkveXku18/zhF2BbSfbQMnCSyP5VXCe/sgCEi62Qm0LYXd1VG2UQz38f4zQ==\n-----END PUBLIC KEY-----\n" - - // JS ellipsys generated public key (65 bytes after base64 decode) - THIS FAILS - const TestJSEllipsysPub3 = "BKOVp2t2JLjodototsMvFbOJ1j9wTC4ITbOrnrb/EoJiQul9eoXmyHpaYnPztjPixFdiHk06NxGLDpxRDm5qXfo=" - - // openssl generated public key - THIS WORKS - const TestKeyOpenSSL = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELtv253KXEWvWjCse0Wp5DprnXp5tp17C1Qtfjk5t/6+HPSc74uMQcp/KV++vc6OXJwk5XdZ8FSkUiU9cYRBo8A==" - - // JS elliptic encoded using base64 encoding of hex encoded pub key - this fails - const TestKeyPub64hex = "MDRhMzk1YTc2Yjc2MjRiOGU4NzY4YjY4YjZjMzJmMTViMzg5ZDYzZjcwNGMyZTA4NGRiM2FiOWViNmZmMTI4MjYyNDJlOTdkN2E4NWU2Yzg3YTVhNjI3M2YzYjYzM2UyYzQ1NzYyMWU0ZDNhMzcxMThiMGU5YzUxMGU2ZTZhNWRmYQ==" - - // decode from base64 string. This succeeds. d2 is 91 bytes - this works - k1 := keys.NewKeyFromEnc(TestKeyPub2) - assert.NotNil(t, k1) - assert.Equal(t, keys.KeyTypeECDSA, k1.KeyType()) - - // decode from openssl generated public key. d2 is 91 bytes - this works - k2 := keys.NewKeyFromEnc(TestKeyOpenSSL) - assert.NotNil(t, k2) - assert.Equal(t, keys.KeyTypeECDSA, k2.KeyType()) - - // a hex key is not supported - k3 := keys.NewKeyFromEnc(TestKeyPub64hex) - assert.Nil(t, k3) - - ////MarshalPKIXPublicKey converts a public key to PKIX, ASN.1 DER form - //x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey) - //pemEncodedPub := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: x509EncodedPub}) - - // Parse JS elliptic generated key. - // THIS FAILS as it is hex based without a DER prefix - // d3 is 65 bytes which should be correct - k4 := keys.NewKeyFromEnc(TestJSEllipsysPub3) - assert.Nil(t, k4) -} - -func TestPrivateKeyPEM(t *testing.T) { - - k1 := keys.NewKey(keyType) - k1Enc := k1.ExportPrivate() - assert.NotEmpty(t, k1Enc) - - k2 := keys.NewKey(keyType) - err := k2.ImportPrivate(k1Enc) - require.NoError(t, err) - - k2Enc := k2.ExportPrivate() - require.NotNil(t, k2Enc) - - isEqual := k1Enc == k2Enc - assert.True(t, isEqual) -} - -func TestInvalidEnc(t *testing.T) { - k1 := keys.NewKey(keyType) - - err := k1.ImportPrivate("PRIVATE KEY") - assert.Error(t, err) - - // note: nkeys have not ability to verify the public key - err = k1.ImportPublic("PUBLIC KEY") - assert.Error(t, err) -} diff --git a/lib/logging/SetLogging.go b/lib/logging/SetLogging.go deleted file mode 100644 index 0c36d357..00000000 --- a/lib/logging/SetLogging.go +++ /dev/null @@ -1,105 +0,0 @@ -// Package logging with logging configuration -package logging - -import ( - "io" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/lmittmann/tint" -) - -// SetLogging initializes the global logger -func SetLogging(levelName string, logFilename string) *slog.Logger { - //// init logging - //const TimeFormat = "Jan _2 15:04:05.0000" - //zlwr := zerolog.ConsoleWriter{ - // Out: os.Stdout, - // TimeFormat: TimeFormat, - // FormatCaller: func(i interface{}) string { - // return filepath.Base(fmt.Sprintf("%s", i)) - // }, - //} - //zerolog.TimeFieldFormat = "Jan _2 15:04:05.0000" - //log.Logger = zerolog.New(zlwr).With().Timestamp().Caller().Logger() - logLevel := slog.LevelInfo - if levelName == "debug" { - logLevel = slog.LevelDebug - } else if strings.HasPrefix(levelName, "warn") { - logLevel = slog.LevelWarn - } else if levelName == "error" { - logLevel = slog.LevelError - } - opts := &tint.Options{ - AddSource: true, - Level: logLevel, - TimeFormat: "Jan _2 15:04:05.0000", - ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { - // include sourcefile - if attr.Key == slog.SourceKey { - source := attr.Value.Any().(*slog.Source) - source.File = filepath.Base(source.File) - //sourcePath := fmt.Sprintf("%s:%d %s", src.File, src.Line, src.Function) - //return slog.String(slog.SourceKey, sourcePath) - } - return attr - }, - } - - // if a file is provided then also log to file - var logWriter io.Writer - logWriter = os.Stdout - if logFilename != "" { - logFile, err := os.OpenFile(logFilename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err == nil { - // log to both stdout and to file - logWriter = io.MultiWriter(os.Stdout, logFile) - } - } - handler := tint.NewHandler(logWriter, opts) - //opts := &slog.HandlerOptions{ - // AddSource: true, - // Level: logLevel, - //} - //handler := slog.NewTextHandler(os.Stdout, opts) - - logger := slog.New(handler) - slog.SetDefault(logger) - //logLogger := slog.NewLogLogger(handler, logLevel) - //_ = http.Server{ErrorLog: logLogger} - return logger -} - -// NewFileLogger returns a new file logger that forks to stdout -// This returns the logger and the file. -func NewFileLogger(logfileName string, asJSON bool) (*slog.Logger, *os.File) { - // setup request logging - if logfileName != "" { - var logWriter io.Writer - var logger *slog.Logger - - logFile, err := os.OpenFile(logfileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - - // log to both stdout and to file - logWriter = io.MultiWriter(os.Stdout, logFile) - if err == nil { - // file logging in JSON - if asJSON { - logHandler := slog.NewJSONHandler(logWriter, nil) - logger = slog.New(logHandler) - } else { - // or just pretty print - logHandler := tint.NewHandler(logWriter, &tint.Options{ - AddSource: true, - Level: slog.LevelInfo, - TimeFormat: "Jan _2 15:04:05.0000"}, - ) - logger = slog.New(logHandler) - } - } - return logger, logFile - } - return slog.Default(), nil -} diff --git a/lib/logging/SetLogging_test.go b/lib/logging/SetLogging_test.go deleted file mode 100644 index ea69dc76..00000000 --- a/lib/logging/SetLogging_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package logging_test - -import ( - "log/slog" - "os" - "testing" - - "github.com/hiveot/hub/lib/logging" -) - -func TestLogging(t *testing.T) { - //wd, _ := os.Getwd() - //logFile := path.Join(wd, "../../test/logs/TestLogging.log") - logFile := "" - - os.Remove(logFile) - logging.SetLogging("info", logFile) - slog.Info("Hello info") - logging.SetLogging("debug", logFile) - slog.Debug("Hello debug") - logging.SetLogging("warn", logFile) - slog.Warn("Hello warn") - logging.SetLogging("error", logFile) - slog.Error("Hello error") - //assert.FileExists(t, logFile) - //os.Remove(logFile) -} diff --git a/lib/messaging/ActionStatus.go b/lib/messaging/ActionStatus.go deleted file mode 100644 index 7137e304..00000000 --- a/lib/messaging/ActionStatus.go +++ /dev/null @@ -1,92 +0,0 @@ -// Package messaging with the 3 flow messages: requests, response and notifications -package messaging - -import ( - "errors" -) - -// Request status provided with the response. -// this aligns with action status values from WoT spec -const ( - // StatusPending - the request has not yet been delivered - StatusPending = "pending" - // StatusRunning - the request is being processed - StatusRunning = "running" - // StatusCompleted - the request processing was completed - StatusCompleted = "completed" - // StatusFailed - the request processing or delivery failed - StatusFailed = "failed" -) - -// ActionStatus is used for tracking the status of an action. -// NOTE: keep this in sync with the digital twin ActionStatus in the TD. -type ActionStatus struct { - - // ActionID that uniquely identifies the action instance. - // This can be an identifier or a URL. - ActionID string `json:"actionID,omitempty"` - - // Error with info when action failed - Error *ErrorValue `json:"error,omitempty"` - - // Input of action - Input any `json:"input,omitempty"` - - // Name of action - Name string `json:"name,omitempty"` - - // Output with Action output - Output any `json:"output,omitempty"` - - // SenderID of the client requesting the action - SenderID string `json:"senderID,omitempty"` - - // State of action with progress: pending, running, completed, failed - State string `json:"state,omitempty"` - - // ThingID digital-twin ThingID the action applies to - ThingID string `json:"thingID,omitempty"` - - // Requested time the action request was received - TimeRequested string `json:"timeRequested,omitempty"` - - // Updated time the action status was last updated - TimeUpdated string `json:"timeUpdated,omitempty"` -} - -// Error response payload -type ErrorValue struct { - // Status code: https://w3c.github.io/wot-profile/#error-responses - Status int `json:"status"` - // Type is a URI reference [RFC3986] that identifies the problem type. - Type string `json:"type"` - // Title contains a short, human-readable summary of the problem type - Title string `json:"title"` - // Detail a human-readable explanation - Detail string `json:"detail,omitempty"` -} - -func (e *ErrorValue) String() string { - return e.Title -} - -// AsError returns an error instance or nil if no error is contained -func (e *ErrorValue) AsError() error { - if e.Title == "" && e.Status == 0 { - return nil - } - return errors.New(e.String()) -} - -// Create an ErrorValue object from the given error. This returns nil if err is nil. -func ErrorValueFromError(err error) *ErrorValue { - if err == nil { - return nil - } - return &ErrorValue{ - Status: 400, // bad request - Type: "Bad request", - Title: err.Error(), - // Detail: "", - } -} diff --git a/lib/messaging/DummyAuthenticator.go b/lib/messaging/DummyAuthenticator.go deleted file mode 100644 index f9306353..00000000 --- a/lib/messaging/DummyAuthenticator.go +++ /dev/null @@ -1,139 +0,0 @@ -package messaging - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/teris-io/shortid" -) - -// DummyAuthenticator for testing the transport protocol bindings -// This implements the IAuthenticator interface. -type DummyAuthenticator struct { - passwords map[string]string - tokens map[string]string - authServerURI string -} - -// AddClient adds a test client and return an auth token -func (d *DummyAuthenticator) AddClient(clientID string, password string) string { - d.passwords[clientID] = password - sessionID := clientID + shortid.MustGenerate() - token := d.CreateSessionToken(clientID, sessionID, 0) - d.tokens[clientID] = token - return token -} - -// AddSecurityScheme adds the security scheme that this authenticator supports. -func (srv *DummyAuthenticator) AddSecurityScheme(tdoc *td.TD) { - - // bearer security scheme for authenticating http and subprotocol connections - format, alg := srv.GetAlg() - - tdoc.AddSecurityScheme("bearer", td.SecurityScheme{ - //AtType: nil, - Description: "JWT dummy token authentication", - //Descriptions: nil, - //Proxy: "", - Scheme: "bearer", // nosec, basic, digest, bearer, psk, oauth2, apikey or auto - Authorization: srv.authServerURI, - Name: "authorization", - Alg: alg, - Format: format, // jwe, cwt, jws, jwt, paseto - In: "header", // query, body, cookie, uri, auto - }) -} - -//func (d *DummyAuthenticator) AddToken(clientID string, token string) { -// d.tokens[clientID] = token -//} - -func (d *DummyAuthenticator) CreateSessionToken( - clientID, sessionID string, validity time.Duration) (token string) { - - if sessionID == "" { - sessionID = shortid.MustGenerate() - } - token = fmt.Sprintf("%s/%s", clientID, sessionID) - // simulate a session with the tokens map - d.tokens[clientID] = token - return token -} - -func (d *DummyAuthenticator) DecodeSessionToken(token string, signedNonce string, nonce string) (clientID string, sessionID string, err error) { - return d.ValidateToken(token) -} - -// GetAlg pretend to use jwt -func (d *DummyAuthenticator) GetAlg() (string, string) { - return "jwt", "es256" -} - -func (d *DummyAuthenticator) Login( - clientID string, password string) (token string, err error) { - - currPass, isClient := d.passwords[clientID] - if isClient && currPass == password { - sessionID := clientID + shortid.MustGenerate() - token = d.CreateSessionToken(clientID, sessionID, 0) - d.tokens[clientID] = token - return token, nil - } - return "", fmt.Errorf("invalid login") -} - -func (d *DummyAuthenticator) Logout(clientID string) { - delete(d.passwords, clientID) - delete(d.tokens, clientID) -} - -func (d *DummyAuthenticator) ValidatePassword(clientID string, password string) (err error) { - currPass, isClient := d.passwords[clientID] - if isClient && currPass == password { - return nil - } - return errors.New("bad login or pass") -} - -func (d *DummyAuthenticator) RefreshToken( - senderID string, oldToken string) (newToken string, err error) { - - tokenClientID, sessionID, err := d.ValidateToken(oldToken) - if err != nil || senderID != tokenClientID { - err = fmt.Errorf("invalid token, client or sender") - } else { - newToken = d.CreateSessionToken(senderID, sessionID, 0) - } - return newToken, err -} -func (d *DummyAuthenticator) SetAuthServerURI(authServerURI string) { - d.authServerURI = authServerURI -} - -func (d *DummyAuthenticator) ValidateToken(token string) (clientID string, sessionID string, err error) { - - parts := strings.Split(token, "/") - if len(parts) != 2 { - return "", "", fmt.Errorf("badToken") - } - clientID = parts[0] - sessionID = parts[1] - // simulate a session by checking if a recent token was issued - _, found := d.tokens[clientID] - if !found { - err = errors.New("no active session") - } - - return clientID, sessionID, err -} - -func NewDummyAuthenticator() *DummyAuthenticator { - d := &DummyAuthenticator{ - passwords: make(map[string]string), - tokens: make(map[string]string), - } - return d -} diff --git a/lib/messaging/IAuthenticator.go b/lib/messaging/IAuthenticator.go deleted file mode 100644 index 5c2a65ea..00000000 --- a/lib/messaging/IAuthenticator.go +++ /dev/null @@ -1,66 +0,0 @@ -package messaging - -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" -) - -// UserLoginArgs defines the arguments of the login function -// Login - Login with password -type UserLoginArgs struct { - - // ClientID with Login ID - ClientID string `json:"clientID,omitempty"` - - // Password with Password - Password string `json:"password,omitempty"` -} - -// IAuthenticator is the interface of the authentication capability to obtain and -// validate session tokens. -type IAuthenticator interface { - // AddSecurityScheme adds the wot securityscheme to the given TD - AddSecurityScheme(tdoc *td.TD) - - // CreateSessionToken creates a signed session token for a client and adds the session - // sessionID is required. For persistent sessions use the clientID. - CreateSessionToken(clientID, sessionID string, validity time.Duration) (token string) - - // DecodeSessionToken and return its claims - DecodeSessionToken(sessionToken string, signedNonce string, nonce string) ( - clientID string, sessionID string, err error) - - // GetAlg returns the supported security format and authentication algorithm. - // This uses the vocabulary as defined in the TD. - // JWT: "ES256", "ES512", "EdDSA" - // paseto: "local" (symmetric), "public" (asymmetric) - GetAlg() (string, string) - - // Login with a password and obtain a new session token with limited duration - // This creates a new session. The token must be refreshed to keep the session alive. - Login(login string, password string) (token string, err error) - - // Logout removes the session - Logout(clientID string) - - // RefreshToken issues a new session token with an updated expiry time. - // This extends the life of the session. - // - // clientID Client whose token to refresh - // oldToken must be valid - // validitySec validity in seconds of the new token - // - // This returns a new token or an error if the old token isn't valid or doesn't match clientID - RefreshToken(senderID string, oldToken string) (newToken string, err error) - - // Set the method to - SetAuthServerURI(authServiceURI string) - - // ValidatePassword checks if the given password is valid for the client - ValidatePassword(clientID string, password string) (err error) - - // ValidateToken validates the auth token and returns the token clientID. - // If the token is invalid an error is returned - ValidateToken(token string) (clientID string, sessionID string, err error) -} diff --git a/lib/messaging/IClientConnection.go b/lib/messaging/IClientConnection.go deleted file mode 100644 index 8a277de6..00000000 --- a/lib/messaging/IClientConnection.go +++ /dev/null @@ -1,28 +0,0 @@ -// Package transports with the interface of a client transport connection -package messaging - -import ( - "github.com/hiveot/hivekit/go/api/td" -) - -// GetFormHandler is the handler that provides the client with the form needed to invoke an operation -// This returns nil if no form is found for the operation. -type GetFormHandler func(op string, thingID string, name string) *td.Form - -// IClientConnection defines the client interface for establishing connections with a server -// Intended for consumers to connect to a Thing Agent/Hub and for Service agents that connect -// to the Hub. -type IClientConnection interface { - IConnection - - // ConnectWithClientCert connects to the server using a client certificate. - // This authentication method is optional - //ConnectWithClientCert(kp keys.IHiveKey, cert *tls.Certificate) (err error) - - // ConnectWithToken connects to the messaging server using an authentication token. - // - // If a connection is already established on this client then it will be closed first. - // - // This connection method must be supported by all transport implementations. - ConnectWithToken(token string) (err error) -} diff --git a/lib/messaging/IConnection.go b/lib/messaging/IConnection.go deleted file mode 100644 index 731f0c83..00000000 --- a/lib/messaging/IConnection.go +++ /dev/null @@ -1,130 +0,0 @@ -package messaging - -import ( - "crypto/x509" - "time" -) - -// Supported transport protocol bindings types -const ( - // WoT http basic protocol without return channel - ProtocolTypeHTTPBasic = "http-basic" - - // websocket sub-protocol - ProtocolTypeWSS = "wss" - - // WoT MQTT protocol over WSS - ProtocolTypeWotMQTTWSS = "mqtt-wss" - - // HiveOT http SSE subprotocol return channel with direct messaging - ProtocolTypeHiveotSSE = "hiveot-sse" - - // HiveOT message envelope passthrough - ProtocolTypePassthrough = "passthrough" -) - -var UnauthorizedError error = unauthorizedError{} - -// UnauthorizedError for dealing with authorization problems -type unauthorizedError struct { - Message string -} - -func (e unauthorizedError) Error() string { - return "Unauthorized: " + e.Message -} - -// ConnectionInfo provides details of a connection -type ConnectionInfo struct { - - // Connection CA - CaCert *x509.Certificate - - // GetClientID returns the authenticated clientID of this connection - ClientID string - - // GetConnectionID returns the client's connection ID belonging to this endpoint - ConnectionID string - - // GetConnectURL returns the full server URL used to establish this connection - ConnectURL string - - // GetProtocolType returns the name of the protocol of this connection - // See ProtocolType... constants above for valid values. - //ProtocolType string - - // Connection timeout settings (clients only) - Timeout time.Duration -} - -// ConnectionHandler handles a change in connection status -// -// connected is true when connected without errors -// err details why connection failed -// c is the connection instance being established or disconnected -type ConnectionHandler func(connected bool, err error, c IConnection) - -// NotificationHandler handles a subscruption notification, send by an agent. -// -// retry sending the response at a later time. -type NotificationHandler func(msg *NotificationMessage) - -// RequestHandler agent processes a request and returns a response. -// -// req is the envelope that contains the request to process -// c is the connection on which the request arrived and on which to send -// asynchronous response(s). -type RequestHandler func(req *RequestMessage, c IConnection) (response *ResponseMessage) - -// ResponseHandler handles a response to a request, send by an agent. -// The handler delivers the response to the client that sent the original request. -// -// This returns an error if the response cannot be delivered. This can be used to -// retry sending the response at a later time. -type ResponseHandler func(msg *ResponseMessage) error - -// IConnection defines the interface of a server or client connection. -// Intended for exchanging messages between servients. -type IConnection interface { - - // Disconnect the client. - Disconnect() - - // GetConnectionInfo return details of the connection - GetConnectionInfo() ConnectionInfo - - // IsConnected returns the current connection status - IsConnected() bool - - // SendNotification [agent] sends a notification to subscribers. - // This returns an error if the notification could not be delivered - SendNotification(notif *NotificationMessage) error - - // SendRequest client sends a request to an agent. - // This returns an error if the request could not be delivered - SendRequest(req *RequestMessage) error - - // SendResponse [agent] sends a response to a request. - // This returns an error if the response could not be delivered - SendResponse(response *ResponseMessage) error - - // SetConnectHandler sets the callback for connection status changes - // This replaces any previously set handler. - SetConnectHandler(handler ConnectionHandler) - - // SetNotificationHandler [client] sets the callback for receiving notifications. - // This replaces any previously set handler. - SetNotificationHandler(handler NotificationHandler) - - // SetRequestHandler set the handler for receiving requests that return a response. - // This replaces any previously set handler. - SetRequestHandler(handler RequestHandler) - - // SetResponseHandler [consumer] sets the callback for receiving unhandled - // asynchronous responses to requests. - // If a request is sent with 'sync' set to true then SendRequest will handle - // the response instead. - // - // This replaces any previously set handler. - SetResponseHandler(handler ResponseHandler) -} diff --git a/lib/messaging/IMessageConverter.go b/lib/messaging/IMessageConverter.go deleted file mode 100644 index df2460b7..00000000 --- a/lib/messaging/IMessageConverter.go +++ /dev/null @@ -1,42 +0,0 @@ -package messaging - -// IMessageConverter converts between the standardized hiveot request, response -// and notification messages, and the underlying protocol specific message format. -// -// Its purpose is to assist in decoupling the consumer from the messaging protocol used. -// -// This is used for the WoT websocket protocol, HttpBasic/SSE-SC protocol, MQTT -// protocol, the native Hiveot transfer and others. -// -// Intended for use by consumers and agents on the client and server side. -type IMessageConverter interface { - // DecodeNotification converts a protocol message to a hiveot notification message - // provide the serialized data to avoid multiple unmarshalls - // This returns nil if this isn't a notification. - DecodeNotification(raw []byte) *NotificationMessage - - // DecodeRequest converts a protocol message to a hiveot request message - // provide the serialized data to avoid multiple unmarshalls - // This returns nil if this isn't a request. - DecodeRequest(raw []byte) *RequestMessage - - // DecodeResponse converts a protocol message to a hiveot response message. - // This returns nil if this isn't a response - DecodeResponse(raw []byte) *ResponseMessage - - // EncodeNotification converts a hiveot NotificationMessage to a native protocol message - // return an error if the message cannot be converted. - EncodeNotification(notif *NotificationMessage) (any, error) - - // EncodeRequest converts a hiveot RequestMessage to a native protocol message - // return an error if the message cannot be converted. - EncodeRequest(req *RequestMessage) (any, error) - - // EncodeResponse converts a hiveot ResponseMessage to a native protocol message - // This returns an error response if the message cannot be converted - EncodeResponse(resp *ResponseMessage) any - - // GetProtocolType provides the protocol type for these messages, - // eg ProtocolTypeWSS - GetProtocolType() string -} diff --git a/lib/messaging/IServerConnection.go b/lib/messaging/IServerConnection.go deleted file mode 100644 index 1dd8e0ee..00000000 --- a/lib/messaging/IServerConnection.go +++ /dev/null @@ -1,14 +0,0 @@ -package messaging - -// IServerConnection is the interface of an incoming client connection on the server. -// Protocol servers must implement this interface to return information to the consumer. -// -// This provides a return channel for sending messages from the digital twin to -// agents or consumers. -// -// Subscription to events or properties can be made externally via this API, -// or handled internally by the protocol handler if the protocol defines the -// messages for subscription. -type IServerConnection interface { - IConnection -} diff --git a/lib/messaging/NotificationMessage.go b/lib/messaging/NotificationMessage.go deleted file mode 100644 index 14aa22e1..00000000 --- a/lib/messaging/NotificationMessage.go +++ /dev/null @@ -1,94 +0,0 @@ -// Package transports with the 3 flow messages: requests, response and notifications -package messaging - -import ( - "github.com/hiveot/hivekit/go/utils" - "github.com/teris-io/shortid" -) - -// ResponseMessage, ActionStatus and ThingValue define the standardized messaging -// envelopes for handling responses. -// Each transport protocol bindings map this format to this specific format. - -// MessageTypeNotification identify the message as a notification. -const MessageTypeNotification = "notification" - -// NotificationMessage for sending asynchronous notifications to a subscriber/observer -// -// The Data field contains the message response data as defined by the operation -// Possible operations in notifications: -// - invokeaction action status as per TD -// - observeproperty property value as per TD, when status==running -// - observeallproperties map[name]value (multiple updates) -// - subscribeevent event value as per TD, when status==running -type NotificationMessage struct { - - // CorrelationID of the request this is a response to, if any. - CorrelationID string `json:"correlationID,omitempty"` - - // Error contains the short error description when status is failed. - // Error string `json:"error"` - - // MessageID unique ID of the message. Intended to detect duplicates. - // Generated by the protocol binding. - MessageID string `json:"messageID,omitempty"` - - // MessageType identifies this message payload as a response - // This is set to the value of MessageTypeNotification - MessageType string `json:"messageType"` - - // Name of the action or property affordance this is a response from. - // This field is optional and intended to help debugging and logging. - Name string `json:"name,omitempty"` - - // The operation this is a notification of. Eg subscribeevent or observeproperty. - Operation string `json:"operation"` - - // Authenticated ID of the agent sending the notification, set by the server. - // The protocol server MUST set this to the authenticated sender. - SenderID string `json:"senderID"` - - // The optional notification status in case of action progress - // this is replaced by ActionStatus payload. - //Status string `json:"status,omitempty"` - - // ThingID of the thing this is a response from. - // For responses passed to consumers this is the digitwin dThingID - // For responses sent by agents this is the agent ThingID - // This field is optional and intended to help debugging and logging. - ThingID string `json:"thingID,omitempty"` - - // Timestamp the notification was created - Timestamp string `json:"timestamp,omitempty"` - - // Value containing the notification data as described in the TD event or property dataschema. - // If the operation is one of the Thing level operations, the output is specified - // by the operation's dataschema. WoT doesn't have this yet so hiveot will - // define the missing bits if any. (see documentation) - Value any `json:"value"` // native - -} - -// ToString is a helper to easily read the notification data as a string -func (notif *NotificationMessage) ToString(maxlen int) string { - return utils.DecodeAsString(notif.Value, maxlen) -} - -// NewNotificationMessage creates a new NotificationMessage instance. -// -// operation is the notification operation -// thingID is the thing the value applies to (destination of action or source of event) -// name is the name of the property, event or action affordance as described in the thing TD -// value is the data as defined in the corresponding affordance dataschema or nil if not applicable -func NewNotificationMessage(operation string, thingID, name string, value any) *NotificationMessage { - notif := &NotificationMessage{ - MessageType: MessageTypeNotification, - Operation: operation, - ThingID: thingID, - Name: name, - Value: value, - Timestamp: utils.FormatNowUTCMilli(), - MessageID: shortid.MustGenerate(), - } - return notif -} diff --git a/lib/messaging/RequestMessage.go b/lib/messaging/RequestMessage.go deleted file mode 100644 index 6c555b50..00000000 --- a/lib/messaging/RequestMessage.go +++ /dev/null @@ -1,178 +0,0 @@ -// Package transports with the 3 flow messages: requests, response and notifications -package messaging - -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/teris-io/shortid" -) - -// RequestMessage defines the standardized message envelope for sending requests. -// Each transport protocol bindings map this format to this specific format. - -// MessageTypeRequest constant that identify a payload as a request -const MessageTypeRequest = "request" - -// RequestMessage for sending a request for an operation on a Thing or service. -// Agents/Things MUST send a response when a request is received and a correlationID -// is included. -// -// The following operations are considered to be requests: -// -// - invokeaction, cancelaction [WoT] -// - queryaction, queryallactions [WoT] -// - readevent, readallevents (of a Thing) [HiveOT extension] -// - subscribe, unsubscribe [WoT] (handled by protocol bindings) -// - observe, unobserve [WoT] (handled by protocol bindings) -// - readproperty, readallproperties [WoT] -// - readtd, readalltds (of a directory or thing) [HiveOT extension] -type RequestMessage struct { - - // CorrelationID of the message. Uniquely identifies the request and must be included - // in the response. - // Notifications can include this to correlate with the subscription. - // Message streams can include this to correlate with the original request. - // This is optional. If omitted, no response will be received. - CorrelationID string `json:"correlationID,omitempty"` - - // Created holds the timestamp the request was created in utc - // This MUST be set by the protocol server if not provided. - Created string `json:"created"` - - // Input for the request as described in the TD affordance dataschema. - // If the operation is one of the Thing level operations, the input is specified - // by the operation's dataschema. WoT doesn't have this yet so hiveot will - // define the missing bits if any. - // Note: queryaction,cancelaction carry actionID in the Input - Input any `json:"input,omitempty"` // native - - // MessageID unique ID of the message. Intended to detect duplicates. - // Generated by the protocol binding if not provided. - MessageID string `json:"messageID"` - - // MessageType identifies this message payload as a request. - // This is required and set to "request". - MessageType string `json:"messageType"` - - // Name of the event, action or property affordance the request is for. - // This field is optional and only required for specific operations. - Name string `json:"name,omitempty"` - - // The operation for this message as defined in TD-1.1 (WotOp...) - // This identifies the request and is a required field. - Operation string `json:"operation"` - - // SenderID is the authenticated ID of the client sending the request. - // The protocol server MUST set this to the authenticated client. - // Intended for services that link requests to the client, such as the state storage service. - // This is specific to the Hub as it proxies requests on behalf of clients. - SenderID string `json:"senderID,omitempty"` - - // ThingID of the thing this request is for. - // For messages from consumers this is the digitwin dThingID - // For messages to agents this is the agent ThingID - // This field is required. - ThingID string `json:"thingID"` -} - -// CreateActionResponse is a helper to easily create an action response. -// This generates an ActionStatus value with the given actionID, state and output. -// -// Typically used by agents to create a response to an invokeaction request. -// Output can be nil if the action has no output or state is not yet completed. -// If there is a transient output it can be used as output with status running. -// -// actionID contains the actionID of the action instance, generated by the device -// state contains the action state, one of Status... constants -// output contains the action output as defined in the action affordance -func (req *RequestMessage) CreateActionResponse( - actionID string, state string, output any, err error) (resp *ResponseMessage, as *ActionStatus) { - - as = &ActionStatus{ - ActionID: actionID, - Error: ErrorValueFromError(err), - Input: req.Input, - Name: req.Name, - SenderID: req.SenderID, - State: state, - Output: output, - ThingID: req.ThingID, - TimeRequested: utils.FormatNowUTCMilli(), - TimeUpdated: utils.FormatNowUTCMilli(), - } - resp = NewResponseMessage( - req.Operation, req.ThingID, req.Name, as, err, req.CorrelationID) - return resp, as -} - -// CreateErrorResponse is a helper to easily create an error response from a request. -// If operation is invokeaction then it generates an ActionStatus response with -// the given error. -// -// err is set when the request has failed. In that case value can contain error details -func (req *RequestMessage) CreateErrorResponse(err error) (errResp *ResponseMessage) { - errResp = NewResponseMessage( - req.Operation, req.ThingID, req.Name, nil, err, req.CorrelationID) - return errResp -} - -// CreateResponse is a helper to easily create a response from a request -// -// If operation is invokeaction then it generates an ActionStatus response with -// the given value as its output and state Completed -// -// value contains the response data -// err is set when the request has failed. In that case value can contain error details -func (req *RequestMessage) CreateResponse(value any, err error) (resp *ResponseMessage) { - // response to invoke action is an ActionStatus - if req.Operation == td.OpInvokeAction { - resp, _ = req.CreateActionResponse("", StatusCompleted, value, err) - } else { - resp = NewResponseMessage( - req.Operation, req.ThingID, req.Name, value, err, req.CorrelationID) - } - return resp -} - -// CreateNotification is a helper to easily create a status update of a running request. -// -// data contains the payload to include in the notification -func (req *RequestMessage) CreateNotification() (notif *NotificationMessage) { - - notif = NewNotificationMessage(req.Operation, req.ThingID, req.Name, nil) - notif.CorrelationID = req.CorrelationID - return notif -} - -// ToString is a helper to easily convert the request input to a string -// maxlen is the maximum string length or 0 for unlimited -func (req *RequestMessage) ToString(maxlen int) string { - return utils.DecodeAsString(req.Input, maxlen) -} - -// ToObject is a helper to easily convert the request input to an object -func (req *RequestMessage) ToObject(input any) error { - return utils.DecodeAsObject(req.Input, &input) -} - -// NewRequestMessage creates a new RequestMessage instance. -// -// operation is the request operation WoTOp... or HTOp... -// thingID is the thing the value applies to (destination of action or source of event) -// name is the name of the property, event or action affordance as described in the thing TD -// input is the request input as defined in the corresponding affordance dataschema. -// correlationID unique ID of the request or empty when no response is expected -func NewRequestMessage(operation string, thingID, name string, input any, correlationID string) *RequestMessage { - return &RequestMessage{ - MessageType: MessageTypeRequest, - Operation: operation, - ThingID: thingID, - Name: name, - Input: input, - CorrelationID: correlationID, - Created: utils.FormatUTCMilli(time.Now()), - MessageID: shortid.MustGenerate(), - } -} diff --git a/lib/messaging/ResponseMessage.go b/lib/messaging/ResponseMessage.go deleted file mode 100644 index 5c6f088c..00000000 --- a/lib/messaging/ResponseMessage.go +++ /dev/null @@ -1,185 +0,0 @@ -// Package messaging with the 3 flow messages: requests, response and notifications -package messaging - -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/teris-io/shortid" -) - -// ResponseMessage, ActionStatus and ThingValue define the standardized messaging -// envelopes for handling responses. -// Each transport protocol bindings map this format to this specific format. - -type AffordanceType string - -const AffordanceTypeEvent AffordanceType = "event" -const AffordanceTypeProperty AffordanceType = "property" -const AffordanceTypeAction AffordanceType = "action" - -// MessageTypeResponse identify the message as a response. -const MessageTypeResponse = "response" - -// ThingValue is the internal API response payload to subscribeevent, observeproperty, -// readevent and readproperty operations. The protocol binding maps between this -// and the protocol way of encoding values. -type ThingValue struct { - // Type of affordance this is a value of: AffordanceTypeProperty|Event|Action - AffordanceType AffordanceType `json:"affordanceType"` - - // Output with Payload - // - // Data in format as described by the thing's affordance - Data any `json:"data,omitempty"` - - // Name with affordance name - // - // Name of the affordance holding the value - Name string `json:"name,omitempty"` - - // ThingID with Thing ID - // - // Digital twin Thing ID - ThingID string `json:"thingID,omitempty"` - - // Timestamp with Timestamp time - // - // Time the value was last updated - Timestamp string `json:"timestamp,omitempty"` -} - -// ToString is a helper to easily read the response output as a string -func (tv *ThingValue) ToString(maxlen int) string { - return utils.DecodeAsString(tv.Data, maxlen) -} -func NewThingValue(affordanceType AffordanceType, thingID, name string, data any, timestamp string) *ThingValue { - tv := &ThingValue{ - AffordanceType: affordanceType, - Data: data, - Name: name, - ThingID: thingID, - Timestamp: timestamp, - } - if tv.Timestamp == "" { - tv.Timestamp = utils.FormatUTCMilli(time.Now()) - } - return tv -} - -// ResponseMessage serves to notify a client of the result of a request. -// -// The Value field contains the message response data as defined by the operation -// Action related response output: -// - invokeaction action output as per TD, when status==completed -// - queryaction []ActionStatus object array -// - queryallactions map [name][]ActionStatus objects -// -// Property related response output: -// - readproperty ThingValue object -// - readallproperties map[name]ThingValue objects -// -// Event related response output -// - readevent ThingValue object -// - readallevents map[name]ThingValue objects -type ResponseMessage struct { - - // CorrelationID of the request this is a response to, if any. - CorrelationID string `json:"correlationID,omitempty"` - - // Error contains the short error description when status is failed. - // Matches RFC9457 https://www.rfc-editor.org/rfc/rfc9457 - Error *ErrorValue `json:"error"` - - // MessageID unique ID of the message. Intended to detect duplicates. - // Generated by the protocol binding. - MessageID string `json:"messageID,omitempty"` - - // MessageType identifies this message payload as a response - // This is set to the value of MessageTypeResponse - MessageType string `json:"messageType"` - - // Name of the action or property affordance this is a response from. - Name string `json:"name"` - - // The operation this is a response to. This MUST be the operation provided in the request. - Operation string `json:"operation"` - - // Authenticated ID of the agent sending the response, set by the server. - // - // This is non-wot and a feature of the hiveot Hub, to allow services to link requests - // to authenticated users. - // - // The Hub protocol server MUST set this to the authenticated sender. - SenderID string `json:"senderID"` - - // ThingID of the thing this is a response from. - // For responses passed to consumers this is the digitwin dThingID - // For responses sent by agents this is the agent ThingID - // This field is optional and intended to help debugging and logging. - ThingID string `json:"thingID,omitempty"` - - // Timestamp the response was created - Timestamp string `json:"timestamp,omitempty"` - - // Value of the response as described in the TD affordance output or value dataschema. - // If the operation is one of the Thing level operations, the value is specified - // by the operation's dataschema. - // In case of actions, the value holds the ActionStatus. - // - // Note that different protocol bindings use a different field depending on the operation. - // The message converter stores the response value(s) into the Value field. - // - // If an error is returned then value optionally contains a detailed error description. - Value any `json:"value"` -} - -// Decode the value in the response -// If response is for an invokeaction then the output is extracted from the ActionStatus -func (resp *ResponseMessage) Decode(output any) error { - if resp.Operation == td.OpInvokeAction { - var as ActionStatus - err := utils.Decode(resp.Value, &as) - if err != nil { - return err - } - if output != nil { - err = utils.Decode(as.Output, output) - } - return err - } - err := utils.Decode(resp.Value, output) - return err -} - -// ToString is a helper to easily read the response output as a string -func (resp *ResponseMessage) ToString(maxlen int) string { - return utils.DecodeAsString(resp.Value, maxlen) -} - -// NewResponseMessage creates a new ResponseMessage instance. -// -// This sets status to completed if err is nil or Failed if err is provided. -// If the status is not completed or failed then set it to the appropriate value after creation. -// -// operation is the request operation WoTOp... or HTOp... -// thingID is the thing the value applies to (destination of action or source of event) -// name is the name of the property, event or action affordance as described in the thing TD -// value is the response data as defined in the corresponding affordance dataschema or nil if not applicable -// err is the optional error response which will set status to bad request -// correlationID ID provided by the request -func NewResponseMessage(operation string, thingID, name string, value any, err error, correlationID string) *ResponseMessage { - resp := &ResponseMessage{ - CorrelationID: correlationID, - Error: ErrorValueFromError(err), - MessageID: shortid.MustGenerate(), - MessageType: MessageTypeResponse, - Name: name, - Operation: operation, - Timestamp: utils.FormatUTCMilli(time.Now()), - ThingID: thingID, - Value: value, - } - return resp -} diff --git a/lib/messaging/converters/PassthroughMessageConverter.go b/lib/messaging/converters/PassthroughMessageConverter.go deleted file mode 100644 index cd6984ee..00000000 --- a/lib/messaging/converters/PassthroughMessageConverter.go +++ /dev/null @@ -1,82 +0,0 @@ -package converters - -import ( - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" -) - -// Passthrough message converter simply passes request, response and notification -// messages as-is. Intended to be used when no WoT exists. -// This implements the IMessageConverter interface -type PassthroughMessageConverter struct { -} - -// DecodeNotification passes the notification message as-is -// Raw is the json serialized encoded message -func (svc *PassthroughMessageConverter) DecodeNotification(raw []byte) *messaging.NotificationMessage { - - var notif messaging.NotificationMessage - err := jsoniter.Unmarshal(raw, ¬if) - //err := tputils.DecodeAsObject(msg, ¬if) - if err != nil || notif.MessageType != messaging.MessageTypeNotification { - return nil - } - return ¬if -} - -// DecodeRequest passes the request message as-is -// Raw is the json serialized encoded message -func (svc *PassthroughMessageConverter) DecodeRequest(raw []byte) *messaging.RequestMessage { - - var req messaging.RequestMessage - err := jsoniter.Unmarshal(raw, &req) - //err := tputils.DecodeAsObject(msg, &req) - if err != nil || req.MessageType != messaging.MessageTypeRequest { - return nil - } - return &req -} - -// DecodeResponse passes the response message as-is -// Raw is the json serialized encoded message -func (svc *PassthroughMessageConverter) DecodeResponse( - raw []byte) *messaging.ResponseMessage { - - var resp messaging.ResponseMessage - err := jsoniter.Unmarshal(raw, &resp) - if err != nil || resp.MessageType != messaging.MessageTypeResponse { - return nil - } - return &resp -} - -// EncodeNotification passes the notification message as-is -func (svc *PassthroughMessageConverter) EncodeNotification(req *messaging.NotificationMessage) (any, error) { - // ensure this field is present as it is needed for decoding - req.MessageType = messaging.MessageTypeNotification - return req, nil -} - -// EncodeRequest passes the request message as-is -func (svc *PassthroughMessageConverter) EncodeRequest(req *messaging.RequestMessage) (any, error) { - // ensure this field is present as it is needed for decoding - req.MessageType = messaging.MessageTypeRequest - return req, nil -} - -// EncodeResponse passes the response message as-is -func (svc *PassthroughMessageConverter) EncodeResponse(resp *messaging.ResponseMessage) any { - // ensure this field is present as it is needed for decoding - resp.MessageType = messaging.MessageTypeResponse - return resp -} - -// GetProtocolType returns the hiveot WSS protocol type identifier -func (svc *PassthroughMessageConverter) GetProtocolType() string { - return messaging.ProtocolTypeWSS -} - -// Create a new instance of the hiveot passthrough message converter -func NewPassthroughMessageConverter() *PassthroughMessageConverter { - return &PassthroughMessageConverter{} -} diff --git a/lib/messaging/converters/WssMessageConverter.go b/lib/messaging/converters/WssMessageConverter.go deleted file mode 100644 index a9085f80..00000000 --- a/lib/messaging/converters/WssMessageConverter.go +++ /dev/null @@ -1,360 +0,0 @@ -package converters - -import ( - "fmt" - "log/slog" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" -) - -// Websocket notification message with all possible fields for all operations -type WssNotificationMessage struct { - messaging.NotificationMessage -} - -// Websocket requests message with all possible fields for all operations -type WssRequestMessage struct { - messaging.RequestMessage - // queryaction - ActionID string `json:"actionID,omitempty"` // input for operation - // readmultipleproperties: array of property names - Names []string `json:"names,omitempty"` - // writeallproperties, writemultipleproperties input - Values any `json:"values,omitempty"` -} -type WssActionStatus struct { - ActionID string `json:"actionID"` - Error *messaging.ErrorValue `json:"error,omitempty"` - Output any `json:"output,omitempty"` // when completed - State string `json:"state"` - TimeRequested string `json:"timeRequested"` - TimeEnded string `json:"timeEnded,omitempty"` // when completed -} - -// Websocket response message with all possible fields for all operations -type WssResponseMessage struct { - messaging.ResponseMessage - - // invokeaction (async), queryaction response contains status - Status *WssActionStatus `json:"status,omitempty"` - - // queryallactions response: - Statuses map[string]WssActionStatus `json:"statuses,omitempty"` - - // readallproperties,readmultipleproperties, - // writeallproperties, writemultipleproperties: - // object with property name-value pairs - Values any `json:"values,omitempty"` - - // invokeaction output value (synchronous) - // for hiveot clients: readallproperties, readmultipleproperties ThingValue map - Output any `json:"output,omitempty"` -} - -// Websocket message converter converts requests, responses and notifications -// between hiveot standardiz envelope and the WoT websocket protocol (draft) messages. -// Websocket messages vary based on the operation. -type WssMessageConverter struct { -} - -// DecodeNotification converts a websocket notification to a hiveot notification message. -// Raw is the json serialized encoded message -func (svc *WssMessageConverter) DecodeNotification(raw []byte) *messaging.NotificationMessage { - - var wssnotif WssNotificationMessage - err := jsoniter.Unmarshal(raw, &wssnotif) - //err := tputils.DecodeAsObject(msg, ¬if) - if err != nil || wssnotif.MessageType != messaging.MessageTypeNotification { - return nil - } - notifmsg := &wssnotif.NotificationMessage - switch wssnotif.Operation { - } - return notifmsg -} - -// DecodeRequest converts a websocket request message to a hiveot request message. -// Raw is the json serialized encoded message. -// Websocket request messages are nearly identical to hiveot, so use passthrough. -// Conversion by operation: -// - cancelaction: copy wss actionID field to input -// - invokeaction: none -// - queryaction: copy wss actionID field to input -// - queryallactions: none -// - writeproperty: -func (svc *WssMessageConverter) DecodeRequest(raw []byte) *messaging.RequestMessage { - - var wssreq WssRequestMessage - err := jsoniter.Unmarshal(raw, &wssreq) - - //err := tputils.DecodeAsObject(msg, &req) - if err != nil || wssreq.MessageType != messaging.MessageTypeRequest { - return nil - } - // query/cancel action messages carry an actionID in the request - // (tentative: https://github.com/w3c/web-thing-protocol/issues/43) - reqmsg := &wssreq.RequestMessage - switch wssreq.Operation { - - case td.OpQueryAction, td.OpCancelAction: - // input is actionID - reqmsg.Input = wssreq.ActionID - } - return reqmsg -} - -// DecodeResponse converts a websocket response message to a hiveot response message. -// Raw is the json serialized encoded message -func (svc *WssMessageConverter) DecodeResponse( - raw []byte) *messaging.ResponseMessage { - - var wssResp WssResponseMessage - err := jsoniter.Unmarshal(raw, &wssResp) - if err != nil { - slog.Warn("DecodeResponse: Can't unmarshal websocket response", "error", err, "raw", string(raw)) - return nil - } - if wssResp.MessageType != messaging.MessageTypeResponse { - return nil - } - - respMsg := &wssResp.ResponseMessage - - // if the response is an error response then no need to decode any further - if respMsg.Error != nil { - return respMsg - } - - switch wssResp.Operation { - - case td.OpCancelAction: - // hiveot response API doesnt contain the actionID. This is okay as the sender knows it. - case td.OpInvokeAction: - // hiveot always returns an ActionStatus object - // - // in websocket profile synchronous actions respond with output, - // while async actions respond with actionID - as := messaging.ActionStatus{ - Name: wssResp.Name, - Output: wssResp.Output, - State: messaging.StatusCompleted, - ThingID: wssResp.ThingID, - } - // if wss contains an actionID the request is pending - if wssResp.Status != nil { - as.State = wssResp.Status.State - as.TimeUpdated = wssResp.Status.TimeRequested - as.TimeUpdated = wssResp.Status.TimeEnded - } - respMsg.Value = as - - case td.OpQueryAction: - // ResponseMessage should contain ActionStatus object - var wssStatus WssActionStatus - err = utils.Decode(wssResp.Status, &wssStatus) - if err != nil { - return nil - } - if respMsg.Value == nil { - // non hiveot server - as := messaging.ActionStatus{ - ActionID: wssStatus.ActionID, - Name: wssResp.Name, - Output: wssStatus.Output, - State: wssStatus.State, - ThingID: wssResp.ThingID, - TimeRequested: wssStatus.TimeRequested, - TimeUpdated: wssStatus.TimeEnded, - } - respMsg.Value = as - } - - case td.OpQueryAllActions: - // ResponseMessage should contain ActionStatus list - var wssStatusMap map[string]WssActionStatus - actionStatusMap := make(map[string]messaging.ActionStatus) - err = utils.Decode(wssResp.Statuses, &wssStatusMap) - if err != nil { - return nil - } - for _, wssStatus := range wssStatusMap { - actionStatusMap[wssResp.Name] = messaging.ActionStatus{ - ThingID: wssResp.ThingID, - Name: wssResp.Name, - ActionID: wssStatus.ActionID, - State: wssStatus.State, - TimeRequested: wssStatus.TimeRequested, - TimeUpdated: wssStatus.TimeEnded, - Output: wssStatus.Output, - } - } - respMsg.Value = actionStatusMap - - case td.OpReadAllProperties, td.OpReadMultipleProperties, - td.OpWriteMultipleProperties: - - // the 'Value' property from the messaging.ResponseMessage embedded struct - // already contains the messaging.ThingValue map. - // But, if the websocket response is from a non-hiveot device then convert - // the websocket 'Values' field k-v map to ThingValue map - tvMap := make(map[string]messaging.ThingValue) - if respMsg.Value == nil { - wssPropValues := make(map[string]any) - utils.DecodeAsObject(wssResp.Values, wssPropValues) - for k, v := range wssPropValues { - tv := messaging.ThingValue{ - AffordanceType: messaging.AffordanceTypeProperty, - Name: k, - Data: v, - ThingID: wssResp.ThingID, - // Timestamp: n/a - } - tvMap[tv.Name] = tv - } - respMsg.Value = tvMap - } - } - return respMsg -} - -// EncodeNotification converts a hiveot RequestMessage to a websocket equivalent message -func (svc *WssMessageConverter) EncodeNotification(notif *messaging.NotificationMessage) (any, error) { - wssNotif := WssNotificationMessage{ - NotificationMessage: *notif, - } - // ensure this field is present as it is needed for decoding - wssNotif.MessageType = messaging.MessageTypeNotification - return wssNotif, nil -} - -// EncodeRequest converts a hiveot RequestMessage to websocket equivalent message -func (svc *WssMessageConverter) EncodeRequest(req *messaging.RequestMessage) (any, error) { - wssReq := WssRequestMessage{ - RequestMessage: *req, - ActionID: req.CorrelationID, - } - // ensure this field is present as it is needed for decoding - wssReq.MessageType = messaging.MessageTypeRequest - switch req.Operation { - case td.OpWriteMultipleProperties: - wssReq.Values = req.Input - case td.OpQueryAction: - // correlationID is used as actionID - wssReq.ActionID = req.CorrelationID - } - return wssReq, nil -} - -// EncodeResponse converts a hiveot ResponseMessage to websocket equivalent message -// This always returns a response -func (svc *WssMessageConverter) EncodeResponse(resp *messaging.ResponseMessage) any { - wssResp := WssResponseMessage{ - ResponseMessage: *resp, - } - - // when the response contains an error instead of a reply - // then there is no data to encode - if resp.Error != nil { - return wssResp - } - - // ensure this field is present as it is needed for decoding - wssResp.MessageType = messaging.MessageTypeResponse - switch resp.Operation { - case td.OpCancelAction: - // actionID of cancelled action ? - // wssResp.ActionID = resp.CorrelationID - case td.OpInvokeAction: - // hiveot invokeaction always contains an ActionStatus object in the response - var as messaging.ActionStatus - err := utils.Decode(resp.Value, &as) - if err != nil { - wssResp.Error = messaging.ErrorValueFromError(err) - } - if as.State == messaging.StatusCompleted { - // websocket synchronous response - wssResp.Output = as.Output - } else { - // websocket asynchronous response returns ActionID - wssResp.Status = &WssActionStatus{ - ActionID: as.ActionID, - Error: as.Error, // error fields are identical - State: as.State, - TimeRequested: as.TimeRequested, - } - } - case td.OpQueryAction: - // convert from messaging.ActionStatus to WssActionStatus - var actionStatus messaging.ActionStatus - err := utils.Decode(resp.Value, &actionStatus) - if err != nil { - wssResp.Error = messaging.ErrorValueFromError(fmt.Errorf("Response does not contain ActionStatus object: %w", err)) - } - wssResp.Status = &WssActionStatus{ - ActionID: actionStatus.ActionID, - Error: actionStatus.Error, // error fields are identical - Output: actionStatus.Output, - State: actionStatus.State, - TimeRequested: actionStatus.TimeRequested, - TimeEnded: actionStatus.TimeUpdated, - } - case td.OpQueryAllActions: - // convert from messaging.ActionStatus map to WssActionStatuses map - // FIXME: response is api.ActionStatus which differs from messaging.ActionStatus - var actionStatusMap map[string]messaging.ActionStatus - err := utils.Decode(resp.Value, &actionStatusMap) - if err != nil { - err = fmt.Errorf("Can't convert ActionStatus map response to websocket type. "+ - "Response does not contain ActionStatus map. "+ - "thingID='%s'; name='%s'; operation='%s'; Received '%s'; Error='%s'", - resp.ThingID, resp.Name, resp.Operation, - utils.DecodeAsString(resp.Value, 200), err.Error()) - wssResp.Error = messaging.ErrorValueFromError(err) - } - wssStatusMap := make(map[string]WssActionStatus) - for _, actionStatus := range actionStatusMap { - wssStatusMap[actionStatus.Name] = WssActionStatus{ - ActionID: actionStatus.ActionID, - Error: actionStatus.Error, // error fields are identical - State: actionStatus.State, - TimeRequested: actionStatus.TimeRequested, - TimeEnded: actionStatus.TimeUpdated, - Output: actionStatus.Output, - } - } - wssResp.Statuses = wssStatusMap - case td.OpReadAllProperties, td.OpReadMultipleProperties: - // convert ThingValue map to map of name-value pairs - // the last updated timestamp is lost. - var thingValueList map[string]messaging.ThingValue - err := utils.DecodeAsObject(resp.Value, &thingValueList) - if err != nil { - err = fmt.Errorf("encodeResponse (%s). Not a ThingValue map; err: %w", resp.Operation, err) - wssResp.Error = messaging.ErrorValueFromError(err) - } - wssPropValues := make(map[string]any) - for _, thingValue := range thingValueList { - wssPropValues[thingValue.Name] = thingValue.ToString(0) - } - wssResp.Values = wssPropValues - // Note that wssResp also includes the ResponseMessage 'Value' property - // which hiveot clients can use to obtain the ThingValue result. - // non-hiveot clients will see the key-value map in 'Values' - case td.OpReadProperty: - } - - return wssResp -} - -// GetProtocolType returns the hiveot WSS protocol type identifier -func (svc *WssMessageConverter) GetProtocolType() string { - return messaging.ProtocolTypeWSS -} - -// Create a new instance of the WoT websocket to hiveot message converter -func NewWssMessageConverter() *WssMessageConverter { - return &WssMessageConverter{} -} diff --git a/lib/plugin/GetAppEnvironment.go b/lib/plugin/GetAppEnvironment.go deleted file mode 100644 index ea7b9c46..00000000 --- a/lib/plugin/GetAppEnvironment.go +++ /dev/null @@ -1,226 +0,0 @@ -package plugin - -import ( - "crypto/x509" - "flag" - "fmt" - "log/slog" - "os" - "os/user" - "path" - "path/filepath" - "strings" - - "github.com/hiveot/hub/lib/certs" - "gopkg.in/yaml.v3" -) - -// DirectoryURL_Arg is the optional commandline argument name with the URL of the directory TD -const DirectoryURL_Arg = "directoryURL" - -// ServerURL_Arg is the optional commandline argument name with the connection URL of the digitwin server -const ServerURL_Arg = "serverURL" - -// AppEnvironment holds the running environment naming conventions. -// Intended for services and plugins. -// This contains folder locations, CA certificate and application clientID -type AppEnvironment struct { - // Directories - BinDir string `yaml:"binDir,omitempty"` // Application binary folder, e.g. launcher, cli, ... - PluginsDir string `yaml:"pluginsDir,omitempty"` // Plugin folder - HomeDir string `yaml:"homeDir,omitempty"` // Home folder, default this is the parent of bin, config, certs and logs - ConfigDir string `yaml:"configDir,omitempty"` // config folder with application and configuration files - ConfigFile string `yaml:"configFile,omitempty"` // Application configuration file. Default is clientID.yaml - CertsDir string `yaml:"certsDir,omitempty"` // Certificates and keys location - LogsDir string `yaml:"logsDir,omitempty"` // Logging output - LogLevel string `yaml:"logLevel,omitempty"` // logging level: error, warning, info, debug - StoresDir string `yaml:"storesDir,omitempty"` // Root of the service stores - ServerURL string `yaml:"serverURL,omitempty"` // forced server to connect to: scheme://host/path or "" for auto - DirectoryURL string `yaml:"directoryURL,omitempty"` // Discovery URL of the directory - - // Credentials - CaCert *x509.Certificate `yaml:"-"` // default cert if loaded - ClientID string `yaml:"clientID"` // the clientID based on the application binary name - KeyFile string `yaml:"keyFile"` // client's key pair file location - TokenFile string `yaml:"tokenFile"` // client's auth token file location -} - -// LoadConfig loads the application configuration from {configDir}/{clientID}.yaml -// -// This returns an error if loading or parsing the config file fails. -// Returns nil if the config file doesn't exist or is loaded successfully. -func (env *AppEnvironment) LoadConfig(cfg interface{}) error { - configFile := env.ConfigFile - if !path.IsAbs(configFile) { - configFile = path.Join(env.CertsDir, configFile) - } - if _, err := os.Stat(configFile); err != nil { - slog.Info("Configuration file not found. Ignored.", "configFile", configFile) - return nil - } - - cfgData, err := os.ReadFile(configFile) - if err != nil { - err = fmt.Errorf("loading config failed: %w", err) - return err - } else { - slog.Info("Loaded configuration file", "configFile", configFile) - err = yaml.Unmarshal(cfgData, cfg) - } - return err -} - -// GetAppEnvironment returns the application environment including folders for use by the Hub services. -// -// Optionally parse commandline flags: -// -// -home alternative home directory. Default is the parent folder of the app binary -// -clientID alternative clientID. Default is the application binary name. -// -config alternative config directory. Default is home/certs -// -configFile alternative application config file. Default is {clientID}.yaml -// -loglevel debug, info, warning (default), error -// -server optional server URL or "" for auto-detect -// -core optional server core or "" for auto-detect -// -// The default 'user based' structure is: -// -// home -// |- bin Core binaries -// |- plugins Plugin binaries -// |- config Service configuration yaml files -// |- certs CA and service certificates -// |- logs Logging output -// |- run PID files and sockets -// |- stores -// |- {service} Store for service -// -// The system based folder structure is used when launched from a path starting -// with /usr or /opt: -// -// /opt/hiveot/bin Application binaries, cli and launcher -// /opt/hiveot/plugins Plugin binaries -// /etc/hiveot/conf.d Service configuration yaml files -// /etc/hiveot/certs CA and service certificates -// /var/log/hiveot Logging output -// /run/hiveot PID files and sockets -// /var/lib/hiveot/{service} Storage of service -// -// This uses os.Args[0] application path to determine the home directory, which is the -// parent of the application binary. -// The default clientID is based on the binary name using os.Args[0]. -// -// homeDir to override the auto-detected or commandline paths. Use "" for defaults. -// withFlags parse the commandline flags for -home and -clientID -func GetAppEnvironment(homeDir string, withFlags bool) AppEnvironment { - var configFile string - var configDir string - var binDir string - var pluginsDir string - var certsDir string - var logsDir string - var storesDir string - var directoryURL string - var serverURL string - // The default clientID is the binary name. This allows for multiple instances - // by copying the binary. - clientID := path.Base(os.Args[0]) - logLevel := os.Getenv("LOGLEVEL") - if logLevel == "" { - logLevel = "info" - } - - // TODO: get default config from environment - os.Environ() - - // default home folder is the parent of the core or plugin binary - if homeDir == "" { - binDir = filepath.Dir(os.Args[0]) - if !path.IsAbs(binDir) { - cwd, _ := os.Getwd() - binDir = path.Join(cwd, binDir) - } - homeDir = filepath.Join(binDir, "..") - } - if withFlags { - // handle commandline options - flag.StringVar(&homeDir, "home", homeDir, "Application home directory") - flag.StringVar(&configDir, "config", configDir, "Configuration directory") - flag.StringVar(&configFile, "configFile", configFile, "Configuration file") - flag.StringVar(&clientID, "clientID", clientID, "Application clientID to authenticate with") - flag.StringVar(&logLevel, "logLevel", logLevel, "logging level: debug, warning, info, error") - flag.StringVar(&directoryURL, DirectoryURL_Arg, directoryURL, "url of directory TD") - flag.StringVar(&serverURL, ServerURL_Arg, serverURL, "connection url for server") - if flag.Usage == nil { - flag.Usage = func() { - fmt.Println("Usage: " + clientID + " [options] ") - fmt.Println() - fmt.Println("Options:") - flag.PrintDefaults() - } - } - flag.Parse() - } - if strings.HasPrefix(homeDir, "~") { - usr, _ := user.Current() - homeDir = path.Join(usr.HomeDir, homeDir[1:]) - } else if !path.IsAbs(homeDir) { - cwd, _ := os.Getwd() - homeDir = path.Join(cwd, homeDir) - } - - // Try to be smart about whether to use the system structure. - // If the path starts with /opt or /usr then use - // the system folder configuration. This might be changed in future if it turns - // out not to be so smart at all. - // Future: make this work on windows - useSystem := strings.HasPrefix(homeDir, "/usr") || - strings.HasPrefix(homeDir, "/opt") - - if useSystem { - homeDir = filepath.Join("/var", "lib", "hiveot") - binDir = filepath.Join("/opt", "hiveot") - pluginsDir = filepath.Join(binDir, "plugins") - configDir = filepath.Join("/etc", "hiveot", "conf.d") - certsDir = filepath.Join("/etc", "hiveot", "certs") - logsDir = filepath.Join("/var", "log", "hiveot") - storesDir = filepath.Join("/var", "lib", "hiveot") - } else { // use application parent dir - binDir = filepath.Join(homeDir, "bin") - pluginsDir = filepath.Join(homeDir, "plugins") - certsDir = filepath.Join(homeDir, "certs") - logsDir = filepath.Join(homeDir, "logs") - storesDir = filepath.Join(homeDir, "stores") - - if configDir == "" { - configDir = filepath.Join(homeDir, "config") - } - } - if configFile == "" { - configFile = path.Join(configDir, clientID+".yaml") - } - // load the CA cert if found - caCertFile := path.Join(certsDir, certs.DefaultCaCertFile) - caCert, _ := certs.LoadX509CertFromPEM(caCertFile) - - // determine the expected location of the service auth key and token - tokenFile := path.Join(certsDir, clientID+".token") - keyFile := path.Join(certsDir, clientID+".key") - - return AppEnvironment{ - BinDir: binDir, - PluginsDir: pluginsDir, - HomeDir: homeDir, - ConfigDir: configDir, - ConfigFile: configFile, - CertsDir: certsDir, - LogsDir: logsDir, - LogLevel: logLevel, - StoresDir: storesDir, - ClientID: clientID, - KeyFile: keyFile, - TokenFile: tokenFile, - CaCert: caCert, - DirectoryURL: directoryURL, - ServerURL: serverURL, - } -} diff --git a/lib/plugin/StartHeartbeat.go b/lib/plugin/StartHeartbeat.go deleted file mode 100644 index f2987341..00000000 --- a/lib/plugin/StartHeartbeat.go +++ /dev/null @@ -1,60 +0,0 @@ -package plugin - -import ( - "context" - "log/slog" - "sync" - "time" -) - -// StartHeartbeat runs a heartbeat process in the background. -// This returns a stop function to end the heartbeat loop. -// -// If the heartbeat function is running then the heartbeat will wait until it completes. -// If the heartbeat isn't running it is stopped immediately. -// -// The timer pauses when the heartbeat function is running. If there is a temporary -// delay in the heartbeat there is no risk of overrun. -// -// interval is the time to wait in between heartbeat invocations -// fn is the heartbeat function -func StartHeartbeat(interval time.Duration, fn func()) (stopFn func()) { - // lock to wait until heartbeat has ended - stopLock := sync.Mutex{} - - ctx, ctxStop := context.WithCancel(context.Background()) - - // stoplock for waiting until the hearthbeat has ended - stopLock.Lock() - go func() { - defer stopLock.Unlock() - slog.Info("Heartbeat started", "interval", interval) - for { - fn() - - // Don't use a timer ticker, simply wait for the interval period or - // for the heartbeat context to cancel. - // This approach does two things: - // 1. The heartbeat only runs if the last run is finished - // 2. Stop instantly between heartbeats and wait until it is finished - // if it was running. - timer, cancelFn := context.WithTimeout(context.Background(), interval) - select { - case <-timer.Done(): - cancelFn() - case <-ctx.Done(): - cancelFn() - slog.Info("Heartbeat stopped") - return - } - } - }() - return func() { - // stop the heartbeat loop - ctxStop() - // the lock releases when the heartbeat loop has exited - stopLock.Lock() - // immediately unlock as this has completed - stopLock.Unlock() - } -} diff --git a/lib/plugin/StartPlugin.go b/lib/plugin/StartPlugin.go index c783b517..fac50cd8 100644 --- a/lib/plugin/StartPlugin.go +++ b/lib/plugin/StartPlugin.go @@ -3,23 +3,15 @@ package plugin import ( "log/slog" "os" + "path" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/clients" + "github.com/hiveot/hivekit/go/modules" + "github.com/hiveot/hivekit/go/modules/factory" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/modules/transport/clients" + "github.com/hiveot/hivekit/go/utils" ) -type PluginConfig struct { - LogLevel string `yaml:""` -} - -// IPlugin interface of protocol bindings and service plugins -type IPlugin interface { - // Start the plugin with the given environment settings and hub connection - // ag is the agent with the capability for publishing and subscribing - Start(ag *agent.Agent) error - Stop() -} - // StartPlugin implements the boilerplate to launch a plugin based on argv // and its config. This does not return until a signal is received. // @@ -39,9 +31,18 @@ type IPlugin interface { // file, primary key, and CA certificate. // certDir contains the service auth tokens // serverURL is the URL of the hub server to connect to, if provided -func StartPlugin(plugin IPlugin, clientID string, certsDir string, serverURL string) { - - cc, token, _, err := clients.ConnectWithTokenFile(clientID, certsDir, serverURL, 0) +func StartPlugin(plugin modules.IHiveModule, clientID string, certsDir string, serverURL string) { + + // temporary locate home as the parten of certs + homeDir := path.Dir(certsDir) + env := factory.NewAppEnvironment(homeDir, true) + cc, err := clients.NewTransportClient( + transport.ProtocolTypeWotWebsocket, serverURL, env.CaCert) + + token := env.GetAppToken() + cc.AuthenticateWithToken(clientID, token) + cc.Connect() + // cc, token, _, err := clients.ConnectWithTokenFile(clientID, certsDir, serverURL, 0) _ = token if err != nil { @@ -49,15 +50,14 @@ func StartPlugin(plugin IPlugin, clientID string, certsDir string, serverURL str os.Exit(1) } // start the service with the agent. - ag := agent.NewAgent(cc, nil, nil, nil, nil, 0) - err = plugin.Start(ag) + // ag := agent.NewAgent(cc, nil, nil, nil, nil, 0) + err = plugin.Start() if err != nil { slog.Error("failed starting service", "err", err.Error()) os.Exit(1) } - WaitForSignal() + utils.WaitForSignal() plugin.Stop() os.Exit(0) - } diff --git a/lib/plugin/WaitForSignal.go b/lib/plugin/WaitForSignal.go deleted file mode 100644 index 657a6a97..00000000 --- a/lib/plugin/WaitForSignal.go +++ /dev/null @@ -1,18 +0,0 @@ -package plugin - -import ( - "os" - "os/signal" - "syscall" -) - -// WaitForSignal waits until a SIGINT or SIGTERM is received -func WaitForSignal() { - - // catch all signals since not explicitly listing - exitChannel := make(chan os.Signal, 1) - - signal.Notify(exitChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) - sigID := <-exitChannel - println("Exiting with signal (", sigID, "): ", os.Args[0], "\n") -} diff --git a/lib/servers/IMessageServer.go b/lib/servers/IMessageServer.go deleted file mode 100644 index 85e374d0..00000000 --- a/lib/servers/IMessageServer.go +++ /dev/null @@ -1,57 +0,0 @@ -package servers - -import ( - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" -) - -// IMessageServer is the interface implemented by all transport protocol bindings -type IMessageServer interface { - - // AddTDForms adds the Forms for using this protocol bindings to the provided TD. - // This adds the operations for reading/writing properties, events and actions - // Original forms must be removed first as they are no longer applicable. - // - // Use includeAffordances to add forms to every affordance, a massive waste of space. - AddTDForms(td *td.TD, includeAffordances bool) - - // CloseAllClientConnections close all connections from the given client. - // Intended to close connections after a logout. - CloseAllClientConnections(clientID string) - - // GetConnectionByConnectionID returns a client connection on the server - GetConnectionByConnectionID(clientID, cid string) messaging.IConnection - - // GetConnectionByClientID returns the connection with the given client ID. - // Intended to find agents to route requests to. - GetConnectionByClientID(agentID string) messaging.IConnection - - // GetForm generates a form for the given operation for this server's transport - // protocol. Intended to update a TD with forms. - // Forms can use the following URI variables for top level Things: - // {op} for operation - // {thingID} the ID of the thing - // {name} the name of the property, event or action affordance - //GetForm(op string, thingID string, name string) *td.Form - - // GetConnectURL returns the full URL to connect to this server . - GetConnectURL() string - - // GetProtocolType returns the server provided protocol type and scheme, where - // protocolType is one of the supported protocol identifiers - GetProtocolType() string - - // SendNotification sends an event or property update notification to connected - // event subscribers or property observers. - // The subscription is handled by the underlying transport protocol. - SendNotification(msg *messaging.NotificationMessage) - - // CloseAll closes all connections but do not stop the server - CloseAll() - - // Start the server and receive connections abd messages - Start() error - - // Stop the server after force closing all connections - Stop() -} diff --git a/lib/servers/ProtocolConfig.go b/lib/servers/ProtocolConfig.go deleted file mode 100644 index 80a94480..00000000 --- a/lib/servers/ProtocolConfig.go +++ /dev/null @@ -1,107 +0,0 @@ -package servers - -import ( - "os" - - "github.com/hiveot/hub/lib/servers/discoserver" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/hiveot/hub/lib/servers/wssserver" -) - -const ( - DefaultHttpsPort = 8444 - DefaultMqttTcpPort = 8883 - DefaultMqttWssPort = 8884 -) - -type ProtocolsConfig struct { - - // Enable mDNS discovery. Default is true. - EnableDiscovery bool `yaml:"enableDiscovery,omitempty"` - // Enable the HiveOT HTTP/Authentication endpoint. Default is true. - EnableHiveotAuth bool `yaml:"enableHiveotAuth,omitempty"` - // Enable the HiveOT HTTP/SSE (sse-sc) sub protocol binding. Default is true. - EnableHiveotSSE bool `yaml:"enableHiveotSSE,omitempty"` - // EnableHttpBasic. Default is true. - EnableHttpBasic bool `yaml:"enableHttpBasic,omitempty"` - // EnableHttpStatic enables a protected static file server, default is false. - EnableHttpStatic bool `yaml:"enableHttStatic,omitempty"` - // Enable the HTTP/WSS sub protocol binding. Default is true. - EnableWSS bool `yaml:"enableWSS,omitempty"` - - // Include forms in each affordance to meet specifications. - // Note that this is not useful when talking to the Hub as all affordances of - // all digital twin TD have the same forms with the hub as endpoint. - // This results in massive duplication of TD content and increases the TD by a lot. - // Don't use forms unless you really need it. - IncludeForms bool `yaml:"includeForms,omitempty"` - - // Enable the MQTT protocol binding, default is false. - //EnableMQTT bool `yaml:"enableMQTT"` - - // The service discovery instance. The default is the hostname - DiscoveryInstanceName string `yaml:"instanceName,omitempty"` - - // DirectoryTDPath contains the HTTP path to read the digitwin directory TD - // Defaults to "/.well-known/wot" as per spec - // This is published by discovery and served by the http server. - DirectoryTDPath string `json:"directoryTDPath,omitempty"` - - // Server hostname used in http - HttpHost string `yaml:"host"` - // https listening port - HttpsPort int `yaml:"httpsPort"` - - // Static file server config. Only used when EnableHttpStatic is true - // HttpStaticBase base path for static file server. - // Default is /static - HttpStaticBase string `yaml:"httpStaticBase,omitempty"` - // HttpStaticDirectory. Storage location of the static file server. - // Default is {home}/stores/httpstatic - HttpStaticDirectory string `yaml:"httpStaticDirectory,omitempty"` - - // HiveOT sse subprotocol connection path - // The full URL is included in discovery record parameters - // with this URL no forms are needed to connect to the hub - HiveotSSEPath string `yaml:"hiveotSsePath"` - - // MQTT host interface - MqttHost string `yaml:"mqttHost"` - // MQTT tcp port - MqttTcpPort int `yaml:"mqttTcpPort"` - // MQTT websocket port - MqttWssPort int `yaml:"mqttWssPort"` - - // Websocket subprotocol connection path - // The full URL is included in discovery record parameters - // with this URL no forms are needed to connect to the hub - WSSPath string `yaml:"wssPath"` -} - -// NewProtocolsConfig creates the default configuration of communication protocols -// This enables https and mdns -func NewProtocolsConfig() ProtocolsConfig { - hostName, _ := os.Hostname() - - cfg := ProtocolsConfig{ - DirectoryTDPath: discoserver.DefaultHttpGetDirectoryTDPath, - EnableHiveotAuth: true, - EnableHiveotSSE: true, - EnableHttpBasic: true, - EnableWSS: true, - EnableDiscovery: true, - HiveotSSEPath: hiveotsseserver.DefaultHiveotSsePath, - HttpStaticBase: httpbasic.DefaultHttpStaticBase, - HttpStaticDirectory: httpbasic.DefaultHttpStaticDirectory, - IncludeForms: true, // for interoperability - DiscoveryInstanceName: hostName, - HttpHost: "", - HttpsPort: DefaultHttpsPort, - MqttHost: hostName, - MqttTcpPort: DefaultMqttTcpPort, - MqttWssPort: DefaultMqttWssPort, - WSSPath: wssserver.DefaultWssPath, - } - return cfg -} diff --git a/lib/servers/connections/ConnectionManager.go b/lib/servers/connections/ConnectionManager.go deleted file mode 100644 index c33f3765..00000000 --- a/lib/servers/connections/ConnectionManager.go +++ /dev/null @@ -1,229 +0,0 @@ -package connections - -import ( - "fmt" - "log/slog" - "slices" - "sync" - - "github.com/hiveot/hub/lib/messaging" -) - -// ConnectionManager manages server connections by their connection IDs. -// -// Connection ID's are used to differentiate between multiple connections from the -// same client. -// -// The connection manager support a lookup of connections by connection ID, sessionID -// and by clientID, where a clientID is the account ID of the client. -type ConnectionManager struct { - // connections by clcid = {clientID}:{connectionID} - connectionsByConnectionID map[string]messaging.IServerConnection - - // connectionIDs by clientID - connectionsByClientID map[string][]string - - // mutex to manage the connections - mux sync.RWMutex -} - -// AddConnection adds a new connection. -// This requires the connection to have a unique client connection ID (connectionID). -// -// If an endpoint with this connectionID exists the existing connection is forcibly closed. -func (cm *ConnectionManager) AddConnection(c messaging.IServerConnection) error { - cm.mux.Lock() - defer cm.mux.Unlock() - - cinfo := c.GetConnectionInfo() - // the client's connectionID for lookup - clcid := cinfo.ClientID + ":" + cinfo.ConnectionID - - // Refuse this if an existing connection with this ID exist - existingConn := cm.connectionsByConnectionID[clcid] - if existingConn != nil { - err := fmt.Errorf("AddConnection. The connection ID '%s' of client '%s' already exists", - cinfo.ConnectionID, cinfo.ClientID) - slog.Error("AddConnection: duplicate ConnectionID", "connectionID", - cinfo.ConnectionID, "err", err.Error()) - // close the existing connection - cm._removeConnection(existingConn) - existingConn = nil - } - cm.connectionsByConnectionID[clcid] = c - // update the client index - clientList := cm.connectionsByClientID[cinfo.ClientID] - if clientList == nil { - clientList = []string{cinfo.ConnectionID} - } else { - clientList = append(clientList, cinfo.ConnectionID) - } - cm.connectionsByClientID[cinfo.ClientID] = clientList - return nil -} - -// CloseAllClientConnections closes all connections of the given client. -func (cm *ConnectionManager) CloseAllClientConnections(clientID string) { - cm.mux.Lock() - defer cm.mux.Unlock() - - cList := cm.connectionsByClientID[clientID] - for _, cid := range cList { - // force-close the connection - clcid := clientID + ":" + cid - c := cm.connectionsByConnectionID[clcid] - if c != nil { - delete(cm.connectionsByConnectionID, clcid) - c.Disconnect() - } - } - delete(cm.connectionsByClientID, clientID) -} - -// CloseAll force-closes all connections -func (cm *ConnectionManager) CloseAll() { - cm.mux.Lock() - defer cm.mux.Unlock() - - slog.Info("RemoveAll. Closing remaining connections", "count", len(cm.connectionsByConnectionID)) - for clcid, c := range cm.connectionsByConnectionID { - _ = clcid - c.Disconnect() - } - cm.connectionsByConnectionID = make(map[string]messaging.IServerConnection) - cm.connectionsByClientID = make(map[string][]string) -} - -// ForEachConnection invoke handler for each client connection -// Intended for publishing event and property updates to subscribers -// This is concurrent safe as the iteration takes place on a copy -func (cm *ConnectionManager) ForEachConnection(handler func(c messaging.IServerConnection)) { - // collect a list of connections - cm.mux.Lock() - connList := make([]messaging.IServerConnection, 0, len(cm.connectionsByClientID)) - for _, c := range cm.connectionsByConnectionID { - connList = append(connList, c) - } - cm.mux.Unlock() - // - for _, c := range connList { - // TODO: TBD pros/cons for running this in the background vs synchronously? - handler(c) - } -} - -// GetConnectionByConnectionID locates the connection of the client using the client's connectionID -// This returns nil if no connection was found with the given connectionID -func (cm *ConnectionManager) GetConnectionByConnectionID(clientID, connectionID string) (c messaging.IServerConnection) { - - clcid := clientID + ":" + connectionID - cm.mux.Lock() - defer cm.mux.Unlock() - c = cm.connectionsByConnectionID[clcid] - return c -} - -// GetConnectionByClientID locates the first connection of the client using its account ID. -// Intended to find agents which only have a single connection. -// This returns nil if no connection was found with the given login -func (cm *ConnectionManager) GetConnectionByClientID(clientID string) (c messaging.IServerConnection) { - - cm.mux.Lock() - defer cm.mux.Unlock() - cList := cm.connectionsByClientID[clientID] - if len(cList) == 0 { - return nil - } - clcid := clientID + ":" + cList[0] - - // return the first connection of this client - c = cm.connectionsByConnectionID[clcid] - if c == nil { - slog.Error("GetConnectionByClientID: the client's connection list has disconnected endpoints", - "clientID", clientID, "nr alleged connections", len(cList)) - } - return c -} - -// GetNrConnections returns the number of client connections and nr of unique clients -func (cm *ConnectionManager) GetNrConnections() (int, int) { - cm.mux.RLock() - defer cm.mux.RUnlock() - return len(cm.connectionsByConnectionID), len(cm.connectionsByClientID) -} - -// SendNotification sends a response notification to subscribers -// For each subscriber, the correlationID of the subscription is used. -func (cm *ConnectionManager) SendNotification(notif *messaging.NotificationMessage) error { - - slog.Debug("SendNotification (to subscribers/observers)", - slog.String("Operation", notif.Operation), - slog.String("dThingID", notif.ThingID), - slog.String("name", notif.Name), - slog.Any("output", notif.Value), - ) - // is determined by the server (like MQTT) - cm.ForEachConnection(func(c messaging.IServerConnection) { - _ = c.SendNotification(notif) - }) - return nil -} - -// _removeConnection removes the connection by its connectionID -// internal function that can be used from a locked section. -// This will close the connnection if it isn't closed already. -// Call this after the connection is closed or before closing. -func (cm *ConnectionManager) _removeConnection(c messaging.IServerConnection) { - cinfo := c.GetConnectionInfo() - clientID := cinfo.ClientID - connectionID := cinfo.ConnectionID - clcid := clientID + ":" + connectionID - existingConn := cm.connectionsByConnectionID[clcid] - // force close the existing connection just in case - if existingConn != nil { - //clientID = existingConn.GetClientID() - existingConn.Disconnect() - delete(cm.connectionsByConnectionID, clcid) - } else if len(cm.connectionsByConnectionID) > 0 { - // this is unexpected. Not all connections were closed but this one is gone. - slog.Warn("RemoveConnection: connectionID not found", - "clcid", clcid) - return - } - // remove the cid from the client connection list - clientCids := cm.connectionsByClientID[clientID] - i := slices.Index(clientCids, connectionID) - if i < 0 { - slog.Info("RemoveConnection: existing connection not in the connectionID list. Was it forcefully removed?", - "clientID", clientID, "connectionID", connectionID) - - // TODO: considering the impact of this going wrong, is it better to recover? - // A: delete the bad entry and try the next connection - // B: close all client connections - - } else { - clientCids = slices.Delete(clientCids, i, i+1) - //clientCids = utils.Remove(clientCids, i) - cm.connectionsByClientID[clientID] = clientCids - } -} - -// RemoveConnection removes the connection by its connectionID -// This will close the connnection if it isn't closed already. -// Call this after the connection is closed or before closing. -func (cm *ConnectionManager) RemoveConnection(c messaging.IServerConnection) { - cm.mux.Lock() - defer cm.mux.Unlock() - cm._removeConnection(c) -} - -// NewConnectionManager creates a new instance of the connection manager -func NewConnectionManager() *ConnectionManager { - - cm := &ConnectionManager{ - connectionsByConnectionID: make(map[string]messaging.IServerConnection), - connectionsByClientID: make(map[string][]string), - mux: sync.RWMutex{}, - } - return cm -} diff --git a/lib/servers/connections/ConnectionManager_test.go b/lib/servers/connections/ConnectionManager_test.go deleted file mode 100644 index 970c38c4..00000000 --- a/lib/servers/connections/ConnectionManager_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package connections_test - -import ( - "errors" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddRemoveConnection(t *testing.T) { - const clientID = "client1" - const remoteAddr = "remote1" - const session1ID = "sess1" - const session2ID = "sess2" - - cm := connections.NewConnectionManager() - c1 := NewDummyConnection(clientID, remoteAddr, session1ID) - err := cm.AddConnection(c1) - require.NoError(t, err) - - // - c2 := NewDummyConnection(clientID, remoteAddr, session2ID) - err = cm.AddConnection(c2) - require.NoError(t, err) - - // connection must exist - cid1 := c1.cinfo.ConnectionID - c1b := cm.GetConnectionByConnectionID(clientID, cid1) - require.NotEmpty(t, c1b) - - // remove the connection - cm.RemoveConnection(c1b) - require.NoError(t, err) - - // connection no longer exists - c1c := cm.GetConnectionByConnectionID(clientID, cid1) - require.Empty(t, c1c) - - // c2 should remain - cid2 := c2.cinfo.ConnectionID - c2a := cm.GetConnectionByConnectionID(clientID, cid2) - require.NotEmpty(t, c2a) - - // again but this time closing connection 2 - c2b := cm.GetConnectionByClientID(clientID) - cm.RemoveConnection(c2b) - //isClosed := c2b.IsClosed() - //assert.True(t,isClosed) - c2b = cm.GetConnectionByConnectionID(clientID, cid2) - require.Empty(t, c2b) - - // close all - cm.CloseAll() -} - -func TestCloseClientConnection(t *testing.T) { - const client1ID = "client1" - const client2ID = "client2" - const remoteAddr = "remote1" - const session1ID = "sess1" - const session2ID = "sess2" - - cm := connections.NewConnectionManager() - c1 := NewDummyConnection(client1ID, remoteAddr, session1ID) - err := cm.AddConnection(c1) - require.NoError(t, err) - - c2 := NewDummyConnection(client2ID, remoteAddr, session2ID) - err = cm.AddConnection(c2) - require.NoError(t, err) - - // connection must exist - c1a := cm.GetConnectionByConnectionID(client1ID, c1.cinfo.ConnectionID) - require.NotNil(t, c1a) - - // close the connection of user1 - cm.CloseAllClientConnections(client1ID) - - // connection no longer exists - c1b := cm.GetConnectionByConnectionID(client1ID, c1.cinfo.ConnectionID) - require.Empty(t, c1b) - - // connection user 2 must still exist - c2a := cm.GetConnectionByConnectionID(client2ID, c2.cinfo.ConnectionID) - require.NotEmpty(t, c2a) - - // close all - cm.CloseAll() - - c2b := cm.GetConnectionByConnectionID(client2ID, c2.cinfo.ConnectionID) - require.Empty(t, c2b) -} - -func TestForEachConnection(t *testing.T) { - const client1ID = "client1" - const client2ID = "client2" - const remoteAddr = "remote1" - const session1ID = "sess1" - const session2ID = "sess2" - - cm := connections.NewConnectionManager() - c1 := NewDummyConnection(client1ID, remoteAddr, session1ID) - err := cm.AddConnection(c1) - require.NoError(t, err) - - c2 := NewDummyConnection(client2ID, remoteAddr, session2ID) - err = cm.AddConnection(c2) - require.NoError(t, err) - - count := 0 - cm.ForEachConnection(func(c messaging.IServerConnection) { - count++ - }) - assert.Equal(t, 2, count) -} - -func TestConnectionTwice(t *testing.T) { - const client1ID = "client1" - const remoteAddr = "remote1" - const session1ID = "sess1" - - cm := connections.NewConnectionManager() - c1 := NewDummyConnection(client1ID, remoteAddr, session1ID) - err := cm.AddConnection(c1) - require.NoError(t, err) - // The first connection with the same cid should be disconnected. - c2 := NewDummyConnection(client1ID, remoteAddr, session1ID) - err = cm.AddConnection(c2) - time.Sleep(time.Millisecond * 100) - require.NoError(t, err) - assert.False(t, c1.IsConnected()) - assert.True(t, c2.IsConnected()) - - // One connection remains - c1a := cm.GetConnectionByConnectionID(client1ID, c1.cinfo.ConnectionID) - require.NotNil(t, c1a) -} - -func TestPublishEventProp(t *testing.T) { - const client1ID = "client1" - const remoteAddr = "remote1" - const session1ID = "sess1" - const agent1ID = "agent1" - const thing1ID = "thing1" - var dThingID = digitwin.MakeDigitwinID(agent1ID, thing1ID) - const evName = "event1" - const propName = "prop1" - var evCount = 0 - var propCount = 0 - - cm := connections.NewConnectionManager() - c1 := NewDummyConnection(client1ID, remoteAddr, session1ID) - c1.SubscribeEvent(dThingID, "") - c1.ObserveProperty(dThingID, "") - c1.SendNotificationHandler = func(notif *messaging.NotificationMessage) { - if notif.Operation == td.OpSubscribeEvent { - evCount++ - } else if notif.Operation == td.OpObserveProperty { - propCount++ - } - } - c1.SendRequestHandler = func(req *messaging.RequestMessage, replyTo messaging.IConnection) *messaging.ResponseMessage { - assert.Fail(t, "unexpected") - return req.CreateResponse(nil, errors.New("unexpected request")) - } - - err := cm.AddConnection(c1) - require.NoError(t, err) - - // publish - notif1 := messaging.NewNotificationMessage( - td.OpSubscribeEvent, dThingID, evName, nil) - err = cm.SendNotification(notif1) - assert.NoError(t, err) - notif2 := messaging.NewNotificationMessage( - td.OpObserveProperty, dThingID, propName, nil) - err = cm.SendNotification(notif2) - assert.NoError(t, err) - - time.Sleep(time.Millisecond * 10) - // should receive 1 event and 1 property message - assert.Equal(t, 1, evCount) - assert.Equal(t, 1, propCount) -} diff --git a/lib/servers/connections/DummyConnection_test.go b/lib/servers/connections/DummyConnection_test.go deleted file mode 100644 index 1d625fa0..00000000 --- a/lib/servers/connections/DummyConnection_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package connections_test - -import ( - "fmt" - - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" -) - -// Dummy connection for testing connection manager -// this implements the IServerConnection interface. -type DummyConnection struct { - cinfo messaging.ConnectionInfo - remoteAddr string - observations connections.Subscriptions - subscriptions connections.Subscriptions - - SendNotificationHandler messaging.NotificationHandler - SendRequestHandler messaging.RequestHandler - SendResponseHandler messaging.ResponseHandler - isConnected bool -} - -func (c *DummyConnection) Disconnect() { - c.isConnected = false -} - -func (c *DummyConnection) GetConnectionInfo() messaging.ConnectionInfo { return c.cinfo } -func (c *DummyConnection) IsConnected() bool { return c.isConnected } - -//func (c *DummyConnection) GetSessionID() string { return c.sessID } - -//func (c *DummyConnection) InvokeAction(thingID string, name string, input any, correlationID string, senderID string) ( -// status string, output any, err error) { -// return transports2.RequestCompleted, nil, nil -//} - -func (c *DummyConnection) SendNotification(msg *messaging.NotificationMessage) error { - if c.SendNotificationHandler != nil { - c.SendNotificationHandler(msg) - } - return nil -} - -func (c *DummyConnection) SendRequest(msg *messaging.RequestMessage) error { - if c.SendRequestHandler != nil && c.observations.IsSubscribed(msg.ThingID, msg.Name) { - c.SendRequestHandler(msg, c) - } - return fmt.Errorf("no request sender set") -} - -func (c *DummyConnection) SendResponse(resp *messaging.ResponseMessage) error { - if c.SendResponseHandler != nil { - return c.SendResponseHandler(resp) - } - return nil -} - -func (c *DummyConnection) SetConnectHandler(h messaging.ConnectionHandler) { -} - -// SetNotificationHandler is ignored as this is an outgoing 1-way connection -func (c *DummyConnection) SetNotificationHandler(h messaging.NotificationHandler) { -} - -// SetRequestHandler is ignored as this is an outgoing 1-way connection -func (c *DummyConnection) SetRequestHandler(h messaging.RequestHandler) { -} - -// SetResponseHandler is ignored as this is an outgoing 1-way connection -func (c *DummyConnection) SetResponseHandler(h messaging.ResponseHandler) { -} - -func (c *DummyConnection) SubscribeEvent(dThingID, name string) { - c.subscriptions.Subscribe(dThingID, name, "subscr-1") -} -func (c *DummyConnection) ObserveProperty(dThingID, name string) { - c.observations.Subscribe(dThingID, name, "observe-1") -} -func (c *DummyConnection) UnsubscribeEvent(dThingID, name string) { - c.subscriptions.Unsubscribe(dThingID, name) -} -func (c *DummyConnection) UnobserveProperty(dThingID, name string) { - c.observations.Unsubscribe(dThingID, name) -} - -//func (c *DummyConnection) WriteProperty(thingID, name string, value any, correlationID string, senderID string) (status string, err error) { -// return "", nil -//} - -func NewDummyConnection(clientID, remoteAddr, cid string) *DummyConnection { - return &DummyConnection{ - cinfo: messaging.ConnectionInfo{ - ConnectionID: cid, - ClientID: clientID, - }, - remoteAddr: remoteAddr, - isConnected: true, - } -} diff --git a/lib/servers/connections/Subscriptions.go b/lib/servers/connections/Subscriptions.go deleted file mode 100644 index 2a9a967b..00000000 --- a/lib/servers/connections/Subscriptions.go +++ /dev/null @@ -1,86 +0,0 @@ -package connections - -import ( - "sync" -) - -// Subscriptions event/property subscriptions of a consumer connection. -// -// This uses "+" as wildcards -type Subscriptions struct { - - // map of subscriptions to correlationID - // subscriptions of this connection in the form {dThingID}.{name} - // not many are expected. - subscriptions map[string]string - - // mutex for access to subscriptions - mux sync.RWMutex -} - -// GetSubscription returns the correlation ID if this client session has subscribed to -// events or properties from the Thing and name. -// If the subscription is unknown then return an empty string. -func (s *Subscriptions) GetSubscription(thingID string, name string) string { - s.mux.RLock() - defer s.mux.RUnlock() - - if len(s.subscriptions) == 0 { - return "" - } - // wildcards - thingWC := "+." + name - nameWC := thingID + ".+" - sub := thingID + "." + name - for k, v := range s.subscriptions { - if k == "+.+" { - // step 1, full wildcard subscriptions - return v - } else if k == thingWC || k == nameWC { - // step 1, thing or name wildcard subscriptions - return v - } else if k == sub { - // step 1, exact match subscriptions - return v - } - } - return "" -} - -// IsSubscribed returns true if this client session has subscribed to -// events or properties from the Thing and name -func (s *Subscriptions) IsSubscribed(thingID string, name string) bool { - corrID := s.GetSubscription(thingID, name) - return corrID != "" -} - -// Subscribe adds a subscription for a thing event/property -func (s *Subscriptions) Subscribe(thingID string, name string, correlationID string) { - s.mux.Lock() - defer s.mux.Unlock() - if thingID == "" { - thingID = "+" - } - if name == "" { - name = "+" - } - subKey := thingID + "." + name - if s.subscriptions == nil { - s.subscriptions = make(map[string]string) - } - s.subscriptions[subKey] = correlationID -} - -// Unsubscribe removes a subscription for a thing event/property -func (s *Subscriptions) Unsubscribe(dThingID string, name string) { - s.mux.Lock() - defer s.mux.Unlock() - if dThingID == "" { - dThingID = "+" - } - if name == "" { - name = "+" - } - subKey := dThingID + "." + name - delete(s.subscriptions, subKey) -} diff --git a/lib/servers/discoserver/DiscoveryServer.go b/lib/servers/discoserver/DiscoveryServer.go deleted file mode 100644 index b32b6470..00000000 --- a/lib/servers/discoserver/DiscoveryServer.go +++ /dev/null @@ -1,107 +0,0 @@ -package discoserver - -import ( - "log/slog" - "net/http" - "net/url" - - "github.com/grandcat/zeroconf" - "github.com/hiveot/hub/lib/servers/httpbasic" -) - -// DiscoveryServer supports the WoT discovery introduction and exploration -// mechanisms for obtaining TD's for use by consumers. -// This: -// - publishes a DNS-SD record of the directory with the service name "_directory._sub._wot._tcp". -// containing a TXT record for 'td', 'type', 'scheme' as described in -// https://w3c.github.io/wot-discovery/#introduction-dns-sd-sec -// - serves the directory TD on the .well-known/wot http endpoint. -// - the directory TD includes the security scheme with link to the authn endpoint. -type DiscoveryServer struct { - - //connectURL to publish with the default connection endpoint - //endpoints map[string]string - - // tdPath holds the path the directory TD can be reached at (well known) - tdPath string - - // The directory TD document in JSON for serving on the path publish in exploration. - dirTDJSON string - // service discovery using mDNS - dnssdServer *zeroconf.Server - // the http server that servers the exploration endpoint - httpTransport *httpbasic.HttpWoTBasicServer -} - -// AddTDForms does not apply to the discovery service -//func (svc *DiscoveryServer) AddTDForms(*td.TD, includeAffordances bool) { -// // nothing to do here -//} - -// HandleGetDirTD returns the requested the TD Directory -// This provides a WoT discovery exploration mechanism -func (svc *DiscoveryServer) HandleGetDirTD(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(svc.dirTDJSON)) -} - -func (svc *DiscoveryServer) Stop() { - if svc.dnssdServer != nil { - svc.dnssdServer.Shutdown() - } -} - -// ServeDirectoryTD registers the handler for serving the Directory TD -func (svc *DiscoveryServer) ServeDirectoryTD(tdPath string, tdJSON string) { - - svc.dirTDJSON = tdJSON - //svc.httpTransport.AddOps(nil, []string{}, http.MethodGet, - // tdPath, svc.HandleGetDirTD) - r := svc.httpTransport.GetPublicRouter() - r.Get(tdPath, svc.HandleGetDirTD) -} - -// StartDiscoveryServer starts a DNS-SD server for serving a Thing Directory over https. -// -// connectURL is a hiveot addon that provides the default connection address -// without the need to use forms. This is intended for connection oriented protocols -// such as websocket, sse-sc, mqtt, and others. The schema identifies the protocol. -// Intended for simplify use when all affordances use the same protocol. -// This is included in the 'base' param of the discovery record. -// -// This sets the authURL to that of the http transport -// -// instanceName to use with DNS-SD. "" is default "hiveot" -// serviceName to use with DNS-SD . "" is default of "wot" -// dirTD is the directory TD in json (required) -// tdPath is the path on which to server the directory TD (Default is /.well-known/wot) -// httpServer is the server to use for serving the TD -// endpoints with additional hiveot protocol connection scheme:URL endpoints -func StartDiscoveryServer(instanceName string, serviceName string, - dirTDJSON string, tdPath string, httpTransport *httpbasic.HttpWoTBasicServer, - endpoints map[string]string) ( - *DiscoveryServer, error) { - - svc := &DiscoveryServer{ - dirTDJSON: dirTDJSON, - tdPath: tdPath, - dnssdServer: nil, - httpTransport: httpTransport, - } - - // serve the Directory TD exploration endpoint - svc.ServeDirectoryTD(tdPath, dirTDJSON) - - // serve the introduction mechanism - tddURL, _ := url.JoinPath(httpTransport.GetConnectURL(), tdPath) - dnssdServer, err := ServeTDDiscovery( - instanceName, serviceName, tddURL, endpoints) - if err != nil { - slog.Error("Failed starting introduction server for DNS-SD", - "HubURL", tddURL, - "tdPath", tdPath, - "err", err.Error()) - } - svc.dnssdServer = dnssdServer - - return svc, err -} diff --git a/lib/servers/discoserver/ServeTDD.go b/lib/servers/discoserver/ServeTDD.go deleted file mode 100644 index 1d6bc67a..00000000 --- a/lib/servers/discoserver/ServeTDD.go +++ /dev/null @@ -1,173 +0,0 @@ -// Package discoserver to publish Hub services for discovery -package discoserver - -import ( - "fmt" - "log/slog" - "net" - "net/url" - "os" - "strconv" - "strings" - - "github.com/grandcat/zeroconf" - "github.com/hiveot/hivekit/go/utils" -) - -// Parameter names in the discovery record -// const AuthParam = "auth" // authentication server -// const ConnectParam = "connect" // hub connection server -const DefaultServiceName = "wot" - -// hiveot endpoint identifiers -const AuthEndpoint = "login" -const WSSEndpoint = "wss" -const SSEEndpoint = "sse" - -// DefaultHttpGetDirectoryTDPath contains the path to the digital twin directory -// TD document uses the 'well-known' path -const DefaultHttpGetDirectoryTDPath = "/.well-known/wot" - -// ServeTDDiscovery publishes a Thing Document Directory discovery record. -// This sets type to 'Directory' and scheme to https. -// -// WoT defines this as service name: _directory._sub._{serviceName}._tcp with the -// TXT record containing the fields 'td', 'type', and 'scheme' -// See also: https://w3c.github.io/wot-discovery/#introduction-dns-sd-sec -// -// endpoints is a hiveot addon that provides connection addresses for transport -// protocols without the need to use forms. This is intended for connection -// oriented protocols such as websocket, sse-sc, mqtt, and others. The schema -// identifies the protocol. -// Intended for simplify use when all affordances use the same protocol. -// -// instanceName is the name of the server instance. "hiveot" for the hub. -// serviceName is the discover name. Default is 'wot'. This can be changed for testing. -// tddURL is the URL the directory TD is served at. -// endpoints contains a map of {scheme:connection} URLs -// -// Returns the discovery service instance. Use Shutdown() when done. -func ServeTDDiscovery( - instanceName string, serviceName string, - tddURL string, endpoints map[string]string, -) ( - *zeroconf.Server, error) { - - parts, err := url.Parse(tddURL) - if err != nil { - return nil, err - } - - // setup the introduction mechanism - if instanceName == "" { - instanceName, _ = os.Hostname() - } - if serviceName == "" { - serviceName = DefaultServiceName - } - tdPath := parts.Path - if tdPath == "" { - tdPath = DefaultHttpGetDirectoryTDPath - } - portString := parts.Port() - portNr, err := strconv.Atoi(portString) - if err != nil { - return nil, err - } - scheme := parts.Scheme - // FIXME: DNS might not work for the local network. Use an IP instead. - // Does this support IPv6? - outIP := utils.GetOutboundIP("") - address := outIP.String() - //address := parts.Hostname() - - subType := "_directory._sub" - // add WoT discovery parameters - params := map[string]string{ - "td": tdPath, - "scheme": scheme, - "type": "Directory", - } - // add connection endpoints as parameters - for ep, epURL := range endpoints { - params[ep] = epURL - } - slog.Info("Serving discovery for address", - slog.String("address", address), - slog.Int("port", portNr), - ) - discoServer, err := ServeDnsSD( - instanceName, serviceName, subType, - address, portNr, params) - - return discoServer, err -} - -// ServeDnsSD publishes a service discovery record. -// -// DNS-SD will publish this as _._._tcp -// -// instanceID is the unique ID of the service instance, usually the plugin-ID -// serviceName is the discover name. For example "wot" -// address service listening IP address -// port service listing port -// params is a map of key-value pairs to include in discovery, eg td, type and scheme in wot -// -// Returns the discovery service instance. Use Shutdown() when done. -func ServeDnsSD(instanceID string, serviceName string, subType string, - address string, port int, params map[string]string) (*zeroconf.Server, error) { - var ips []string - - slog.Info("ServeDnsSD", - slog.String("instanceID", instanceID), - slog.String("serviceName", serviceName), - slog.String("address", address), - slog.Int("port", port), - "params", params) - if serviceName == "" { - err := fmt.Errorf("Empty serviceName") - return nil, err - } - - // only the local domain is supported - domain := "local." - hostname, _ := os.Hostname() - - // if the given address isn't a valid IP address. try to resolve it instead - ips = []string{address} - if net.ParseIP(address) == nil { - // was a hostname provided instead IP? - hostname = address - parts := strings.Split(address, ":") // remove port - actualIP, err := net.LookupIP(parts[0]) - if err != nil { - // can't continue without a valid address - slog.Error("Provided address is not an IP and cannot be resolved", - "address", address, "err", err) - return nil, err - } - ips = []string{actualIP[0].String()} - } - - ifaces, err := utils.GetInterfaces(ips[0]) - if err != nil || len(ifaces) == 0 { - slog.Warn("Address does not appear on any interface. Continuing anyways", "address", ips[0]) - } - // add a text record with key=value pairs - textRecord := []string{} - for k, v := range params { - textRecord = append(textRecord, fmt.Sprintf("%s=%s", k, v)) - } - // I don't like this 'hiding' of the service type, but it is too DNS-SD specific - serviceType := fmt.Sprintf("_%s._tcp", serviceName) - if subType != "" { - //serviceType = serviceType + "," + subType - serviceType = serviceType + ",test1" - } - server, err := zeroconf.RegisterProxy( - instanceID, serviceType, domain, int(port), hostname, ips, textRecord, ifaces) - if err != nil { - slog.Error("Failed to start the zeroconf server", "err", err) - } - return server, err -} diff --git a/lib/servers/discoserver/ServeTDD_test.go b/lib/servers/discoserver/ServeTDD_test.go deleted file mode 100644 index 69cb23c3..00000000 --- a/lib/servers/discoserver/ServeTDD_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package discoserver_test - -import ( - "fmt" - "testing" - "time" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/clients/discoclient" - "github.com/hiveot/hub/lib/servers/discoserver" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// serviceID is the service publishing the record, thing or directory -const testServiceID = "hiveot-test" - -// the service name is normally 'wot'. -const testServiceName = "wot-test" -const testServicePath = "/discovery/path" -const testServicePort = 9999 - -func TestDNSSDScan(t *testing.T) { - - records, err := discoclient.DnsSDScan("", time.Second*2, false) - fmt.Printf("Found %d records in scan", len(records)) - - assert.NoError(t, err) - assert.Greater(t, len(records), 0, "No DNS records found") -} - -// Test the discovery client and server -func TestDiscover(t *testing.T) { - testServiceAddress := utils.GetOutboundIP("").String() - endpoints := map[string]string{"wss": "wss://localhost/wssendpoint"} - - tddURL := fmt.Sprintf("https://%s:%d%s", testServiceAddress, testServicePort, testServicePath) - discoServer, err := discoserver.ServeTDDiscovery( - testServiceID, testServiceName, tddURL, endpoints) - - assert.NoError(t, err) - assert.NotNil(t, discoServer) - - // Test if it is discovered - records := discoclient.DiscoverTDD(testServiceName, time.Second, false) - require.NotEmpty(t, records) - rec0 := records[0] - assert.Equal(t, testServiceID, rec0.Instance) - assert.Equal(t, testServiceAddress, rec0.Addr) - assert.Equal(t, testServicePath, rec0.TD) - assert.Equal(t, true, rec0.IsDirectory) - - time.Sleep(time.Millisecond) // prevent race error in server - discoServer.Shutdown() -} - -func TestDiscoViaDomainName(t *testing.T) { - testServiceAddress := "localhost" - - discoServer, err := discoserver.ServeDnsSD( - testServiceID, testServiceName, "", - testServiceAddress, testServicePort, nil) - - assert.NoError(t, err) - assert.NotNil(t, discoServer) - - // Test if it is discovered - records := discoclient.DiscoverTDD(testServiceName, time.Second, true) - require.NoError(t, err) - require.True(t, len(records) > 0) - rec0 := records[0] - assert.Equal(t, "127.0.0.1", rec0.Addr) - //assert.True(t, strings.HasPrefix(rec0.HostName, testServiceAddress)) - //assert.Equal(t, testServicePort, discoPort) - - time.Sleep(time.Millisecond) // prevent race error in server - discoServer.Shutdown() -} - -func TestDiscoverBadPort(t *testing.T) { - serviceID := "idprov-test" - badPort := 0 - address := utils.GetOutboundIP("").String() - _, err := discoserver.ServeDnsSD( - serviceID, testServiceName, "", address, badPort, nil) - - assert.Error(t, err) -} - -func TestNoInstanceID(t *testing.T) { - serviceID := "serviceID" - address := utils.GetOutboundIP("").String() - - _, err := discoserver.ServeDnsSD( - "", testServiceName, "", - address, testServicePort, nil) - assert.Error(t, err) // missing instance name - - _, err = discoserver.ServeDnsSD( - serviceID, "", "", address, testServicePort, nil) - assert.Error(t, err) // missing service name -} - -func TestDiscoverNotFound(t *testing.T) { - instanceID := "idprov-test-id" - serviceName := "idprov-test" - address := utils.GetOutboundIP("").String() - - discoServer, err := discoserver.ServeDnsSD( - instanceID, serviceName, "", - address, testServicePort, nil) - - assert.NoError(t, err) - - // Test if it is discovered - records := discoclient.DiscoverTDD(testServiceName, time.Second, true) - _ = records - assert.Equal(t, 0, len(records)) - - time.Sleep(time.Millisecond) // prevent race error in server - discoServer.Shutdown() -} - -func TestBadAddress(t *testing.T) { - instanceID := "idprov-test-id" - - discoServer, err := discoserver.ServeDnsSD( - instanceID, testServiceName, "", "notanipaddress", testServicePort, nil) - - assert.Error(t, err) - assert.Nil(t, discoServer) -} - -func TestExternalAddress(t *testing.T) { - instanceID := "idprov-test-id" - - discoServer, err := discoserver.ServeDnsSD( - instanceID, testServiceName, "", "1.2.3.4", testServicePort, nil) - - // expect a warning - assert.NoError(t, err) - time.Sleep(time.Millisecond) // prevent race error with server - discoServer.Shutdown() -} diff --git a/lib/servers/hiveotsseserver/HiveotSseServer.go b/lib/servers/hiveotsseserver/HiveotSseServer.go deleted file mode 100644 index f70bca4f..00000000 --- a/lib/servers/hiveotsseserver/HiveotSseServer.go +++ /dev/null @@ -1,229 +0,0 @@ -package hiveotsseserver - -import ( - "fmt" - "log/slog" - "sync" - - "github.com/go-chi/chi/v5" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/messaging/converters" - "github.com/hiveot/hub/lib/servers/connections" -) - -const ( - DefaultHiveotSsePath = "/hiveot/sse" - - // DefaultHiveotPostRequestHRef HTTP endpoint that accepts HiveOT RequestMessage envelopes - DefaultHiveotPostRequestHRef = "/hiveot/request" - - // DefaultHiveotPostResponseHRef HTTP endpoint that accepts HiveOT ResponseMessage envelopes - DefaultHiveotPostResponseHRef = "/hiveot/response" - - // DefaultHiveotPostNotificationHRef HTTP endpoint that accepts HiveOT NotificationMessage envelopes - DefaultHiveotPostNotificationHRef = "/hiveot/notification" - - SSEOpConnect = "sse-connect" - HiveotSSESchema = "sse" -) - -// HiveotSseServer is a protocol binding transport server for both HTTP, and the -// SSE-SC sub-protocol. SSE-SC is refers to a single-connection SSE protocol. -// This protocol supports full asynchronous messaging using http/SSE but is not a WoT standard. -// -// Note: The use of SSE is optional. This binding still serves an important role as the -// HTTPp protocol binding, and provides login and token refresh endpoints. -// -// This SSE protocol implementation is not a WoT specified protocol. It is however -// easier and more efficient to use, requiring only a single connection. -// It uses the hiveot RequestMessage and ResponseMessage envelopes for all messaging, or -// an alternative message converter can be provided to support a different message -// envelope format. -// -// This can be used with any golang HTTP server, including the http-basic or -// websocket http server as long as it can register routes. -// -// Usages: -// -// 1. Thing Agents that run servers. For example the HiveOT Hub. The agent -// serves HTTP/SSE cm from consumers. Requests are received over HTTP -// and asynchronous responses are sent back over SSE. HTTP requests and SSE -// cm must carry the same 'cid' to correlate HTTP requests with the -// SSE return channel from the same client. -// The HiveOT Hub uses this as part of multiple servers that serve the -// digital twin repository content. -// -// 2. Consumers that run servers. For example the Hub is a consumer of Thing agents -// that connect to the Hub. Since the connection is reversed, the requests are -// now sent over SSE to the Hub while the response is sent as a HTTP post to the hub. -// -// 3. An agent/consumer hybrid that runs a server. For example, the HiveOT Hub. -// Another Thing agent or service connect to the Hub to receive requests and -// at the same time can send consumer requests over http and receive responses -// over SSE. -// -// # Note that the direction of connection is independent of this transport does not determine the -// -// All SSE messages use the 'event' and 'data' field as per SSE standard. The event -// field contains the operation while the data field contains the RequestMessage -// or ResponseMessage envelope. -// -// SSE 'event' field contains the request or response message type, indicating the -// message payload. -type HiveotSseServer struct { - - // manage the incoming SSE cm - cm *connections.ConnectionManager - - // SSE protocol message converter - converter messaging.IMessageConverter - - // mutex for updating cm - mux sync.RWMutex - - // registered handler of incoming cm - serverConnectHandler messaging.ConnectionHandler - - // The connection address for subscription and URL to connect using SSE - connectAddr string - connectURL string - - // The router to add to on start - router chi.Router - - // registered handler of incoming requests (which return a reply) - serverNotificationHandler messaging.NotificationHandler - // registered handler of incoming requests (which return a reply) - serverRequestHandler messaging.RequestHandler - // registered handler of incoming responses (which sends a reply to the request sender) - serverResponseHandler messaging.ResponseHandler - - // The SSE connection path - ssePath string -} - -// AddRoutes adds routes to the HTTP server for connecting to SSE, (Un)Subscribe, -// and (Un)Observe using hiveot RequestMessage and ResponseMessage envelopes. -// This - -// AddTDForms for connecting to SSE, Subscribe, Observe, Send Requests, read and query -// using hiveot RequestMessage and ResponseMessage envelopes. -func (srv *HiveotSseServer) AddTDForms(tdi *td.TD, includeAffordances bool) { - - // TODO: add the hiveot http endpoints - //srv.httpBasicServer.AddOps() - // forms are handled through the http binding - //return srv.httpBasicServer.AddTDForms(tdi, includeAffordances) -} - -func (srv *HiveotSseServer) CloseAll() { - srv.cm.CloseAll() -} - -// CloseAllClientConnections close all cm from the given client. -// Intended to close cm after a logout. -func (srv *HiveotSseServer) CloseAllClientConnections(clientID string) { - srv.cm.ForEachConnection(func(c messaging.IServerConnection) { - cinfo := c.GetConnectionInfo() - if cinfo.ClientID == clientID { - c.Disconnect() - } - }) -} - -// GetConnectURL returns SSE connection URL of the server -// This uses the custom 'ssesc' schema which is non-wot compatible. -func (srv *HiveotSseServer) GetConnectURL() string { - return srv.connectURL -} - -func (srv *HiveotSseServer) GetProtocolType() string { - return messaging.ProtocolTypeHiveotSSE -} - -// GetConnectionByConnectionID returns the connection with the given connection ID -func (srv *HiveotSseServer) GetConnectionByConnectionID(clientID, cid string) messaging.IConnection { - return srv.cm.GetConnectionByConnectionID(clientID, cid) -} - -// GetConnectionByClientID returns the connection with the given client ID -func (srv *HiveotSseServer) GetConnectionByClientID(agentID string) messaging.IConnection { - return srv.cm.GetConnectionByClientID(agentID) -} - -// GetSseConnection returns the SSE Connection with the given ID -// This returns nil if not found or if the connectionID is not -func (srv *HiveotSseServer) GetSseConnection(clientID, connectionID string) *HiveotSseServerConnection { - c := srv.cm.GetConnectionByConnectionID(clientID, connectionID) - if c == nil { - return nil - } - sseConn, isValid := c.(*HiveotSseServerConnection) - if !isValid { - return nil - } - return sseConn -} - -// SendNotification sends a property update or event notification message to subscribers -func (srv *HiveotSseServer) SendNotification(msg *messaging.NotificationMessage) { - // pass the notification to all subscribed clients - srv.cm.ForEachConnection(func(c messaging.IServerConnection) { - _ = c.SendNotification(msg) - }) - return -} - -// Start listening for incoming SSE connections -func (srv *HiveotSseServer) Start() error { - slog.Info("Starting SSE server, Listening on: " + srv.GetConnectURL()) - - // TODO: detect if already listening - // Add the routes used in SSE connection and subscription requests - srv.CreateRoutes(srv.ssePath, srv.router) - - return nil -} - -// Stop disconnects clients and remove connection listening -func (srv *HiveotSseServer) Stop() { - //Close all incoming SSE cm - srv.cm.CloseAll() - srv.DeleteRoutes(srv.ssePath, srv.router) -} - -// NewHiveotSseServer returns a new SSE-SC sub-protocol binding. -// This is only a 1-way binding that adds an SSE based return channel to the http binding. -// -// This adds http methods for (un)subscribing to events and properties and -// adds new cm to the connection manager for callbacks. -// -// router is the protected route that serves sse connections on the ssePath -// -// This fails if no ssePath is provided -func NewHiveotSseServer( - connectAddr string, ssePath string, - router chi.Router, - handleConnect messaging.ConnectionHandler, - handleNotification messaging.NotificationHandler, - handleRequest messaging.RequestHandler, - handleResponse messaging.ResponseHandler, -) *HiveotSseServer { - if ssePath == "" { - return nil - } - connectURL := fmt.Sprintf("%s://%s%s", HiveotSSESchema, connectAddr, ssePath) - srv := &HiveotSseServer{ - ssePath: ssePath, - router: router, - converter: converters.NewPassthroughMessageConverter(), - cm: connections.NewConnectionManager(), - serverConnectHandler: handleConnect, - serverNotificationHandler: handleNotification, - serverRequestHandler: handleRequest, - serverResponseHandler: handleResponse, - connectURL: connectURL, - } - return srv -} diff --git a/lib/servers/hiveotsseserver/HiveotSseServerConnection.go b/lib/servers/hiveotsseserver/HiveotSseServerConnection.go deleted file mode 100644 index e74b6a36..00000000 --- a/lib/servers/hiveotsseserver/HiveotSseServerConnection.go +++ /dev/null @@ -1,443 +0,0 @@ -package hiveotsseserver - -import ( - "fmt" - "log/slog" - "net/http" - "sync" - "sync/atomic" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" - jsoniter "github.com/json-iterator/go" -) - -type SSEEvent struct { - EventType string // type of message, e.g. event, action or other - Payload string // message content -} - -// SSEPingEvent can be used by the server to ping the client that the connection is ready -const SSEPingEvent = "sse-ping" - -// HiveotSseServerConnection handles the SSE connection by remote client -// -// The Sse-sc protocol binding uses a 'hiveot' message envelope for sending messages -// between server and consumer. -// -// This implements the IServerConnection interface for sending messages to -// the client over SSE. -type HiveotSseServerConnection struct { - // Connection information such as clientID, cid, address, protocol etc - cinfo messaging.ConnectionInfo - - //// connection ID (from header, without clientID prefix) - //connectionID string - // - //// clientID is the account ID of the agent or consumer - //clientID string - - // connection remote address - remoteAddr string - - // incoming sse request - httpReq *http.Request - - isConnected atomic.Bool - - // track last used time to auto-close inactive cm - lastActivity time.Time - - // mutex for controlling writing and closing - mux sync.RWMutex - - // notify client of a connect or disconnect - connectionHandler messaging.ConnectionHandler - // handler for requests send by clients - appRequestHandlerPtr atomic.Pointer[messaging.RequestHandler] - // handler for responses sent by agents - responseHandlerPtr atomic.Pointer[messaging.ResponseHandler] - // handler for notifications sent by agents - notificationHandlerPtr atomic.Pointer[messaging.NotificationHandler] - - sseChan chan SSEEvent - - subscriptions connections.Subscriptions - observations connections.Subscriptions - // - correlData map[string]chan any - - // fallback to the SSE mode instead of the SSE-SC mode - // enabled when subscription headers are present on connect. - sseFallback bool -} - -//type HttpActionStatus struct { -// CorrelationID string `json:"request_id"` -// ThingID string `json:"thingID"` -// Name string `json:"name"` -// Data any `json:"data"` -// Error string `json:"error"` -//} - -// _send sends a request, response or notification message to the client over SSE. -// This is different from the WoT SSE subprotocol in that the payload is the -// message envelope and can carry any operation. -func (c *HiveotSseServerConnection) _send(msgType string, msg any) (err error) { - - payloadJSON, _ := jsoniter.MarshalToString(msg) - sseMsg := SSEEvent{ - EventType: msgType, - Payload: payloadJSON, - } - c.mux.Lock() - defer c.mux.Unlock() - if c.isConnected.Load() { - slog.Debug("_send", - slog.String("to", c.cinfo.ClientID), - slog.String("MessageType", msgType), - ) - c.sseChan <- sseMsg - } - // as long as the channel exists, delivery will take place - return nil -} - -// Disconnect closes the connection and ends the read loop -func (c *HiveotSseServerConnection) Disconnect() { - c.mux.Lock() - defer c.mux.Unlock() - if c.isConnected.Load() { - if c.sseChan != nil { - close(c.sseChan) - } - c.isConnected.Store(false) - } -} - -// GetConnectionInfo returns the client's connection details -func (c *HiveotSseServerConnection) GetConnectionInfo() messaging.ConnectionInfo { - return c.cinfo -} - -// GetConnectURL returns the connection URL of this connection -//func (c *HiveotSseServerConnection) GetConnectURL() string { -// return c.httpReq.URL.String() -//} - -// IsConnected returns the connection status -func (c *HiveotSseServerConnection) IsConnected() bool { - return c.isConnected.Load() -} - -// Handle incoming request messages. -// -// A response is only expected if the request is handled, otherwise nil is returned -// and a response is received asynchronously. -// In case of invoke-action, the response is always an ActionStatus object. -// -// This returns one of 3 options: -// 1. on completion, return handled=true, an optional output -// 2. on error, return handled=true, output optional error details and error the error message -// 3. on async status, return handled=false, output optional, error nil -func (c *HiveotSseServerConnection) onRequestMessage( - req *messaging.RequestMessage) (handled bool, output *messaging.ResponseMessage, err error) { - - // handle subscriptions - handled = true - switch req.Operation { - case td.OpSubscribeEvent, td.OpSubscribeAllEvents: - c.subscriptions.Subscribe(req.ThingID, req.Name, req.CorrelationID) - case td.OpUnsubscribeEvent, td.OpUnsubscribeAllEvents: - c.subscriptions.Unsubscribe(req.ThingID, req.Name) - case td.OpObserveProperty, td.OpObserveAllProperties: - c.observations.Subscribe(req.ThingID, req.Name, req.CorrelationID) - case td.OpUnobserveProperty, td.OpUnobserveAllProperties: - c.observations.Unsubscribe(req.ThingID, req.Name) - default: - handled = false - } - if handled { - // subscription requests dont have output - err = nil - return handled, nil, nil - } - // not a subscription request, pass it to the application - hPtr := c.appRequestHandlerPtr.Load() - if hPtr == nil { - // internal error - err = fmt.Errorf("HiveotSseServerConnection:onRequestMessage: no request handler registered") - return true, nil, err - } - - resp := (*hPtr)(req, c) - // responses are optional - if resp != nil { - // the hiveot binding returns the response envelope - output = resp - handled = true - err = nil - if resp.Error != nil { - err = resp.Error.AsError() - handled = true - } - } else { - // no response yet, return a 201 - handled = false - err = nil - output = nil - } - return handled, output, err -} - -// SendNotification sends a notification to the client if subscribed. -func (c *HiveotSseServerConnection) SendNotification( - notif *messaging.NotificationMessage) (err error) { - - if notif.Operation == td.OpSubscribeEvent || notif.Operation == td.OpSubscribeAllEvents { - correlationID := c.subscriptions.GetSubscription(notif.ThingID, notif.Name) - if correlationID != "" { - slog.Info("SendNotification (subscribed event)", - slog.String("clientID", c.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - err = c._send(messaging.MessageTypeNotification, notif) - } - } else if notif.Operation == td.OpObserveProperty || notif.Operation == td.OpObserveAllProperties { - correlationID := c.observations.GetSubscription(notif.ThingID, notif.Name) - if correlationID != "" { - slog.Info("SendNotification (observed property)", - slog.String("clientID", c.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - err = c._send(messaging.MessageTypeNotification, notif) - } - } else if notif.Operation == td.OpInvokeAction { - // action progress update - slog.Info("SendNotification (action status)", - slog.String("clientID", c.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - err = c._send(messaging.MessageTypeNotification, notif) - } else { - slog.Warn("Unknown notification: " + notif.Operation) - //err = c._send(msg) - } - return err -} - -// SendRequest sends a request message to an agent over SSE. -func (c *HiveotSseServerConnection) SendRequest(req *messaging.RequestMessage) error { - // This simply sends the message as-is - return c._send(messaging.MessageTypeRequest, req) -} - -// SendResponse send a response from server to client over SSE. -func (c *HiveotSseServerConnection) SendResponse(resp *messaging.ResponseMessage) error { - // This simply sends the message as-is - return c._send(messaging.MessageTypeResponse, resp) -} - -// Serve serves SSE cm. -// This listens for outgoing requests on the given channel -// It ends when the client disconnects or the connection is closed with Close() -// Sse requests are refused if no valid session is found. -func (c *HiveotSseServerConnection) Serve(w http.ResponseWriter, r *http.Request) { - // Set headers for SSE response - //w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Expose-Headers", "Content-Type") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate, max-age=0, no-transform") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Content-Encoding", "none") //https://stackoverflow.com/questions/76375157/client-not-receiving-server-sent-events-from-express-js-server - - // establish a client event channel for sending messages back to the client - c.mux.Lock() - c.sseChan = make(chan SSEEvent, 1) - c.mux.Unlock() - - // _send a ping event as the go-sse client doesn't have a 'connected callback' - pingEvent := SSEEvent{EventType: SSEPingEvent} - c.mux.Lock() - c.sseChan <- pingEvent - c.mux.Unlock() - - slog.Debug("SseConnection.Serve new SSE connection", - slog.String("clientID", c.cinfo.ClientID), - slog.String("connectionID", c.cinfo.ConnectionID), - slog.String("protocol", r.Proto), - slog.String("remoteAddr", c.remoteAddr), - ) - sendLoop := true - - // close the channel when the connection drops - go func() { - select { - case <-r.Context().Done(): // remote client connection closed - slog.Debug("SseConnection: Remote client disconnected (read context)") - // close channel when no-one is writing - // in the meantime keep reading to prevent deadlock - c.Disconnect() - - } - }() - - // read the message channel for sending messages until it closes - for sendLoop { // sseMsg := range sseChan { - select { - // keep reading to prevent blocking on channel on write - case sseMsg, ok := <-c.sseChan: // received event - var err error - - if !ok { // channel was closed by session - // avoid further writes - sendLoop = false - // ending the read loop and returning will close the connection - break - } - slog.Debug("SseConnection: sending sse event to client", - //slog.String("sessionID", c.sessionID), - slog.String("clientID", c.cinfo.ClientID), - slog.String("connectionID", c.cinfo.ConnectionID), - slog.String("sse eventType", sseMsg.EventType), - ) - var n int - n, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", - sseMsg.EventType, sseMsg.Payload) - //_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", - // sseMsg.EventType, sseMsg.ID, sseMsg.Payload) - if err != nil { - // the connection might be closing. - // don't exit the loop until the receive-channel is closed. - // just keep processing the message until that happens - // closed go channels panic when written to. So keep reading. - slog.Error("SseConnection: Error writing SSE event", - slog.String("Event", sseMsg.EventType), - slog.String("SenderID", c.cinfo.ClientID), - slog.Int("size", len(sseMsg.Payload)), - ) - } else { - slog.Debug("SseConnection: SSE write to client", - slog.String("SenderID", c.cinfo.ClientID), - slog.String("Event", sseMsg.EventType), - slog.Int("N bytes", n)) - } - w.(http.Flusher).Flush() - } - } - //cs.DeleteSSEChan(sseChan) - slog.Debug("SseConnection.Serve: sse connection closed", - slog.String("remote", r.RemoteAddr), - slog.String("clientID", c.cinfo.ClientID), - slog.String("connectionID", c.cinfo.ConnectionID), - ) -} - -// SetConnectHandler set the connection changed callback. Used by the connection manager. -func (c *HiveotSseServerConnection) SetConnectHandler(cb messaging.ConnectionHandler) { - c.mux.Lock() - c.connectionHandler = cb - c.mux.Unlock() -} - -// SetNotificationHandler sets the handler for incoming notification messages from the -// http connection. -// -// Handlers of notifications must register a callback using SetNotificationHandler on the connection. -// -// Note on how this works: The global http server receives notifications as http requests. -// To make it look like the notification came from this connection it looks up the -// connection using the clientID and connectionID and passes the message to the -// registered notification handler on this connection. -// -// By default, the server registers itself as the notification handler when the connection -// is created. It is safe to set a different handler for applications that -// handle each connection separately, for example a server side consumer instance. -func (c *HiveotSseServerConnection) SetNotificationHandler(cb messaging.NotificationHandler) { - if cb == nil { - c.notificationHandlerPtr.Store(nil) - } else { - c.notificationHandlerPtr.Store(&cb) - } -} - -// SetRequestHandler sets the handler for incoming request messages from the -// http connection. -// -// The hiveot server design requires that the messages are coming from the connections. -// Handlers of requests must register a callback using SetRequestHandler on the connection. -// -// Note on how this works: The global http server receives http requests. To make -// it look like the request came from this connection it looks up the connection -// using the clientID and connectionID and passes the message to the registered -// request handler on this connection. -// -// By default, the server registers itself as the request handler when the connection -// is created. It is safe to set a different request handler for applications that -// handle each connection separately, for example an 'Agent' instance. -func (c *HiveotSseServerConnection) SetRequestHandler(cb messaging.RequestHandler) { - if cb == nil { - c.appRequestHandlerPtr.Store(nil) - } else { - c.appRequestHandlerPtr.Store(&cb) - } -} - -// SetResponseHandler sets the handler for incoming response messages from the -// http connection. -// -// The hiveot server design requires that the messages are coming from the connections. -// Handlers of responses must register a callback using SetResponseHandler on the connection. -// -// Note on how this works: The global http server receives responses as http requests. -// To make it look like the response came from this connection it looks up the -// connection using the clientID and connectionID and passes the message to the -// registered response handler on this connection. -// -// By default, the server registers itself as the response handler when the connection -// is created. It is safe to set a different response handler for applications that -// handle each connection separately, for example a server side consumer instance. -func (c *HiveotSseServerConnection) SetResponseHandler(cb messaging.ResponseHandler) { - if cb == nil { - c.responseHandlerPtr.Store(nil) - } else { - c.responseHandlerPtr.Store(&cb) - } -} - -// NewHiveotSseConnection creates a new SSE connection instance. -// This implements the IServerConnection interface. -func NewHiveotSseConnection(clientID string, cid string, remoteAddr string, - httpReq *http.Request, sseFallback bool) *HiveotSseServerConnection { - - cinfo := messaging.ConnectionInfo{ - CaCert: nil, - ClientID: clientID, - ConnectionID: cid, - ConnectURL: httpReq.URL.String(), - //ProtocolType: messaging.ProtocolTypeHiveotSSE, - Timeout: 0, - } - c := &HiveotSseServerConnection{ - cinfo: cinfo, - remoteAddr: remoteAddr, - httpReq: httpReq, - lastActivity: time.Now(), - mux: sync.RWMutex{}, - observations: connections.Subscriptions{}, - subscriptions: connections.Subscriptions{}, - correlData: make(map[string]chan any), - sseFallback: sseFallback, - } - c.isConnected.Store(true) - - // interface check - var _ messaging.IServerConnection = c - return c -} diff --git a/lib/servers/hiveotsseserver/HiveotSseServerRoutes.go b/lib/servers/hiveotsseserver/HiveotSseServerRoutes.go deleted file mode 100644 index 21a338bd..00000000 --- a/lib/servers/hiveotsseserver/HiveotSseServerRoutes.go +++ /dev/null @@ -1,285 +0,0 @@ -package hiveotsseserver - -import ( - "fmt" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/hiveot/hub/lib/servers/tlsserver" -) - -// routes for handling http server requests - -// HiveOTPostResponseHRef is the HTTP path that accepts HiveOT ResponseMessage envelopes -// intended for agents that post responses. -//const HiveOTPostResponseHRef = "/hiveot/response" -//const HiveOTGetSseConnectHRef = "/hiveot/sse-sc" - -// CreateRoutes add the routes used in SSE-SC sub-protocol -// This is simple, one endpoint to connect, and one to pass requests, using URI variables -func (srv *HiveotSseServer) CreateRoutes(ssePath string, r chi.Router) { - r.Get(ssePath, srv.Serve) - r.Post(DefaultHiveotPostNotificationHRef, srv.HandleNotificationMessage) - r.Post(DefaultHiveotPostRequestHRef, srv.HandleRequestMessage) - r.Post(DefaultHiveotPostResponseHRef, srv.HandleResponseMessage) - - // Connect serves the SSE-SC protocol - //srv.httpBasicServer.AddOps(nil, []string{SSEOpConnect}, - // http.MethodGet, srv.ssePath, srv.Serve) - // - //// Handle notification messages from agents, containing a notification message envelope. - //srv.httpBasicServer.AddOps(nil, - // []string{"*"}, - // http.MethodPost, DefaultHiveotPostNotificationHRef, srv.HandleNotificationMessage) - // - //// Handle request messages using a single path with URI variables. - //srv.httpBasicServer.AddOps(nil, - // []string{"*"}, - // http.MethodPost, DefaultHiveotPostRequestHRef, srv.HandleRequestMessage) - // - //// Handle response messages from agents, containing a response message envelope. - //srv.httpBasicServer.AddOps(nil, - // []string{"*"}, - // http.MethodPost, DefaultHiveotPostResponseHRef, srv.HandleResponseMessage) -} - -// DeleteRoutes removes the routes used in SSE-SC sub-protocol -func (srv *HiveotSseServer) DeleteRoutes(ssePath string, r chi.Router) { - r.Delete(ssePath, srv.Serve) - r.Delete(DefaultHiveotPostNotificationHRef, srv.HandleNotificationMessage) - r.Delete(DefaultHiveotPostRequestHRef, srv.HandleRequestMessage) - r.Delete(DefaultHiveotPostResponseHRef, srv.HandleResponseMessage) -} - -// HandleNotificationMessage handles responses sent by agents. -// -// As WoT doesn't support reverse connections this is only used by hiveot agents -// that connect as clients. In that case the server is the consumer. -// -// This receives a NotificationMessage envelope and passes it to the corresponding -// connection as if the connection received the response itself. -// -// Message flow: agent POST response -> server forwards to -> connection -> -// forwards to subscriber (which is the server again, or a consumer) -// -// The message body is unmarshalled and included as the response. -func (srv *HiveotSseServer) HandleNotificationMessage(w http.ResponseWriter, r *http.Request) { - - // 1. Decode the message - rp, err := httpbasic.GetRequestParams(r) - if err != nil { - slog.Error(err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - notif := srv.converter.DecodeNotification(rp.Payload) - if notif == nil || notif.Operation == "" { - err = fmt.Errorf("HandleResponseMessage: missing notification in payload") - slog.Error(err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - notif.SenderID = rp.ClientID - - // A NotificationMessage was received but the client doesn't have an SSE connection - // http requests without connectionID should not receive responses. - // why is there a connectionID? - c := srv.GetSseConnection(rp.ClientID, rp.ConnectionID) - if c == nil { - // this is possible when: - // 1. a connectionID is provided in a request but no SSE has been established. - // if the connectionID is provided you'd expect an SSE connection so this is a warning - // 2. the server restarted and the client reconnects with the same connectionID? - // hmm, did the agent sse connection get lost? - slog.Warn("HandleNotificationMessage. No connection to handle the notification/subscription.", - "senderID", rp.ClientID, - "connectionID", rp.ConnectionID, - "thingID", rp.ThingID, - "name", rp.Name, - "operation", rp.Op, - ) - } else { - // pass it on to the server notification flow handler - h := c.notificationHandlerPtr.Load() - if h != nil { - (*h)(notif) - } - } - - tlsserver.WriteReply(w, true, nil, err) -} - -// HandleRequestMessage handles request messages sent by consumers or agents. -// -// This endpoint only handles requests for the SSE subprotocol. -// An SSE connection must already be established. -// -// This locates the corresponding connection and passes the request to the connection -// to make it seem like the connection received the request message itself. -// The connection then processes the request, handles subscriptions, or forwards -// the request to the connection subscriber, which by default is this server. -// -// Note: If the result status isn't completed or failed then a separate response -// message will be sent asynchronously by the agent, containing an ActionStatus message payload. -func (srv *HiveotSseServer) HandleRequestMessage(w http.ResponseWriter, r *http.Request) { - var output *messaging.ResponseMessage - var handled bool - - // 1. Decode the request message - rp, err := httpbasic.GetRequestParams(r) - if err != nil { - slog.Error(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } - req := srv.converter.DecodeRequest(rp.Payload) - if req == nil || req.Operation == "" { - err = fmt.Errorf("HandleRequestMessage: missing request in payload") - slog.Error(err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - - // Use the authenticated clientID as the sender - req.SenderID = rp.ClientID - connectionID := rp.ConnectionID - - // 1. handle ping operation internally - if req.Operation == td.HTOpPing { - //resp := req.CreateResponse("pong", nil) - output = req.CreateResponse("pong", nil) - handled = true - err = nil - // debugger bug not stopping on WriteReply when at the bottom? - } else { - // 2. locate the connection that handles the request. - c := srv.GetSseConnection(req.SenderID, connectionID) - if c == nil { - // When using the sse subprotocol endpoint this is an error. - err = fmt.Errorf("HandleRequestMessage: no corresponding connection") - handled = true - slog.Error("HandleRequestMessage. No connection to handle the request.", - "clientID", rp.ClientID, "connectionID", connectionID, - "correlationID", req.CorrelationID) - } else { - // 3. pass it on to the application - handled, output, err = c.onRequestMessage(req) - } - } - // 4. Return the response - tlsserver.WriteReply(w, handled, output, err) -} - -// HandleResponseMessage handles responses sent by agents. -// -// As WoT doesn't support reverse connections this is only used by hiveot agents -// that connect as clients. In that case the server is the consumer. -// -// This receives a ResponseMessage envelope and passes it to the corresponding -// connection as if the connection received the response itself. -// -// Message flow: agent POST response -> server forwards to -> connection -> -// forwards to subscriber (which is the server again, or a consumer) -// -// The message body is unmarshalled and included as the response. -func (srv *HiveotSseServer) HandleResponseMessage(w http.ResponseWriter, r *http.Request) { - - // 1. Decode the request message - rp, err := httpbasic.GetRequestParams(r) - if err != nil { - slog.Error(err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - resp := srv.converter.DecodeResponse(rp.Payload) - if resp == nil || resp.Operation == "" { - err = fmt.Errorf("HandleResponseMessage: missing response in payload") - slog.Error(err.Error()) - w.WriteHeader(http.StatusBadRequest) - return - } - //resp := transports.NewResponseMessage(rp.Op, rp.ThingID, rp.Name, rp.Data, err, rp.CorrelationID) - resp.SenderID = rp.ClientID - - // FIXME: a ResponseMessage (notification) was received but the client doesnt have an SSE connection - // http requests without connectionID should not receive responses. - // why is there a connectionID? - c := srv.GetSseConnection(rp.ClientID, rp.ConnectionID) - if c == nil { - // this is possible when a connectionID is provided in a request but no SSE - // has been established. Not an error. - //err = fmt.Errorf("HandleResponseMessage: no corresponding connection") - slog.Info("HandleResponseMessage. No connection to handle the response/subscription.", - "clientID", rp.ClientID, - "connectionID", rp.ConnectionID, - "name", rp.Name, - ) - } else { - h := c.responseHandlerPtr.Load() - if h != nil { - err = (*h)(resp) - } - } - //if srv.serverResponseHandler == nil { - // err = fmt.Errorf("No response handler registered for operation '%s'", rp.Op) - //} else { - // // forward the response to the internal handler for further processing. - // // If a result is available immediately, it will be embedded into the http - // // response body, otherwise a status pending is returned. - // err = srv.serverResponseHandler(resp) - // if resp.Error != "" { - // err = errors.New(resp.Error) - // } - //} - // - tlsserver.WriteReply(w, true, nil, err) -} - -// Serve a new incoming hiveot sse connection. -// This doesn't return until the connection is closed by either client or server. -func (srv *HiveotSseServer) Serve(w http.ResponseWriter, r *http.Request) { - - //An active session is required before accepting the request. This is created on - //authentication/login. Until then SSE cm are blocked. - rp, err := httpbasic.GetRequestParams(r) - - if err != nil { - slog.Warn("SSESC Serve. No session available yet, telling client to delay retry to 10 seconds", - "remoteAddr", r.RemoteAddr) - - // set retry to a large number - // see https://javascript.info/server-sent-events#reconnection - errMsg := fmt.Sprintf("retry: %s\nevent:%s\n\n", - "10000", "logout") - http.Error(w, errMsg, http.StatusUnauthorized) - //w.Write([]byte(errMsg)) - w.(http.Flusher).Flush() - return - } - // add the new sse connection - sseFallback := false // TODO - // the sse connection can only be used to *send* messages to the remote client - c := NewHiveotSseConnection( - rp.ClientID, rp.ConnectionID, r.RemoteAddr, r, sseFallback) - - // By default the server collects the requests/responses to pass it to subscribers - // If a consumer takes over the connection (connection reversal) it will register - // its own handlers. - c.SetNotificationHandler(srv.serverNotificationHandler) - c.SetRequestHandler(srv.serverRequestHandler) - c.SetResponseHandler(srv.serverResponseHandler) - err = srv.cm.AddConnection(c) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - // don't return until the connection is closed - c.Serve(w, r) - - // finally cleanup the connection - srv.cm.RemoveConnection(c) -} diff --git a/lib/servers/httpbasic/AddSessionFromToken.go b/lib/servers/httpbasic/AddSessionFromToken.go deleted file mode 100644 index 1a046ddf..00000000 --- a/lib/servers/httpbasic/AddSessionFromToken.go +++ /dev/null @@ -1,68 +0,0 @@ -package httpbasic - -import ( - "context" - "log/slog" - "net/http" - - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/tlsserver" -) - -// AddSessionFromToken middleware decodes the bearer session token in the authorization header. -// -// Session tokens can be provided through a bearer token or a client cookie. The token -// must match with an existing session ID. -// -// This distinguishes two types of tokens. Those with and those without a session ID. -// If the token contains a session ID then that session must exist or the token is invalid. -// User tokens are typically session tokens. Closing the session (logout) invalidates the token, -// even if it hasn't yet expired. Sessions are currently only stored in memory so a service -// restart also invalidates all session tokens. -// -// Non-session tokens, are used by services and device agents. These tokens are generated -// on provisioning or token renewal and last until their expiry. -// -// The session can be retrieved from the request context using GetSessionFromContext() -// -// The client session contains the client ID, and stats for the current session. -// If no valid session is found this will reply with an unauthorized status code. -// -// pubKey is the public key from the keypair used in creating the session token. -func AddSessionFromToken(userAuthn messaging.IAuthenticator) func(next http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - - bearerToken, err := tlsserver.GetBearerToken(r) - if err != nil { - // see https://w3c.github.io/wot-discovery/#exploration-secboot - // response with unauthorized and point to using the bearer token method - errMsg := "AddSessionFromToken: " + err.Error() - w.Header().Add("WWW-Authenticate", "Bearer") - http.Error(w, errMsg, http.StatusUnauthorized) - slog.Warn(errMsg) - return - } - //check if the token is properly signed - clientID, sid, err := userAuthn.ValidateToken(bearerToken) - if err != nil || clientID == "" { - w.Header().Add("WWW-Authenticate", "Bearer") - http.Error(w, err.Error(), http.StatusUnauthorized) - slog.Warn("AddSessionFromToken: Invalid session token:", - "err", err, "clientID", clientID) - return - } else if clientID == "" { - w.Header().Add("WWW-Authenticate", "Bearer") - http.Error(w, "missing clientID", http.StatusUnauthorized) - slog.Warn("AddSessionFromToken: Missing clientID") - return - } - - // make session available in context - //ctx := context.WithValue(r.Context(), subprotocols.SessionContextID, cs) - ctx := context.WithValue(r.Context(), SessionContextID, sid) - ctx = context.WithValue(ctx, ContextClientID, clientID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } -} diff --git a/lib/servers/httpbasic/GetRequestParams.go b/lib/servers/httpbasic/GetRequestParams.go deleted file mode 100644 index 5e823f29..00000000 --- a/lib/servers/httpbasic/GetRequestParams.go +++ /dev/null @@ -1,100 +0,0 @@ -package httpbasic - -import ( - "errors" - "io" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - jsoniter "github.com/json-iterator/go" -) - -const SessionContextID = "session" -const ContextClientID = "clientID" - -// RequestParams contains the parameters read from the HTTP request -type RequestParams struct { - ClientID string // authenticated client ID - ThingID string // the thing ID if defined in the URL as {thingID} - CorrelationID string // tentative as it isn't in the spec - Name string // the affordance name if defined in the URL as {name} - ConnectionID string // connectionID as provided by the client - Op string // the operation if defined in the URL as {op} - Payload []byte // the raw request payload (body) -} - -// GetRequestParams reads the client session, URL parameters and body payload from the -// http request context. -// -// The session context is set by the http middleware. If the session is not available then -// this returns an error. Note that the session middleware handler will block any request -// that requires a session. -// -// This protocol binding determines three variables, {thingID}, {name} and {op} from the path. -// It unmarshal's the request body into 'data', if given. -// -// {operation} is the operation -// {thingID} is the agent or digital twin thing ID -// {name} is the property, event or action name. '+' means 'all' -func GetRequestParams(r *http.Request) (reqParam RequestParams, err error) { - - // get the required client session of this agent - reqParam.ClientID, err = GetClientIdFromContext(r) - if err != nil { - // This is an internal error. The middleware session handler would have blocked - // a request that required a session before getting here. - slog.Error(err.Error()) - return reqParam, err - } - correlationID := r.Header.Get(CorrelationIDHeader) - reqParam.CorrelationID = correlationID - - // A connection ID distinguishes between different connections from the same client. - // This is used to correlate http requests with out-of-band responses like a SSE - // return channel. - // If a 'cid' header exists, use it as the connection ID. - headerCID := r.Header.Get(ConnectionIDHeader) - if headerCID == "" { - // FIXME: this is only an issue with hiveot-sse. Maybe time to retire it? - // alt: use a session-id from the auth token - two browser connections would - // share this however. - - // http-basic isn't be bothered. Each WoT sse connection is the subscription - // (only a single subscription per sse connection which is nearly useless) - slog.Info("GetRequestParams: missing connection-id, only a single " + - "connection is supported") - } - - reqParam.ConnectionID = headerCID - - // URLParam names must match the path variables set in the router. - reqParam.ThingID = chi.URLParam(r, HttpBasicThingIDURIVar) - reqParam.Name = chi.URLParam(r, HttpBasicNameURIVar) - reqParam.Op = chi.URLParam(r, HttpBasicOperationURIVar) - if r.Body != nil { - reqParam.Payload, _ = io.ReadAll(r.Body) - } - - return reqParam, err -} - -// GetClientIdFromContext returns the authenticated clientID for the given request -func GetClientIdFromContext(r *http.Request) (clientID string, err error) { - ctxClientID := r.Context().Value(ContextClientID) - if ctxClientID == nil { - return "", errors.New("no clientID in context") - } - clientID = ctxClientID.(string) - return clientID, nil -} - -// Convenience function to unmarshal the payload into the given data struct -// If no payload is available this does nothing and data remains unchanged -func (rp *RequestParams) Unmarshal(data any) error { - if len(rp.Payload) == 0 { - return nil - } - err := jsoniter.Unmarshal(rp.Payload, data) - return err -} diff --git a/lib/servers/httpbasic/HttpBasicForms.go b/lib/servers/httpbasic/HttpBasicForms.go deleted file mode 100644 index da7de07c..00000000 --- a/lib/servers/httpbasic/HttpBasicForms.go +++ /dev/null @@ -1,100 +0,0 @@ -package httpbasic - -import ( - "fmt" - "net/http" - - "github.com/hiveot/hivekit/go/api/td" -) - -var HttpKnownOperations = []string{ - td.OpQueryAllActions, td.OpReadAllProperties, td.OpWriteMultipleProperties, - td.OpReadProperty, td.OpWriteProperty, td.OpInvokeAction, td.OpQueryAction} - -// AddTDForms sets the forms for use of http-basic to the given TD. -// -// This sets the base to the http connect URL and uses relative hrefs for the forms. -// The href MUST match the route defined in: HttpBasicRoutes.HttpBasicAffordanceOperationPath -// e.g.: "https://host:port/things/{op}/{thingID}/{name}" -// -// As content-Type is the default application/json it is omitted. -// -// If the method names for get operations is default GET then it is omitted from the form. -// -// includeAffordances adds forms for all affordances to be compliant with the specifications. -func (srv *HttpWoTBasicServer) AddTDForms(tdoc *td.TD, includeAffordances bool) { - // defaults to https://host:port/ - tdoc.Base = fmt.Sprintf("%s", srv.GetConnectURL()) - - // add thing level forms that apply to all things. http-basic only supports read/write - tdoc.Forms = append(tdoc.Forms, - srv.createThingLevelForm(td.OpQueryAllActions, http.MethodGet, tdoc.ID), - //srv.createThingLevelForm(td.OpReadAllEvents, http.MethodGet, tdoc.ID), - srv.createThingLevelForm(td.OpReadAllProperties, http.MethodGet, tdoc.ID), - srv.createThingLevelForm(td.OpWriteMultipleProperties, http.MethodPost, tdoc.ID), - ) - if includeAffordances { - srv.AddAffordanceForms(tdoc) - } -} - -// AddAffordanceForms adds forms to affordances for interacting using the websocket protocol binding -// http-basic only supports read-write -func (srv *HttpWoTBasicServer) AddAffordanceForms(tdoc *td.TD) { - for name, aff := range tdoc.Actions { - f := srv.createAffordanceForm(td.OpInvokeAction, http.MethodPost, tdoc.ID, name) - aff.AddForm(f) - f = srv.createAffordanceForm(td.OpQueryAction, http.MethodGet, tdoc.ID, name) - aff.AddForm(f) - } - //for name, aff := range tdoc.Events { - //f = srv.createAffordanceForm(td.HTOpReadEvent, http.MethodPut, baseURL, tdoc.ID, name) - //aff.AddForm(f) - //} - for name, aff := range tdoc.Properties { - if !aff.WriteOnly { - // http-basic doesn't support observe/unobserve - //f := srv.createAffordanceForm(td.OpObserveProperty, http.MethodGet, tdoc.ID, name) - //aff.AddForm(f) - //f = srv.createAffordanceForm(td.OpUnobserveProperty, http.MethodGet, tdoc.ID, name) - //aff.AddForm(f) - f := srv.createAffordanceForm(td.OpReadProperty, http.MethodGet, tdoc.ID, name) - aff.AddForm(f) - } - if !aff.ReadOnly { - f := srv.createAffordanceForm(td.OpWriteProperty, http.MethodPut, tdoc.ID, name) - aff.AddForm(f) - } - } -} - -// createAffordanceForm returns a form for a thing action/event/property affordance operation -// the href in the form has the format "{base}/{op}/{thingID}/{name} -// where {base} is https:// -// Note: in theory these can be replaced with thing level forms using URI variables, except -// for the issue that WoT doesn't support this. -// -// The baseURL is the URL -func (srv *HttpWoTBasicServer) createAffordanceForm(op string, httpMethod string, - thingID string, name string) td.Form { - - href := fmt.Sprintf("%s/%s/%s/%s", HttpBaseFormOp, op, thingID, name) - form := td.NewForm(op, href) - if httpMethod != "" && httpMethod != http.MethodGet { - form.SetMethodName(httpMethod) - } - // contentType has a default of application/json - //form["contentType"] = "application/json" - return form -} - -// createThingLevelForm returns a form for a thing level http operation -// the href in the form has the format "https://host:port/things/{op}/{thingID} -func (srv *HttpWoTBasicServer) createThingLevelForm(op string, httpMethod string, thingID string) td.Form { - // href is relative to base - href := fmt.Sprintf("%s/%s/%s", HttpBaseFormOp, op, thingID) - form := td.NewForm(op, href) - form.SetMethodName(httpMethod) - //form["contentType"] = "application/json" - return form -} diff --git a/lib/servers/httpbasic/HttpBasicMessages.go b/lib/servers/httpbasic/HttpBasicMessages.go deleted file mode 100644 index 218ab040..00000000 --- a/lib/servers/httpbasic/HttpBasicMessages.go +++ /dev/null @@ -1,53 +0,0 @@ -package httpbasic - -// message types defined in the http basic profile -// https://w3c.github.io/wot-profile/#http-basic-profile-protocol-binding - -//// status values used in the HttpActionStatus response -//const ( -// HttpActionStatusPending = "pending" -// HttpActionStatusRunning = "running" -// HttpActionStatusCompleted = "completed" -// HttpActionStatusFailed = "failed" -//) - -// HttpActionStatusMessage the status of an asynchronous action invocation request -type HttpActionStatusMessage struct { - // Status is one of "pending","running","completed" or "failed" - Status string `json:"status,omitempty"` - - Output any `json:"output,omitempty"` - - //Error message, if any, containing problem details format (RF7807) - // For now just text - Error string `json:"error,omitempty"` - - // The [URL] of an HttpActionStatus resource which can be used by queryaction - // and cancelaction operations - Href string `json:"href,omitempty"` - - // A timestamp indicating the time at which the Thing received the request - // to execute the action. - TimeRequested string `json:"timeRequested,omitempty"` - - //A timestamp indicating the time at which the Thing successfully completed - //executing the action, or failed to execute the action. - TimeEnded string `json:"timeEnded,omitempty"` -} - -// https://w3c.github.io/wot-profile/#error-responses -// If an HTTP error response contains a body, the content of that body MUST conform -// with the Problem Details format [RFC7807]. -// https://www.rfc-editor.org/rfc/rfc7807#section-3 -type HttpErrorResponse struct { - // URI reference identifying the problem type - // Not sure what to put in here. Problem types don't exist in URI format. - Type string `json:"type,omitempty"` - // Human readable short summary - Title string `json:"title"` - // HTTP status code generated by the origin server - // Likely 403 (no auth - Status int `json:"status.omitempty"` - Detail string `json:"detail,omitempty"` // Detail of the error - Instance string `json:"instance,omitempty"` // Resource that identifies the occurrance of the problem -} diff --git a/lib/servers/httpbasic/HttpBasicRoutes.go b/lib/servers/httpbasic/HttpBasicRoutes.go deleted file mode 100644 index 8fffdb6e..00000000 --- a/lib/servers/httpbasic/HttpBasicRoutes.go +++ /dev/null @@ -1,195 +0,0 @@ -package httpbasic - -import ( - "slices" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/tlsserver" - jsoniter "github.com/json-iterator/go" - - "io" - "log/slog" - "net/http" -) - -// handleAffordanceOperation converts the http request to a request message and pass it to the registered request handler -// this read request params for {operation}, {thingID} and {name} -func (srv *HttpWoTBasicServer) handleAffordanceOperation(w http.ResponseWriter, r *http.Request) { - var output any - var handled bool - - // 1. Decode the request message - rp, err := GetRequestParams(r) - if err != nil { - slog.Error(err.Error()) - w.WriteHeader(http.StatusUnauthorized) - return - } - // Use the authenticated clientID as the sender - var input any - err = rp.Unmarshal(&input) - req := messaging.NewRequestMessage(rp.Op, rp.ThingID, rp.Name, input, "") - req.SenderID = rp.ClientID - req.CorrelationID = rp.CorrelationID - - // filter on allowed operations - if !slices.Contains(HttpKnownOperations, req.Operation) { - slog.Warn("Unsupported operation for http-basic", - "method", r.Method, "URL", r.URL.String(), - "operation", req.Operation, "thingID", req.ThingID, "name", req.Name, "clientID", req.SenderID) - w.WriteHeader(http.StatusBadRequest) - return - } - - // http-basic is connectionless so connectionID is not used here - //connectionID := rp.ConnectionID - - // pass request it on to the application - resp := srv.serverRequestHandler(req, nil) - if resp == nil { - // no response available - } else { - output = resp.Value - if resp.Error != nil { - err = resp.Error.AsError() - } - } - - // 4. Return the response - tlsserver.WriteReply(w, handled, output, err) -} - -// handleThingOperation converts the http request to a request message and pass it to the registered request handler -func (srv *HttpWoTBasicServer) handleThingOperation(w http.ResponseWriter, r *http.Request) { - // same same - srv.handleAffordanceOperation(w, r) -} - -// HandleLogin handles a login request and returns an auth token. -// -// Body contains {"login":name, "password":pass} format -// This is the only unprotected route supported. -// This uses the configured session authenticator. -func (srv *HttpWoTBasicServer) HandleLogin(w http.ResponseWriter, r *http.Request) { - var reply any - var args map[string]string - - payload, err := io.ReadAll(r.Body) - if err == nil { - err = jsoniter.Unmarshal(payload, &args) - } - if err == nil { - // the login is handled in-house and has an immediate return - // TODO: use-case for 3rd party login? oauth2 process support? tbd - // FIXME: hard-coded keys!? ugh - clientID := args["login"] - password := args["password"] - reply, err = srv.authenticator.Login(clientID, password) - slog.Info("HandleLogin", "clientID", clientID) - } - if err != nil { - slog.Warn("HandleLogin failed:", "err", err.Error()) - tlsserver.WriteError(w, err, http.StatusUnauthorized) - return - } - // TODO: set client session cookie for browser clients - //srv.sessionManager.SetSessionCookie(cs.sessionID,token) - tlsserver.WriteReply(w, true, reply, nil) -} - -// HandleAuthRefresh refreshes the auth token using the session authenticator. -// The session authenticator is that of the authn service. This allows testing with a dummy -// authenticator without having to run the authn service. -func (srv *HttpWoTBasicServer) HandleAuthRefresh(w http.ResponseWriter, r *http.Request) { - var newToken string - var oldToken string - rp, err := GetRequestParams(r) - - if err == nil { - jsoniter.Unmarshal(rp.Payload, &oldToken) - slog.Info("HandleAuthRefresh", "clientID", rp.ClientID) - newToken, err = srv.authenticator.RefreshToken(rp.ClientID, oldToken) - } - if err != nil { - slog.Warn("HandleAuthRefresh failed:", "err", err.Error()) - tlsserver.WriteError(w, err, 0) - return - } - tlsserver.WriteReply(w, true, newToken, nil) -} - -// HandleLogout ends the session and closes all client connections -func (srv *HttpWoTBasicServer) HandleLogout(w http.ResponseWriter, r *http.Request) { - // use the authenticator - rp, err := GetRequestParams(r) - if err == nil { - slog.Info("HandleLogout", "clientID", rp.ClientID) - srv.authenticator.Logout(rp.ClientID) - } - tlsserver.WriteReply(w, true, nil, err) -} - -// HandlePing with http handler returns a pong response -func (srv *HttpWoTBasicServer) HandlePing(w http.ResponseWriter, r *http.Request) { - // simply return a pong message - tlsserver.WriteReply(w, true, "pong", nil) -} - -// setupRouting creates the middleware chain for handling requests, including -// recoverer, compression and token verification for protected routes. -// -// This includes the unprotected routes for login and ping (for now) -// This includes the protected routes for refresh and logout. (for now) -// Everything else should be added by the sub-protocols. -// -// Routes are added by (sub)protocols such as http-basic, sse and wss. -func (srv *HttpWoTBasicServer) setupRouting(router chi.Router) http.Handler { - - // TODO: add csrf support in posts - //csrfMiddleware := csrf.Protect( - // []byte("32-byte-long-auth-key"), - // csrf.SameSite(csrf.SameSiteStrictMode)) - - //-- add the middleware before routes - router.Use(middleware.Recoverer) - //router.Use(middleware.Logger) // todo: proper logging strategy - //router.Use(csrfMiddleware) - router.Use(middleware.Compress(5, - "text/html", "text/css", "text/javascript", "image/svg+xml")) - - //--- public routes do not require an authenticated session - router.Group(func(r chi.Router) { - // sub-protocols can add public routes - srv.publicRoutes = r - - //r.Get("/static/*", staticFileServer.ServeHTTP) - // build-in REST API for easy login to obtain a token - - // register authentication endpoints - // FIXME: determine how WoT wants auth endpoints to be published - r.Post(HttpPostLoginPath, srv.HandleLogin) - r.Get(HttpGetPingPath, srv.HandlePing) - }) - - //--- private routes that requires authentication (as published in the TD) - router.Group(func(r chi.Router) { - - // client sessions authenticate the sender - r.Use(AddSessionFromToken(srv.authenticator)) - - // sub-protocols can add protected routes - srv.protectedRoutes = r - - // register generic handlers for operations on Thing and affordance level - // these endpoints are published in the forms of each TD. See also AddTDForms. - r.HandleFunc(HttpBasicAffordanceOperationPath, srv.handleAffordanceOperation) - r.HandleFunc(HttpBasicThingOperationPath, srv.handleThingOperation) - - // http supported authentication endpoints - r.Post(HttpPostRefreshPath, srv.HandleAuthRefresh) - r.Post(HttpPostLogoutPath, srv.HandleLogout) - }) - return router -} diff --git a/lib/servers/httpbasic/HttpBasicServer.go b/lib/servers/httpbasic/HttpBasicServer.go deleted file mode 100644 index f99c6d07..00000000 --- a/lib/servers/httpbasic/HttpBasicServer.go +++ /dev/null @@ -1,214 +0,0 @@ -package httpbasic - -import ( - "fmt" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" -) - -// HTTP-basic profile constants -const ( - // ConnectionIDHeader is intended for linking return channels to requests. - // intended for separated return channel like sse. - ConnectionIDHeader = "cid" - // CorrelationIDHeader is the header to be able to link requests to out of band responses - // tentative as it isn't part of the wot spec - CorrelationIDHeader = "correlationID" - - // HttpPostLoginPath is the fixed authentication endpoint of the hub - HttpPostLoginPath = "/authn/login" - HttpPostLogoutPath = "/authn/logout" - HttpPostRefreshPath = "/authn/refresh" - HttpGetPingPath = "/ping" - - // The generic path for thing operations over http using URI variables - HttpBaseFormOp = "/things" - HttpBasicAffordanceOperationPath = "/things/{operation}/{thingID}/{name}" - HttpBasicThingOperationPath = "/things/{operation}/{thingID}" - HttpBasicOperationURIVar = "operation" - HttpBasicThingIDURIVar = "thingID" - HttpBasicNameURIVar = "name" - - // static file server routes - DefaultHttpStaticBase = "/static" - DefaultHttpStaticDirectory = "stores/httpstatic" // relative to home -) - -// HttpWoTBasicServer provides the http-basic protocol binding using the provided http server. -// This is the simplest protocol binding supported by hiveot. -// Features: -// - security bootstrapping as per https://w3c.github.io/wot-discovery/#exploration-secboot -// - login to obtain a bearer token: POST {base}/authn/login -// - refresh bearer token: POST {base}/authn/refresh -// -// - post/get thing operations: POST {base}/things/{op}/{thingID} -// - post/get affordance operations: POST {base}/things/{op}/{thingID}/{name} -// -// This uses the provided httpserver instance. -type HttpWoTBasicServer struct { - // authenticator for logging in and validating session tokens - authenticator messaging.IAuthenticator - - // connection host and port the server can be reached at - connectAddr string - - // Thing level operations added by the http router - //operations []HttpOperation - - // the root http router - router *chi.Mux - - // The routes that require authentication. These can be used by sub-protocol bindings. - protectedRoutes chi.Router - // The routes that do not require authentication. These can be used by sub-protocol bindings. - publicRoutes chi.Router - - // notification handler to allow devices to send notifications over http - // intended for use by integration with 3rd party libraries - serverNotificationHandler messaging.NotificationHandler - - // handler for incoming request messages - // (after converting requests to the standard internal format) - serverRequestHandler messaging.RequestHandler - - // response handler to allow devices to send responses over http - // intended for use by integration with 3rd party libraries - serverResponseHandler messaging.ResponseHandler -} - -// CloseAll does nothing as http is connectionless. -func (srv *HttpWoTBasicServer) CloseAll() { -} - -// CloseAllClientConnections does nothing as http is connectionless. -func (srv *HttpWoTBasicServer) CloseAllClientConnections(clientID string) { - _ = clientID -} - -// EnableStatic adds a path to read files from the static directory. Auth required. -// -// base is the base path on which to serve the static files, eg: "/static" -// staticRoot is the root directory where static files are kept. This must be a full path. -func (srv *HttpWoTBasicServer) EnableStatic(base string, staticRoot string) error { - if srv.protectedRoutes == nil || base == "" { - return fmt.Errorf("no protected route or invalid parameters") - } - var staticFileServer http.Handler - // if staticRoot == "" { - // staticFileServer = http.FileServer( - // &StaticFSWrapper{ - // FileSystem: http.FS(src.EmbeddedStatic), - // FixedModTime: time.Now(), - // }) - // } else { - // during development when run from the 'hub' project directory - staticFileServer = http.FileServer(http.Dir(staticRoot)) - // } - staticPath := base + "/*" - srv.protectedRoutes.Get(staticPath, staticFileServer.ServeHTTP) - return nil -} - -// GetAuthServerURI returns the URI of the authentication server to include in the TD security scheme -// FIXME: Should this be some kind of authorization flow with a web page? -// This is currently just the login endpoint (post /authn/login). -// The http server might need to include a web page where users can enter their login -// name and password, although that won't work for machines... tbd -// -// Note that web browsers do not directly access the runtime endpoints. -// Instead a web server (hiveoview or other) provides the user interface. -// Including the auth endpoint here is currently just a hint. How to integrate this? -func (srv *HttpWoTBasicServer) GetAuthServerURI() string { - return HttpPostLoginPath -} - -// GetConnectionByConnectionID returns nil as http-basic is connectionless -func (srv *HttpWoTBasicServer) GetConnectionByConnectionID(clientID, cid string) messaging.IConnection { - _ = clientID - _ = cid - return nil -} - -// GetConnectionByClientID returns returns nil as http-basic is connectionless -func (srv *HttpWoTBasicServer) GetConnectionByClientID(agentID string) messaging.IConnection { - _ = agentID - return nil -} - -// GetConnectURL returns connection url of the http server -func (srv *HttpWoTBasicServer) GetConnectURL() string { - - baseURL := fmt.Sprintf("https://%s", srv.connectAddr) - return baseURL -} - -// GetForm returns a form for the given operation -func (srv *HttpWoTBasicServer) GetForm(operation string, thingID string, name string) *td.Form { - // FIXME: why does a server need this? - its in the TD for the client ...??? - return nil -} - -// GetProtectedRouter return the router for adding protected paths. -// Protected means the client is authenticated. -func (srv *HttpWoTBasicServer) GetProtectedRouter() chi.Router { - return srv.protectedRoutes -} - -func (srv *HttpWoTBasicServer) GetProtocolType() string { - return messaging.ProtocolTypeHTTPBasic -} - -// GetPublicRouter return the router for adding public paths. -func (srv *HttpWoTBasicServer) GetPublicRouter() chi.Router { - return srv.publicRoutes -} - -// SendNotification does nothing as http-basic is connectionless -func (srv *HttpWoTBasicServer) SendNotification(msg *messaging.NotificationMessage) { -} - -// Start listening on the routes -func (srv *HttpWoTBasicServer) Start() error { - slog.Info("Starting http-basic server, Listening on: " + srv.GetConnectURL()) - - //srv.setupRouting(srv.router) - return nil -} -func (srv *HttpWoTBasicServer) Stop() { -} - -// NewWoTHttpBasicServer creates a new WoT http-basic protocol binding. -// Intended for use as server for sub-protocols such as sse and wss. -// -// connectAddr is the host:port the server can be reached at. -// router is the router to register paths at. -// -// On startup this creates a public and protected route. Protected routes can be -// registered by sub-protocols. This http-basic handles the connection authentication. -func NewWoTHttpBasicServer( - connectAddr string, - router *chi.Mux, - authenticator messaging.IAuthenticator, - - handleNotification messaging.NotificationHandler, - handleRequest messaging.RequestHandler, - handleResponse messaging.ResponseHandler, -) *HttpWoTBasicServer { - - srv := &HttpWoTBasicServer{ - authenticator: authenticator, - connectAddr: connectAddr, - serverNotificationHandler: handleNotification, - serverRequestHandler: handleRequest, - serverResponseHandler: handleResponse, - router: router, - } - // TODO: I'd rather not setup routes until start - srv.setupRouting(srv.router) - - return srv -} diff --git a/lib/servers/httpbasic/StaticFileWrapper.go b/lib/servers/httpbasic/StaticFileWrapper.go deleted file mode 100644 index 950385dd..00000000 --- a/lib/servers/httpbasic/StaticFileWrapper.go +++ /dev/null @@ -1,42 +0,0 @@ -package httpbasic - -import ( - "net/http" - "os" - "time" -) - -// StaticFSWrapper adds a timestamp to files from embed. -// Without it, static files have the current time, causing cache-control to fail. -// From: https://github.com/golang/go/issues/44854 -type StaticFSWrapper struct { - http.FileSystem - FixedModTime time.Time -} - -func (f *StaticFSWrapper) Open(name string) (http.File, error) { - file, err := f.FileSystem.Open(name) - - return &StaticFileWrapper{File: file, fixedModTime: f.FixedModTime}, err -} - -type StaticFileWrapper struct { - http.File - fixedModTime time.Time -} - -func (f *StaticFileWrapper) Stat() (os.FileInfo, error) { - - fileInfo, err := f.File.Stat() - - return &StaticFileInfoWrapper{FileInfo: fileInfo, fixedModTime: f.fixedModTime}, err -} - -type StaticFileInfoWrapper struct { - os.FileInfo - fixedModTime time.Time -} - -func (f *StaticFileInfoWrapper) ModTime() time.Time { - return f.fixedModTime -} diff --git a/lib/servers/mqttserver/MqttTransportServer.go b/lib/servers/mqttserver/MqttTransportServer.go deleted file mode 100644 index 9db9e4e6..00000000 --- a/lib/servers/mqttserver/MqttTransportServer.go +++ /dev/null @@ -1,124 +0,0 @@ -package mqttserver - -import ( - "crypto/tls" - "crypto/x509" - "fmt" - "log/slog" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" -) - -type MqttTransportServer struct { - host string - tcpPort int - wssPort int - serverCert *tls.Certificate - caCert *x509.Certificate - authenticator messaging.IAuthenticator - handleRequest messaging.RequestHandler - handleResponse messaging.ResponseHandler - cm *connections.ConnectionManager - - // convert operation to topics (for building forms) - op2Topic map[string]string -} - -func (svc *MqttTransportServer) AddTDForms(td *td.TD, includeAffordances bool) error { - //svc.AddThingLevelForms(td) - //svc.AddPropertiesForms(td) - //svc.AddEventsForms(td) - //svc.AddActionForms(td) - return nil -} - -// GetForm returns a new HTTP form for the given operation -// Intended for Thing level operations -func (svc *MqttTransportServer) GetForm(op, thingID, name string) td.Form { - controlPacket := "" - topic, found := svc.op2Topic[op] - if !found { - slog.Error("GetForm. Operation doesn't have corresponding message type", - "op", op) - return nil - } - switch op { - case td.OpSubscribeEvent, td.OpSubscribeAllEvents, - td.OpObserveProperty, td.OpObserveAllProperties: - controlPacket = "subscribe" - case td.OpUnsubscribeEvent, td.OpUnsubscribeAllEvents, - td.OpUnobserveProperty, td.OpUnobserveAllProperties: - controlPacket = "unsubscribe" - case td.OpReadProperty, td.OpReadAllProperties, - td.OpWriteProperty, td.OpWriteMultipleProperties, - td.OpInvokeAction: - // NOTE: the spec recommends to use subscribe for reading properties, but that - // makes no sense (yet). - // https://w3c.github.io/wot-binding-templates/bindings/protocols/mqtt/index.html#default-mappings - controlPacket = "publish" - default: - controlPacket = "publish" - - } - - connectURL := svc.GetConnectURL() - form := td.Form{} - form["op"] = op - form["href"] = connectURL - form["mqv:retain"] = "false" - form["mqv:qos"] = "1" - form["mqv:topic"] = topic - form["mqv:controlPacket"] = controlPacket - - slog.Warn("GetForm. No form found for operation", - "op", op) - return form -} - -// GetServerURL returns base path of the server -func (svc *MqttTransportServer) GetConnectURL() string { - connectURL := fmt.Sprintf("mqtts://%s:%d", svc.host, svc.tcpPort) - return connectURL -} - -// SendResponse sends a response to subscribers and observers -func (svc *MqttTransportServer) SendResponse(notif messaging.ResponseMessage) { - // this is needed so mqtt can broadcast once via the message bus instead all individual connections - // tbd. An embedded mqtt server can still send per connection? - slog.Error("todo: implement") -} - -// Stop the mqtt broker -func (svc *MqttTransportServer) Stop() { - slog.Warn("Stopping MqttTransportServer not yet implemented") -} - -// StartMqttTransportServer creates and starts a new instance of the Mqtt broker -// -// Call stop to end the transport server. -// -// config -// privKey -// caCert -// sessionAuth for creating and validating authentication tokens -// dtwService that handles digital thing requests -func StartMqttTransportServer(host string, tcpPort int, wssPort int, - serverCert *tls.Certificate, - caCert *x509.Certificate, - authenticator messaging.IAuthenticator, - cm *connections.ConnectionManager, - handleRequest messaging.RequestHandler, - handleResponse messaging.ResponseHandler, -) (*MqttTransportServer, error) { - svc := &MqttTransportServer{ - serverCert: serverCert, - caCert: caCert, - authenticator: authenticator, - cm: cm, - handleRequest: handleRequest, - handleResponse: handleResponse, - } - return svc, fmt.Errorf("Not yet implemented") -} diff --git a/lib/servers/tests/actions_test.go b/lib/servers/tests/actions_test.go deleted file mode 100644 index ac2fcf26..00000000 --- a/lib/servers/tests/actions_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package tests - -import ( - "context" - "errors" - "log/slog" - "net/http" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/teris-io/shortid" -) - -// TestInvokeActionFromConsumerToServer: classic 'consumer talks to the server' -// as if it is a Thing. In this test the server replies. -// (routing is not part of this package) -func TestInvokeActionFromConsumerToServer(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - //var outputVal atomic.Value - var testOutput string - var testActionStatus messaging.ActionStatus - - var inputVal atomic.Value - var testMsg1 = "hello world 1" - var thingID = "thing1" - var actionName = "action1" - - // the server will receive the action request and return an immediate result - requestHandler := func(req *messaging.RequestMessage, replyTo messaging.IConnection) *messaging.ResponseMessage { - if req.Operation == td.OpInvokeAction { - inputVal.Store(req.Input) - // CreateResponse returns ActionStatus - return req.CreateResponse(req.Input, nil) - } - assert.Fail(t, "Not expecting this") - return req.CreateResponse(nil, errors.New("unexpected request")) - } - // 1. start the servers - srv, cancelFn := StartTransportServer(nil, requestHandler, nil) - _ = srv - defer cancelFn() - - // 2. connect a client - cc1, cl1, token := NewConsumer(testClientID1) - defer cc1.Disconnect() - require.NotEmpty(t, token) - ctx1, release1 := context.WithTimeout(context.Background(), time.Minute) - defer release1() - - // since there is no waiting for a response when sending the request, the - // client should receive an action/request response via the response callback - cl1.SetResponseHandler(func(resp *messaging.ResponseMessage) error { - slog.Info("testOutput was updated asynchronously via the message handler") - // response should be an ActionStatus object - err2 := utils.Decode(resp.Value, &testActionStatus) - if assert.NoError(t, err2) { - err2 = utils.DecodeAsObject(testActionStatus.Output, &testOutput) - assert.NoError(t, err2) - } - release1() - return err2 - }) - - // 3. invoke the action without waiting for a result - // the response handler above will receive the result - // testOutput can be updated as an immediate result or via the callback message handler - req := messaging.NewRequestMessage(td.OpInvokeAction, thingID, actionName, testMsg1, shortid.MustGenerate()) - resp, err := cl1.SendRequest(req, false) - // waitForCompletion is false so no response yet - require.NoError(t, err) - assert.Nil(t, resp) - <-ctx1.Done() - - // whether receiving completed or delivered depends on the binding - require.Equal(t, testMsg1, testOutput) - - // 4. verify that the server received it and send a reply - assert.NoError(t, err) - assert.Equal(t, testMsg1, inputVal.Load()) - assert.Equal(t, testMsg1, testOutput) - - // 5. Again but wait for the action result - var result1 string - err = cl1.InvokeAction(thingID, actionName, testMsg1, &result1) - assert.NoError(t, err) - assert.Equal(t, testMsg1, result1) -} - -// Warning: this is a bit of a mind bender if you're used to classic consumer->thing interaction. -// This test uses a Thing agent as a client and have it reply to a request from the server. -// The server in this case passes on a message received from a consumer, which is also a client. -func TestInvokeActionFromServerToAgent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var reqVal atomic.Value - var replyVal atomic.Value - var testMsg1 = "hello world 1" - var testMsg2 = "hello world 2" - var thingID = "thing1" - var actionKey = "action1" - var corrID = "correlation-1" - - // 1. start the server. register a message handler for receiving an action status - // async reply from the agent after the server sends an invoke action. - // Note that WoT doesn't cover this use-case so this uses hiveot vocabulary operation. - - ctx1, cancelFn1 := context.WithTimeout(context.Background(), time.Minute) - defer cancelFn1() - // server receives agent response - responseHandler := func(resp *messaging.ResponseMessage) error { - var responseData string - // The server receives a response message from the agent - // (which normally is forwarded to the remote consumer; but not in this test) - assert.NotEmpty(t, resp.CorrelationID) - assert.Equal(t, td.OpInvokeAction, resp.Operation) - - slog.Info("serverHandler: Received action response from agent", - "op", resp.Operation, - "output", resp.Value, - ) - err := resp.Decode(&responseData) - assert.NoError(t, err) - - replyVal.Store(responseData) - cancelFn1() - return nil - } - srv, cancelFn2 := StartTransportServer(nil, nil, responseHandler) - _ = srv - defer cancelFn2() - - // 2a. connect as an agent - cc1, ag1client, token := NewAgent(testAgentID1) - require.NotEmpty(t, token) - defer cc1.Disconnect() - - // an agent receives requests from the server - ag1client.SetRequestHandler(func(req *messaging.RequestMessage, replyTo messaging.IConnection) *messaging.ResponseMessage { - // agent receives action request and returns a result - slog.Info("Agent receives request", "op", req.Operation) - assert.Equal(t, testClientID1, req.SenderID) - reqVal.Store(req.Input) - go func() { - time.Sleep(time.Millisecond) - // separately send a completed response - resp := req.CreateResponse(testMsg2, nil) - _ = replyTo.SendResponse(resp) - }() - // the response is sent asynchronously - return nil - }) - - // Send the action request from the server to the agent (the agent is connected as a client) - // and expect result using the request status message sent by the agent. - time.Sleep(time.Millisecond) - ag1Server := srv.GetConnectionByClientID(testAgentID1) - require.NotNil(t, ag1Server) - req := messaging.NewRequestMessage(td.OpInvokeAction, thingID, actionKey, testMsg1, corrID) - req.SenderID = testClientID1 - req.CorrelationID = "rpc-TestInvokeActionFromServerToAgent" - err := ag1Server.SendRequest(req) - require.NoError(t, err) - - // wait until the agent has sent a reply - <-ctx1.Done() - time.Sleep(time.Millisecond * 10) - - // if all went well the agent received the request and the server its response - assert.Equal(t, testMsg1, reqVal.Load()) - assert.Equal(t, testMsg2, replyVal.Load()) -} - -// TestQueryActions consumer queries the server for actions -// The server receives a QueryAction request and sends a response -func TestQueryActions(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var testMsg1 = "hello world 1" - var thingID = "dtw:thing1" - var actionKey = "action1" - var actionID = "correlationID-123" - - // 1. start the server. register a request handler for receiving a request - // from the agent after the server sends an invoke action. - // Note that WoT doesn't cover this use-case so this uses hiveot vocabulary operation. - requestHandler := func(req *messaging.RequestMessage, replyTo messaging.IConnection) *messaging.ResponseMessage { - - assert.NotNil(t, replyTo) - assert.NotNil(t, req.CorrelationID) - switch req.Operation { - case td.OpQueryAction: - // reply a response carrying the queried action status - actStat := messaging.ActionStatus{ - ThingID: req.ThingID, - Name: req.Name, - ActionID: actionID, - Output: testMsg1, - State: messaging.StatusCompleted, - TimeRequested: req.Created, - TimeUpdated: utils.FormatNowUTCMilli(), - } - - return req.CreateResponse(actStat, nil) - - //replyTo.SendResponse(msg.ThingID, msg.Name, output, msg.CorrelationID) - case td.OpQueryAllActions: - // include an error status to ensure encode/decode of an error status works - actStat := map[string]messaging.ActionStatus{ - actionKey: { - ThingID: req.ThingID, - Name: actionKey, - ActionID: actionID, - Output: testMsg1, - State: messaging.StatusCompleted, - TimeRequested: req.Created, - TimeUpdated: utils.FormatNowUTCMilli(), - }, - "action-2": { - ThingID: req.ThingID, - Name: "action-2", - ActionID: actionID, - Error: &messaging.ErrorValue{ - Status: http.StatusBadRequest, - Type: "http://testerror/", - Title: "Testing error", - Detail: "test error detail", - }, - State: messaging.StatusFailed, - TimeRequested: utils.FormatNowUTCMilli(), - TimeUpdated: utils.FormatNowUTCMilli(), - }, - "action-3": { - ThingID: req.ThingID, - Name: "action-3", - ActionID: actionID, - Output: "other output", - State: messaging.StatusCompleted, - TimeRequested: utils.FormatNowUTCMilli(), - TimeUpdated: utils.FormatNowUTCMilli(), - }} - resp := req.CreateResponse(actStat, nil) - return resp - } - return req.CreateResponse(nil, errors.New("unexpected response "+req.Operation)) - } - - // 1. start the servers - srv, cancelFn := StartTransportServer(nil, requestHandler, nil) - _ = srv - defer cancelFn() - - // 2. connect as a consumer - cc1, cl1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - - // 3. Query action status - var status messaging.ActionStatus - err := cl1.Rpc(td.OpQueryAction, thingID, actionKey, nil, &status) - require.NoError(t, err) - require.Equal(t, thingID, status.ThingID) - require.Equal(t, actionKey, status.Name) - - // 4. Query all actions - var statusMap map[string]messaging.ActionStatus - err = cl1.Rpc(td.OpQueryAllActions, thingID, actionKey, nil, &statusMap) - require.NoError(t, err) - require.Equal(t, 3, len(statusMap)) -} diff --git a/lib/servers/tests/connect_test.go b/lib/servers/tests/connect_test.go deleted file mode 100644 index 8972dbd6..00000000 --- a/lib/servers/tests/connect_test.go +++ /dev/null @@ -1,565 +0,0 @@ -package tests - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/clients/authclient" - "github.com/hiveot/hub/lib/clients/httpclient" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/hiveot/hub/lib/servers/tlsserver" - "github.com/hiveot/hub/lib/servers/wssserver" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const testTimeout = time.Second * 300 -const testAgentID1 = "agent1" -const testClientID1 = "client1" -const testServerHttpPort = 9445 -const testServerHttpURL = "https://localhost:9445" - -// const testServerHiveotHttpBasicURL = "wss://localhost:9445" + server.DefaultHiveotHttpBasicPath -const testServerHiveotSseURL = "sse://localhost:9445" + hiveotsseserver.DefaultHiveotSsePath -const testServerHiveotWssURL = "wss://localhost:9445" + wssserver.DefaultWssPath - -//const testServerMqttWssURL = "mqtts://localhost:9447" - -var defaultProtocol = messaging.ProtocolTypeHiveotSSE - -// var defaultProtocol = messaging.ProtocolTypeWSS - -var transportServer servers.IMessageServer -var dummyAuthenticator *messaging.DummyAuthenticator -var certBundle = certs.CreateTestCertBundle() - -// NewClient creates a new connected client with the given client ID. The -// transport server must be started first. -// -// This uses the server to generate an auth token. -// This panics if a client cannot be created. -// ClientID is only used for logging -func NewTestClient(clientID string) (messaging.IClientConnection, string) { - fullURL := testServerHttpURL - token := dummyAuthenticator.AddClient(clientID, clientID) - - switch defaultProtocol { - case messaging.ProtocolTypeHiveotSSE: - fullURL = testServerHiveotSseURL - - case messaging.ProtocolTypeWSS: - fullURL = testServerHiveotWssURL - - case messaging.ProtocolTypeHTTPBasic: - fullURL = testServerHttpURL - - //case messaging.ProtocolTypeWotMQTTWSS: - // fullURL = testServerMqttWssURL - } - caCert := certBundle.CaCert - cc, err := clients.ConnectWithToken(clientID, token, caCert, fullURL, testTimeout) - if err != nil { - panic("NewClient failed:" + err.Error()) - } - return cc, token -} - -// NewAgent creates a new connected agent client with the given ID. The -// transport server must be started first. -// -// This uses the clientID as password -// This panics if a client cannot be created -func NewAgent(clientID string) (messaging.IClientConnection, *agent.Agent, string) { - cc, token := NewTestClient(clientID) - - agent := agent.NewAgent(cc, nil, nil, nil, nil, testTimeout) - return cc, agent, token -} - -// NewConsumer creates a new connected consumer client with the given ID. -// The transport server must be started first. -// -// This uses the clientID as password -// This panics if a client cannot be created -func NewConsumer(clientID string) ( - messaging.IClientConnection, *consumer.Consumer, string) { - - cc, token := NewTestClient(clientID) - co := consumer.NewConsumer(cc, testTimeout) - return cc, co, token -} - -// Create a new form for the given operation -// This uses the default protocol to generate the Form -//func NewForm(op, thingID, name string) *td.Form { -// switch defaultProtocol { -// -// } -// form := transportServer.GetForm(op, thingID, name) -// return form -//} - -// start the 'defaultProtocol' transport server. This is one of http-basic, -// http-sse or websocket. -// This panics if the server cannot be created -func StartTransportServer( - notifHandler messaging.NotificationHandler, - reqHandler messaging.RequestHandler, - respHandler messaging.ResponseHandler, -) (srv servers.IMessageServer, cancelFunc func()) { - - caCert := certBundle.CaCert - serverCert := certBundle.ServerCert - dummyAuthenticator = messaging.NewDummyAuthenticator() - if reqHandler == nil { - reqHandler = DummyRequestHandler - } - if respHandler == nil { - respHandler = DummyResponseHandler - } - // cert uses localhost - tlsServer, httpRouter := tlsserver.NewTLSServer( - certBundle.ServerAddr, testServerHttpPort, serverCert, caCert) - err := tlsServer.Start() - if err != nil { - panic("unable to start TLS server: " + err.Error()) - } - - // http wotbasic is required for http sub-protocols and authentication - connectAddr := fmt.Sprintf("%s:%d", certBundle.ServerAddr, testServerHttpPort) - httpBasicServer := httpbasic.NewWoTHttpBasicServer( - connectAddr, httpRouter, dummyAuthenticator, notifHandler, reqHandler, respHandler) - err = httpBasicServer.Start() - - switch defaultProtocol { - case messaging.ProtocolTypeHTTPBasic: - // http only, no subprotocol bindings - transportServer = httpBasicServer - - case messaging.ProtocolTypeHiveotSSE: - transportServer = hiveotsseserver.NewHiveotSseServer( - connectAddr, hiveotsseserver.DefaultHiveotSsePath, - httpBasicServer.GetProtectedRouter(), - nil, notifHandler, reqHandler, respHandler) - err = transportServer.Start() - - case messaging.ProtocolTypeWSS: - transportServer = wssserver.NewWssServer( - connectAddr, wssserver.DefaultWssPath, - httpBasicServer.GetProtectedRouter(), - nil, notifHandler, reqHandler, respHandler) - err = transportServer.Start() - - default: - err = errors.New("unknown protocol name: " + defaultProtocol) - } - - if err != nil { - panic("Unable to create protocol server: " + err.Error()) - } - //transportServer.SetRequestHandler(cm.AddConnection) - //transportServer.SetMessageHandler(cm.AddConnection) - - return transportServer, func() { - if transportServer != nil { - transportServer.Stop() - } - if tlsServer != nil { - tlsServer.Stop() - } - } -} - -// func DummyNotificationHandler(notification transports.NotificationMessage) { -// -// slog.Info("DummyNotificationHandler: Received notification", "op", notification.Operation) -// //replyTo.SendResponse(msg.ThingID, msg.Name, "result", msg.CorrelationID) -// } -func DummyRequestHandler(req *messaging.RequestMessage, - c messaging.IConnection) (resp *messaging.ResponseMessage) { - var output any - var err error - _ = c - slog.Info("DummyRequestHandler: Received request", "op", req.Operation) - //if req.Operation == td.HTOpRefresh { - // oldToken := req.ToString(0) - // output, err = dummyAuthenticator.RefreshToken(req.SenderID, oldToken) - //} else if req.Operation == td.HTOpLogout { - // dummyAuthenticator.Logout(c.GetClientID()) - //} else { - output = req.Input // echo - //} - return req.CreateResponse(output, err) -} -func DummyResponseHandler(response *messaging.ResponseMessage) error { - - slog.Info("DummyResponse: Received response", "op", response.Operation) - return nil -} - -// TestMain sets logging -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - result := m.Run() - os.Exit(result) -} - -// test create a server and connect a client -func TestStartStop(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - srv, cancelFn := StartTransportServer(nil, nil, nil) - _ = srv - defer cancelFn() - cc1, co1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - assert.NotNil(t, co1) - - isConnected := cc1.IsConnected() - assert.True(t, isConnected) -} - -// login/refresh use the http-basic or http-sse binding -func TestLoginRefresh(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - srv, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - // ensure the client exists - cc1, co1, token1 := NewConsumer(testClientID1) - _ = co1 - - isConnected := cc1.IsConnected() - // FIXME: separate auth from the client - //assert.False(t, isConnected) - // - //token1, err := cc1.ConnectWithPassword(testClientID1) - //require.NoError(t, err) - //require.NotEmpty(t, token1) - - // check if both client and server have the connection ID - // the server prefixes it with clientID- to ensure no client can steal another's ID - // removed this test as it is only important for http/sse. Other tests will fail - // if they are incorrect. - //cid1 := co1.GetConnectionID() - //assert.NotEmpty(t, cid1) - //srvConn := cm.GetConnectionByClientID(testClientID1) - //require.NotNil(t, srvConn) - //cid1server := srvConn.GetConnectionID() - //assert.Equal(t, testClientID1+"-"+cid1, cid1server) - - isConnected = cc1.IsConnected() - assert.True(t, isConnected) - - parts, err := url.Parse(srv.GetConnectURL()) - require.NoError(t, err) - authCl := authclient.NewAuthClient(parts.Host, certBundle.CaCert, testTimeout) - token2, err := authCl.RefreshToken(token1) - - // refresh should succeed - //token2, err := co1.RefreshToken(token1) - require.NoError(t, err) - require.NotEmpty(t, token2) - - // end the connection - cc1.Disconnect() - time.Sleep(time.Millisecond * 1) - - // should be able to reconnect with the new token - // NOTE: the runtime session manager doesn't allow this as - // the session no longer exists, but the dummyAuthenticator doesn't care. - err = cc1.ConnectWithToken(token2) - require.NoError(t, err) - - //token3, err := co1.RefreshToken(token2) - token3, err := authCl.RefreshToken(token2) - assert.NoError(t, err) - assert.NotEmpty(t, token3) - - // end the session - cc1.Disconnect() -} - -func TestLogout(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - srv, cancelFn := StartTransportServer(nil, nil, nil) - _ = srv - defer cancelFn() - - // check if this test still works with a valid login - cc1, co1, token1 := NewConsumer(testClientID1) - _ = cc1 - _ = co1 - defer co1.Disconnect() - assert.NotEmpty(t, token1) - - // logout - authCl := authclient.NewAuthClientFromConnection(cc1, token1) - err := authCl.Logout() - - //authenticator.Logout(cc1, "") - //err := co1.Logout() - t.Log("logged out, some warnings are expected next") - assert.NoError(t, err) - - // This causes Refresh to fail - token2, err := authCl.RefreshToken(token1) - //token2, err := co1.RefreshToken(token1) - assert.Error(t, err) - assert.Empty(t, token2) -} - -//func TestBadLogin(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// -// srv, cancelFn := StartTransportServer(nil, nil) -// defer cancelFn() -// -// cc1, co1, _ := NewConsumer(testClientID1, srv.GetForm) -// -// // check if this test still works with a valid login -// token1, err := cc1.ConnectWithPassword(testClientID1) -// assert.NoError(t, err) -// -// // failed logins -// t.Log("Expecting ConnectWithPassword to fail") -// token2, err := cc1.ConnectWithPassword("bad-pass") -// assert.Error(t, err) -// assert.Empty(t, token2) -// -// // can't refresh when no longer connected -// t.Log("Expecting RefreshToken to fail") -// token4, err := co1.RefreshToken(token1) -// assert.Error(t, err) -// assert.Empty(t, token4) -// -// // disconnect should always succeed -// cc1.Disconnect() -// -// // bad client ID -// t.Log("Expecting ConnectWithPassword('BadID') to fail") -// cc2, _, _ := NewConsumer("badID", srv.GetForm) -// token5, err := cc2.ConnectWithPassword(testClientID1) -// assert.Error(t, err) -// assert.Empty(t, token5) -//} - -func TestBadRefresh(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - srv, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - cc1, co1, token1 := NewConsumer(testClientID1) - _ = co1 - _ = token1 - defer cc1.Disconnect() - - // set the token - t.Log("Expecting SetBearerToken('bad-token') to fail") - err := cc1.ConnectWithToken("bad-token") - require.Error(t, err) - - // reconnect with a valid token and connect with a bad client-id - err = cc1.ConnectWithToken(token1) - assert.NoError(t, err) - parts, _ := url.Parse(srv.GetConnectURL()) - authCl := authclient.NewAuthClient( - parts.Host, certBundle.CaCert, testTimeout) - validToken, err := authCl.RefreshToken(token1) - //validToken, err := co1.RefreshToken(token1) - assert.NoError(t, err) - assert.NotEmpty(t, validToken) - cc1.Disconnect() -} - -// Auto-reconnect using hub client and server -func TestReconnect(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - const thingID = "thing1" - const actionKey = "action1" - const agentID = "agent1" - var reconnectedCallback atomic.Bool - var dThingID = digitwin.MakeDigitwinID(agentID, thingID) - var srv servers.IMessageServer - var cancelFn func() - - // this test handler receives an action and returns a 'pending status', - // it is intended to prove reconnect works. - handleRequest := func(req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - slog.Info("Received request", "op", req.Operation) - var err error - // prove that the return channel is connected - if req.Operation == td.OpInvokeAction { - go func() { - // send an asynchronous result after a short time - time.Sleep(time.Millisecond * 10) - require.NotNil(t, c, "client doesnt have a connection") - output := req.Input - cinfo := c.GetConnectionInfo() - c2 := srv.GetConnectionByConnectionID(cinfo.ClientID, cinfo.ConnectionID) - assert.NotEmpty(t, c2) - resp := req.CreateResponse(output, nil) - err = c.SendResponse(resp) - assert.NoError(t, err) - }() - // nothing to return yet - return nil - } - err = errors.New("unexpected request") - return req.CreateResponse("", err) - } - // start the servers and connect as a client - srv, cancelFn = StartTransportServer(nil, handleRequest, nil) - defer cancelFn() - - // connect as client - cc1, co1, _ := NewConsumer(testClientID1) - //token := dummyAuthenticator.CreateSessionToken(testClientID1, "", 0) - //err := cc1.ConnectWithToken(token) - //require.NoError(t, err) - defer cc1.Disconnect() - - // wait until the connection is established - - // 3. close connection server side but keep the session. - // This should trigger auto-reconnect on the client. - t.Log("--- force disconnecting all clients ---") - srv.CloseAll() - - // give client time to reconnect - ctx1, cancelFn1 := context.WithTimeout(context.Background(), time.Second) - defer cancelFn1() - cc1.SetConnectHandler(func(connected bool, err error, c messaging.IConnection) { - if connected { - cancelFn1() - reconnectedCallback.Store(true) - } - }) - <-ctx1.Done() - - // 4. invoke an action which should return a value - // An RPC call is the ultimate test - var rpcArgs = "rpc test" - var rpcResp string - time.Sleep(time.Millisecond * 1000) - err := co1.Rpc(td.OpInvokeAction, dThingID, actionKey, &rpcArgs, &rpcResp) - require.NoError(t, err) - assert.Equal(t, rpcArgs, rpcResp) - - // expect the re-connected callback to be invoked - assert.True(t, reconnectedCallback.Load()) -} - -func TestPing(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - srv, cancelFn := StartTransportServer(nil, nil, nil) - _ = srv - defer cancelFn() - cc1, co1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - - //_, err := cc1.ConnectWithPassword(testClientID1) - //require.NoError(t, err) - - err := co1.Ping() - assert.NoError(t, err) - - // FIXME: SSE server sends ping event but it isn't received until later??? - - var output any - err = co1.Rpc(td.HTOpPing, "", "", nil, &output) - assert.Equal(t, "pong", output) - assert.NoError(t, err) -} - -// Test getting form for unknown operation -//func TestBadForm(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// -// _, cancelFn := StartTransportServer(nil, nil, nil) -// defer cancelFn() -// -// form := NewForm("bad-operation", "", "") -// assert.Nil(t, form) -//} - -// Test getting server URL -func TestServerURL(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - srv, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - serverURL := srv.GetConnectURL() - _, err := url.Parse(serverURL) - require.NoError(t, err) -} - -// Test ping/login/refresh using http-basic -func TestHttpBasic(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const testPass = "password-test" - - // all transport servers use http-basic - // this also creates a dummy authenticator - srv, cancelFn := StartTransportServer(nil, nil, nil) - _ = srv - defer cancelFn() - token1 := dummyAuthenticator.AddClient(testClientID1, testPass) - _ = token1 - - // connect using http-basic - serverURL := srv.GetConnectURL() - - htb := httpclient.NewHttpBasicClient(serverURL, testClientID1, - nil, certBundle.CaCert, nil, time.Second) - //err := htb.ConnectWithToken(token) - //require.NoError(t, err) - - // 1: Ping - _, _, code, err := htb.Send(http.MethodGet, httpbasic.HttpGetPingPath, nil) - require.NoError(t, err) - require.Equal(t, 200, code) - - // 2: Login - loginBody := fmt.Sprintf(`{"login":"%s", "password":"%s"}`, testClientID1, testPass) - body, headers, code, err := htb.Send(http.MethodPost, httpbasic.HttpPostLoginPath, []byte(loginBody)) - require.NoError(t, err) - require.Equal(t, 200, code) - require.NotEmpty(t, body) - require.NotEmpty(t, headers) - var token2 string - err = json.Unmarshal(body, &token2) - require.NoError(t, err) - - // 3: Refresh using auth token - err = htb.SetBearerToken(token2) - assert.NoError(t, err) - refreshBody, _ := json.Marshal(token2) - body, headers, code, err = htb.Send(http.MethodPost, httpbasic.HttpPostRefreshPath, refreshBody) - require.NoError(t, err) - require.Equal(t, 200, code) - require.NotEmpty(t, body) - require.NotEmpty(t, headers) - -} diff --git a/lib/servers/tests/events_test.go b/lib/servers/tests/events_test.go deleted file mode 100644 index f55f80c3..00000000 --- a/lib/servers/tests/events_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package tests - -import ( - "context" - "log/slog" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// test event messages between agent, server and client -// this uses the client and server helpers defined in connect_test.go - -// Test subscribing and receiving all events by consumer -func TestSubscribeAll(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var rxVal atomic.Value - var testMsg1 = "hello world 1" - var testMsg2 = "hello world 2" - var agentID = "agent1" - var thingID = "dtw:thing1" - var eventKey = "event11" - var agentRxEvent atomic.Bool - - // 1. start the servers - srv, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - - // 2. connect as consumers - cconn1, cons1, _ := NewConsumer(testClientID1) - defer cconn1.Disconnect() - - cconn2, cons2, _ := NewConsumer(testClientID1) - defer cconn2.Disconnect() - - // ensure that agents can also subscribe (they cant use forms) - agConn1, agent1, _ := NewAgent(agentID) - defer agConn1.Disconnect() - - // FIXME: test subscription by agent - - // set the handler for events and subscribe - ctx, cancelFn := context.WithTimeout(context.Background(), time.Minute) - defer cancelFn() - - cons1.SetNotificationHandler(func(ev *messaging.NotificationMessage) { - slog.Info("client 1 receives event") - // receive event - rxVal.Store(ev.Value) - //cancelFn() - }) - cons2.SetNotificationHandler(func(ev *messaging.NotificationMessage) { - slog.Info("client 2 receives event") - }) - agent1.SetNotificationHandler(func(ev *messaging.NotificationMessage) { - // receive event - slog.Info("Agent receives event") - agentRxEvent.Store(true) - cancelFn() - }) - - // Subscribe to events. Each binding implements this as per its spec - err := cons1.Subscribe("", "") - assert.NoError(t, err) - err = cons2.Subscribe(thingID, eventKey) - assert.NoError(t, err) - err = agent1.Subscribe("", "") - assert.NoError(t, err) - - // 3. Server sends event to consumers - time.Sleep(time.Millisecond * 10) - notif1 := messaging.NewNotificationMessage( - td.OpSubscribeEvent, thingID, eventKey, testMsg1) - srv.SendNotification(notif1) - - // 4. subscriber should have received them - <-ctx.Done() - time.Sleep(time.Millisecond * 10) - assert.Equal(t, testMsg1, rxVal.Load()) - assert.True(t, agentRxEvent.Load()) - - // Unsubscribe from events - err = cons1.Unsubscribe("", "") - assert.NoError(t, err) - time.Sleep(time.Millisecond * 10) // async take time - err = cons2.Unsubscribe(thingID, eventKey) - assert.NoError(t, err) - err = agent1.Unsubscribe("", "") - assert.NoError(t, err) - agentRxEvent.Store(false) - - // 5. Server sends another event to consumers - notif2 := messaging.NewNotificationMessage( - td.OpSubscribeEvent, thingID, eventKey, testMsg2) - srv.SendNotification(notif2) - time.Sleep(time.Millisecond) - // update not received - assert.Equal(t, testMsg1, rxVal.Load(), "Unsubscribe didnt work") - assert.False(t, agentRxEvent.Load()) - - // -} - -// Agent sends events to server -// This is used if the Thing agent is connected as a client, and does not -// run a server itself. -// FIXME: server should subscribe to agent as a consumer -func TestPublishEventsByAgent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var evVal atomic.Value - var testMsg = "hello world" - var thingID = "thing1" - var eventKey = "event11" - - // 1. start the transport - // handler of event notification on the server - notificationHandler := func(msg *messaging.NotificationMessage) { - evVal.Store(msg.Value) - } - srv, cancelFn := StartTransportServer(notificationHandler, nil, nil) - _ = srv - defer cancelFn() - - // 2. connect as an agent - agConn1, agent1, _ := NewAgent(testAgentID1) - defer agConn1.Disconnect() - - // 3. agent publishes an event - err := agent1.PubEvent(thingID, eventKey, testMsg) - time.Sleep(time.Millisecond) // time to take effect - require.NoError(t, err) - - // event received by server - rxMsg2 := evVal.Load() - require.NotNil(t, rxMsg2) - assert.Equal(t, testMsg, rxMsg2) -} - -//// Consumer reads events from agent -//func TestReadEvent(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// var thingID = "thing1" -// var eventKey = "event11" -// var eventValue = "value11" -// var timestamp = "eventtime" -// -// // 1. start the agent transport with the request handler -// // in this case the consumer connects to the agent (unlike when using a hub) -// agentReqHandler := func(req *transports.RequestMessage, c transports.IConnection) *transports.ResponseMessage { -// if req.Operation == td.HTOpReadEvent && req.ThingID == thingID && req.Name == eventKey { -// evVal := transports.ThingValue{ -// ID: "ud1", -// Name: req.Name, -// Value: eventValue, -// ThingID: thingID, -// Timestamp: timestamp, -// } -// resp := req.CreateResponse(evVal, nil) -// resp.Timestamp = timestamp -// return resp -// } -// return req.CreateResponse(nil, errors.New("unexpected request")) -// } -// srv, cancelFn := StartTransportServer(agentReqHandler, nil) -// _ = srv -// defer cancelFn() -// -// // 2. connect as a consumer -// cc1, consumer1, _ := NewConsumer(testClientID1, srv.GetForm) -// defer cc1.Disconnect() -// -// rxVal, err := consumer1.ReadEvent(thingID, eventKey) -// require.NoError(t, err) -// assert.Equal(t, eventValue, rxVal.Value) -// assert.Equal(t, timestamp, rxVal.Timestamp) -//} - -// Consumer reads events from agent -//func TestReadAllEvents(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// var thingID = "thing1" -// var event1Name = "event1" -// var event2Name = "event2" -// var event1Value = "value1" -// var event2Value = "value2" -// -// // 1. start the agent transport with the request handler -// // in this case the consumer connects to the agent (unlike when using a hub) -// agentReqHandler := func(req *transports.RequestMessage, c transports.IConnection) *transports.ResponseMessage { -// if req.Operation == td.HTOpReadAllEvents { -// output := make(map[string]*transports.ResponseMessage) -// output[event1Name] = transports.NewResponseMessage(td.OpSubscribeEvent, thingID, event1Name, event1Value, nil, "") -// output[event2Name] = transports.NewResponseMessage(td.OpSubscribeEvent, thingID, event2Name, event2Value, nil, "") -// resp := req.CreateResponse(output, nil) -// return resp -// } -// return req.CreateResponse(nil, errors.New("unexpected request")) -// } -// srv, cancelFn := StartTransportServer(agentReqHandler, nil) -// _ = srv -// defer cancelFn() -// -// // 2. connect as a consumer -// cc1, consumer1, _ := NewConsumer(testClientID1, srv.GetForm) -// defer cc1.Disconnect() -// -// evMap, err := consumer1.ReadAllEvents(thingID) -// require.NoError(t, err) -// require.Equal(t, 2, len(evMap)) -// require.Equal(t, event1Value, evMap[event1Name].Value) -// require.Equal(t, event2Value, evMap[event2Name].Value) -//} diff --git a/lib/servers/tests/properties_test.go b/lib/servers/tests/properties_test.go deleted file mode 100644 index 8a031f71..00000000 --- a/lib/servers/tests/properties_test.go +++ /dev/null @@ -1,197 +0,0 @@ -// test property messages between the protocol client and server -package tests - -import ( - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// test property messages between agent, server and client -// this uses the client and server helpers defined in connect_test.go - -// Test observing and receiving all properties by consumer -func TestObservePropertyByConsumer(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var rxVal1 atomic.Value - var rxVal2 atomic.Value - var thingID = "dtw:thing1" - var propertyKey1 = "property1" - var propertyKey2 = "property2" - var propValue1 = "value1" - var propValue2 = "value2" - - // 1. start the server - srv, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - - // 2. connect with two consumers - cc1, cl1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - cc2, cl2, _ := NewConsumer(testClientID1) - defer cc2.Disconnect() - - // set the handler for property updates and subscribe - cl1.SetNotificationHandler(func(ev *messaging.NotificationMessage) { - rxVal1.Store(ev.Value) - }) - cl2.SetNotificationHandler(func(ev *messaging.NotificationMessage) { - rxVal2.Store(ev.Value) - }) - - // Client1 subscribes to one, client 2 to all property updates - err := cl1.ObserveProperty(thingID, propertyKey1) - require.NoError(t, err) - err = cl2.ObserveProperty("", "") - require.NoError(t, err) - time.Sleep(time.Millisecond) // time to take effect - - // 3. Server sends a property update to consumers - notif1 := messaging.NewNotificationMessage( - td.OpObserveProperty, thingID, propertyKey1, propValue1) - srv.SendNotification(notif1) - - // 4. both observers should have received it - time.Sleep(time.Millisecond) - assert.Equal(t, propValue1, rxVal1.Load()) - assert.Equal(t, propValue1, rxVal2.Load()) - - // 5. client 1 unobserves - err = cl1.UnobserveProperty(thingID, propertyKey1) - require.NoError(t, err) - time.Sleep(time.Millisecond * 10) // time to take effect - - // 6. Server sends a property update to consumers - notif2 := messaging.NewNotificationMessage( - td.OpObserveProperty, thingID, propertyKey1, propValue2) - srv.SendNotification(notif2) - notif3 := messaging.NewNotificationMessage( - td.OpObserveProperty, thingID, propertyKey2, propValue2) - srv.SendNotification(notif3) - - // 7. property should not have been received - time.Sleep(time.Millisecond * 10) - assert.Equal(t, propValue1, rxVal1.Load()) - assert.Equal(t, propValue2, rxVal2.Load()) - - // 8. client 2 unobserves - err = cl2.UnobserveProperty("", "") - time.Sleep(time.Millisecond * 10) - notif4 := messaging.NewNotificationMessage( - td.OpObserveProperty, thingID, propertyKey2, propValue1) - srv.SendNotification(notif4) - // no change is expected - assert.Equal(t, propValue2, rxVal2.Load()) - -} - -// Agent publishes property updates to subscribers -func TestPublishPropertyByAgent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var evVal atomic.Value - var thingID = "thing1" - var propKey1 = "property1" - var propValue1 = "value1" - - // handler of property updates on the server - notificationHandler := func(msg *messaging.NotificationMessage) { - evVal.Store(msg.Value) - } - - // 1. start the transport - srv, cancelFn := StartTransportServer(notificationHandler, nil, nil) - _ = srv - defer cancelFn() - - // 2. connect as an agent - agConn1, ag1, _ := NewAgent(testAgentID1) - defer agConn1.Disconnect() - - // 3. agent publishes a property update to subscribers - err := ag1.PubProperty(thingID, propKey1, propValue1) - require.NoError(t, err) - time.Sleep(time.Millisecond) // time to take effect - - // property received by server - rxMsg2 := evVal.Load() - require.NotNil(t, rxMsg2) - assert.Equal(t, propValue1, rxMsg2) -} - -// Consumer reads property from agent -func TestReadProperty(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var thingID = "dtw:thing1" - var propKey = "propKey1" - var propValue = "value11" - var timestamp = "mytime" - - // 1. start the agent transport with the request handler - // in this case the consumer connects to the agent (unlike when using a hub) - agentReqHandler := func(req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - if req.Operation == td.OpReadProperty && req.ThingID == thingID && req.Name == propKey { - tv := messaging.NewThingValue(messaging.AffordanceTypeProperty, - "thingID", req.Name, propValue, timestamp) - resp := req.CreateResponse(tv, nil) - resp.Timestamp = timestamp - return resp - } - return req.CreateResponse(nil, errors.New("unexpected request")) - } - srv, cancelFn := StartTransportServer(nil, agentReqHandler, nil) - _ = srv - defer cancelFn() - - // 2. connect as a consumer - cc1, consumer1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - - rxVal, err := consumer1.ReadProperty(thingID, propKey) - require.NoError(t, err) - assert.Equal(t, propValue, rxVal.Data) - assert.Equal(t, timestamp, rxVal.Timestamp) -} - -// Consumer reads events from agent -func TestReadAllProperties(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var thingID = "dtw:thing1" - var name1 = "prop1" - var name2 = "prop2" - var value1 = "value1" - var value2 = "value2" - - // 1. start the agent transport with the request handler - // in this case the consumer connects to the agent (unlike when using a hub) - agentReqHandler := func(req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - if req.Operation == td.OpReadAllProperties { - output := make(map[string]*messaging.ThingValue) - output[name1] = messaging.NewThingValue( - messaging.AffordanceTypeProperty, thingID, name1, value1, "") - output[name2] = messaging.NewThingValue( - messaging.AffordanceTypeProperty, thingID, name2, value2, "") - resp := req.CreateResponse(output, nil) - return resp - } - return req.CreateResponse(nil, errors.New("unexpected request")) - } - srv, cancelFn := StartTransportServer(nil, agentReqHandler, nil) - _ = srv - defer cancelFn() - - // 2. connect as a consumer - cc1, consumer1, _ := NewConsumer(testClientID1) - defer cc1.Disconnect() - - propMap, err := consumer1.ReadAllProperties(thingID) - require.NoError(t, err) - require.Equal(t, 2, len(propMap)) - require.Equal(t, value1, propMap[name1].Data) - require.Equal(t, value2, propMap[name2].Data) -} diff --git a/lib/servers/tests/td_test.go b/lib/servers/tests/td_test.go deleted file mode 100644 index 1df6b560..00000000 --- a/lib/servers/tests/td_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package tests - -import ( - "testing" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" -) - -// test TD messages and forms -// this uses the client and server helpers defined in connect_test.go - -const DeviceTypeSensor = "hiveot:sensor" - -//// Test consumer reads a TD from agent via the server -//func TestReadTDFromAgent(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// var thingID = "thing1" -// -// // 1. start the transport -// srv, cancelFn := StartTransportServer(nil, nil) -// _ = srv -// defer cancelFn() -// -// // 2. connect as an agent -// agConn1, ag1, _ := NewAgent(testAgentID1) -// defer agConn1.Disconnect() -// -// // 3. agent creates TD -// td1 := td.NewTD(thingID, "My gadget", DeviceTypeSensor) -// -// // agent request handler to read TD -// agentReqHandler := func(req *transports.RequestMessage, -// connection transports.IConnection) *transports.ResponseMessage { -// t.Log("Received request: " + req.Operation) -// if req.Operation == td.HTOpReadTD { -// tdJSON, err := jsoniter.Marshal(td1) -// return req.CreateResponse(tdJSON, err) -// } else if req.Operation == td.HTOpReadAllTDs { -// tdJSON, err := jsoniter.Marshal(td1) -// return req.CreateResponse([]string{string(tdJSON)}, err) -// } else { -// return req.CreateResponse(nil, -// errors.New("agent receives unknown request: "+req.Operation)) -// } -// } -// ag1.SetRequestHandler(agentReqHandler) -// -// // 4. verify the TD can be read from the agent -// c := srv.GetConnectionByClientID(testAgentID1) -// require.True(t, c.IsConnected()) -// // c is server side connection of the agent. The hub is the consumer of the agent. -// consumer := consumer.NewConsumer(c, testTimeout) -// tdList, err := consumer.ReadAllTDs() -// require.NoError(t, err) -// require.True(t, len(tdList) > 0) -// -// var td2 td.TD -// err = jsoniter.UnmarshalFromString(tdList[0], &td2) -// require.NoError(t, err) -// assert.Equal(t, td1.ID, td2.ID) -// assert.Equal(t, td1.Title, td2.Title) -// assert.Equal(t, td1.AtType, td2.AtType) -//} - -// Test if forms are indeed added to a TD, describing the transport protocol binding operations -func TestAddForms(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var thingID = "thing1" - - // handler of TDs on the server - // 1. start the transport - _, cancelFn := StartTransportServer(nil, nil, nil) - defer cancelFn() - - // 2. Create a TD - tdi := td.NewTD(thingID, "My gadget", vocab.DeviceSensor) - - // 3. add forms - transportServer.AddTDForms(tdi, true) - - // 4. Check that at least 1 form are present - // TODO: add the hiveot endpoints - //assert.GreaterOrEqual(t, len(tdi.Forms), 1) -} - -//// Agent Publishes TD to the directory -//func TestPublishTD(t *testing.T) { -// t.Logf("---%s---\n", t.Name()) -// var thingID = "thing1" -// var rxTD atomic.Value -// -// // 2. Create a TD -// td1 := td.NewTD(thingID, "My gadget", DeviceTypeSensor) -// td1JSON, _ := jsoniter.MarshalToString(td1) -// -// // handler of TDs on the server -// requestHandler := func(msg *transports.RequestMessage, -// c transports.IConnection) *transports.ResponseMessage { -// var err error -// if msg.Operation == td.HTOpUpdateTD { -// assert.Equal(t, thingID, msg.ThingID) -// assert.Equal(t, td1JSON, msg.Input) -// assert.NotEmpty(t, msg.Input) -// rxTD.Store(msg.Input) -// } else { -// err = fmt.Errorf("Unexpected operation: %s" + msg.Operation) -// } -// resp := msg.CreateResponse(nil, err) -// return resp -// } -// -// // 1. start the transport server with the TD handler -// srv, cancelFn := StartTransportServer(requestHandler, nil) -// _ = srv -// defer cancelFn() -// -// // 2. Connect as agent -// cc1, ag1, _ := NewAgent(testAgentID1) -// defer cc1.Disconnect() -// -// // Agent publishes the TD -// err := ag1.UpdateThing(td1) -// require.NoError(t, err) -// time.Sleep(time.Millisecond * 10) -// -// // check reception -// require.Equal(t, td1JSON, rxTD.Load()) -//} diff --git a/lib/servers/tlsserver/GetBearerToken.go b/lib/servers/tlsserver/GetBearerToken.go deleted file mode 100644 index 11d965a3..00000000 --- a/lib/servers/tlsserver/GetBearerToken.go +++ /dev/null @@ -1,27 +0,0 @@ -package tlsserver - -import ( - "fmt" - "net/http" - "strings" -) - -// GetBearerToken returns the bearer token from the HTTP request authorization header -// Returns an error if no token present or token isn't a bearer token -func GetBearerToken(req *http.Request) (string, error) { - authHeader := req.Header.Get("Authorization") - if authHeader == "" { - return "", fmt.Errorf("tlsserver: no Authorization header") - } - - parts := strings.Split(authHeader, " ") - if len(parts) != 2 { - return "", fmt.Errorf("tlsserver: invalid Authorization header") - } - authType := strings.ToLower(parts[0]) - authTokenString := parts[1] - if authType != "bearer" { - return "", fmt.Errorf("tlsserver: not a bearer token") - } - return authTokenString, nil -} diff --git a/lib/servers/tlsserver/TLSServer.go b/lib/servers/tlsserver/TLSServer.go deleted file mode 100644 index 003cee2a..00000000 --- a/lib/servers/tlsserver/TLSServer.go +++ /dev/null @@ -1,178 +0,0 @@ -// Package tlsserver with TLS server for use by plugins and testing -package tlsserver - -import ( - "context" - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "os" - "strings" - "time" - - "github.com/lmittmann/tint" - - "github.com/go-chi/chi/v5" - "github.com/rs/cors" -) - -// TLSServer is a simple TLS MsgServer supporting BASIC, Jwt and client certificate authentication -type TLSServer struct { - address string - port int - caCert *x509.Certificate - serverCert *tls.Certificate - httpServer *http.Server - router *chi.Mux -} - -// Router returns the router for this server. -// This router has cors protection enabled. -func (srv *TLSServer) Router() *chi.Mux { - return srv.router -} - -// Start the TLS server using the provided CA and Server certificates. -// If a client certificate is provided it must be valid and signed by the CA. -// This configures handling of CORS requests to allow: -// - any origin by returning the requested origin (not using wildcard '*'). -// - any method, eg PUT, POST, GET, PATCH, -// - headers "Origin", "Accept", "Content-Type", "X-Requested-With" -func (srv *TLSServer) Start() error { - var err error - - slog.Info("Starting TLS server", "address", srv.address, "port", srv.port) - if srv.caCert == nil || srv.serverCert == nil { - err := fmt.Errorf("missing CA or server certificate") - slog.Error(err.Error()) - return err - } - - caCertPool := x509.NewCertPool() - caCertPool.AddCert(srv.caCert) - - serverTLSConf := &tls.Config{ - Certificates: []tls.Certificate{*srv.serverCert}, - ClientAuth: tls.VerifyClientCertIfGiven, - ClientCAs: caCertPool, - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: false, - } - logHandler := tint.NewHandler(os.Stdout, &tint.Options{ - AddSource: true, Level: slog.LevelInfo, TimeFormat: "Jan _2 15:04:05.0000"}) - logger := slog.NewLogLogger(logHandler, slog.LevelDebug) - - // handle CORS using the cors plugin - // see also: https://stackoverflow.com/questions/43871637/no-access-control-allow-origin-header-is-present-on-the-requested-resource-whe - // TODO: add configuration for CORS origin: allowed, sameaddress, exact - c := cors.New(cors.Options{ - // return the origin as allowed origin - AllowOriginFunc: func(orig string) bool { - // local requests are always allowed, even over http (for testing) - todo: disable in production - if strings.HasPrefix(orig, "https://127.0.0.1") || strings.HasPrefix(orig, "https://localhost") || - strings.HasPrefix(orig, "http://127.0.0.1") || strings.HasPrefix(orig, "http://localhost") { - slog.Debug("TLSServer.AllowOriginFunc: Cors origin Is True", "origin", orig) - return true - } else if strings.HasPrefix(orig, "https://"+srv.address) { - slog.Debug("TLSServer.AllowOriginFunc: Cors origin Is True", "origin", orig) - return true - } else if orig == "" { - // same-origin is allowed - return true - } - slog.Warn("TLSServer.AllowOriginFunc: Cors: invalid origin:", "origin", orig) - // for testing just warn about invalid origin - return true - }, - // default allowed headers is "Origin", "Accept", "Content-Type", "X-Requested-With" (missing authorization) - AllowedHeaders: []string{"Origin", "Accept", "Content-Type", "Authorization", "Headers"}, - //AllowedHeaders: []string{"*"}, - // default is get/put/patch/post/delete/head - AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}, - Debug: false, - //Debug: true, // the AllowOriginFunc above does the reporting - AllowCredentials: true, - Logger: logger, - }) - handler := c.Handler(srv.router) - - srv.httpServer = &http.Server{ - Addr: fmt.Sprintf("%s:%d", srv.address, srv.port), - // ReadTimeout: 5 * time.Minute, // 5 min to allow for delays when 'curl' on OSx prompts for username/password - // WriteTimeout: 10 * time.Second, - Handler: handler, - TLSConfig: serverTLSConf, - //ErrorLog: log.Default(), - } - l, err := net.Listen("tcp", srv.httpServer.Addr) - if err != nil { - return err - } - // mutex to capture error result in case startup in the background failed - go func() { - // serverTLSConf contains certificate and key - err2 := srv.httpServer.ServeTLS(l, "", "") - //err2 := srv.httpServer.ListenAndServeTLS("", "") - if err2 != nil && !errors.Is(err2, http.ErrServerClosed) { - err = fmt.Errorf("TLS Server start error: %s", err2.Error()) - slog.Error(err.Error()) - } else { - slog.Info("TLSServer stopped") - } - }() - return err -} - -// Stop the TLS server and close all connections -// this waits until for up to 3 seconds for connections are closed. After that -// continue. -func (srv *TLSServer) Stop() { - slog.Info("Stopping TLS server") - - if srv.httpServer != nil { - // note that this does not (cannot?) close existing client connections - ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*3) - err := srv.httpServer.Shutdown(ctx) - if err != nil { - slog.Error("Stop: TLS server graceful shutdown failed. Forcing Remove", "err", err.Error()) - _ = srv.httpServer.Close() - } - cancelFn() - } -} - -// NewTLSServer creates a new TLS MsgServer instance with authentication support. -// This returns the chi-go router which can be used to add routes and middleware. -// This server supports the "message-id" header for received requests. -// -// Use Start() to start listening. -// -// The middleware handlers included with the server can be used for authentication. -// -// address server listening address -// port listening port -// serverCert Server TLS certificate -// caCert CA certificate to verify client certificates -// -// returns TLS server and router for handling requests -func NewTLSServer(address string, port int, - serverCert *tls.Certificate, - caCert *x509.Certificate, -) (*TLSServer, *chi.Mux) { - - srv := &TLSServer{ - caCert: caCert, - serverCert: serverCert, - router: chi.NewRouter(), - } - //// support for CORS response headers - //srv.router.Use(mux.CORSMethodMiddleware(srv.router)) - - srv.address = address - srv.port = port - return srv, srv.router -} diff --git a/lib/servers/tlsserver/TLSServer_test.go b/lib/servers/tlsserver/TLSServer_test.go deleted file mode 100644 index 8cdfeac4..00000000 --- a/lib/servers/tlsserver/TLSServer_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package tlsserver_test - -import ( - "fmt" - "log/slog" - "net/http" - "os" - "testing" - "time" - - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/servers/tlsserver" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var serverAddress string -var serverPort int = 9445 -var clientHostPort string -var testCerts certs.TestCertBundle - -// TestMain runs a http server -// Used for all test cases in this package -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - slog.Info("------ TestMain of TLSServer_test.go ------") - // serverAddress = hubnet.GetOutboundIP("").String() - // use the localhost interface for testing - serverAddress = "127.0.0.1" - - // hostnames := []string{serverAddress} - clientHostPort = fmt.Sprintf("%s:%d", serverAddress, serverPort) - - testCerts = certs.CreateTestCertBundle() - res := m.Run() - - time.Sleep(time.Second) - os.Exit(res) -} - -func TestStartStop(t *testing.T) { - t.Log("TestStartStop") - srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, - testCerts.ServerCert, testCerts.CaCert) - _ = router - err := srv.Start() - assert.NoError(t, err) - srv.Stop() -} - -func TestNoServerCert(t *testing.T) { - t.Log("TestNoServerCert") - srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, - nil, testCerts.CaCert) - _ = router - err := srv.Start() - assert.Error(t, err) -} - -// connect without authentication -func TestNoAuth(t *testing.T) { - t.Log("TestNoAuth") - path1 := "/hello" - path1Hit := 0 - srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, - testCerts.ServerCert, testCerts.CaCert) - router.Get(path1, func(w http.ResponseWriter, req *http.Request) { - // expect no bearer token - bearerToken, err := tlsserver.GetBearerToken(req) - assert.Error(t, err) - assert.Empty(t, bearerToken) - slog.Info("TestNoAuth: path1 hit") - path1Hit++ - }) - - err := srv.Start() - require.NoError(t, err) - defer srv.Stop() - - cl := tlsclient.NewTLSClient(clientHostPort, nil, testCerts.CaCert, time.Second*120) - _, _, err = cl.Get(path1) - assert.NoError(t, err) - assert.Equal(t, 1, path1Hit) - - cl.Close() -} - -func TestTokenAuth(t *testing.T) { - t.Log("TestTokenAuth") - path1 := "/test1" - path1Hit := 0 - //loginID1 := "user1" - token1 := "abcd" - badToken := "badtoken" - - // setup server and client environment - srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, - testCerts.ServerCert, testCerts.CaCert) - //srv.EnableBasicAuth(func(userID, password string) bool { - // path1Hit++ - // return userID == loginID1 && password == password1 - //}) - router.Get(path1, func(w http.ResponseWriter, req *http.Request) { - // expect a bearer token - bearerToken, err := tlsserver.GetBearerToken(req) - assert.NoError(t, err) - if bearerToken == token1 { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusUnauthorized) - } - slog.Info("TestBearerAuth: path1 hit") - path1Hit++ - }) - err := srv.Start() - require.NoError(t, err) - defer srv.Stop() - - // create a client and login - cl := tlsclient.NewTLSClient(clientHostPort, nil, testCerts.CaCert, time.Second*120) - require.NoError(t, err) - defer cl.Close() - cl.SetAuthToken(token1) - - // test the auth with a GET request - _, _, err = cl.Get(path1) - assert.NoError(t, err) - assert.Equal(t, 1, path1Hit) - - // test a failed login - cl.Close() - cl.SetAuthToken(badToken) - _, _, err = cl.Get(path1) - assert.Error(t, err) - assert.Equal(t, 2, path1Hit) // should not increase - -} - -//func TestCertAuth(t *testing.T) { -// path1 := "/hello" -// path1Hit := 0 -// srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, -// testCerts.ServerCert, testCerts.CaCert) -// err := srv.Start() -// assert.NoError(t, err) -// // handler can be added any time -// srv.AddHandler(path1, func(string, http.ResponseWriter, *http.Request) { -// slog.Info("TestAuthCert: path1 hit") -// path1Hit++ -// }) -// -// cl := tlsclient.NewTLSClient(clientHostPort, testCerts.CaCert) -// require.NoError(t, err) -// err = cl.ConnectWithClientCert(testCerts.ClientCert) -// assert.NoError(t, err) -// _, err = cl.Get(path1) -// assert.NoError(t, err) -// assert.Equal(t, 1, path1Hit) -// -// cl.Remove() -// srv.Stop() -//} - -// Test valid authentication using JWT -//func TestQueryParams(t *testing.T) { -// path2 := "/hello" -// path2Hit := 0 -// srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, -// testCerts.ServerCert, testCerts.CaCert) -// err := srv.Start() -// assert.NoError(t, err) -// srv.AddHandler(path2, func(userID string, resp http.ResponseWriter, req *http.Request) { -// // query string -// q1 := srv.GetQueryString(req, "query1", "") -// assert.Equal(t, "bob", q1) -// // fail not a number -// _, err := srv.GetQueryInt(req, "query1", 0) // not a number -// assert.Error(t, err) -// // query of number -// q2, _ := srv.GetQueryInt(req, "query2", 0) -// assert.Equal(t, 3, q2) -// // default should work -// q3 := srv.GetQueryString(req, "query3", "default") -// assert.Equal(t, "default", q3) -// // multiple parameters fail -// _, err = srv.GetQueryInt(req, "multi", 0) -// assert.Error(t, err) -// path2Hit++ -// }) -// -// cl := tlsclient.NewTLSClient(clientHostPort, testCerts.CaCert) -// require.NoError(t, err) -// err = cl.ConnectWithClientCert(testCerts.ClientCert) -// assert.NoError(t, err) -// -// _, err = cl.Get(fmt.Sprintf("%s?query1=bob&query2=3&multi=a&multi=b", path2)) -// assert.NoError(t, err) -// assert.Equal(t, 1, path2Hit) -// -// cl.Remove() -// srv.Stop() -//} - -func TestWriteResponse(t *testing.T) { - t.Log("TestWriteResponse") - path2 := "/hello" - message := "hello world" - path2Hit := 0 - srv, router := tlsserver.NewTLSServer(serverAddress, serverPort, - testCerts.ServerCert, testCerts.CaCert) - err := srv.Start() - assert.NoError(t, err) - defer srv.Stop() - router.Get(path2, func(w http.ResponseWriter, req *http.Request) { - _, _ = w.Write([]byte(message)) - w.WriteHeader(http.StatusOK) - //srv.WriteBadRequest(resp, "bad request") - //srv.WriteInternalError(resp, "internal error") - //srv.WriteNotFound(resp, "not found") - //srv.WriteNotImplemented(resp, "not implemented") - //srv.WriteUnauthorized(resp, "unauthorized") - path2Hit++ - }) - - cl := tlsclient.NewTLSClient(clientHostPort, nil, testCerts.CaCert, time.Second*120) - require.NoError(t, err) - defer cl.Close() - reply, _, err := cl.Get(path2) - assert.NoError(t, err) - assert.Equal(t, 1, path2Hit) - assert.Equal(t, message, string(reply)) -} - -func TestBadPort(t *testing.T) { - t.Log("TestBadPort") - srv, router := tlsserver.NewTLSServer(serverAddress, 1, // bad port - testCerts.ServerCert, testCerts.CaCert) - _ = router - err := srv.Start() - defer srv.Stop() - assert.Error(t, err) -} diff --git a/lib/servers/tlsserver/WriteReply.go b/lib/servers/tlsserver/WriteReply.go deleted file mode 100644 index e433375a..00000000 --- a/lib/servers/tlsserver/WriteReply.go +++ /dev/null @@ -1,63 +0,0 @@ -package tlsserver - -import ( - "errors" - "log/slog" - "net/http" - - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" -) - -// WriteError is a convenience function that logs and writes an error -// If the reply has an error then write a bad request with the error as payload -// This also writes the StatusHeader containing StatusFailed. -func WriteError(w http.ResponseWriter, err error, code int) { - if code == 0 { - code = http.StatusBadRequest - } - if err != nil { - slog.Warn("Request error: ", "err", err.Error()) - http.Error(w, err.Error(), code) - } else { - //replyHeader := w.Header() - //replyHeader.Set(StatusHeader, transports.StatusCompleted) - w.WriteHeader(code) - } -} - -// WriteReply is a convenience function that serializes the data and writes it as a response, -// optionally reporting an error with code BadRequest. -// -// when handled, this returns a 200 status code if no error is returned. -// handled is false means the request is in progress. This returns a 201. -// if an err is returned this returns a 400 bad request or 403 unauthorized error code -// the data can contain error details. -func WriteReply( - w http.ResponseWriter, handled bool, data any, err error) { - var payloadJSON string - - if data != nil { - payloadJSON, _ = jsoniter.MarshalToString(data) - } - if err != nil { - if errors.Is(err, messaging.UnauthorizedError) { - http.Error(w, err.Error(), http.StatusUnauthorized) - } else { - http.Error(w, err.Error(), http.StatusBadRequest) - } - } else if handled { - if payloadJSON != "" { - _, _ = w.Write([]byte(payloadJSON)) - } - // Code 200: https://w3c.github.io/wot-profile/#example-17 - w.WriteHeader(http.StatusOK) - } else { - // not handled no error. response will be async - // Code 201: https://w3c.github.io/wot-profile/#sec-http-sse-profile - w.WriteHeader(201) - if payloadJSON != "" { - _, _ = w.Write([]byte(payloadJSON)) - } - } -} diff --git a/lib/servers/transportmanager/TransportManager.go b/lib/servers/transportmanager/TransportManager.go deleted file mode 100644 index 98a33cce..00000000 --- a/lib/servers/transportmanager/TransportManager.go +++ /dev/null @@ -1,395 +0,0 @@ -package transportmanager - -import ( - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log/slog" - "net/url" - - "github.com/go-chi/chi/v5" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers" - "github.com/hiveot/hub/lib/servers/discoserver" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/hiveot/hub/lib/servers/tlsserver" - "github.com/hiveot/hub/lib/servers/wssserver" -) - -// TransportManager aggregates multiple transport protocol servers and manages -// the connection and session management. -// -// This implements the ITransportBinding interface like the protocols it manages. -// -// Incoming requests and response are passed to the provided handlers. -// To send an asynchronous request or a response use the SendRequest/SendResponse -// methods, or SendNotification to broadcast using the binding's way of handling -// subscriptions. -type TransportManager struct { - - // protocol transport bindings for events, actions and rpc requests - // The embedded binding can be used directly with embedded services - //discoveryTransport *discotransport.DiscoveryTransport - - // authenticator for validating incoming connections and to set the security scheme in TDs. - // currently a single authenticator is used. - // Maybe this should be dependent on the server? - authenticator messaging.IAuthenticator - - //http server - httpServer *tlsserver.TLSServer - httpRouter *chi.Mux - - // http-basic server required for discovery and auth - httpBasicServer *httpbasic.HttpWoTBasicServer - - // transport protocol bindings in order of startup - servers []servers.IMessageServer - // transport protocol bindings by protocol ID - serversByProtocol map[string]servers.IMessageServer - - // Registered handler for processing received notifications - notificationHandler messaging.NotificationHandler - // Registered handler for processing received requests - requestHandler messaging.RequestHandler - // Registered handler for processing received responses - responseHandler messaging.ResponseHandler - // serve TDD discovery, if enabled. This needs a handler for TDD requests - discoServer *discoserver.DiscoveryServer - - // PreferredProtocolType to publish in discovery - PreferredProtocolType string -} - -// AddTDForms adds forms to the given TD for all available transports -// This also adds the security scheme as supported by the authenticator. -func (svc *TransportManager) AddTDForms(tdoc *td.TD, includeAffordances bool) { - - svc.authenticator.AddSecurityScheme(tdoc) - for _, srv := range svc.servers { - srv.AddTDForms(tdoc, includeAffordances) - } - // MQTT - //if svc.mqttTransport != nil { - // svc.mqttTransport.AddTDForms(tdoc,includeAffordances) - //} - - // CoAP ? -} - -// CloseAll closes all client connections -func (svc *TransportManager) CloseAll() { - for _, srv := range svc.servers { - srv.CloseAll() - } -} - -// CloseAllClientConnections close all connections from the given client. -// Intended to close connections after a logout. -func (svc *TransportManager) CloseAllClientConnections(clientID string) { - for _, srv := range svc.servers { - srv.CloseAllClientConnections(clientID) - } -} - -// GetConnectURL returns URL of the protocol. -// If protocolType is empty then the 'preferred' protocol type is used. -// This returns an empty URL if the protocol is not supported. -func (svc *TransportManager) GetConnectURL() (connectURL string) { - protocolType := svc.PreferredProtocolType - srv, found := svc.serversByProtocol[protocolType] - if found { - return srv.GetConnectURL() - } - return "" -} - -// GetConnectionByClientID returns the first connection belonging to the given clientID. -// Intended to send requests to an agent which only have a single connection. -// If a protocol isn't available the default https url is returned -func (svc *TransportManager) GetConnectionByClientID(clientID string) messaging.IConnection { - for _, srv := range svc.servers { - c := srv.GetConnectionByClientID(clientID) - if c != nil { - return c - } - } - return nil -} - -// GetConnectionByConnectionID returns the connection of the given ID -// If a protocol isn't available the default https url is returned -func (svc *TransportManager) GetConnectionByConnectionID(clientID, cid string) messaging.IConnection { - for _, srv := range svc.servers { - c := srv.GetConnectionByConnectionID(clientID, cid) - if c != nil { - return c - } - } - return nil -} - -// GetHiveotEndpoints return available endpoints -func (svc *TransportManager) GetHiveotEndpoints() map[string]string { - endpoints := make(map[string]string) - for _, s := range svc.servers { - connectURL := s.GetConnectURL() - parts, _ := url.Parse(connectURL) - endpoints[parts.Scheme] = connectURL - } - return endpoints -} - -// GetProtocolType returns the preferred protocol -func (svc *TransportManager) GetProtocolType() string { - pref := svc.PreferredProtocolType - s := svc.serversByProtocol[pref] - if s == nil { - return "" - } - return s.GetProtocolType() -} - -// GetServer returns the server for the given protocol type -// This returns nil if the protocol is not active. -func (svc *TransportManager) GetServer(protocolType string) servers.IMessageServer { - s := svc.serversByProtocol[protocolType] - return s -} - -// Pass incoming notifications from any of the transport protocols to the registered handler -func (svc *TransportManager) handleNotification(notif *messaging.NotificationMessage) { - if svc.notificationHandler != nil { - svc.notificationHandler(notif) - } -} - -// Pass incoming requests from any of the transport protocols to the registered handler -func (svc *TransportManager) handleRequest( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - if svc.requestHandler != nil { - return svc.requestHandler(req, c) - } - slog.Error("Received request but no request handler is set in the TransportManager", - "senderID", req.SenderID, "operation", req.Operation) - err := errors.New("no request handler set") - return req.CreateResponse(nil, err) -} - -// Pass incoming responses from any of the transport protocols to the registered handler -func (svc *TransportManager) handleResponse(resp *messaging.ResponseMessage) error { - if svc.responseHandler != nil { - return svc.responseHandler(resp) - } - return errors.New("No response handler set") -} - -// SendNotification broadcast an event or property change to subscribers clients -func (svc *TransportManager) SendNotification(notification *messaging.NotificationMessage) { - // pass it to protocol servers to use their way of sending messages to subscribers - // CloseAllClientConnections close all connections from the given client. - // Intended to close connections after a logout. - for _, srv := range svc.servers { - srv.SendNotification(notification) - } -} - -// StartDiscovery starts the introduction and exploration discovery of the directory -// TD document. -// -// instanceName alternate service instance, "" is hiveot. Intended for testing. -// -// This serves two discovery mechanisms. -// 1. The WoT discovery through the directory TD and Thing forms -// 2. The HiveOT discovery through the use of connection URLs and Request/Response -// message envelopes without the need for forms. -func (svc *TransportManager) StartDiscovery(instanceName string, tdPath string, dirTD string) (err error) { - if svc.discoServer != nil { - err = fmt.Errorf("StartDiscovery: already running") - slog.Error(err.Error()) - return err - } - // Get a list of hiveot endpoints for secondary discovery - endpoints := svc.GetHiveotEndpoints() - - // start directory introduction and exploration discovery server - svc.discoServer, err = discoserver.StartDiscoveryServer( - instanceName, "", dirTD, tdPath, - svc.httpBasicServer, endpoints) - return err -} - -// Stop the protocol servers in reverse order -func (svc *TransportManager) Stop() { - if svc.discoServer != nil { - svc.discoServer.Stop() - svc.discoServer = nil - } - for i := len(svc.servers) - 1; i >= 0; i-- { - srv := svc.servers[i] - srv.Stop() - } - svc.servers = nil - svc.serversByProtocol = nil - svc.httpServer.Stop() - // mqtt - //if svc.mqttTransport != nil { - // svc.mqttTransport.Stop() - //} - //if svc.embeddedTransport != nil { - // svc.embeddedTransport.Stop() - //} - slog.Info("Runtime transport stopped") - -} - -// Start starts the transport protocol manager and listens for incoming connections and messages. -// -// This returns an error if any server fails to start. -// -// Discovery has to be started separately with StartDiscovery, if desired, and must -// be provided with the Directory TD document to serve. -// -// The transport manager implements the ITransportBinding API. -func (svc *TransportManager) Start() (err error) { - slog.Info("Start listening for requests") - - err = svc.httpServer.Start() - if err != nil { - return err - } - - // TODO: on error stop all servers that were already started - for _, srv := range svc.servers { - err = srv.Start() - if err != nil { - return err - } - } - - return err -} - -// NewTransportManager creates a new instance of the transport protocol manager. -// The transport manager implements the ITransportBinding API. -// -// The authenticator is used to validate connections and set security scheme in AddTDForms. -func NewTransportManager(cfg *servers.ProtocolsConfig, - serverCert *tls.Certificate, - caCert *x509.Certificate, - authenticator messaging.IAuthenticator, - notifHandler messaging.NotificationHandler, - reqHandler messaging.RequestHandler, - respHandler messaging.ResponseHandler, -) (svc *TransportManager) { - - svc = &TransportManager{ - //serverCert: serverCert, - //caCert: caCert, - authenticator: authenticator, - servers: make([]servers.IMessageServer, 0), - serversByProtocol: make(map[string]servers.IMessageServer), - notificationHandler: notifHandler, - requestHandler: reqHandler, - responseHandler: respHandler, - //PreferredProtocolType: transports.ProtocolTypeHTTPBasic, - //dtwService: dtwService, - } - // the embedded transport protocol is required for the runtime - // Embedded services are: authn, authz, directory, inbox, outbox services - //svc.embeddedTransport = embedded.StartEmbeddedBinding() - - // Http-basic is needed for all subprotocols and for the auth endpoint - if cfg.EnableHiveotAuth || cfg.EnableWSS || cfg.EnableHiveotSSE { - - svc.PreferredProtocolType = messaging.ProtocolTypeHTTPBasic - - // 1.A HTTP server required for http-basic - // if host is empty then listen on all interfaces - svc.httpServer, svc.httpRouter = tlsserver.NewTLSServer( - cfg.HttpHost, cfg.HttpsPort, serverCert, caCert) - - httpAddr := fmt.Sprintf("%s:%d", cfg.HttpHost, cfg.HttpsPort) - if cfg.HttpHost == "" { - connectIP := utils.GetOutboundIP("") - httpAddr = fmt.Sprintf("%s:%d", connectIP.String(), cfg.HttpsPort) - } - - // message converter is baked into the http basic protocol - svc.httpBasicServer = httpbasic.NewWoTHttpBasicServer( - httpAddr, svc.httpRouter, - authenticator, - svc.handleNotification, - svc.handleRequest, - svc.handleResponse) - if cfg.EnableHttpStatic { - svc.httpBasicServer.EnableStatic(cfg.HttpStaticBase, cfg.HttpStaticDirectory) - } - - // http-basic provides the login method - svc.authenticator.SetAuthServerURI(svc.httpBasicServer.GetAuthServerURI()) - - // FIXME: routes only available after start - protectedRouter := svc.httpBasicServer.GetProtectedRouter() - - //err = err2 - svc.serversByProtocol[messaging.ProtocolTypeHTTPBasic] = svc.httpBasicServer - svc.servers = append(svc.servers, svc.httpBasicServer) - - // 2. HiveOT HTTP/SSE-SC sub-protocol - if cfg.EnableHiveotSSE { - ssePath := hiveotsseserver.DefaultHiveotSsePath - if cfg.HiveotSSEPath != "" { - ssePath = cfg.HiveotSSEPath - } - hiveotSseServer := hiveotsseserver.NewHiveotSseServer( - httpAddr, ssePath, protectedRouter, - nil, - svc.handleNotification, - svc.handleRequest, - svc.handleResponse) - svc.serversByProtocol[messaging.ProtocolTypeHiveotSSE] = hiveotSseServer - svc.servers = append(svc.servers, hiveotSseServer) - // sse is better than http-basic - svc.PreferredProtocolType = messaging.ProtocolTypeHiveotSSE - } - - // 3. WSS protocol - if cfg.EnableWSS { - wssPath := wssserver.DefaultWssPath - if cfg.WSSPath != "" { - wssPath = cfg.WSSPath - } - hiveotWssServer := wssserver.NewWssServer( - httpAddr, wssPath, protectedRouter, - //svc.httpBasicServer, - nil, - svc.handleNotification, - svc.handleRequest, - svc.handleResponse, - ) - - svc.serversByProtocol[messaging.ProtocolTypeWSS] = hiveotWssServer - svc.servers = append(svc.servers, hiveotWssServer) - - // wss is better than http, sse or wot wss - svc.PreferredProtocolType = messaging.ProtocolTypeWSS - } - } - //if cfg.EnableMQTT { - // converter := &converters.MqttMessageConverter{} - //svc.mqttsTransport, err = mqttserver.StartMqttTransportServer( - // cfg.MqttHost, cfg.MqttTcpPort, cfg.MqttWssPort, - // serverCert, caCert, - // authenticator, cm, converter, - // handleRequest, - // handleResponse, - //) - //svc.servers = append(svc.servers, svc.mqttsTransport) - //} - return svc -} diff --git a/lib/servers/wssserver/WssForms.go b/lib/servers/wssserver/WssForms.go deleted file mode 100644 index 3011fb5e..00000000 --- a/lib/servers/wssserver/WssForms.go +++ /dev/null @@ -1,65 +0,0 @@ -package wssserver - -import ( - "github.com/hiveot/hivekit/go/api/td" -) - -// AddTDForms adds forms for use of this protocol to the given TD. -// -// Since the contentType is the default application/json it is omitted -// -// 'includeAffordances' adds forms to all affordances to be compliant with the specifications. -// This is a massive waste of space in the TD. -func (srv *WssServer) AddTDForms(tdoc *td.TD, includeAffordances bool) { - - // 1 form for all operations - form := td.NewForm("", srv.GetConnectURL(), SubprotocolWSS) - form["op"] = []string{ - td.OpQueryAllActions, - td.OpObserveAllProperties, td.OpUnobserveAllProperties, - td.OpReadAllProperties, // td.OpWriteMultipleProperties, - td.OpSubscribeAllEvents, td.OpUnsubscribeAllEvents, - } - //form["contentType"] = "application/json" - - tdoc.Forms = append(tdoc.Forms, form) - - // Add forms to all affordances to be compliant with the specifications. - // This is a massive waste of space in the TD. - if includeAffordances { - srv.AddAffordanceForms(tdoc) - } -} - -// AddAffordanceForms adds forms to affordances for interacting using the websocket protocol binding -func (srv *WssServer) AddAffordanceForms(tdoc *td.TD) { - href := srv.GetConnectURL() - for name, aff := range tdoc.Actions { - _ = name - form := td.NewForm("", href, SubprotocolWSS) - form["op"] = []string{td.OpInvokeAction, td.OpQueryAction} - aff.AddForm(form) - // cancel action is currently not supported - } - for name, aff := range tdoc.Events { - _ = name - form := td.NewForm("", href, SubprotocolWSS) - form["op"] = []string{td.OpSubscribeEvent, td.OpUnsubscribeEvent} - aff.AddForm(form) - } - for name, aff := range tdoc.Properties { - _ = name - form := td.NewForm("", href, SubprotocolWSS) - ops := []string{} - if !aff.WriteOnly { - ops = append(ops, td.OpReadProperty, td.OpObserveProperty, td.OpUnobserveProperty) - } - if !aff.ReadOnly { - ops = append(ops, td.OpWriteProperty) - } - - form["op"] = ops - aff.AddForm(form) - - } -} diff --git a/lib/servers/wssserver/WssServer.go b/lib/servers/wssserver/WssServer.go deleted file mode 100644 index 7e551fcc..00000000 --- a/lib/servers/wssserver/WssServer.go +++ /dev/null @@ -1,214 +0,0 @@ -package wssserver - -import ( - "fmt" - "log/slog" - "net/http" - "sync" - - "github.com/go-chi/chi/v5" - "github.com/gorilla/websocket" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/messaging/converters" - "github.com/hiveot/hub/lib/servers/connections" - "github.com/hiveot/hub/lib/servers/httpbasic" -) - -const ( - DefaultWssPath = "/wot/wss" - SubprotocolWSS = "websocket" - WssSchema = "wss" -) - -// WssServer is a websocket transport protocol server for use with HiveOT and WoT -// messages. -// -// Use AddEndpoint to add a service endpoint to listen on and a corresponding message converter. -// -// While intended for the Hub, it can also be used in stand-alone Things that -// run their own servers. An https server is required. -// -// The difference with the WoT Websocket protocol is that it transport the Request -// and Response messages directly as-is, using JSON encoding. -// -// Connections support event subscription and property observe requests, and sends -// updates as Responses with the subscription correlationID. -type WssServer struct { - - // manage the incoming connections - cm *connections.ConnectionManager - - // the connection URL for this websocket server - connectURL string - - // The router to register with - router chi.Router - - // registered handler to notify of incoming connections - serverConnectHandler messaging.ConnectionHandler - - // registered handler of incoming notifications - serverNotificationHandler messaging.NotificationHandler - // registered handler of incoming requests (which return a reply) - serverRequestHandler messaging.RequestHandler - // registered handler of incoming responses (which sends a reply to the request sender) - serverResponseHandler messaging.ResponseHandler - - // Conversion between websocket messages and the standard hiveot message envelope. - messageConverter messaging.IMessageConverter - - // mutex for updating cm - mux sync.RWMutex - - // listening path for incoming connections - wssPath string -} - -func (srv *WssServer) CloseAll() { - srv.cm.CloseAll() -} - -// CloseAllClientConnections close all cm from the given client. -// Intended to close cm after a logout. -func (srv *WssServer) CloseAllClientConnections(clientID string) { - srv.cm.ForEachConnection(func(c messaging.IServerConnection) { - cinfo := c.GetConnectionInfo() - if cinfo.ClientID == clientID { - c.Disconnect() - } - }) -} - -// GetConnectURL returns websocket connection URL of the server -func (srv *WssServer) GetConnectURL() string { - return srv.connectURL -} - -// GetConnectionByConnectionID returns the connection with the given connection ID -func (srv *WssServer) GetConnectionByConnectionID(clientID, cid string) messaging.IConnection { - return srv.cm.GetConnectionByConnectionID(clientID, cid) -} - -// GetConnectionByClientID returns the connection with the given client ID -func (srv *WssServer) GetConnectionByClientID(agentID string) messaging.IConnection { - return srv.cm.GetConnectionByClientID(agentID) -} - -// GetProtocolType returns the protocol type of this server -func (srv *WssServer) GetProtocolType() string { - return messaging.ProtocolTypeWSS -} - -// SendNotification sends a property update or event response message to subscribers -func (srv *WssServer) SendNotification(msg *messaging.NotificationMessage) { - // pass the response to all subscribed cm - srv.cm.ForEachConnection(func(c messaging.IServerConnection) { - _ = c.SendNotification(msg) - }) -} - -// Serve a new websocket connection. -// This creates an instance of the HiveotWSSConnection handler for reading and -// writing messages. -// -// This doesn't return until the connection is closed by either client or server. -// -// serverRequestHandler and serverResponseHandler are used as handlers for incoming -// messages. -func (srv *WssServer) Serve(w http.ResponseWriter, r *http.Request) { - //An active session is required before accepting the request. This is created on - //authentication/login. Until then SSE cm are blocked. - clientID, err := httpbasic.GetClientIdFromContext(r) - - slog.Info("Receiving Websocket connection", slog.String("clientID", clientID)) - - if err != nil { - slog.Warn("Serve. No clientID", - "remoteAddr", r.RemoteAddr) - errMsg := "no auth session available. Login first." - http.Error(w, errMsg, http.StatusUnauthorized) - return - } - - // upgrade and validate the connection - var upgrader = websocket.Upgrader{} // use default options - wssConn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - slog.Warn("Serve. Connection upgrade failed", - "clientID", clientID, "err", err.Error()) - return - } - - c := NewWSSServerConnection(clientID, r, wssConn, srv.messageConverter) - c.SetNotificationHandler(srv.serverNotificationHandler) - c.SetRequestHandler(srv.serverRequestHandler) - c.SetResponseHandler(srv.serverResponseHandler) - - err = srv.cm.AddConnection(c) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - // don't return until the connection is closed - c.ReadLoop(r.Context(), wssConn) - - // if this fails then the connection is already closed (CloseAll) - err = wssConn.Close() - _ = err - // finally cleanup the connection - srv.cm.RemoveConnection(c) - if srv.serverConnectHandler != nil { - srv.serverConnectHandler(false, nil, c) - } -} - -// Start listening for incoming SSE connections -func (srv *WssServer) Start() error { - slog.Info("Starting websocket server, Listening on: " + srv.GetConnectURL()) - // TODO: detect if already listening - srv.router.Get(srv.wssPath, srv.Serve) - return nil -} - -// Stop disconnects clients and remove connection listening -func (srv *WssServer) Stop() { - srv.CloseAll() - srv.router.Delete(srv.wssPath, srv.Serve) -} - -// NewWssServer returns a new websocket protocol server. Use Start() to activate routes. -// -// The given message converter maps between the underlying websocket message and the -// hiveot Request/ResponseMessage envelopes. -// -// connectAddr is the host:port of the webserver -// wsspath is the path of the websocket endpoint that will listen on the server -// router is the protected route that serves websocket on the wssPath -// handleConnect optional callback for each websocket connection -// handleNotification optional callback to invoke when a notification message is received -// handleRequesst optional callback to invoke when a request message is received -// handleResponse optional callback to invoke when a response message is received -func NewWssServer( - connectAddr string, - wssPath string, - router chi.Router, - handleConnect messaging.ConnectionHandler, - handleNotification messaging.NotificationHandler, - handleRequest messaging.RequestHandler, - handleResponse messaging.ResponseHandler, -) *WssServer { - - connectURL := fmt.Sprintf("%s://%s%s", WssSchema, connectAddr, wssPath) - srv := &WssServer{ - connectURL: connectURL, - serverConnectHandler: handleConnect, - serverNotificationHandler: handleNotification, - serverRequestHandler: handleRequest, - serverResponseHandler: handleResponse, - messageConverter: converters.NewWssMessageConverter(), - cm: connections.NewConnectionManager(), - router: router, - wssPath: wssPath, - } - return srv -} diff --git a/lib/servers/wssserver/WssServerConnection.go b/lib/servers/wssserver/WssServerConnection.go deleted file mode 100644 index 2efce17b..00000000 --- a/lib/servers/wssserver/WssServerConnection.go +++ /dev/null @@ -1,375 +0,0 @@ -package wssserver - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" - jsoniter "github.com/json-iterator/go" - "github.com/teris-io/shortid" -) - -type WSSMessage map[string]any - -// WssServerConnection is the server side instance of a connection by a client. -// This implements the IServerConnection interface for sending messages to -// agent or consumers. -type WssServerConnection struct { - // Connection information such as clientID, cid, address, protocol etc - cinfo messaging.ConnectionInfo - - // connection ID - //connectionID string - - // clientID is the account ID of the agent or consumer - //clientID string - - // connection request remote address - httpReq *http.Request - - isConnected atomic.Bool - - // track last used time to auto-close inactive cm - lastActivity time.Time - - // mutex for controlling writing and closing - mux sync.RWMutex - - // handler for notifications sent by agents - notificationHandlerPtr atomic.Pointer[messaging.NotificationHandler] - // handler for requests send by clients - requestHandlerPtr atomic.Pointer[messaging.RequestHandler] - // handler for responses sent by agents - responseHandlerPtr atomic.Pointer[messaging.ResponseHandler] - // notify client of a connect or disconnect - connectionHandlerPtr atomic.Pointer[messaging.ConnectionHandler] - - // event subscriptions and property observations by consumers - observations connections.Subscriptions - subscriptions connections.Subscriptions - - // converter for request/response messages - messageConverter messaging.IMessageConverter - - // underlying websocket connection - wssConn *websocket.Conn -} - -// _send encodes and sends the websocket message to the connected client -func (sc *WssServerConnection) _send(msg any) (err error) { - - if !sc.isConnected.Load() { - err = fmt.Errorf( - "_send: connection with client '%s' is now closed", sc.cinfo.ClientID) - slog.Warn(err.Error()) - } else { - raw, _ := jsoniter.MarshalToString(msg) - // websockets do not allow concurrent write - sc.mux.Lock() - defer sc.mux.Unlock() - err = sc.wssConn.WriteMessage(websocket.TextMessage, []byte(raw)) - if err != nil { - err = fmt.Errorf("_send write error: %s", err) - } - } - return err -} - -// Disconnect closes the connection and ends the read loop -func (sc *WssServerConnection) Disconnect() { - sc.mux.Lock() - defer sc.mux.Unlock() - if sc.isConnected.Load() { - sc.onConnection(false, nil) - _ = sc.wssConn.Close() - } -} - -// GetConnectionInfo returns the client's connection details -func (sc *WssServerConnection) GetConnectionInfo() messaging.ConnectionInfo { - return sc.cinfo -} - -// IsConnected returns the connection status -func (sc *WssServerConnection) IsConnected() bool { - return sc.isConnected.Load() -} -func (sc *WssServerConnection) onConnection(connected bool, err error) { - sc.isConnected.Store(connected) - chPtr := sc.connectionHandlerPtr.Load() - if chPtr != nil { - (*chPtr)(connected, err, sc) - } -} - -// onMessage handles an incoming websocket message -// The message is converted into a request or response and passed on to the registered handler. -// Messages handled by the transport binding: -// - Ping -// - (Un)ObserveProperty and (Un)ObserveAllProperties -// - (Un)SubscribeEvent and (Un)SubscribeAllEvents -func (sc *WssServerConnection) onMessage(raw []byte) { - var err error - sc.mux.Lock() - sc.lastActivity = time.Now() - sc.mux.Unlock() - var notif *messaging.NotificationMessage - var req *messaging.RequestMessage - var resp *messaging.ResponseMessage - - // both non-agents and agents receive responses - notif = sc.messageConverter.DecodeNotification(raw) - if notif == nil { - resp = sc.messageConverter.DecodeResponse(raw) - if resp == nil { - req = sc.messageConverter.DecodeRequest(raw) - } - } - - if notif != nil { - notif.SenderID = sc.cinfo.ClientID - hPtr := sc.notificationHandlerPtr.Load() - if hPtr == nil { - slog.Error("HandleWssMessage: no notification handler set", - "clientID", sc.cinfo.ClientID, - "operation", notif.Operation, - ) - return - } - // pass the response to the registered handler - (*hPtr)(notif) - } else if resp != nil { - // only agents send responses - resp.SenderID = sc.cinfo.ClientID - rhPtr := sc.responseHandlerPtr.Load() - if rhPtr != nil { - err = (*rhPtr)(resp) - } - } else if req != nil { - // sender is identified by the server, not the client - // note that this field is still useful for services that need to know the sender - req.SenderID = sc.cinfo.ClientID - switch req.Operation { - case td.HTOpPing: - resp = req.CreateResponse("pong", nil) - - case td.OpSubscribeEvent, td.OpSubscribeAllEvents: - sc.subscriptions.Subscribe(req.ThingID, req.Name, req.CorrelationID) - resp = req.CreateResponse(nil, nil) - - case td.OpUnsubscribeEvent, td.OpUnsubscribeAllEvents: - sc.subscriptions.Unsubscribe(req.ThingID, req.Name) - resp = req.CreateResponse(nil, nil) - - case td.OpObserveProperty, td.OpObserveAllProperties: - sc.observations.Subscribe(req.ThingID, req.Name, req.CorrelationID) - resp = req.CreateResponse(nil, nil) - - case td.OpUnobserveProperty, td.OpUnobserveAllProperties: - sc.observations.Unsubscribe(req.ThingID, req.Name) - resp = req.CreateResponse(nil, nil) - default: - rhPtr := sc.requestHandlerPtr.Load() - if rhPtr != nil { - resp = (*rhPtr)(req, sc) - } - } - if resp != nil { - err = sc.SendResponse(resp) - } - } else { - slog.Warn( - "HandleWssMessage: Message is not a notification, request or response") - } - if err != nil { - slog.Warn("Error handling websocket message", "err", err.Error()) - } -} - -// ReadLoop reads incoming websocket messages in a loop, until connection closes or context is cancelled -func (sc *WssServerConnection) ReadLoop(ctx context.Context, wssConn *websocket.Conn) { - - //var readLoop atomic.Bool - sc.onConnection(true, nil) - - // close the client when the context ends drops - go func() { - select { - case <-ctx.Done(): // remote client connection closed - slog.Debug("WssServerConnection.ReadLoop: Remote client disconnected") - // close channel when no-one is writing - // in the meantime keep reading to prevent deadlock - _ = wssConn.Close() - sc.onConnection(false, nil) - } - }() - // read messages from the client until the connection closes - for sc.isConnected.Load() { // sseMsg := range sseChan { - _, raw, err := wssConn.ReadMessage() - if err != nil { - // avoid further writes - sc.onConnection(false, err) - // ending the read loop and returning will close the connection - break - } - // process the message in the background to free up the socket - go sc.onMessage(raw) - } -} - -// SendNotification sends a response to the client if subscribed. -// this is a response to a long-running subscription request -// If this returns an error then no response was sent. -func (sc *WssServerConnection) SendNotification( - notif *messaging.NotificationMessage) (err error) { - - if !strings.HasPrefix(notif.ThingID, "dtw") { - //panic("missing dtw prefix") // for testing - // normally notifications are from digital twins with an ID containing the dtw: prefix - slog.Error("SendNotification: ThingID has no dtw: prefix", - "thingID", notif.ThingID, "senderID", notif.SenderID) - } - switch notif.Operation { - case td.OpSubscribeEvent, td.OpSubscribeAllEvents: - correlationID := sc.subscriptions.GetSubscription(notif.ThingID, notif.Name) - if correlationID != "" { - slog.Info("SendNotification (event subscription)", - slog.String("clientID", sc.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("event name", notif.Name), - ) - msg, _ := sc.messageConverter.EncodeNotification(notif) - err = sc._send(msg) - } - case td.OpObserveProperty, td.OpObserveMultipleProperties, td.OpObserveAllProperties: - correlationID := sc.observations.GetSubscription(notif.ThingID, notif.Name) - if correlationID != "" { - slog.Info("SendNotification (observed property(ies))", - slog.String("clientID", sc.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - msg, _ := sc.messageConverter.EncodeNotification(notif) - err = sc._send(msg) - } - case td.OpInvokeAction: - // action progress update, for original sender only - slog.Info("SendNotification (action status)", - slog.String("clientID", sc.cinfo.ClientID), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - ) - msg, _ := sc.messageConverter.EncodeNotification(notif) - err = sc._send(msg) - default: - slog.Warn("Unknown notification: " + notif.Operation) - } - return err -} - -// SendRequest sends the request to the client (agent). -// -// Intended to be used on cm that are agents for Things and connect to the hub -// as a client (connection reversal). -// If this server is the Thing agent then there is no need for this method. -// -// If this returns an error then no request was sent. -func (sc *WssServerConnection) SendRequest(req *messaging.RequestMessage) error { - msg, err := sc.messageConverter.EncodeRequest(req) - if err == nil { - err = sc._send(msg) - } - return err -} - -// SendResponse sends a response to the remote client. -// If this returns an error then no response was sent. -func (sc *WssServerConnection) SendResponse(resp *messaging.ResponseMessage) (err error) { - - //slog.Info("SendResponse (server->client)", - // slog.String("clientID", sc.cinfo.ClientID), - // slog.String("correlationID", resp.CorrelationID), - // slog.String("operation", resp.Operation), - // slog.String("name", resp.Name), - // slog.String("status", resp.Status), - // slog.String("type", resp.MessageType), - // slog.String("senderID", resp.SenderID), - //) - - msg := sc.messageConverter.EncodeResponse(resp) - err = sc._send(msg) - return err -} - -func (sc *WssServerConnection) SetConnectHandler(cb messaging.ConnectionHandler) { - if cb == nil { - sc.connectionHandlerPtr.Store(nil) - } else { - sc.connectionHandlerPtr.Store(&cb) - } -} - -// SetNotificationHandler set the application handler for received notifications -func (sc *WssServerConnection) SetNotificationHandler(cb messaging.NotificationHandler) { - if cb == nil { - sc.notificationHandlerPtr.Store(nil) - } else { - sc.notificationHandlerPtr.Store(&cb) - } -} -func (sc *WssServerConnection) SetRequestHandler(cb messaging.RequestHandler) { - if cb == nil { - sc.requestHandlerPtr.Store(nil) - } else { - sc.requestHandlerPtr.Store(&cb) - } -} -func (sc *WssServerConnection) SetResponseHandler(cb messaging.ResponseHandler) { - if cb == nil { - sc.responseHandlerPtr.Store(nil) - } else { - sc.responseHandlerPtr.Store(&cb) - } -} - -// NewWSSServerConnection creates a new Websocket connection instance for use by -// agents and consumers. -// This implements the IServerConnection interface. -func NewWSSServerConnection( - clientID string, r *http.Request, - wssConn *websocket.Conn, - messageConverter messaging.IMessageConverter, -) *WssServerConnection { - - cid := "WSS" + shortid.MustGenerate() - - cinfo := messaging.ConnectionInfo{ - CaCert: nil, - ClientID: clientID, - ConnectionID: cid, - ConnectURL: r.URL.String(), - //ProtocolType: messageConverter.GetProtocolType(), - Timeout: 0, - } - c := &WssServerConnection{ - wssConn: wssConn, - cinfo: cinfo, - //clientID: clientID, - messageConverter: messageConverter, - httpReq: r, - lastActivity: time.Time{}, - mux: sync.RWMutex{}, - observations: connections.Subscriptions{}, - subscriptions: connections.Subscriptions{}, - } - return c -} diff --git a/lib/testenv/StartMqttTestServer.go.old b/lib/testenv/StartMqttTestServer.go.old deleted file mode 100644 index 3a369340..00000000 --- a/lib/testenv/StartMqttTestServer.go.old +++ /dev/null @@ -1,30 +0,0 @@ -package testenv - -// -//// StartMqttTestServer creates a MQTT test server. -//func StartMqttTestServer() ( -// msgServer msgserver_old.IMsgServer, -// certBundle certs.TestCertBundle, -// err error) { -// -// certBundle = certs.CreateTestCertBundle() -// -// serverCfg := &mqttmsgserver.MqttServerConfig{ -// Host: "localhost", -// Port: 9990, -// WSPort: 9991, -// CaCert: certBundle.CaCert, -// CaKey: certBundle.CaKey, -// InMemUDSName: mqtttransport.MqttInMemUDSTest, -// LogLevel: "info", -// } -// tmpDir := path.Join(os.TempDir(), "mqtt-testserver") -// _ = os.RemoveAll(tmpDir) -// err = serverCfg.Setup(tmpDir, tmpDir, false) -// if err == nil { -// mqttServer := service.NewMqttMsgServer(serverCfg, authapi.DefaultRolePermissions) -// err = mqttServer.Start() -// msgServer = mqttServer -// } -// return msgServer, certBundle, err -//} diff --git a/lib/testenv/TestServer.go b/lib/testenv/TestServer.go deleted file mode 100644 index 7e70e713..00000000 --- a/lib/testenv/TestServer.go +++ /dev/null @@ -1,366 +0,0 @@ -package testenv - -import ( - "fmt" - "log/slog" - "math/rand" - "os" - "path" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/runtime" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - jsoniter "github.com/json-iterator/go" -) - -// TestDir is the default test directory -var TestDir = path.Join(os.TempDir(), "hiveot-test") - -const TestHttpsPort = 9444 -const TestMqttTcpPort = 9883 -const TestMqttWssPort = 9884 - -const defaultAgentProtocol = messaging.ProtocolTypeWSS - -// const defaultAgentProtocol = messaging.ProtocolTypeHiveotSSE - -const defaultConsumerProtocol = messaging.ProtocolTypeWSS - -// const defaultConsumerProtocol = messaging.ProtocolTypeHiveotSSE - -const defaultServiceProtocol = messaging.ProtocolTypeWSS - -// const defaultServiceProtocol = messaging.ProtocolTypeHiveotSSE - -var testTDs = []struct { - ID string - Title string - DeviceType string - NrEvents int - NrProps int - NrActions int -}{ - {ID: "thing-1", Title: "Environmental Sensor", - DeviceType: vocab.DeviceSensorEnvironment, NrEvents: 1, NrProps: 1, NrActions: 3}, - {ID: "thing-2", Title: "Light Switch", - DeviceType: vocab.DeviceActuatorLight, NrEvents: 2, NrProps: 2, NrActions: 0}, - {ID: "thing-3", Title: "Power meter", - DeviceType: vocab.DeviceMeterElectric, NrEvents: 3, NrProps: 3, NrActions: 1}, - {ID: "thing-4", Title: "Multisensor", - DeviceType: vocab.DeviceSensorMulti, NrEvents: 4, NrProps: 4, NrActions: 2}, - {ID: "thing-5", Title: "Alarm", - DeviceType: vocab.DeviceActuatorAlarm, NrEvents: 2, NrProps: 2, NrActions: 2}, -} - -var PropTypes = []string{vocab.PropDeviceMake, vocab.PropDeviceModel, - vocab.PropDeviceDescription, vocab.PropDeviceFirmwareVersion, vocab.PropLocationCity} -var EventTypes = []string{vocab.PropElectricCurrent, vocab.PropElectricVoltage, - vocab.PropElectricPower, vocab.PropEnvTemperature, vocab.PropEnvPressure} -var ActionTypes = []string{vocab.ActionDimmer, vocab.ActionSwitch, - vocab.ActionSwitchToggle, vocab.ActionValveOpen, vocab.ActionValveClose} - -// TestServer for testing application services. -// Usage: run NewTestServer() followed by Start(clean) -type TestServer struct { - Certs certs.TestCertBundle - TestDir string - ConnectTimeout time.Duration - // - Config *runtime.RuntimeConfig - AppEnv plugin.AppEnvironment - Runtime *runtime.Runtime - // which protocol each client uses - AgentProtocol string - ServiceProtocol string - ConsumerProtocol string -} - -// AddConnectConsumer creates a new test user with the given role and returns a client, -// consumer and a new session token. -// In case of error this panics. -func (test *TestServer) AddConnectConsumer( - clientID string, clientRole authz.ClientRole) (consumer *consumer.Consumer, cc messaging.IClientConnection, token string) { - - password := clientID - err := test.Runtime.AuthnSvc.AdminSvc.AddConsumer(clientID, - authn.AdminAddConsumerArgs{ClientID: clientID, DisplayName: "my name", Password: password}) - if err == nil { - err = test.Runtime.AuthzSvc.SetClientRole(clientID, - authz.AdminSetClientRoleArgs{ClientID: clientID, Role: clientRole}) - } - if err != nil { - panic("Failed adding client:" + err.Error()) - } - - loginArgs := authn.UserLoginArgs{ - ClientID: clientID, - Password: password, - } - token, err = test.Runtime.AuthnSvc.UserSvc.Login(clientID, loginArgs) - - if err != nil { - panic("Failed connect with password:" + err.Error()) - } - - cc, co := test.GetConsumerConnection(clientID, test.ConsumerProtocol) - _ = cc.ConnectWithToken(token) - return co, cc, token -} - -// AddConnectAgent creates a new agent test client. -// Agents use non-session tokens and survive a server restart. -// This returns the agent's connection token. -func (test *TestServer) AddConnectAgent(agentID string) (agent *agent.Agent, cc messaging.IClientConnection, token string) { - - token, err := test.Runtime.AuthnSvc.AdminSvc.AddAgent(agentID, - authn.AdminAddAgentArgs{ClientID: agentID, DisplayName: "agent name"}) - - if err == nil { - err = test.Runtime.AuthzSvc.SetClientRole(agentID, - authz.AdminSetClientRoleArgs{ClientID: agentID, Role: authz.ClientRoleAgent}) - } - if err != nil { - panic("AddConnectAgent: Failed adding client:" + err.Error()) - } - cc, ag := test.GetAgentConnection(agentID, test.AgentProtocol) - - err = cc.ConnectWithToken(token) - if err != nil { - err = fmt.Errorf("AddConnectAgent: Failed connecting using token. "+ - "SenderID='%s': %s", agentID, err.Error()) - panic(err) - } - - return ag, cc, token -} - -// AddConnectService creates a new service test client. -// Services are agents and use non-session tokens and survive a server restart. -// This returns the service's connection token. -// -// This sets the getForm handler of the client to the protocol server. (for testing) -// -// clientType can be one of ClientTypeAgent or ClientTypeService -func (test *TestServer) AddConnectService(serviceID string) (ag *agent.Agent, token string) { - - // 1: register the service - token, err := test.Runtime.AuthnSvc.AdminSvc.AddService(serviceID, - authn.AdminAddServiceArgs{ClientID: serviceID, DisplayName: "service name"}) - if err == nil { - err = test.Runtime.AuthzSvc.SetClientRole(serviceID, - authz.AdminSetClientRoleArgs{ClientID: serviceID, Role: authz.ClientRoleService}) - } - if err != nil { - panic("AddConnectService: Failed adding client:" + err.Error()) - } - // 2: create and connect a client. The getForm is needed for agents to call other services. - s := test.Runtime.TransportsMgr.GetServer(test.ServiceProtocol) - connectURL := s.GetConnectURL() - //server := test.Runtime.TransportsMgr.GetServer(test.ServiceProtocol) - //getForm := server.GetForm - cc, err := clients.NewClient(serviceID, test.Certs.CaCert, connectURL, test.ConnectTimeout) - if err == nil { - err = cc.ConnectWithToken(token) - ag = agent.NewAgent(cc, nil, nil, nil, nil, 0) - } - if err != nil { - panic("AddConnectService: Failed connecting using token. serviceID=" + serviceID) - } - - return ag, token -} - -// AddTD adds a test TD document to the runtime -// if td is nil then a random TD will be added -func (test *TestServer) AddTD(agentID string, td *td.TD) *td.TD { - if td == nil { - i := rand.Intn(99882) - td = test.CreateTestTD(i) - } - tdJSON, _ := jsoniter.MarshalToString(td) - err := test.Runtime.DigitwinSvc.DirSvc.UpdateThing(agentID, tdJSON) - //ag := test.Runtime.TransportsMgr.GetEmbedded().NewClient(agentID) - //err := ag.PubEvent(td.ID, vocab.EventNameTD, string(tdJSON)) - if err != nil { - slog.Error("AddTD: Failed adding TD") - } - return td -} - -// AddTDs adds a bunch of test TD documents -func (test *TestServer) AddTDs(agentID string, count int) { - for i := 0; i < count; i++ { - _ = test.AddTD(agentID, nil) - } -} - -// CreateTestTD returns a test TD with ID "thing-{i}", and a variable -// number of properties, events and actions. -// -// properties are named "prop-{i} -// events are named "event-{i} -// actions are named "action-{i} -// -// The first 10 are predefined and always the same. A higher number generates at random. -// i is the index. -func (test *TestServer) CreateTestTD(i int) (tdi *td.TD) { - ttd := testTDs[0] - if i < len(testTDs) { - ttd = testTDs[i] - } else { - ttd.ID = fmt.Sprintf("thing-%d", rand.Intn(99823)) - } - - tdi = td.NewTD(ttd.ID, ttd.Title, ttd.DeviceType) - // add random properties - for n := 0; n < ttd.NrProps; n++ { - propName := fmt.Sprintf("prop-%d", n) - tdi.AddProperty(propName, "title-"+PropTypes[n], "", td.DataTypeString). - SetAtType(PropTypes[n]) - } - // add random events - for n := 0; n < ttd.NrEvents; n++ { - evName := fmt.Sprintf("event-%d", n) - tdi.AddEvent(evName, "title-"+EventTypes[n], "", - &td.DataSchema{Type: td.DataTypeString}). - SetAtType(EventTypes[n]) - } - // add random actions - for n := 0; n < ttd.NrActions; n++ { - actionName := fmt.Sprintf("action-%d", n) - tdi.AddAction(actionName, "title-"+ActionTypes[n], "", - &td.DataSchema{Type: td.DataTypeString}, - ).SetAtType(ActionTypes[n]) - } - - return tdi -} - -// GetAgentConnection returns a hub connection for an agent and protocol. -// This sets 'getForm' to the handler provided by the protocol server. For testing only. -func (test *TestServer) GetAgentConnection(agentID string, protocolType string) ( - messaging.IClientConnection, *agent.Agent) { - - s := test.Runtime.TransportsMgr.GetServer(protocolType) - connectURL := s.GetConnectURL() - - cc, _ := clients.NewClient(agentID, test.Certs.CaCert, connectURL, test.ConnectTimeout) - ag := agent.NewAgent(cc, nil, nil, nil, nil, test.ConnectTimeout) - return cc, ag -} - -// GetConsumerConnection returns a hub connection for a consumer and protocol. -// This sets 'getForm' to the handler provided by the protocol server. For testing only. -func (test *TestServer) GetConsumerConnection(clientID string, protocolType string) ( - messaging.IClientConnection, *consumer.Consumer) { - // - //getForm := func(op string, thingID string, name string) *td.Form { - // return test.Runtime.GetForm(op, protocolName) - //} - - s := test.Runtime.TransportsMgr.GetServer(protocolType) - connectURL := s.GetConnectURL() - // FIXME: need an option for Wot protocol - cc, _ := clients.NewClient(clientID, test.Certs.CaCert, connectURL, test.ConnectTimeout) - co := consumer.NewConsumer(cc, test.ConnectTimeout) - return cc, co -} - -// GetForm returns the form for the given operation and transport protocol binding -//func (test *TestServer) GetForm(op string, thingID string, name string) *td.Form { -// -// // test default test server consumer protocol (only consumers need forms) -// srv := test.Runtime.TransportsMgr.GetServer(test.ConsumerProtocol) -// return srv.GetForm(op, thingID, name) -//} - -// GetServerURL returns the connection URL to use for the given client type -func (test *TestServer) GetServerURL(clientType authn.ClientType) (serverURL string) { - - protocolType := test.ConsumerProtocol - if clientType == authn.ClientTypeService { - protocolType = test.ServiceProtocol - } else if clientType == authn.ClientTypeAgent { - protocolType = test.AgentProtocol - } - s := test.Runtime.TransportsMgr.GetServer(protocolType) - if s == nil { - return "" - } - return s.GetConnectURL() -} - -// LoginWithPassword helper function to obtain an auth token from a password -func (test *TestServer) LoginWithPassword(loginID string, password string) (string, error) { - token, err := test.Runtime.AuthnSvc.SessionAuth.Login(loginID, password) - return token, err -} - -// Start the test server. -// This panics if something goes wrong. -func (test *TestServer) Start(clean bool) { - //logging.SetLogging("info", "") - - if clean { - _ = os.RemoveAll(test.TestDir) - } - test.AppEnv = plugin.GetAppEnvironment(test.TestDir, false) - // - test.Config.ProtocolsConfig.HttpHost = "localhost" - test.Config.ProtocolsConfig.HttpsPort = TestHttpsPort - test.Config.ProtocolsConfig.MqttHost = "localhost" - test.Config.ProtocolsConfig.MqttTcpPort = TestMqttTcpPort - test.Config.ProtocolsConfig.MqttWssPort = TestMqttWssPort - test.Config.CaCert = test.Certs.CaCert - test.Config.CaKey = test.Certs.CaKey - test.Config.ServerKey = test.Certs.ServerKey - test.Config.ServerCert = test.Certs.ServerCert - - //err := test.Config.Setup(&test.AppEnv) - //if err != nil { - // panic("unable to setup test server config") - //} - test.Runtime = runtime.NewRuntime(test.Config) - err := test.Runtime.Start(&test.AppEnv) - if err != nil { - panic("unable to start test server runtime: " + err.Error()) - } -} - -// Stop the test server -func (test *TestServer) Stop() { - test.Runtime.Stop() -} - -func NewTestServer() *TestServer { - srv := TestServer{ - TestDir: TestDir, - Certs: certs.CreateTestCertBundle(), - Config: runtime.NewRuntimeConfig(), - // change these for running all tests with different protocols - AgentProtocol: defaultAgentProtocol, - ServiceProtocol: defaultServiceProtocol, - ConsumerProtocol: defaultConsumerProtocol, - ConnectTimeout: time.Second * 120, // testing extra long - } - // the test server uses the test instance to differentiate from hiveot - srv.Config.ProtocolsConfig.DiscoveryInstanceName = "test" - return &srv -} - -// StartTestServer creates and starts the test server -// This panics if start fails. -func StartTestServer(clean bool) *TestServer { - srv := NewTestServer() - srv.Start(clean) - return srv -} diff --git a/runtime/README-runtime.md b/runtime/README-runtime.md index 323ed1ad..1408d354 100644 --- a/runtime/README-runtime.md +++ b/runtime/README-runtime.md @@ -2,107 +2,14 @@ ## Status -The runtime is in alpha. It is functional but breaking changes can be expected. +The runtime is being reworked to use hivekit. Detailed progress status below: -### refactor hiveot messaging - complete - -* hiveot messaging can be used separate from digital twin runtime [complete] -* protocols use the request/response messaging API [complete] - * WoT http-basic [complete] - * WoT websocket [spec in development] - * HiveOT websocket [complete] - * HiveOT SSE [complete] -* client and server for supported protocols [complete] -* consumers and agents independent of the connection direction [complete] -* discovery of directory (with hiveot extensions) [complete] - -### digital twin Thing directory [partial] -* CRUD directory [complete] -* compatible with WoT discovery and directory API [in progress] - -### digital twin state management [partial] -* Store Thing and service configuration and state [complete] -* Services can persist their configuration in their digital twin [in progress] - -### authentication [partial] -* token based authentication [complete] -* digest authentication [todo] -* oauth2 authentication [todo] - -### authorization [partial] -* role based authorization [complete] -* service authorization [partial] - * digitwin configuration integration [todo] - -### routing between consumers and digital twin [complete] - -* invoking actions [complete] -* read properties [complete] -* query actions [complete] -* subscribe events [partial] - subscription [complete] - retrieve recent events [todo] -* observing properties [complete] - -### routing between digital twin and agents [partial] - -* invoking actions [complete] -* read properties [complete] -* query actions [complete] -* subscribe events [partial] - subscription [complete] - retrieve recent events [todo] -* observing properties [complete] -* invoking actions - * agent request flow [partial] - * agent response flow [partial] - - -## phase 3 - refactor service plugins [complete] - -Service plugins: -- are extensions to the Hub and integral part of the runtime. -- run in their own process. -- can be started and stopped independently -- are managed through the launcher (also a plugin) -- store their configuration on their digital twin - - publish changed configurations - digitwin stores it - - read from digital twin ? how? different Thing IDs -- publish a TD -- include optional authorization - -* hiveoview [complete] - * internal store for client state (remove state service) [complete] - * publish a TD [partial] - * manage configuration via digital twin [todo] - -* certs - * support letsencrypt [todo] - * publish a TD [todo] - * manage configuration via digital twin [todo] - -* launcher [complete] - * publish a TD [complete] - * manage configuration via digital twin [todo] - * launching on startup - * events for service start/stop/status [todo] - * - -* history [partial] - * store events [complete] - * store property changes [complete] - * store action history [todo] - * store only stateful (unsafe) actions [todo] - * manage configuration via digital twin [todo] - ## Summary -The HiveOT runtime provides routing of events and actions between devices, services and -consumers using one or multiple transport protocols. The runtime serves digital twins of -IoT devices to consumers, containing the device Thing Definition and state. +The HiveOT runtime provides routing of events and actions between devices, services and consumers using one or multiple transport protocols. The runtime serves digital twins of IoT devices to consumers, containing the device Thing Definition and state. The runtime includes services for messaging, authentication and authorization and managing digital twin instances of IoT devices and services. It contains: diff --git a/runtime/Runtime.go b/runtime/Runtime.go deleted file mode 100644 index feec934c..00000000 --- a/runtime/Runtime.go +++ /dev/null @@ -1,294 +0,0 @@ -package runtime - -import ( - "log/slog" - "os" - "path" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/lib/servers/transportmanager" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/service" - authz "github.com/hiveot/hub/runtime/authz/api" - service2 "github.com/hiveot/hub/runtime/authz/service" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/runtime/digitwin/router" - service4 "github.com/hiveot/hub/runtime/digitwin/service" - jsoniter "github.com/json-iterator/go" -) - -// Runtime is the Hub runtime. This is the bare-bone core of the hub that operates the -// communication protocols with services for auth, inbox, outbox and directory. -type Runtime struct { - cfg *RuntimeConfig - - AuthnSvc *service.AuthnService - AuthzSvc *service2.AuthzService - AuthnAgent *service.AuthnAgent - AuthzAgent *service2.AuthzAgent - DigitwinSvc *service4.DigitwinService - DigitwinRouter *router.DigitwinRouter - TransportsMgr *transportmanager.TransportManager - - // logging of request and response messages - requestLogger *slog.Logger - requestLogFile *os.File - - // logging of notification messages - notifLogger *slog.Logger - notifLogFile *os.File - - // logging of all other runtime messages - runtimeLogger *slog.Logger - runtimeLogFile *os.File -} - -// GetForm returns the form for an operation using a transport protocol binding -// These forms point to the use of a digital twin via the hub runtime. -// If the protocol is not found this returns a nil and might cause a panic -//func (r *Runtime) GetForm(op string, protocol string) (f *td.Form) { -// srv := r.TransportsMgr.GetServer(protocol) -// if srv != nil { -// return srv.GetForm(op, "", "") -// } -// return nil -//} - -// GetConnectURL returns the URL for connecting with the given protocol type. -// If the protocol is not available, the https fallback is returned. -func (r *Runtime) GetConnectURL() string { - return r.TransportsMgr.GetConnectURL() -} - -// GetTD returns the TD with the given digitwin ID. -// This passes the request to the digitwin directory store. -// Intended for testing to get a TD. -func (r *Runtime) GetTD(dThingID string) (td *td.TD) { - tdJSON, err := r.DigitwinSvc.DirSvc.RetrieveThing("", dThingID) - if err != nil { - return nil - } - _ = jsoniter.UnmarshalFromString(tdJSON, &td) - return td -} - -// Start the Hub runtime. -// This starts the runtime authn, authz, digitwin and transport services. -func (r *Runtime) Start(env *plugin.AppEnvironment) error { - slog.Warn("Starting HiveOT runtime") - err := r.cfg.Setup(env) - if err != nil { - return err - } - // setup logging sinks - if r.cfg.RuntimeLog != "" { - runtimeLogfileName := path.Join(env.LogsDir, r.cfg.RuntimeLog) - logging.SetLogging(env.LogLevel, runtimeLogfileName) - } - - // startup - - // 1: setup the Authentication service - // This creates an authenticator for authenticating connections and adding security scheme to TD. - // The transport manager calls SetAuthServerURI() to set the authentication endpoint for including in the TD. - r.AuthnSvc, err = service.StartAuthnService(&r.cfg.Authn) - if err != nil { - return err - } - r.AuthnAgent = service.StartAuthnAgent(r.AuthnSvc) - - // 2: start authorization service and add its account - r.AuthzSvc, err = service2.StartAuthzService(&r.cfg.Authz, r.AuthnSvc.AuthnStore) - if err != nil { - return err - } - // provide admin access to the authz agent - prof := authn.ClientProfile{ - ClientID: authz.AdminAgentID, - ClientType: authn.ClientTypeService, - } - _ = r.AuthnSvc.AuthnStore.Add(authz.AdminAgentID, prof) - _ = r.AuthnSvc.AuthnStore.SetRole(authz.AdminAgentID, string(authz.ClientRoleService)) - r.AuthzAgent, err = service2.StartAuthzAgent(r.AuthzSvc) - if err != nil { - return err - } - - // 3: The digitwin service provides a directory for digital twin Things and notifies - // of changes to digital twin state. - prof = authn.ClientProfile{ - ClientID: digitwin.ThingDirectoryAgentID, - ClientType: authn.ClientTypeService, - } - _ = r.AuthnSvc.AuthnStore.Add(digitwin.ThingDirectoryAgentID, prof) - _ = r.AuthnSvc.AuthnStore.SetRole(digitwin.ThingDirectoryAgentID, string(authz.ClientRoleService)) - r.DigitwinSvc, _, err = service4.StartDigitwinService( - env.StoresDir, r.SendNotification, r.cfg.ProtocolsConfig.IncludeForms) - if err != nil { - return err - } - dtwAgent := service4.NewDigitwinAgent(r.DigitwinSvc) - - // 4: The digitwin router receives all incoming requests, responses and notifications - // from agents and consumers. - // - // Digital twin requests are forward to the digital twin service. - // Authentication requests are forwarded to the authn service - // Authorization requests are forwarded to the authz service - // - // Actions for remote Things are forwarded to the Things using the transport - // manager, if set. The transport manager can be set with 'SetTransportServer'. - // - // Internal services are authn,authz and the digital twin directory and value service. - r.DigitwinRouter = router.NewDigitwinRouter( - r.DigitwinSvc, - dtwAgent.HandleRequest, - r.AuthnAgent.HandleRequest, - r.AuthzAgent.HandleAction, - r.AuthzAgent.HasPermission, - nil) - - // 5: Create the transports but do not start yet. - // - // The authenticator provided by the authn service (authnSvc.SessionAuth) is used to - // both authenticate connections and to add the securityScheme to TDs in TransportManager.AddTDForms. - // - // Note if you're trying to find out where the digital twin TDs get their forms and - // security fields then the answer is in TransportManager.AddTDForms. - // On startup the TransportManager is given the AuthnSvc.SessionAuth authenticator and - // it updates it with the login URI. In AddTDForms it uses the authenticator to set the - // security scheme and uses each of the protocols to set the forms for affordances. - // Look for AddTDForms in each protocol. This is a rather roundabout way of getting - // the security scheme in the TD but necessary as it is pluggable. - // See AuthnSvc above - r.TransportsMgr = transportmanager.NewTransportManager( - &r.cfg.ProtocolsConfig, - r.cfg.ServerCert, - r.cfg.CaCert, - r.AuthnSvc.SessionAuth, - r.DigitwinRouter.HandleNotification, - r.DigitwinRouter.HandleRequest, - r.DigitwinRouter.HandleResponse, - ) - // the router needs the transport to forward requests and send responses - r.DigitwinRouter.SetTransportServer(r.TransportsMgr) - - // generated digitwin TDs include forms produced by transport protocols - r.DigitwinSvc.SetFormsHook(r.TransportsMgr.AddTDForms) - - // 6. Setup logging of requests and notifications - if r.cfg.RequestLog != "" { - requestLogfileName := path.Join(env.LogsDir, r.cfg.RequestLog) - r.requestLogger, r.requestLogFile = logging.NewFileLogger( - requestLogfileName, r.cfg.LogfileInJson) - r.DigitwinRouter.SetRequestLogger(r.requestLogger) - } - if r.cfg.NotifLog != "" { - notifLogfileName := path.Join(env.LogsDir, r.cfg.NotifLog) - r.notifLogger, r.notifLogFile = logging.NewFileLogger( - notifLogfileName, r.cfg.LogfileInJson) - r.DigitwinRouter.SetNotifLogger(r.notifLogger) - } - if err != nil { - return err - } - - // 7: Setup the directory with TDs and permissions for agents and consumers - // Add the TDs of the built-in services (authn,authz,directory,values) to the directory - _ = r.DigitwinSvc.DirSvc.UpdateThing(authn.AdminAgentID, authn.AdminTD) - _ = r.DigitwinSvc.DirSvc.UpdateThing(authn.UserAgentID, authn.UserTD) - _ = r.DigitwinSvc.DirSvc.UpdateThing(authz.AdminAgentID, authz.AdminTD) - // The digital twin of the directoryTD will be used by discovery (below) to publish the .well-know/wot - _ = r.DigitwinSvc.DirSvc.UpdateThing(digitwin.ThingDirectoryAgentID, digitwin.ThingDirectoryTD) - _ = r.DigitwinSvc.DirSvc.UpdateThing(digitwin.ThingValuesAgentID, digitwin.ThingValuesTD) - - // set agent permissions to update the directory - // agents can update the directory with their TDs - err = r.AuthzSvc.SetPermissions(digitwin.ThingDirectoryAgentID, authz.ThingPermissions{ - AgentID: digitwin.ThingDirectoryAgentID, - ThingID: digitwin.ThingDirectoryServiceID, - Allow: []authz.ClientRole{authz.ClientRoleAgent}, - }) - if err != nil { - slog.Error("failed SetPermissions. Continuing...", "err", err.Error()) - } - // anyone else can read the directory, except those with no role - err = r.AuthzSvc.SetPermissions(digitwin.ThingDirectoryAgentID, authz.ThingPermissions{ - AgentID: digitwin.ThingDirectoryAgentID, - ThingID: digitwin.ThingDirectoryServiceID, - Deny: []authz.ClientRole{authz.ClientRoleNone}, - }) - if err != nil { - slog.Error("failed SetPermissions. Continuing...", "err", err.Error()) - } - // with the router in place activate the transport server to receive and send messages - err = r.TransportsMgr.Start() - if err != nil { - return err - } - - // 8: last, start discovery and exploration of the digital twin directory - if r.cfg.ProtocolsConfig.EnableDiscovery { - // the directory TD was added above (r.DigitwinSvc.DirSvc.UpdateThing) - dirTDJson, err := r.DigitwinSvc.DirSvc.RetrieveThing(digitwin.ThingDirectoryAgentID, digitwin.ThingDirectoryDThingID) - if err == nil { - protocolsCfg := r.cfg.ProtocolsConfig - err = r.TransportsMgr.StartDiscovery( - r.cfg.ProtocolsConfig.DiscoveryInstanceName, protocolsCfg.DirectoryTDPath, dirTDJson, - ) - } - if err != nil { - slog.Error("failed starting discovery. Continuing anyways...", "err", err.Error()) - } - } - return err -} - -// SendNotification sends an event or property response message to subscribers. -// This simply forwards the notification to the transport manager. -func (r *Runtime) SendNotification(notif *messaging.NotificationMessage) { - r.TransportsMgr.SendNotification(notif) -} - -func (r *Runtime) Stop() { - slog.Warn("Stopping the HiveOT runtime") - // wait a little to allow ongoing connection closure to complete - time.Sleep(time.Millisecond * 10) - //nrConnections, _ := r.CM.GetNrConnections() - - if r.AuthnSvc != nil { - r.AuthnSvc.Stop() - } - if r.AuthzSvc != nil { - r.AuthzSvc.Stop() - } - if r.DigitwinSvc != nil { - r.DigitwinSvc.Stop() - } - if r.TransportsMgr != nil { - r.TransportsMgr.Stop() - } - if r.notifLogFile != nil { - _ = r.notifLogFile.Close() - r.notifLogFile = nil - } - if r.requestLogFile != nil { - _ = r.requestLogFile.Close() - r.requestLogFile = nil - } - if r.runtimeLogFile != nil { - _ = r.runtimeLogFile.Close() - r.runtimeLogFile = nil - } -} - -func NewRuntime(cfg *RuntimeConfig) *Runtime { - r := &Runtime{ - cfg: cfg, - } - return r -} diff --git a/runtime/RuntimeConfig.go b/runtime/RuntimeConfig.go deleted file mode 100644 index d7c46323..00000000 --- a/runtime/RuntimeConfig.go +++ /dev/null @@ -1,309 +0,0 @@ -package runtime - -import ( - "crypto/tls" - "crypto/x509" - "errors" - "fmt" - "log/slog" - "os" - "path" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/lib/servers" - service2 "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authz/service" - "gopkg.in/yaml.v3" -) - -const DefaultServerCertFile = "hubCert.pem" - -// FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key -const DefaultServerKeyFile = "hubKey.pem" - -// RuntimeConfig holds the digital twin runtime and protocol bindings configuration: -// * determines the config, data and certificate storage paths -// * load or generate the CA and server keys and certificates -type RuntimeConfig struct { - - // middleware and services config. These all work out of the box with their defaults. - Authn service2.AuthnConfig `yaml:"authn"` - Authz service.AuthzConfig `yaml:"authz"` - ProtocolsConfig servers.ProtocolsConfig `yaml:"protocols"` - - // Runtime logging - LogLevel string `yaml:"logLevel,omitempty"` // default: warn - //LogFile string `yaml:"logFile,omitempty"` // default: no logfile - - // RequestLog with logging of request and response messages - // file is -.log - RequestLog string `yaml:"requestLog,omitempty"` // default: no log file - // NotifLog filename for logging of notification messages - NotifLog string `yaml:"notifLog,omitempty"` - // RuntimeLog filename for other runtime messages - RuntimeLog string `yaml:"runtimeLog,omitempty"` - - //RequestLogStdout bool `yaml:"requestLogStdout"` // fork to stdout - LogfileInJson bool `yaml:"logfileInJson"` // logfile in json format - - // Runtime data directory for storage of digital twins - DataDir string `yaml:"dataDir,omitempty"` // default is server default - - // certificate file names or full path - CaCertFile string `yaml:"caCertFile"` // default: caCert.pem - // FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key - CaKeyFile string `yaml:"caKeyFile"` // default: caKey.pem - ServerCertFile string `yaml:"serverCertFile"` // default: hubCert.pem - // FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key - ServerKeyFile string `yaml:"serverKeyFile"` // default: hubKey.pem - // The certs and keys to use by the runtime. - // These are set directly on startup. - CaCert *x509.Certificate `yaml:"-"` // preset, load, or error - CaKey keys.IHiveKey `yaml:"-"` // preset, load, or error - ServerKey keys.IHiveKey `yaml:"-"` // generated, loaded (used as signing key) - ServerCert *tls.Certificate `yaml:"-"` // generated - -} - -// SetupCerts load or generate certificates. -// If certificates are preloaded then do nothing. -// If a CA doesn't exist then generate and save a new self-signed cert valid for localhost,127.0.0.1 and outbound IP -// The server certificates is always regenerated and saved. -// This panics if certs cannot be setup. -func (cfg *RuntimeConfig) setupCerts(env *plugin.AppEnvironment) { - var err error - certsDir := env.CertsDir - - // setup files and folders - if _, err = os.Stat(certsDir); err != nil { - if err2 := os.MkdirAll(certsDir, 0755); err2 != nil && errors.Is(err, os.ErrExist) { - errMsg := fmt.Errorf("unable to create certs directory '%s': %w", certsDir, err) - panic(errMsg) - } - } - - caCertPath := cfg.CaCertFile - if !path.IsAbs(caCertPath) { - caCertPath = path.Join(certsDir, caCertPath) - } - caKeyPath := cfg.CaKeyFile - if !path.IsAbs(caKeyPath) { - caKeyPath = path.Join(certsDir, caKeyPath) - } - // 1: load the CA if available - if cfg.CaCert == nil { - slog.Info("loading CA certificate and key") - cfg.CaCert, err = certs.LoadX509CertFromPEM(caCertPath) - } else { - // save the caCert and its key - err = certs.SaveX509CertToPEM(cfg.CaCert, caCertPath) - if err == nil && cfg.CaKey != nil { - err = cfg.CaKey.ExportPrivateToFile(caKeyPath) - } - } - // only load the ca key if the cert was loaded - if cfg.CaCert != nil && cfg.CaKey == nil { - cfg.CaKey, err = keys.NewKeyFromFile(caKeyPath) - } - - // 2: if no CA exists, create a self-signed certificate - if err != nil || cfg.CaCert == nil || cfg.CaKey == nil { - slog.Warn("creating a self-signed CA certificate and key", "caCertPath", caCertPath) - - cfg.CaCert, cfg.CaKey, err = certs.CreateCA("hiveot", 365*10) - if err != nil { - panic("Unable to create a CA cert: " + err.Error()) - } - - err = cfg.CaKey.ExportPrivateToFile(caKeyPath) - if err == nil { - err = certs.SaveX509CertToPEM(cfg.CaCert, caCertPath) - } - if err != nil { - panic("Unable to save the CA cert or key: " + err.Error()) - } - } - - // 3: Load or create a new server private key if it doesn't exist - // As this key is used to sign auth tokens, save it after creation - serverKeyPath := cfg.ServerKeyFile - if !path.IsAbs(serverKeyPath) { - serverKeyPath = path.Join(certsDir, serverKeyPath) - } - - // load the server key if available - newServerKey := false - if cfg.ServerKey == nil { - slog.Info("Loading server key", "serverKeyPath", serverKeyPath) - cfg.ServerKey, err = keys.NewKeyFromFile(serverKeyPath) - } else { - slog.Info("Using provided server key") - err = nil - } - if err != nil || cfg.ServerKey == nil { - newServerKey = true - slog.Warn("Creating server key") - cfg.ServerKey = keys.NewKey(cfg.CaKey.KeyType()) // use same key type as CA - err = cfg.ServerKey.ExportPrivateToFile(serverKeyPath) - if err != nil { - slog.Error("Unable to save the server key", "err", err) - } - } - // load or create a new server cert - serverCertPath := cfg.ServerCertFile - if !path.IsAbs(serverCertPath) { - serverCertPath = path.Join(certsDir, serverCertPath) - } - hostName, _ := os.Hostname() - if cfg.ServerCert == nil || !newServerKey { - cfg.ServerCert, err = certs.LoadTLSCertFromPEM(serverCertPath, serverKeyPath) - if err == nil { - slog.Info("loaded Server certificate and key") - } - } - - if cfg.ServerCert == nil { - serverID := "dtr-" + hostName - ou := "hiveot" - outboundIP := utils.GetOutboundIP("") - names := []string{"localhost", "127.0.0.1", hostName, outboundIP.String()} - - // regenerate a new server cert, valid for 1 year - serverCert, err := certs.CreateServerCert( - serverID, ou, 365, cfg.ServerKey, names, cfg.CaCert, cfg.CaKey) - if err != nil { - panic("Unable to create a server cert: " + err.Error()) - } - cfg.ServerCert = certs.X509CertToTLS(serverCert, cfg.ServerKey) - - slog.Info("Writing server cert", "serverCertPath", serverCertPath) - err = certs.SaveX509CertToPEM(serverCert, serverCertPath) - if err != nil { - slog.Error("writing server cert failed: ", "err", err) - } - } -} - -// setupDirectories creates missing directories -// parameter new deletes existing data directories first. Careful! -func (cfg *RuntimeConfig) setupDirectories(env *plugin.AppEnvironment) error { - - if path.Clean(env.HomeDir) == "/etc" { - return fmt.Errorf("home cannot be /etc") - } else if path.Clean(env.HomeDir) == "/tmp" { - return fmt.Errorf("home cannot be /tmp. Choose a subdir") - } else if path.Clean(path.Dir(env.HomeDir)) == "/home" { - return fmt.Errorf("application home directory cannot be someone's home directory") - } - //if _, err2 := os.Stat(env.HomeDir); err2 == nil && new { - // println("Setup new. Removing certs, stores and logs directories") - // // keep old config as there is no way to re-install defaults - // //_ = os.RemoveAll(env.ConfigDir) - // _ = os.RemoveAll(env.CertsDir) - // _ = os.RemoveAll(env.StoresDir) - // _ = os.RemoveAll(env.LogsDir) - //} - - if _, err2 := os.Stat(env.HomeDir); err2 != nil { - err := os.MkdirAll(env.HomeDir, 0755) - if err != nil { - err = fmt.Errorf("unable to create home directory '%s': %w", env.HomeDir, err) - return err - } - } - // 2. ensure the directories exist - if _, err2 := os.Stat(env.HomeDir); err2 != nil { - _ = os.MkdirAll(env.HomeDir, 0755) - } - if _, err2 := os.Stat(env.BinDir); err2 != nil { - _ = os.MkdirAll(env.BinDir, 0755) - } - if _, err2 := os.Stat(env.PluginsDir); err2 != nil { - _ = os.MkdirAll(env.PluginsDir, 0755) - } - if _, err2 := os.Stat(env.CertsDir); err2 != nil { - _ = os.MkdirAll(env.CertsDir, 0755) - } - if _, err2 := os.Stat(env.ConfigDir); err2 != nil { - _ = os.MkdirAll(env.ConfigDir, 0755) - } - if _, err2 := os.Stat(env.LogsDir); err2 != nil { - _ = os.MkdirAll(env.LogsDir, 0755) - } - if _, err2 := os.Stat(env.StoresDir); err2 != nil { - _ = os.MkdirAll(env.StoresDir, 0755) - } - return nil -} - -// Setup the runtime environment along with certificate and key files. -// This: -// 1. Load the runtime config file if any exists -// 2. Creates missing directories -// 3. Create missing certificates, including a self-signed CA. -// 5. Create auth keys for certs and launcher services -// 6. Create a default launcher config if none exists -// -// env holds the application directory environment -func (cfg *RuntimeConfig) Setup(env *plugin.AppEnvironment) error { - var err error - //logging.SetLogging(cfg.LogLevel, cfg.LogFile) - slog.Info("Digital Twin Runtime setup", - slog.String("home", env.HomeDir), - slog.String("config", env.ConfigFile), - ) - - // 0: Load config file if given - if _, err = os.Stat(env.ConfigFile); err == nil { - data, err := os.ReadFile(env.ConfigFile) - if err != nil { - return fmt.Errorf("unable to load config: %w", err) - } - err = yaml.Unmarshal(data, cfg) - if err != nil { - return fmt.Errorf("unable to parse config: %w", err) - } - } - - // 2: Setup directories - err = cfg.setupDirectories(env) - if err != nil { - return err - } - // 3: Setup certificates - cfg.setupCerts(env) - - // 4: setup authn config - cfg.Authn.Setup(env.CertsDir, env.StoresDir) - - // 5: setup authz config - cfg.Authz.Setup(env.StoresDir) - - // 4. configure protocol bindings - return err -} - -// NewRuntimeConfig returns a new runtime configuration instance with default values -// This can be used out of the box or be loaded from a yaml configuration file. -// -// The CA and Server certificate and keys must be set after creation. -func NewRuntimeConfig() *RuntimeConfig { - cfg := &RuntimeConfig{ - Authn: service2.NewAuthnConfig(), - Authz: service.NewAuthzConfig(), - ProtocolsConfig: servers.NewProtocolsConfig(), - LogLevel: "info", // error, warning, info, debug - NotifLog: "", // no logfile - RequestLog: "", // no request logfile - RuntimeLog: "", // no logfile - - CaCertFile: certs.DefaultCaCertFile, - CaKeyFile: certs.DefaultCaKeyFile, - ServerCertFile: DefaultServerCertFile, - ServerKeyFile: DefaultServerKeyFile, - } - return cfg -} diff --git a/runtime/RuntimeDirectory_test.go b/runtime/RuntimeDirectory_test.go deleted file mode 100644 index 59ae9753..00000000 --- a/runtime/RuntimeDirectory_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package runtime_test - -import ( - "encoding/json" - "fmt" - "io" - "net/url" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/discoserver" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - digitwinapi "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// FIXME: add tests to check that the runtime requests the TDs of newly connected agents - -func TestAddRemoveTD(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - const agentID = "agent1" - const userID = "user1" - const agThing1ID = "thing1" - - r := startRuntime() - defer r.Stop() - - // Create the agent and its TDs to query - td1 := td.NewTD(agThing1ID, "Title", vocab.DeviceSensorMulti) - td1JSON, _ := jsoniter.MarshalToString(td1) - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - - // Create the consumer - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // Add the TD - err := digitwinapi.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - assert.NoError(t, err) - - // Get returns a serialized TD object - // use the helper directory client rpc method - var dtThing1ID = digitwin.MakeDigitwinID(agentID, agThing1ID) - td3JSON, err := digitwinapi.ThingDirectoryRetrieveThing(ag1.Consumer, dtThing1ID) - require.NoError(t, err) - var td3 td.TD - err = jsoniter.UnmarshalFromString(td3JSON, &td3) - require.NoError(t, err) - assert.Equal(t, dtThing1ID, td3.ID) - - // RemoveTD from the directory - err = co1.Rpc(td.OpInvokeAction, digitwinapi.ThingDirectoryDThingID, - digitwinapi.ThingDirectoryDeleteThingMethod, dtThing1ID, nil) - require.NoError(t, err) - - // after removal of the TD, getTD should return an error - t.Log("Reading non-existing TD should fail") - td4Json, err := digitwinapi.ThingDirectoryRetrieveThing(co1, dtThing1ID) - require.Error(t, err) - require.Empty(t, td4Json) -} - -func TestReadTDs(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - const agentID = "agent1" - const userID = "user1" - //const agThing1ID = "thing1" - //var dtThing1ID = things.MakeDigiTwinThingID(agentID, agThing1ID) - - r := startRuntime() - defer r.Stop() - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // add a whole bunch of things - ts.AddTDs(agentID, 1200) - - //td1JSON, _ := json.Marshal(td1) - //stat := ag.PubEvent(agThing1ID, vocab.EventNameTD, td1JSON) - //assert.Empty(t, stat.Error) - - // GetThings returns a serialized TD object - // 1. Use actions - args := digitwinapi.ThingDirectoryRetrieveAllThingsArgs{Limit: 10} - tdList1 := []string{} - err := co1.Rpc(td.OpInvokeAction, digitwinapi.ThingDirectoryDThingID, - digitwinapi.ThingDirectoryRetrieveAllThingsMethod, args, &tdList1) - require.NoError(t, err) - require.True(t, len(tdList1) > 0) - - // 2. Try it the easy way using the generated client code - tdList2, err := digitwinapi.ThingDirectoryRetrieveAllThings(co1, 333, 02) - require.NoError(t, err) - require.True(t, len(tdList2) > 0) - - // the TD must now have forms - tdi := td.TD{} - err = json.Unmarshal([]byte(tdList2[20]), &tdi) - require.NoError(t, err) - for name, aff := range tdi.Properties { - _ = name - require.True(t, len(aff.Forms) > 0) - form := aff.Forms[0] - assert.NotEmpty(t, form.GetOperation()) - assert.NotEmpty(t, form.GetHRef()) - } - for name, aff := range tdi.Actions { - _ = name - require.True(t, len(aff.Forms) > 0) - form := aff.Forms[0] - assert.NotEmpty(t, form.GetOperation()) - assert.NotEmpty(t, form.GetHRef()) - } - for name, aff := range tdi.Events { - _ = name - require.True(t, len(aff.Forms) > 0) - form := aff.Forms[0] - assert.NotEmpty(t, form.GetOperation()) - assert.NotEmpty(t, form.GetHRef()) - } -} - -func TestReadTDsRest(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - const agentID = "agent1" - const userID = "user1" - - r := startRuntime() - defer r.Stop() - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - co1, _, token := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // add a whole bunch of things - ts.AddTDs(agentID, 100) - - serverURL := ts.GetServerURL(authn.ClientTypeConsumer) - // FIXME: use the consumer protocol - urlParts, _ := url.Parse(serverURL) - cl2 := tlsclient.NewTLSClient(urlParts.Host, nil, ts.Certs.CaCert, time.Second*30) - cl2.SetAuthToken(token) - - tdJSONList, err := digitwinapi.ThingDirectoryRetrieveAllThings(co1, 100, 0) - require.NoError(t, err) - - // tds are sent as an array of JSON, first unpack the array of JSON strings - tdList, err := td.UnmarshalTDList(tdJSONList) - require.NoError(t, err) - require.Equal(t, 100, len(tdList)) // 100 is the given limit - - // check reading a single td - tdJSON, err := digitwinapi.ThingDirectoryRetrieveThing(co1, tdList[0].ID) - require.NoError(t, err) - require.NotEmpty(t, tdJSON) -} - -func TestTDEvent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - const agentID = "agent1" - const userID = "user1" - tdCount := atomic.Int32{} - - r := startRuntime() - defer r.Stop() - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - cl1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer cl1.Disconnect() - - // wait to directory TD updated events - notifHandler := func(msg *messaging.NotificationMessage) { - if msg.Operation == td.OpSubscribeEvent && - msg.ThingID == digitwinapi.ThingDirectoryDThingID && - msg.Name == digitwinapi.ThingDirectoryEventThingUpdated { - - // decode the TD - tdi := td.TD{} - payload := msg.ToString(0) - err := jsoniter.UnmarshalFromString(payload, &tdi) - assert.NoError(t, err) - tdCount.Add(1) - } - } - cl1.SetNotificationHandler(notifHandler) - err := cl1.Subscribe("", "") - require.NoError(t, err) - defer cl1.Unsubscribe("", "") - - time.Sleep(time.Millisecond * 100) - require.NoError(t, err) - - // add a TD and expect an event from the directory - ts.AddTDs(agentID, 1) - time.Sleep(time.Millisecond * 1000) - assert.Equal(t, int32(1), tdCount.Load()) -} - -// Discover the directory on the well-known endpoint -func TestDiscoverDirectory(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const userID = "user1" - var dirTD *td.TD - - r := startRuntime() - defer r.Stop() - cl1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer cl1.Disconnect() - - httpSrv := r.TransportsMgr.GetServer(messaging.ProtocolTypeHTTPBasic) - httpSrv.GetConnectURL() - - tdURL := fmt.Sprintf("%s%s", httpSrv.GetConnectURL(), discoserver.DefaultHttpGetDirectoryTDPath) - - http2Cl := tlsclient.NewHttp2TLSClient(ts.Certs.CaCert, nil, time.Second) - resp, err := http2Cl.Get(tdURL) - require.NoError(t, err) - respBody, err := io.ReadAll(resp.Body) - require.NoError(t, err) - - err = json.Unmarshal(respBody, &dirTD) - require.NoError(t, err) - -} diff --git a/runtime/RuntimeValues_test.go b/runtime/RuntimeValues_test.go deleted file mode 100644 index 2dc7a3fd..00000000 --- a/runtime/RuntimeValues_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package runtime_test - -import ( - "log/slog" - "sync/atomic" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQueryActions(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const userID = "user1" - const data = "Hello world" - const actionID = "action-0" - var updateChan1 = make(chan bool) - - r := startRuntime() - defer r.Stop() - // agent receives actions and sends events - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - // consumer sends actions and receives events - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // step 1: agent publishes a TD: dtw:agent1:thing-1 - td1 := ts.CreateTestTD(0) - var dThing1ID = digitwin.MakeDigitwinID(agentID, td1.ID) - - ag1.SetRequestHandler(func(msg *messaging.RequestMessage, - c messaging.IConnection) *messaging.ResponseMessage { - - slog.Info("request: "+msg.Operation, "correlationID", msg.CorrelationID) - return msg.CreateResponse(data, nil) - }) - - co1.SetNotificationHandler(func(msg *messaging.NotificationMessage) { - slog.Info("notification: " + msg.Operation) - // signal notification received - updateChan1 <- true - }) - err := co1.Subscribe("", "") - require.NoError(t, err) - time.Sleep(time.Millisecond) - //err = ag1.UpdateThing(td1) - td1JSON, _ := jsoniter.MarshalToString(td1) - err = digitwin.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - - // step 2: consumer publish an action to the agent it should return as - // a notification. - require.NoError(t, err) - <-updateChan1 - - // this action is recorded - err = co1.InvokeAction(dThing1ID, actionID, data, nil) - require.NoError(t, err) - - // get the latest action values from the thing - // use the API generated from the digitwin TD document using tdd2api - apiActionStatusMap, err := digitwin.ThingValuesQueryAllActions(co1, dThing1ID) - require.NoError(t, err) - // this should be convertable to the messaging.ActionStatus map - var msgActionStatusMap map[string]messaging.ActionStatus - err = utils.Decode(apiActionStatusMap, &msgActionStatusMap) - require.NoError(t, err) - - // value must match that of the action in step 1 and match its correlationID - actVal := msgActionStatusMap[actionID] - assert.Equal(t, data, actVal.Input) - assert.Equal(t, messaging.StatusCompleted, actVal.State) - - // last, use consumer to query actions - actionMap2, err := co1.QueryAllActions(dThing1ID) - require.NoError(t, err) - assert.Equal(t, len(msgActionStatusMap), len(actionMap2)) -} - -// Get events from the outbox using the experimental http REST api -func TestReadEvents(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const key1 = "key1" - const userID = "user1" - const data = "Hello world" - - r := startRuntime() - defer r.Stop() - - // agent for publishing events - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - // consumer for reading events - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // step 1: agent publishes a TD first: dtw:agent1:thing-1 - // is requested. hiveot uses it to determine if a response is required. - td1 := ts.CreateTestTD(0) - //err := ag1.UpdateThing(td1) - td1JSON, _ := jsoniter.MarshalToString(td1) - err := digitwin.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - require.NoError(t, err) - time.Sleep(time.Millisecond * 10) - - // step 2: agent publishes an event - err = ag1.PubEvent(td1.ID, key1, data) - require.NoError(t, err) - time.Sleep(time.Millisecond * 1) - - // step 3: read all events - var dThing1ID = digitwin.MakeDigitwinID(agentID, td1.ID) - dtwValues, err := digitwin.ThingValuesReadAllEvents(co1, dThing1ID) - require.NoError(t, err) - require.NotZero(t, len(dtwValues)) - require.Equal(t, data, dtwValues[key1].Data) - - // read latest using the generated client api - valueMap, err := digitwin.ThingValuesReadAllEvents(co1, dThing1ID) - require.NoError(t, err) - require.NotNil(t, valueMap) - require.Equal(t, len(dtwValues), len(valueMap)) - require.Equal(t, data, valueMap[key1].Data) -} - -func TestHttpsGetProps(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const key1 = "key1" - const key2 = "key2" - const userID = "user1" - const data1 = "Hello world" - const data2 = 25 - - r := startRuntime() - defer r.Stop() - - // agent publishes properties - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - // consumer reads properties - cl2, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer cl2.Disconnect() - - // step 1: agent publishes a TD first: dtw:agent1:thing-1 - td1 := ts.CreateTestTD(0) - td1JSON, _ := jsoniter.MarshalToString(td1) - err := digitwin.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - time.Sleep(time.Millisecond * 10) - require.NoError(t, err) - - //err = ag1.PubProperty(td1.ID, key1, data1) - //err = ag1.PubProperty(td1.ID, key2, data2) - err = ag1.PubProperty(td1.ID, key1, data1) - err = ag1.PubProperty(td1.ID, key2, data2) - - require.NoError(t, err) - // - time.Sleep(time.Millisecond * 10) - var dThingID = digitwin.MakeDigitwinID(agentID, td1.ID) - valueMap, err := cl2.ReadAllProperties(dThingID) - //valueMap, err := digitwin.ThingValuesReadAllProperties(cl2, dThingID) - require.NoError(t, err) - require.Equal(t, 2, len(valueMap)) - - // note: golang unmarshalls integers as float64. - data2raw := valueMap[key2].Data.(float64) - require.Equal(t, data2, int(data2raw)) -} - -func TestSubscribeValues(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const userID = "user1" - const key1 = "key1" - const key2 = "key2" - const data1 = "Hello world" - const data2 = 25 - var msgCount atomic.Int32 - - r := startRuntime() - defer r.Stop() - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - cl1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer cl1.Disconnect() - - // 1. consumer subscribes to events/properties - err := cl1.Subscribe("", "") - require.NoError(t, err) - cl1.SetNotificationHandler(func(msg *messaging.NotificationMessage) { - msgCount.Add(1) - }) - - // 2: agent creates a TD first: dtw:agent1:thing-1 - this sends a notification - td1 := ts.CreateTestTD(0) - - // 3: agent publishes notification - //err = ag1.UpdateThing(td1) - td1JSON, _ := jsoniter.MarshalToString(td1) - err = digitwin.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - - time.Sleep(time.Millisecond * 100) - - propMap := map[string]any{} - propMap[key1] = data1 - propMap[key2] = data2 - - err = ag1.PubEvent(td1.ID, key1, data1) - err = ag1.PubEvent(td1.ID, key2, data2) - require.NoError(t, err) - - time.Sleep(time.Millisecond * 100) - // one thing updated and two notification events - assert.Equal(t, int32(3), msgCount.Load()) -} - -func TestWriteProperties(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const userID = "user1" - const key1 = "key1" - const data1 = "Hello world" - var msgCount atomic.Int32 - - r := startRuntime() - defer r.Stop() - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - co1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer co1.Disconnect() - - // step 1: agent publishes a TD first: dtw:agent1:thing-1 - td1 := ts.CreateTestTD(0) - //err := ag1.UpdateThing(td1) - td1JSON, _ := jsoniter.MarshalToString(td1) - err := digitwin.ThingDirectoryUpdateThing(ag1.Consumer, td1JSON) - - // agents listen for property write requests - ag1.SetRequestHandler(func(msg *messaging.RequestMessage, - c messaging.IConnection) *messaging.ResponseMessage { - if msg.Operation == td.OpWriteProperty && msg.Name == key1 { - msgCount.Add(1) - } - return msg.CreateResponse(msg.Input, nil) - }) - - // consumer subscribes to events/properties changes - err = co1.ObserveProperty("", "") - require.NoError(t, err) - - co1.SetResponseHandler(func(msg *messaging.ResponseMessage) error { - // expect an action status message that is the result of invokeaction - if msg.Name == key1 { - msgCount.Add(1) - } - return nil - }) - time.Sleep(time.Millisecond * 100) - - dThingID := digitwin.MakeDigitwinID(agentID, td1.ID) - //stat2 := co1.WriteProperty(dThingID, key1, data1) - //require.Empty(t, stat2.Error) - err = co1.Rpc(td.OpWriteProperty, dThingID, key1, data1, nil) - require.NoError(t, err) - time.Sleep(time.Millisecond * 100) - - // write property results in a request on the agent - // the confirmation response is handled in the rpc - assert.Equal(t, int32(1), msgCount.Load()) - - err = co1.ObserveProperty("", "") - assert.NoError(t, err) - err = co1.UnobserveProperty("", "") - assert.NoError(t, err) -} diff --git a/runtime/Runtime_test.go b/runtime/Runtime_test.go index 4f308bc0..94e4df09 100644 --- a/runtime/Runtime_test.go +++ b/runtime/Runtime_test.go @@ -1,33 +1,31 @@ package runtime_test import ( - "log/slog" "os" "sync/atomic" "testing" "time" - "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/api/msg" + "github.com/hiveot/hivekit/go/modules/authn" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/testenv" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/clients/authclient" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/testenv" "github.com/hiveot/hub/runtime" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var ts *testenv.TestServer +var testEnv *testenv.TestEnv // start the test runtime func startRuntime() *runtime.Runtime { - logging.SetLogging("info", "") - ts = testenv.StartTestServer(true) - return ts.Runtime + utils.SetLogging("info", "") + testEnv = testenv.NewTestEnv() + testEnv.StartTestServer(testenv.DefaultProtocol) + rt := runtime.NewRuntime() + return rt } // TestMain for all authn tests, setup of default folders and filenames @@ -49,8 +47,10 @@ func TestLoginAsAgent(t *testing.T) { t.Logf("---%s---\n", t.Name()) r := startRuntime() - agent, cc, token := ts.AddConnectAgent(agentID) - authCl := authclient.NewAuthClientFromConnection(cc, token) + agent, ccag, token := testEnv.NewRCAgent(agentID, nil) //AddConnectAgent(agentID) + defer agent.Stop() + defer ccag.Close() + authCl := authnpkg.NewAuthnUserClient(agent) t2, err := authCl.RefreshToken(token) require.NoError(t, err) assert.NotEmpty(t, t2) @@ -60,7 +60,6 @@ func TestLoginAsAgent(t *testing.T) { _ = t3 require.NoError(t, err) - agent.Disconnect() r.Stop() //time.Sleep(time.Millisecond * 100) } @@ -69,8 +68,10 @@ func TestLoginAsConsumer(t *testing.T) { t.Logf("---%s---\n", t.Name()) r := startRuntime() - consumer, cc, token := ts.AddConnectConsumer(clientID, authz.ClientRoleManager) - authCl := authclient.NewAuthClientFromConnection(cc, token) + co1, cc, token := testEnv.NewConnectedConsumer(clientID, authn.ClientRoleManager, false) + defer co1.Stop() + defer cc.Close() + authCl := authnpkg.NewAuthnUserClient(cc) t2, err := authCl.RefreshToken(token) require.NoError(t, err) @@ -81,7 +82,6 @@ func TestLoginAsConsumer(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, t3) - consumer.Disconnect() r.Stop() //time.Sleep(time.Millisecond * 100) } @@ -93,7 +93,7 @@ func TestMultiConnectSingleClient(t *testing.T) { const agentID = "agent1" const testConnections = int32(100) const eventName = "event1" - var clients = make([]messaging.IClientConnection, 0) + var clients = make([]transport.ITransportClient, 0) var connectCount atomic.Int32 var disConnectCount atomic.Int32 var messageCount atomic.Int32 @@ -101,14 +101,15 @@ func TestMultiConnectSingleClient(t *testing.T) { // 1: setup: start a runtime and connect N clients r := startRuntime() - ag1, _, _ := ts.AddConnectAgent(agentID) - td1 := ts.AddTD(agentID, nil) - cl1, _, token1 := ts.AddConnectConsumer(clientID1, authz.ClientRoleOperator) + ag1, ccag, _ := testEnv.NewRCAgent(agentID, nil) + defer ccag.Close() + td1 := testEnv.AddTD(agentID, nil) + cl1, _, token1 := testEnv.NewConnectedConsumer(clientID1, authn.ClientRoleOperator, false) - onConnection := func(connected bool, err error, c messaging.IConnection) { - if connected { + onConnection := func(newStatus transport.ConnectionStatus, c transport.ITransportClient) { + if newStatus == transport.StatusConnected { connectCount.Add(1) - } else { + } else if newStatus == transport.StatusClosed || newStatus == transport.StatusLost { disConnectCount.Add(1) } } @@ -116,19 +117,19 @@ func TestMultiConnectSingleClient(t *testing.T) { // messageCount.Add(1) // return req.CreateResponse() //} - onNotification := func(msg *messaging.NotificationMessage) { + onNotification := func(notif *msg.NotificationMessage) { messageCount.Add(1) } // 2: connect and subscribe clients and verify for range testConnections { - cc, consumer := ts.GetConsumerConnection(clientID1, ts.ConsumerProtocol) + co, cc, _ := testEnv.NewConnectedConsumer(clientID1, authn.ClientRoleOperator, false) cc.SetConnectHandler(onConnection) - consumer.SetNotificationHandler(onNotification) - err := cc.ConnectWithToken(token1) + co.SetNotificationHook(onNotification) + err := cc.AuthenticateWithToken(clientID1, token1) require.NoError(t, err) // allow server to register its connection time.Sleep(waitafterconnect) - err = consumer.Subscribe("", "") + err = co.Subscribe("", "") require.NoError(t, err) clients = append(clients, cc) } @@ -138,9 +139,9 @@ func TestMultiConnectSingleClient(t *testing.T) { //require.Equal(t, testConnections, ts.Runtime.TransportsMgr.GetNrConnections(), "ts connect count mismatch") // 3: agent publishes an event, which should be received N times - err := ag1.PubEvent(td1.ID, eventName, "a value") + ag1.PubEvent(td1.ID, eventName, "a value") //err := ag1.PubEvent(td1.ID, eventName, "a value", "message1") - require.NoError(t, err) + // require.NoError(t, err) // event should have been received N times (in debug mode this can be rather slow) time.Sleep(time.Millisecond * 500) @@ -148,18 +149,18 @@ func TestMultiConnectSingleClient(t *testing.T) { // 4: disconnect for _, c := range clients { - c.Disconnect() + c.Close() } - cl1.Disconnect() + cl1.Stop() // disconnection notification should have been received N times time.Sleep(waitafterconnect * 3) require.Equal(t, testConnections, disConnectCount.Load(), "disconnect count mismatch") // 5: no more messages should be received after disconnecting messageCount.Store(0) - err = ag1.PubEvent(td1.ID, eventName, "a value") - require.NoError(t, err) - ag1.Disconnect() + ag1.PubEvent(td1.ID, eventName, "a value") + // require.NoError(t, err) + ag1.Stop() // zero events should have been received time.Sleep(time.Millisecond * 100) @@ -172,138 +173,6 @@ func TestMultiConnectSingleClient(t *testing.T) { //time.Sleep(time.Millisecond * 100) } -func TestActionWithDeliveryConfirmation(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const userID = "user1" - const actionID = "action-1" // match the test TD action - var actionPayload = "payload1" - var expectedReply = actionPayload + ".reply" - var rxMsg messaging.RequestMessage - - r := startRuntime() - defer r.Stop() - logging.SetLogging("warning", "") - //slog.SetLogLoggerLevel(slog.LevelWarn) - ag1, _, _ := ts.AddConnectAgent(agentID) - cl1, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - - // step 1: agent publishes a TD - td1 := ts.CreateTestTD(0) - thingID := td1.ID - ts.AddTD(agentID, td1) - - // connect the agent and user clients - defer ag1.Disconnect() - defer cl1.Disconnect() - - // Agent receives action request which we'll handle here - agentRequestHandler := func(req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - rxMsg = *req - reply := utils.DecodeAsString(req.Input, 0) + ".reply" - // TODO WSS doesn't support the senderID in the message. How important is this? - // option1: not important - no use-case - // option2: extend the websocket InvokeAction message format with a SenderID - //assert.Equal(t, cl1.GetClientID(), msg.SenderID) - //stat.Failed(msg, fmt.Errorf("failuretest")) - slog.Info("TestActionWithDeliveryConfirmation: agent1 delivery complete", "correlationID", req.CorrelationID) - return req.CreateResponse(reply, nil) - } - ag1.SetRequestHandler(agentRequestHandler) - - // client sends action to agent and expect a 'delivered' result - // The RPC method returns an error if no reply is received - dThingID := digitwin.MakeDigitwinID(agentID, thingID) - - var result string - err := cl1.Rpc(td.OpInvokeAction, dThingID, actionID, actionPayload, &result) - require.NoError(t, err) - assert.Equal(t, expectedReply, result) - assert.Equal(t, thingID, rxMsg.ThingID) - assert.Equal(t, actionID, rxMsg.Name) - assert.Equal(t, td.OpInvokeAction, rxMsg.Operation) - -} - -// Services and agents should auto-reconnect when server is restarted -func TestServiceReconnect(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const userID = "user1" - var rxMsg atomic.Pointer[messaging.RequestMessage] - var actionPayload = "payload1" - var expectedReply = actionPayload + ".reply" - - r := startRuntime() - // r is stopped below - - // give server time to start up before connecting - time.Sleep(time.Millisecond * 10) - - ag1, _, _ := ts.AddConnectAgent(agentID) - defer ag1.Disconnect() - - // step 1: ensure the thing TD exists - td1 := ts.CreateTestTD(0) - thingID := td1.ID - actionID := "action-1" // match the test TD action - ts.AddTD(agentID, td1) - - // time for agent to connect - time.Sleep(time.Millisecond * 1000) - - hasAgent := ts.Runtime.TransportsMgr.GetConnectionByClientID(ag1.GetClientID()) - require.NotNil(t, hasAgent) - - // Agent receives action request which we'll handle here - ag1.SetRequestHandler(func(msg *messaging.RequestMessage, - c messaging.IConnection) *messaging.ResponseMessage { - - var req string - rxMsg.Store(msg) - _ = utils.DecodeAsObject(msg.Input, &req) - output := req + ".reply" - slog.Info("agent1 delivery complete", "correlationID", msg.CorrelationID) - return msg.CreateResponse(output, nil) - }) - - // give connection time to be established before stopping the server - time.Sleep(time.Millisecond * 10) - - // after restarting the server, ag1's connection should automatically be re-established - // TBD what is the go-sse reconnect algorithm? How to know it triggered? - t.Log("--- restarting the runtime; 1 existing connection remaining") - r.Stop() - time.Sleep(time.Millisecond * 10) - - err := r.Start(&ts.AppEnv) - require.NoError(t, err) - defer r.Stop() - t.Log("--- server restarted; expecting an agent reconnect") - - // wait for the agent reconnect - time.Sleep(time.Millisecond * 2000) - - hasAgent = ts.Runtime.TransportsMgr.GetConnectionByClientID(ag1.GetClientID()) - assert.NotNil(t, hasAgent) - - cl2, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleManager) - defer cl2.Disconnect() - // FIXME: wait for an actual reconnect - time.Sleep(time.Second * 1) - - // this rpc call succeeds after agent1 has automatically reconnected - dThingID := digitwin.MakeDigitwinID(agentID, thingID) - var reply string - err = cl2.Rpc(td.OpInvokeAction, dThingID, actionID, &actionPayload, &reply) - require.NoError(t, err) - - require.NoError(t, err, "auto-reconnect didn't take place") - rx2 := rxMsg.Load() - require.NotNil(t, rx2) - require.Equal(t, expectedReply, string(reply)) -} - // test that regular users don't have admin access to authn, authz func TestAccess(t *testing.T) { t.Logf("---%s---\n", t.Name()) @@ -312,20 +181,21 @@ func TestAccess(t *testing.T) { r := startRuntime() defer r.Stop() - hc, _, token := ts.AddConnectConsumer(clientID, authz.ClientRoleViewer) - defer hc.Disconnect() + cc1, token := testEnv.NewConnectedClient(clientID, authn.ClientRoleAdmin) + defer cc1.Close() _ = token //f := r.GetForm(td.OpInvokeAction, hc.GetProtocolType()) // regulars users should not have authn and authz admin access - clientProfiles, err := authn.AdminGetProfiles(hc) + authnAdmin := authnpkg.NewAuthnAdminClient(cc1) + clientProfiles, err := authnAdmin.GetProfiles() require.Error(t, err, "regular users should not have access to authn.Admin") require.Empty(t, clientProfiles) //time.Sleep(time.Millisecond * 100) - role, err := authz.AdminGetClientRole(hc, clientID) - require.Error(t, err, "regular users should not have access to authz.Admin") - require.Empty(t, role) + prof, err := authnAdmin.GetClientProfile(clientID) + require.Error(t, err, "regular users should not have access to authn.Admin") + require.Empty(t, prof.Role) //time.Sleep(time.Millisecond * 100) } diff --git a/runtime/authn/AuthnAdmin_test.go b/runtime/authn/AuthnAdmin_test.go deleted file mode 100644 index 3db9d862..00000000 --- a/runtime/authn/AuthnAdmin_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package authn_test - -import ( - "testing" - - "github.com/hiveot/hub/lib/keys" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// NOTE: this uses default settings from Authn_test.go - -// Create manage users -func TestAddRemoveClientsSuccess(t *testing.T) { - deviceID := "device1" - deviceKP := keys.NewKey(keys.KeyTypeECDSA) - deviceKeyPub := deviceKP.ExportPublic() - serviceID := "service1" - serviceKP := keys.NewKey(keys.KeyTypeECDSA) - serviceKeyPub := serviceKP.ExportPublic() - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - //hc := embedded.NewEmbeddedClient(serviceID, adminHandler) - - //err := svc.AdminSvc.AddConsumer(serviceID, - // authn.AdminAddConsumerArgs{ "user1", "user 1", "pass1") - err := svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{ClientID: "user1", DisplayName: "user 1", Password: "pass1"}) - assert.NoError(t, err) - // duplicate should update - err = svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{ClientID: "user1", DisplayName: "user 1 updated", Password: "pass1"}) - assert.NoError(t, err) - - err = svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{ClientID: "user2", DisplayName: "user 2", Password: "pass2"}) - assert.NoError(t, err) - err = svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{ClientID: "user3", DisplayName: "user 3", Password: "pass2"}) - assert.NoError(t, err) - err = svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{ClientID: "user4", DisplayName: "user 4", Password: "pass2"}) - assert.NoError(t, err) - - _, err = svc.AdminSvc.AddAgent(serviceID, - authn.AdminAddAgentArgs{ClientID: deviceID, DisplayName: "agent 1", PubKey: deviceKeyPub}) - assert.NoError(t, err) - - _, err = svc.AdminSvc.AddService(serviceID, - authn.AdminAddServiceArgs{ClientID: serviceID, DisplayName: "service 1", PubKey: serviceKeyPub}) - assert.NoError(t, err) - - // update the server. users can connect and have unlimited access - profiles, err := svc.AdminSvc.GetProfiles(serviceID) - require.NoError(t, err) - assert.Equal(t, 6+3, len(profiles)) - - err = svc.AdminSvc.RemoveClient(serviceID, "user1") - assert.NoError(t, err) - err = svc.AdminSvc.RemoveClient(serviceID, "user1") // remove is idempotent - assert.NoError(t, err) - err = svc.AdminSvc.RemoveClient(serviceID, "user2") - assert.NoError(t, err) - err = svc.AdminSvc.RemoveClient(serviceID, deviceID) - assert.NoError(t, err) - err = svc.AdminSvc.RemoveClient(serviceID, serviceID) - assert.NoError(t, err) - - profiles, err = svc.AdminSvc.GetProfiles(serviceID) - - require.NoError(t, err) - assert.Equal(t, 2+3, len(profiles)) - - clEntries := svc.AdminSvc.GetEntries() - assert.Equal(t, 2+3, len(clEntries)) - - err = svc.AdminSvc.AddConsumer(serviceID, - authn.AdminAddConsumerArgs{"user1", "user 1", "pass1"}) - assert.NoError(t, err) -} - -// Create manage users -func TestAddRemoveClientsFail(t *testing.T) { - const adminID = "administrator-1" - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // missing clientID should fail - _, err := svc.AdminSvc.AddService(adminID, authn.AdminAddServiceArgs{"", "user 1", ""}) - assert.Error(t, err) - - // a bad key is not an error - err = svc.AdminSvc.AddConsumer(adminID, authn.AdminAddConsumerArgs{"user2", "user 2", "badkey"}) - assert.NoError(t, err) -} - -func TestUpdateClientPassword(t *testing.T) { - var tu1ID = "tu1ID" - var tuPass1 = "tuPass1" - var tuPass2 = "tuPass2" - const adminID = "administrator-1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - err := svc.AdminSvc.AddConsumer( - adminID, authn.AdminAddConsumerArgs{tu1ID, "user 1", tuPass1}) - require.NoError(t, err) - - err = svc.SessionAuth.ValidatePassword(tu1ID, tuPass1) - require.NoError(t, err) - - err = svc.AdminSvc.SetClientPassword( - adminID, authn.AdminSetClientPasswordArgs{tu1ID, tuPass2}) - require.NoError(t, err) - - err = svc.SessionAuth.ValidatePassword(tu1ID, tuPass1) - require.Error(t, err) - - err = svc.SessionAuth.ValidatePassword(tu1ID, tuPass2) - require.NoError(t, err) -} - -func TestUpdatePubKey(t *testing.T) { - var tu1ID = "tu1ID" - var tu1Pass = "tu1Pass" - const adminID = "administrator-1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with. don't set the public key yet - err := svc.AdminSvc.AddConsumer(adminID, authn.AdminAddConsumerArgs{tu1ID, "user 2", tu1Pass}) - require.NoError(t, err) - // - token := svc.SessionAuth.CreateSessionToken(tu1ID, "", 0) - require.NotEmpty(t, token) - - // update the public key - kp := keys.NewKey(keys.KeyTypeECDSA) - profile, err := svc.AdminSvc.GetClientProfile(adminID, tu1ID) - require.NoError(t, err) - profile.PubKey = kp.ExportPublic() - err = svc.AdminSvc.UpdateClientProfile(adminID, profile) - assert.NoError(t, err) - - // check result - profile2, err := svc.AdminSvc.GetClientProfile(adminID, tu1ID) - require.NoError(t, err) - assert.Equal(t, kp.ExportPublic(), profile2.PubKey) -} - -func TestNewAgentToken(t *testing.T) { - var tu1ID = "ag1ID" - var tu1Name = "agent 1" - - const adminID = "administrator-1" - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add agent to test with and connect - _, err := svc.AdminSvc.AddAgent(adminID, authn.AdminAddAgentArgs{tu1ID, tu1Name, ""}) - require.NoError(t, err) - - // get a new token - token, err := svc.AdminSvc.NewAgentToken(adminID, tu1ID) - require.NoError(t, err) - require.NotEmpty(t, token) - - // login with new token - clientID, _, err := svc.SessionAuth.ValidateToken(token) - require.NoError(t, err) - require.Equal(t, tu1ID, clientID) - -} - -func TestUpdateProfile(t *testing.T) { - var tu1ID = "tu1ID" - var tu1Name = "test user 1" - - const adminID = "administrator-1" - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with and connect - err := svc.AdminSvc.AddConsumer(adminID, authn.AdminAddConsumerArgs{tu1ID, tu1Name, "pass0"}) - require.NoError(t, err) - //tu1Key, _ := testServer.MsgServer.CreateKP() - - // update display name - const newDisplayName = "new display name" - profile, err := svc.AdminSvc.GetClientProfile(adminID, tu1ID) - require.NoError(t, err) - profile.DisplayName = newDisplayName - err = svc.AdminSvc.UpdateClientProfile(adminID, profile) - assert.NoError(t, err) - - // verify - profile2, err := svc.AdminSvc.GetClientProfile(adminID, tu1ID) - - require.NoError(t, err) - assert.Equal(t, newDisplayName, profile2.DisplayName) -} - -func TestUpdateProfileFail(t *testing.T) { - const adminID = "administrator-1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - err := svc.AdminSvc.UpdateClientProfile(adminID, authn.ClientProfile{ClientID: "badclient"}) - assert.Error(t, err) -} diff --git a/runtime/authn/AuthnUser_test.go b/runtime/authn/AuthnUser_test.go deleted file mode 100644 index 40191466..00000000 --- a/runtime/authn/AuthnUser_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package authn_test - -import ( - "testing" - - "github.com/hiveot/hub/lib/keys" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestClientUpdatePubKey(t *testing.T) { - var user1ID = "user1ID" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with. don't set the public key yet - err := svc.AdminSvc.AddConsumer("", - authn.AdminAddConsumerArgs{user1ID, user1ID, "user1"}) - profile, err := svc.AdminSvc.GetClientProfile("", user1ID) - require.NoError(t, err) - assert.Equal(t, user1ID, profile.ClientID) - assert.Equal(t, user1ID, profile.DisplayName) - assert.NotEmpty(t, profile.Updated) - - // update the public key - kp := keys.NewKey(keys.KeyTypeECDSA) - profile2, err := svc.UserSvc.GetProfile(user1ID) - assert.Equal(t, user1ID, profile2.ClientID) - require.NoError(t, err) - err = svc.UserSvc.UpdatePubKey(user1ID, kp.ExportPublic()) - assert.NoError(t, err) - - // check result - profile3, err := svc.UserSvc.GetProfile(user1ID) - require.NoError(t, err) - assert.Equal(t, user1ID, profile3.ClientID) - assert.Equal(t, kp.ExportPublic(), profile3.PubKey) -} - -// Note: RefreshToken is only possible when using JWT. -func TestLoginRefresh(t *testing.T) { - var user1ID = "user1ID" - var tu1Pass = "tu1Pass" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with - err := svc.AdminSvc.AddConsumer( - user1ID, authn.AdminAddConsumerArgs{user1ID, "testuser1", ""}) - require.NoError(t, err) - - err = svc.UserSvc.UpdatePassword(user1ID, tu1Pass) - require.NoError(t, err) - - token1, err := svc.UserSvc.Login(user1ID, authn.UserLoginArgs{user1ID, tu1Pass}) - require.NoError(t, err) - - cid2, sid2, err := svc.SessionAuth.ValidateToken(token1) - assert.Equal(t, user1ID, cid2) - assert.NotEmpty(t, sid2) - require.NoError(t, err) - - // RefreshToken the token - token2, err := svc.UserSvc.RefreshToken(user1ID, token1) - require.NoError(t, err) - require.NotEmpty(t, token2) - - // ValidateToken the new token - cid3, sid3, err := svc.SessionAuth.ValidateToken(token2) - assert.Equal(t, user1ID, cid3) - assert.Equal(t, sid2, sid3) - require.NoError(t, err) -} - -func TestLoginRefreshFail(t *testing.T) { - var user1ID = "testuser1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // RefreshToken the token non-existing - resp, err := svc.UserSvc.RefreshToken(user1ID, "badToken") - _ = resp - require.Error(t, err) -} - -func TestUpdatePassword(t *testing.T) { - - var user1ID = "user1ID" - var tu1Name = "test user 1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with - err := svc.AdminSvc.AddConsumer(user1ID, authn.AdminAddConsumerArgs{user1ID, tu1Name, "oldpass"}) - require.NoError(t, err) - - // login should succeed - _, err = svc.UserSvc.Login(user1ID, authn.UserLoginArgs{user1ID, "oldpass"}) - require.NoError(t, err) - - // change password - err = svc.UserSvc.UpdatePassword(user1ID, "newpass") - require.NoError(t, err) - - // login with old password should now fail - //t.Log("an error is expected logging in with the old password") - _, err = svc.UserSvc.Login(user1ID, authn.UserLoginArgs{user1ID, "oldpass"}) - require.Error(t, err) - - // re-login with new password - _, err = svc.UserSvc.Login(user1ID, authn.UserLoginArgs{user1ID, "newpass"}) - require.NoError(t, err) -} - -func TestUpdatePasswordFail(t *testing.T) { - var user1ID = "user1ID" - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - err := svc.UserSvc.UpdatePassword(user1ID, "newpass") - assert.Error(t, err) -} - -func TestUpdateName(t *testing.T) { - - var user1ID = "user1ID" - var tu1Name = "test user 1" - var tu2Name = "test user 1" - - svc, stopFn := startTestAuthnService(defaultHash) - defer stopFn() - - // add user to test with - err := svc.AdminSvc.AddConsumer(user1ID, authn.AdminAddConsumerArgs{user1ID, tu1Name, "oldpass"}) - require.NoError(t, err) - - profile, err := svc.UserSvc.GetProfile(user1ID) - require.NoError(t, err) - assert.Equal(t, tu1Name, profile.DisplayName) - - err = svc.UserSvc.UpdateName(user1ID, tu2Name) - require.NoError(t, err) - profile2, err := svc.UserSvc.GetProfile(user1ID) - require.NoError(t, err) - - assert.Equal(t, tu2Name, profile2.DisplayName) -} diff --git a/runtime/authn/Authn_test.go b/runtime/authn/Authn_test.go deleted file mode 100644 index 9e97ac4d..00000000 --- a/runtime/authn/Authn_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package authn_test - -import ( - "os" - "path" - "testing" - "time" - - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/service" - "github.com/stretchr/testify/assert" -) - -var testDir = path.Join(os.TempDir(), "test-authn") -var authnConfig config.AuthnConfig -var defaultHash = config.PWHASH_ARGON2id - -// This test file sets up the environment for testing authn admin and client services. - -// launch the authn service and return the server side message handlers for using and managing it. -func startTestAuthnService(testHash string) ( - svc *service.AuthnService, stopFn func()) { - - _ = os.RemoveAll(testDir) - _ = os.MkdirAll(testDir, 0700) - - // the password file to use - passwordFile := path.Join(testDir, "test.passwd") - - authnConfig = config.NewAuthnConfig() - authnConfig.Setup(testDir, testDir) - authnConfig.PasswordFile = passwordFile - authnConfig.AgentTokenValidityDays = 1 - authnConfig.Encryption = testHash - - svc, err := service.StartAuthnService(&authnConfig) - if err != nil { - panic("Error starting authn admin service:" + err.Error()) - } - //ag, err := service.StartAuthnAgent(svc, nil) - - //return svc, ag.HandleMessage, func() { - return svc, func() { - svc.Stop() - - // let background tasks finish - time.Sleep(time.Millisecond * 100) - } -} - -// TestMain creates a test environment -// Used for all test cases in this package -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - res := m.Run() - if res == 0 { - _ = os.RemoveAll(testDir) - } - os.Exit(res) -} - -// Start the authn service and list clients -func TestStartStop(t *testing.T) { - // this creates the admin user key - svc, stopFn := startTestAuthnService(defaultHash) - assert.NotNil(t, svc.AdminSvc) - assert.NotNil(t, svc.UserSvc) - assert.NotNil(t, svc.AuthnStore) - assert.NotNil(t, svc.SessionAuth) - defer stopFn() -} diff --git a/runtime/authn/README.md b/runtime/authn/README.md deleted file mode 100644 index 4d9d5397..00000000 --- a/runtime/authn/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# authn - HiveOT Authentication Service - -## Objective - -Manage Hub client profiles and corresponding authentication keys. - -## Status - -This service is functional but breaking changes should be expected. - -## Scope - -In-scope is to provide identity management for users, devices, and services on the local network, with support for password, certificate, and token based authentication. - -## Summary - -This service provides the following capabilities: - -1. Client management to add and remove Hub clients such as devices, users and services. Client information is persisted in an authentication store. -2. Profile management for use by clients to update their profile and update login tokens. - -Authentication can be used by the protocol bindings. It is up to each protocol binding to decide whether to use the authn service for generating session tokens. - -An optional golang client API is included and support for other languages is planned. - -## Password Storage - -Passwords are stored by the service using bcrypt or argon2id hashing. While argon2id is chosen as it is one of the strongest hash algorithms (2024) that is resistant to GPU cracking attacks and side channel attacks. [See wikipedia](https://en.wikipedia.org/wiki/Argon2). In future other algorithms can be supported as needed. - -## Authentication Session Tokens - -On login, authn generates an authentication token, signed using the server's private key, containing the client's session-id. This session-id is used for all issued tokens of this client. If no session exists on login one is created. To use the token, the client must both have a valid token and an active session. Session expire with a configurable timeout. Typically 30 days, after which the user must login again. - -Logout will remove the session which forces all clients to login again on all devices. Issued tokens will be rendered useless, event if they haven't expired yet. - -Tokens are generated using JWT/ES256. A change to use Paseto is planned. This will not impact the client other than having to login again to receive a new token. - -## Usage - -This service is part of the hub runtime. - -To function as intended, the service must have access to the filesystem to store passwords and service authentication keys. The default path are the hiveot 'certs' directory for the certificate and hiveot 'stores/authn' directory for the profile and password storage. Location of files can be changed in the runtime.yaml config. - diff --git a/runtime/authn/api/AdminAgentAPI.go b/runtime/authn/api/AdminAgentAPI.go deleted file mode 100644 index 82c9248b..00000000 --- a/runtime/authn/api/AdminAgentAPI.go +++ /dev/null @@ -1,154 +0,0 @@ -// Package authn with the agent request handler for using service 'Admin' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authn - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IAdminService defines the interface of the 'Admin' service -// -// This defines a method for each of the actions in the TD. -// -type IAdminService interface { - - // AddAgent Add Agent - // Create an account for IoT device agents - // This returns a agent authentication token - AddAgent(senderID string, agentInfo AdminAddAgentArgs) (token string, err error) - - // AddConsumer Add Consumer - // Add an end-user client - AddConsumer(senderID string, consumerInfo AdminAddConsumerArgs) error - - // AddService Add Service - // Add a service account - // This returns a service authentication token - AddService(senderID string, serviceInfo AdminAddServiceArgs) (token string, err error) - - // GetClientProfile Get Client Profile - // Get the profile information describing a client - GetClientProfile(senderID string, clientID string) (profile ClientProfile, err error) - - // GetProfiles Get Profiles - // Get a list of all client profiles - GetProfiles(senderID string) (clientProfiles []ClientProfile, err error) - - // GetSessions Get Sessions - // Get a list of active sessions - GetSessions(senderID string) (AdminGetSessions AdminGetSessionsResp, err error) - - // NewAgentToken New Agent Token - // Obtain a new authentication token for an agent or service - // This returns a new service or agent authentication token - NewAgentToken(senderID string, agentID string) (token string, err error) - - // RemoveClient Remove Client - // Remove a client account - RemoveClient(senderID string, clientID string) error - - // SetClientPassword Set Client Password - // Update the password of a consumer - SetClientPassword(senderID string, clientInfo AdminSetClientPasswordArgs) error - - // UpdateClientProfile Update Client Profile - // Update the details of a client - UpdateClientProfile(senderID string, clientProfile ClientProfile) error -} - -// NewHandleAdminRequest returns an agent handler for Thing 'admin' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleAdminRequest(svc IAdminService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "addAgent": - args := AdminAddAgentArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.AddAgent(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "addConsumer": - args := AdminAddConsumerArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.AddConsumer(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "getClientProfile": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.GetClientProfile(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "getProfiles": - if err == nil { - output, err = svc.GetProfiles(msg.SenderID) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "getSessions": - if err == nil { - output, err = svc.GetSessions(msg.SenderID) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "removeClient": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.RemoveClient(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "setClientPassword": - args := AdminSetClientPasswordArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.SetClientPassword(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "updateClientProfile": - var args ClientProfile - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.UpdateClientProfile(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "addService": - args := AdminAddServiceArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.AddService(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "newAgentToken": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.NewAgentToken(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/authn/api/AdminConsumerAPI.go b/runtime/authn/api/AdminConsumerAPI.go deleted file mode 100644 index dc9a4aae..00000000 --- a/runtime/authn/api/AdminConsumerAPI.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package authn with the agent request handler for using service 'Admin' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authn - -import "github.com/hiveot/hub/lib/consumer" - - -// AdminAddAgent client method - Add Agent. -// Create an account for IoT device agents -func AdminAddAgent(hc *consumer.Consumer, clientID string, displayName string, pubKey string)(token string, err error){ - var args = AdminAddAgentArgs{clientID, displayName, pubKey} - err = hc.Rpc("invokeaction", AdminDThingID, AdminAddAgentMethod, &args, &token) - return -} - -// AdminAddConsumer client method - Add Consumer. -// Add an end-user client -func AdminAddConsumer(hc *consumer.Consumer, clientID string, displayName string, password string)(err error){ - var args = AdminAddConsumerArgs{clientID, displayName, password} - err = hc.Rpc("invokeaction", AdminDThingID, AdminAddConsumerMethod, &args, nil) - return -} - -// AdminAddService client method - Add Service. -// Add a service account -func AdminAddService(hc *consumer.Consumer, clientID string, displayName string, pubKey string)(token string, err error){ - var args = AdminAddServiceArgs{clientID, displayName, pubKey} - err = hc.Rpc("invokeaction", AdminDThingID, AdminAddServiceMethod, &args, &token) - return -} - -// AdminGetClientProfile client method - Get Client Profile. -// Get the profile information describing a client -func AdminGetClientProfile(hc *consumer.Consumer, clientID string)(profile ClientProfile, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminGetClientProfileMethod, &clientID, &profile) - return -} - -// AdminGetProfiles client method - Get Profiles. -// Get a list of all client profiles -func AdminGetProfiles(hc *consumer.Consumer)(clientProfiles []ClientProfile, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminGetProfilesMethod, nil, &clientProfiles) - return -} - -// AdminGetSessions client method - Get Sessions. -// Get a list of active sessions -func AdminGetSessions(hc *consumer.Consumer)(clientSessions AdminGetSessionsResp, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminGetSessionsMethod, nil, &clientSessions) - return -} - -// AdminNewAgentToken client method - New Agent Token. -// Obtain a new authentication token for an agent or service -func AdminNewAgentToken(hc *consumer.Consumer, agentID string)(token string, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminNewAgentTokenMethod, &agentID, &token) - return -} - -// AdminRemoveClient client method - Remove Client. -// Remove a client account -func AdminRemoveClient(hc *consumer.Consumer, clientID string)(err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminRemoveClientMethod, &clientID, nil) - return -} - -// AdminSetClientPassword client method - Set Client Password. -// Update the password of a consumer -func AdminSetClientPassword(hc *consumer.Consumer, clientID string, password string)(err error){ - var args = AdminSetClientPasswordArgs{clientID, password} - err = hc.Rpc("invokeaction", AdminDThingID, AdminSetClientPasswordMethod, &args, nil) - return -} - -// AdminUpdateClientProfile client method - Update Client Profile. -// Update the details of a client -func AdminUpdateClientProfile(hc *consumer.Consumer, clientProfile ClientProfile)(err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminUpdateClientProfileMethod, &clientProfile, nil) - return -} diff --git a/runtime/authn/api/AdminTypes.go b/runtime/authn/api/AdminTypes.go deleted file mode 100644 index 87999086..00000000 --- a/runtime/authn/api/AdminTypes.go +++ /dev/null @@ -1,148 +0,0 @@ -// Package authn with types of service 'Admin' from agent 'authn' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package authn - - -//--- Constants --- - -// AdminAgentID is the account ID of the agent managing the Thing. -const AdminAgentID = "authn" - -// AdminServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const AdminServiceID = "Admin" - -// AdminDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const AdminDThingID = "dtw:authn:Admin" - -// Property, Event and Action names -const ( - AdminPropNrClients = "nrClients" - AdminEventAdded = "added" - AdminEventRemoved = "removed" - AdminActionAddAgent = "addAgent" - AdminActionAddConsumer = "addConsumer" - AdminActionGetClientProfile = "getClientProfile" - AdminActionNewAgentToken = "newAgentToken" - AdminActionRemoveClient = "removeClient" - AdminActionSetClientPassword = "setClientPassword" - AdminActionAddService = "addService" - AdminActionGetProfiles = "getProfiles" - AdminActionGetSessions = "getSessions" - AdminActionUpdateClientProfile = "updateClientProfile" -) -//--- Argument and Response struct for action of Thing 'admin' --- - -const AdminAddAgentMethod = "addAgent" - -// AdminAddAgentArgs defines the arguments of the addAgent function -// Add Agent - Create an account for IoT device agents -type AdminAddAgentArgs struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // DisplayName with Display Name - DisplayName string `json:"displayName,omitempty"` - - // PubKey with Public Key - PubKey string `json:"pubKey,omitempty"` -} - -const AdminAddConsumerMethod = "addConsumer" - -// AdminAddConsumerArgs defines the arguments of the addConsumer function -// Add Consumer - Add an end-user client -type AdminAddConsumerArgs struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // DisplayName with Display Name - DisplayName string `json:"displayName,omitempty"` - - // Password with Password - Password string `json:"password,omitempty"` -} - -const AdminAddServiceMethod = "addService" - -// AdminAddServiceArgs defines the arguments of the addService function -// Add Service - Add a service account -type AdminAddServiceArgs struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // DisplayName with Display Name - DisplayName string `json:"displayName,omitempty"` - - // PubKey with Public Key - PubKey string `json:"pubKey,omitempty"` -} - -const AdminGetClientProfileMethod = "getClientProfile" - -// AdminGetClientProfileResp defines the response of the getClientProfile function -// Get Client Profile - Get the profile information describing a client -// AdminGetClientProfileResp defines a Profile data schema. -type AdminGetClientProfileResp ClientProfile - - -const AdminGetProfilesMethod = "getProfiles" - -// AdminGetProfilesResp defines the response of the getProfiles function -// Get Profiles - Get a list of all client profiles -// AdminGetProfilesResp defines a Client Profiles data schema. -type AdminGetProfilesResp []struct { - - // AdminGetProfilesResp with Client Profile - AdminGetProfilesResp *ClientProfile `json:"AdminGetProfilesResp,omitempty"` -} - - -const AdminGetSessionsMethod = "getSessions" - -// AdminGetSessionsResp defines the response of the getSessions function -// Get Sessions - Get a list of active sessions -// AdminGetSessionsResp defines a Client Sessions data schema. -type AdminGetSessionsResp []struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // Created with Session created - Created string `json:"created,omitempty"` - - // Expiry with Session expires - Expiry string `json:"expiry,omitempty"` -} - - -const AdminNewAgentTokenMethod = "newAgentToken" - -const AdminRemoveClientMethod = "removeClient" - -const AdminSetClientPasswordMethod = "setClientPassword" - -// AdminSetClientPasswordArgs defines the arguments of the setClientPassword function -// Set Client Password - Update the password of a consumer -// -// Client ID and password -type AdminSetClientPasswordArgs struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // Password with Password - Password string `json:"password,omitempty"` -} - -const AdminUpdateClientProfileMethod = "updateClientProfile" - - -// AdminTD contains the raw TD of this service for publication to the Hub -const AdminTD = `{"actions":{"addAgent":{"@type":"hiveot:function","description":"Create an account for IoT device agents","title":"Add Agent","idempotent":true,"input":{"title":"Agent Info","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"displayName":{"title":"Display Name","readOnly":false,"type":"string"},"pubKey":{"title":"Public Key","readOnly":false,"type":"string"}}},"output":{"title":"Token","description":"Agent authentication token","readOnly":false,"type":"string"}},"addConsumer":{"@type":"hiveot:function","description":"Add an end-user client","title":"Add Consumer","idempotent":true,"input":{"title":"Consumer Info","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"displayName":{"title":"Display Name","readOnly":false,"type":"string"},"password":{"title":"Password","readOnly":false,"type":"string"}}}},"addService":{"@type":"hiveot:function","description":"Add a service account","title":"Add Service","idempotent":true,"input":{"title":"Service Info","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"displayName":{"title":"Display Name","readOnly":false,"type":"string"},"pubKey":{"title":"Public Key","readOnly":false,"type":"string"}}},"output":{"title":"Token","description":"Service authentication token","readOnly":false,"type":"string"}},"getClientProfile":{"@type":"hiveot:function","description":"Get the profile information describing a client","title":"Get Client Profile","idempotent":true,"input":{"title":"Client ID","readOnly":false,"type":"string"},"output":{"title":"Profile","readOnly":false,"type":"object","schema":"ClientProfile"},"safe":true},"getProfiles":{"@type":"hiveot:function","description":"Get a list of all client profiles","title":"Get Profiles","idempotent":true,"output":{"title":"Client Profiles","readOnly":false,"type":"array","items":{"title":"Client Profile","readOnly":false,"type":"object","schema":"ClientProfile"}},"safe":true},"getSessions":{"@type":"hiveot:function","description":"Get a list of active sessions","title":"Get Sessions","idempotent":true,"output":{"title":"Client Sessions","readOnly":false,"type":"array","items":{"title":"Client session","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"created":{"title":"Session created","readOnly":false,"type":"string"},"expiry":{"title":"Session expires","readOnly":false,"type":"string"}}}},"safe":true},"newAgentToken":{"@type":"hiveot:function","description":"Obtain a new authentication token for an agent or service","title":"New Agent Token","input":{"title":"Agent ID","readOnly":false,"type":"string"},"output":{"title":"Token","description":"New service or agent authentication token","readOnly":false,"type":"string"}},"removeClient":{"@type":"hiveot:function","description":"Remove a client account","title":"Remove Client","input":{"title":"Client ID","readOnly":false,"type":"string"}},"setClientPassword":{"@type":"hiveot:function","description":"Update the password of a consumer","title":"Set Client Password","input":{"title":"Client Info","description":"Client ID and password","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"password":{"title":"Password","readOnly":false,"type":"string"}}}},"updateClientProfile":{"@type":"hiveot:function","description":"Update the details of a client","title":"Update Client Profile","input":{"title":"Client Profile","readOnly":false,"type":"ClientProfile"}}},"@context":["https://www.w3.org/2022/wot/td/v1.1",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":"Service","created":"2024-06-04T17:00:00.000Z","description":"HiveOT runtime service for administration of consumers, Thing agents and services","events":{"added":{"description":"A new client was added","title":"Client Added","data":{"title":"Client ID","readOnly":false,"type":"string"}},"removed":{"description":"A client was removed","title":"Client Removed","data":{"title":"Client ID","readOnly":false,"type":"string"}}},"id":"Admin","modified":"2025-01-28T17:00:00.000Z","properties":{"nrClients":{"title":"Number of clients","readOnly":true,"type":"integer","forms":null}},"security":null,"securityDefinitions":null,"title":"Authentication Administration Service","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/authn/api/UserAgentAPI.go b/runtime/authn/api/UserAgentAPI.go deleted file mode 100644 index 6623f503..00000000 --- a/runtime/authn/api/UserAgentAPI.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package authn with the agent request handler for using service 'User' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authn - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IUserService defines the interface of the 'User' service -// -// This defines a method for each of the actions in the TD. -// -type IUserService interface { - - // GetProfile Get Client Profile - GetProfile(senderID string) (resp ClientProfile, err error) - - // Login Login - // Login with password - Login(senderID string, args UserLoginArgs) (token string, err error) - - // Logout Logout - // Logout from all devices - Logout(senderID string) error - - // RefreshToken Request a new auth token for the current client - RefreshToken(senderID string, oldToken string) (newToken string, err error) - - // UpdateName Request changing the display name of the current client - UpdateName(senderID string, newName string) error - - // UpdatePassword Update Password - // Request changing the password of the current client - UpdatePassword(senderID string, password string) error - - // UpdatePubKey Update Public Key - // Request changing the public key on file of the current client. - UpdatePubKey(senderID string, publicKeyPEM string) error -} - -// NewHandleUserRequest returns an agent handler for Thing 'user' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleUserRequest(svc IUserService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "logout": - if err == nil { - err = svc.Logout(msg.SenderID) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "refreshToken": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.RefreshToken(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "updateName": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.UpdateName(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "updatePassword": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.UpdatePassword(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "updatePubKey": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.UpdatePubKey(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "getProfile": - if err == nil { - output, err = svc.GetProfile(msg.SenderID) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "login": - args := UserLoginArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.Login(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/authn/api/UserConsumerAPI.go b/runtime/authn/api/UserConsumerAPI.go deleted file mode 100644 index 5e19c09b..00000000 --- a/runtime/authn/api/UserConsumerAPI.go +++ /dev/null @@ -1,61 +0,0 @@ -// Package authn with the agent request handler for using service 'User' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authn - -import "github.com/hiveot/hub/lib/consumer" - - -// UserGetProfile client method - Get Client Profile. -func UserGetProfile(hc *consumer.Consumer)(resp ClientProfile, err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserGetProfileMethod, nil, &resp) - return -} - -// UserLogin client method - Login. -// Login with password -func UserLogin(hc *consumer.Consumer, clientID string, password string)(token string, err error){ - var args = UserLoginArgs{clientID, password} - err = hc.Rpc("invokeaction", UserDThingID, UserLoginMethod, &args, &token) - return -} - -// UserLogout client method - Logout. -// Logout from all devices -func UserLogout(hc *consumer.Consumer)(err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserLogoutMethod, nil, nil) - return -} - -// UserRefreshToken client method - Request a new auth token for the current client. -func UserRefreshToken(hc *consumer.Consumer, oldToken string)(newToken string, err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserRefreshTokenMethod, &oldToken, &newToken) - return -} - -// UserUpdateName client method - Request changing the display name of the current client. -func UserUpdateName(hc *consumer.Consumer, newName string)(err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserUpdateNameMethod, &newName, nil) - return -} - -// UserUpdatePassword client method - Update Password. -// Request changing the password of the current client -func UserUpdatePassword(hc *consumer.Consumer, password string)(err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserUpdatePasswordMethod, &password, nil) - return -} - -// UserUpdatePubKey client method - Update Public Key. -// Request changing the public key on file of the current client. -func UserUpdatePubKey(hc *consumer.Consumer, publicKeyPEM string)(err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserUpdatePubKeyMethod, &publicKeyPEM, nil) - return -} diff --git a/runtime/authn/api/UserTypes.go b/runtime/authn/api/UserTypes.go deleted file mode 100644 index 175f5785..00000000 --- a/runtime/authn/api/UserTypes.go +++ /dev/null @@ -1,117 +0,0 @@ -// Package authn with types of service 'User' from agent 'authn' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package authn - - -//--- Constants --- - -// UserAgentID is the account ID of the agent managing the Thing. -const UserAgentID = "authn" - -// UserServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const UserServiceID = "User" - -// UserDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const UserDThingID = "dtw:authn:User" - -// Property, Event and Action names -const ( - UserActionUpdatePassword = "updatePassword" - UserActionUpdatePubKey = "updatePubKey" - UserActionGetProfile = "getProfile" - UserActionLogin = "login" - UserActionLogout = "logout" - UserActionRefreshToken = "refreshToken" - UserActionUpdateName = "updateName" -) - -//--- Schema definitions of Thing 'user' --- - -// ClientProfile defines a Client Profile data schema. -// -// This contains client information of device agents, services and consumers -type ClientProfile struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // ClientType with Client Type - ClientType ClientType `json:"clientType,omitempty"` - - // Disabled with Disabled - // - // This client account has been disabled - Disabled bool `json:"disabled,omitempty"` - - // DisplayName with Display Name - DisplayName string `json:"displayName,omitempty"` - - // PubKey with Public Key - PubKey string `json:"pubKey,omitempty"` - - // Updated with Client name or auth updated timestamp - Updated string `json:"updated,omitempty"` -} - -// ClientType enumerator -// -// identifies the client's category -type ClientType string -const ( - - // ClientTypeAgent for Agent - // - // Agents represent one or more devices - ClientTypeAgent ClientType = "agent" - - // ClientTypeService for Service - // - // Service enrich information - ClientTypeService ClientType = "service" - - // ClientTypeConsumer for Consumer - // - // Consumers are end-users of information - ClientTypeConsumer ClientType = "consumer" -) - -//--- Argument and Response struct for action of Thing 'user' --- - -const UserGetProfileMethod = "getProfile" - -// UserGetProfileResp defines the response of the getProfile function -// Get Client Profile - -// UserGetProfileResp defines a data schema. -type UserGetProfileResp ClientProfile - - -const UserLoginMethod = "login" - -// UserLoginArgs defines the arguments of the login function -// Login - Login with password -type UserLoginArgs struct { - - // ClientID with Login ID - ClientID string `json:"clientID,omitempty"` - - // Password with Password - Password string `json:"password,omitempty"` -} - -const UserLogoutMethod = "logout" - -const UserRefreshTokenMethod = "refreshToken" - -const UserUpdateNameMethod = "updateName" - -const UserUpdatePasswordMethod = "updatePassword" - -const UserUpdatePubKeyMethod = "updatePubKey" - - -// UserTD contains the raw TD of this service for publication to the Hub -const UserTD = `{"actions":{"getProfile":{"@type":"hiveot:function","title":"Get Client Profile","output":{"readOnly":false,"type":"object","schema":"ClientProfile"},"safe":true},"login":{"@type":"hiveot:function","description":"Login with password","title":"Login","input":{"readOnly":false,"type":"object","properties":{"clientID":{"title":"Login ID","readOnly":false,"type":"string"},"password":{"title":"Password","readOnly":false,"type":"string"}}},"output":{"title":"Token","readOnly":false,"type":"string"}},"logout":{"@type":"hiveot:function","description":"Logout from all devices","title":"Logout","idempotent":true},"refreshToken":{"@type":"hiveot:function","title":"Request a new auth token for the current client","input":{"title":"Old Token","readOnly":false,"type":"string"},"output":{"title":"New Token","readOnly":false,"type":"string"}},"updateName":{"@type":"hiveot:function","title":"Request changing the display name of the current client","idempotent":true,"input":{"title":"New Name","readOnly":false,"type":"string"}},"updatePassword":{"@type":"hiveot:function","description":"Request changing the password of the current client","title":"Update Password","idempotent":true,"input":{"title":"Password","readOnly":false,"type":"string"}},"updatePubKey":{"@type":"hiveot:function","description":"Request changing the public key on file of the current client.","title":"Update Public Key","idempotent":true,"input":{"title":"Public Key PEM","description":"Public Key in PEM format","readOnly":false,"type":"string"}}},"@context":["https://www.w3.org/2022/wot/td/v1.1",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":"Service","created":"2024-06-04T17:00:00.000Z","description":"HiveOT runtime service for users","events":{},"id":"User","modified":"2024-06-04T17:00:00.000Z","properties":{},"schemaDefinitions":{"ClientProfile":{"title":"Client Profile","description":"This contains client information of device agents, services and consumers","readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"clientType":{"title":"Client Type","readOnly":false,"type":"ClientType"},"disabled":{"title":"Disabled","description":"This client account has been disabled","readOnly":false,"type":"bool"},"displayName":{"title":"Display Name","readOnly":false,"type":"string"},"pubKey":{"title":"Public Key","readOnly":false,"type":"string"},"updated":{"title":"Client name or auth updated timestamp","readOnly":false,"type":"string"}}},"ClientType":{"title":"Client Type","description":"identifies the client's category","oneOf":[{"title":"Agent","description":"Agents represent one or more devices","const":"agent","readOnly":false},{"title":"Service","description":"Service enrich information","const":"service","readOnly":false},{"title":"Consumer","description":"Consumers are end-users of information","const":"consumer","readOnly":false}],"readOnly":false,"type":"string"}},"security":null,"securityDefinitions":null,"title":"Authentication User Service","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/authn/authenticator/Authenticator_test.go b/runtime/authn/authenticator/Authenticator_test.go deleted file mode 100644 index 04f97212..00000000 --- a/runtime/authn/authenticator/Authenticator_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package authenticator_test - -import ( - "crypto/ed25519" - "os" - "path" - "testing" - "time" - - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authenticator" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/sessions" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var authnStore authnstore.IAuthnStore -var testDir = path.Join(os.TempDir(), "test-authn") -var defaultHash = config.PWHASH_ARGON2id - -func NewAuthenticator() (messaging.IAuthenticator, *sessions.SessionManager) { - passwordFile := path.Join(testDir, "test.passwd") - authnStore = authnstore.NewAuthnFileStore(passwordFile, defaultHash) - //signingKey := keys.NewEcdsaKey() - //svc := authenticator.NewJWTAuthenticator(authnStore, signingKey) - signingKey := keys.NewEd25519Key().PrivateKey().(ed25519.PrivateKey) - sm := sessions.NewSessionmanager() - svc := authenticator.NewPasetoAuthenticator(authnStore, signingKey, sm) - svc.SetAuthServerURI("/fake/server/endpoint") - return svc, sm -} - -func TestCreateSessionToken(t *testing.T) { - const clientID = "user1" - const pass1 = "pass1" - //const clientType = authn.ClientTypeConsumer - sessionID := "session1" - - svc, sm := NewAuthenticator() - _ = authnStore.Add(clientID, authn.ClientProfile{ - ClientID: clientID, - ClientType: authn.ClientTypeConsumer, - Disabled: false, - DisplayName: "test", - }) - err := authnStore.SetPassword(clientID, pass1) - require.NoError(t, err) - - token1 := svc.CreateSessionToken(clientID, sessionID, time.Minute) - assert.NotEmpty(t, token1) - - // decode it - clientID2, sid2, err := svc.DecodeSessionToken(token1, "", "") - require.NoError(t, err) - require.Equal(t, clientID, clientID2) - require.Equal(t, sessionID, sid2) - - // validate the new token. Without a session this fails - clientID3, sid3, err := svc.ValidateToken(token1) - require.Error(t, err) - require.Equal(t, clientID, clientID3) - require.Equal(t, sessionID, sid3) - - _, err = svc.Login(clientID, pass1) - - // create a persistent session token (use clientID as sessionID) - token2 := svc.CreateSessionToken(clientID, clientID, time.Minute) - clientID4, sid4, err := svc.ValidateToken(token2) - require.NoError(t, err) - require.Equal(t, clientID, clientID4) - require.Equal(t, clientID, sid4) - - // session info must exist - sessInfo, found := sm.GetSessionByClientID(clientID) - assert.True(t, found) - assert.Equal(t, clientID4, sessInfo.ClientID) - assert.NotEmpty(t, sessInfo.Created) - assert.NotEmpty(t, sessInfo.Expiry) - -} - -func TestBadTokens(t *testing.T) { - const clientID = "user1" - //const clientType = authn.ClientTypeConsumer - sessionID := "session1" - - svc, _ := NewAuthenticator() - - token1 := svc.CreateSessionToken(clientID, sessionID, time.Minute) - assert.NotEmpty(t, token1) - - // try to refresh as a different client - - // refresh - badToken := token1 + "-bad" - _, _, err := svc.ValidateToken(badToken) - require.Error(t, err) - - // expired - token2 := svc.CreateSessionToken(clientID, sessionID, -1) - clientID2, sid2, err := svc.ValidateToken(token2) - require.Error(t, err) - assert.Empty(t, clientID2) - assert.Empty(t, sid2) - -} diff --git a/runtime/authn/authenticator/JWTAuthenticator.go b/runtime/authn/authenticator/JWTAuthenticator.go deleted file mode 100644 index cafad6dd..00000000 --- a/runtime/authn/authenticator/JWTAuthenticator.go +++ /dev/null @@ -1,299 +0,0 @@ -package authenticator - -import ( - "crypto/x509" - "encoding/base64" - "fmt" - "log/slog" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/keys" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/sessions" - "github.com/teris-io/shortid" -) - -// JWTAuthenticator for generating and validating session tokens. -// This implements the IAuthenticator interface -type JWTAuthenticator struct { - // key used to create and verify session tokens - signingKey keys.IHiveKey - // authentication store for login verification - authnStore authnstore.IAuthnStore - // - authServerURI string - // - AgentTokenValidityDays int - ConsumerTokenValidityDays int - ServiceTokenValidityDays int - - // sessionmanager tracks session IDs - sm *sessions.SessionManager - - // signing method used - signingMethod jwt.SigningMethod // default SigningMethodES256 -} - -// AddSecurityScheme adds the security scheme that this authenticator supports. -// http supports bearer tokens for request authentication, basic and digest authentication -// for logging in. -func (srv *JWTAuthenticator) AddSecurityScheme(tdoc *td.TD) { - - // bearer security scheme for authenticating http and subprotocol connections - format, alg := srv.GetAlg() - - tdoc.AddSecurityScheme("bearer_jwt", td.SecurityScheme{ - //AtType: nil, - Description: "Bearer token authentication", - //Descriptions: nil, - //Proxy: "", - Scheme: "bearer", // nosec, basic, digest, bearer, psk, oauth2, apikey or auto - Authorization: srv.authServerURI, // authentication service URI - Name: "authorization", - Alg: alg, - Format: format, // jwe, cwt, jws, jwt, paseto - In: "header", // query, body, cookie, uri, auto - }) - // bearer security scheme for authenticating http digest connections - // tbd. clients should login and use bearer tokens. - //tdoc.AddSecurityScheme("digest_sc", td.SecurityScheme{ - // Description: "Digest authentication", - // Scheme: "digest", // nosec, basic, digest, bearer, psk, oauth2, apikey or auto - // In: "body", // query, header, body, cookie, uri, auto - //}) -} - -// CreateSessionToken creates a new session token for the client -// -// clientID is the account ID of a known client -// sessionID for which this token is valid. Use clientID to allow no session (agents) -// validityDays is the token validity period in days or 0 for default based on client type -// -// This returns the token -func (svc *JWTAuthenticator) CreateSessionToken( - clientID string, sessionID string, validityDays int) (token string) { - - // TODO: add support for nonce challenge with client pubkey - - // CreateSessionToken creates a signed JWT session token for a client. - // The token is constructed with MapClaims containing "ID" as session ID and - // "clientID" identifying the connectied client. - // The token is signed with the given signing key-pair and valid for the given duration. - - validity := time.Hour * 24 * time.Duration(validityDays) - expiryTime := time.Now().Add(validity) - signingKeyPub, _ := x509.MarshalPKIXPublicKey(svc.signingKey.PublicKey()) - signingKeyPubStr := base64.StdEncoding.EncodeToString(signingKeyPub) - - // Create the JWT claims, which includes the username, clientType and expiry time - claims := jwt.MapClaims{ - //"alg": "ES256", // jwt.SigningMethodES256, - "typ": "JWT", - //"aud": authInfo.SenderID, // recipient of the jwt - "sub": clientID, // subject of the jwt, eg the client - "iss": signingKeyPubStr, // issuer of the jwt (public key) - "exp": expiryTime.Unix(), // expiry time. Seconds since epoch - "iat": time.Now().Unix(), // issued at. Seconds since epoch - - // custom claim fields - "clientID": clientID, - "sessionID": sessionID, - } - - // Declare the token with the algorithm used for signing, and the claims - claimsToken := jwt.NewWithClaims(svc.signingMethod, claims) - sessionToken, _ := claimsToken.SignedString(svc.signingKey.PrivateKey()) - - return sessionToken -} - -// DecodeSessionToken verifies the given JWT token and returns its claims. -// optionally verify the signed nonce using the client's public key. -// This returns the auth info stored in the token. -// -// nonce based verification to prevent replay attacks is intended for future version. -// -// token is the jwt token string containing a session token -// This returns the client info reconstructed from the token or an error if invalid -func (svc *JWTAuthenticator) DecodeSessionToken(token string, signedNonce string, nonce string) ( - clientID string, sessionID string, err error) { - - signingKeyPub, _ := x509.MarshalPKIXPublicKey(svc.signingKey.PublicKey()) - signingKeyPubStr := base64.StdEncoding.EncodeToString(signingKeyPub) - - claims := jwt.MapClaims{} - jwtToken, err := jwt.ParseWithClaims(token, &claims, - func(token *jwt.Token) (interface{}, error) { - return svc.signingKey.PublicKey(), nil - }, jwt.WithValidMethods([]string{ - jwt.SigningMethodES256.Name, - jwt.SigningMethodES384.Name, - jwt.SigningMethodES512.Name, - "EdDSA", - }), - jwt.WithIssuer(signingKeyPubStr), // url encoded string - jwt.WithExpirationRequired(), - ) - claimsClientID := claims["clientID"] - if claimsClientID != nil { - clientID = claimsClientID.(string) - } - sid := claims["sessionID"] - if sid != nil { - sessionID = sid.(string) - } - if err != nil || jwtToken == nil || !jwtToken.Valid { - return clientID, sessionID, - fmt.Errorf("ValidateToken: %w", err) - } - return clientID, sessionID, nil -} - -// GetAlg returns the authentication scheme (jwt) and algorithm -func (svc *JWTAuthenticator) GetAlg() (string, string) { - return "jwt", svc.signingMethod.Alg() -} - -// Login with password and generate a session token -// Intended for end-users that want to establish a session. -// -// clientID is the client to log in -// password to verify -// sessionID of the new session or "" to generate a new session ID -// -// This returns a session token, its session ID, or an error if failed -func (svc *JWTAuthenticator) Login(clientID string, password string) (token string, err error) { - var sessionID string - // a user login always creates a session token - err = svc.ValidatePassword(clientID, password) - if err != nil { - return "", err - } - - // check if this user has an existing session. Generate the token using its - // existing sessionID. - cs, found := svc.sm.GetSessionByClientID(clientID) - if found { - // use the existing session id and renew the session expiry - sessionID = cs.SessionID - } else { - // password login always uses the consumer token validity - sessionID = shortid.MustGenerate() - } - // create the session to allow token refresh - svc.sm.NewSession(clientID, sessionID) - token = svc.CreateSessionToken(clientID, sessionID, svc.ConsumerTokenValidityDays) - - return token, err -} - -// Logout removes the client session -func (svc *JWTAuthenticator) Logout(clientID string) { - cs, found := svc.sm.GetSessionByClientID(clientID) - if found { - svc.sm.Remove(cs.SessionID) - } -} - -// RefreshToken requests a new token based on the old token -// This requires that the existing session is still valid -func (svc *JWTAuthenticator) RefreshToken( - senderID string, clientID string, oldToken string) (newToken string, err error) { - - // validation only succeeds if there is an active session - tokenClientID, sessionID, err := svc.ValidateToken(oldToken) - if err != nil || clientID != senderID || clientID != tokenClientID { - return newToken, fmt.Errorf("SenderID mismatch") - } - // must still be a valid client - prof, err := svc.authnStore.GetProfile(senderID) - _ = prof - if err != nil || prof.Disabled { - return newToken, fmt.Errorf("Profile for '%s' is disabled", clientID) - } - validityDays := svc.ConsumerTokenValidityDays - if prof.ClientType == authn.ClientTypeAgent { - validityDays = svc.AgentTokenValidityDays - } else if prof.ClientType == authn.ClientTypeService { - validityDays = svc.ServiceTokenValidityDays - } - newToken = svc.CreateSessionToken(clientID, sessionID, validityDays) - return newToken, err -} - -// SetAuthServerURI this sets the server endpoint needed to login. -// This is included when adding the TD security scheme in AddSecurityScheme() -func (svc *JWTAuthenticator) SetAuthServerURI(serverURI string) { - svc.authServerURI = serverURI -} -func (svc *JWTAuthenticator) ValidatePassword(clientID, password string) (err error) { - clientProfile, err := svc.authnStore.VerifyPassword(clientID, password) - _ = clientProfile - return err -} - -// ValidateToken the session token -// For agents, the sessionID equals the clientID and no session check will take place. (sessions are for consumers only) -func (svc *JWTAuthenticator) ValidateToken(token string) (clientID string, sessionID string, err error) { - clientID, sid, err := svc.DecodeSessionToken(token, "", "") - if err != nil { - return "", "", err - } - // agents don't require a session - // TBD: if agents do need sessions then the sessions need to be persisted and restored. - // This is a bit of a pain to manage so a future consideration. - if clientID == sid { - return clientID, sid, nil - } - cs, found := svc.sm.GetSessionBySessionID(sid) - if !found { - slog.Warn("ValidateToken. No session found for client", "clientID", clientID) - return "", "", fmt.Errorf("Session is no longer valid") - } - // if the session has expired, remove it - if cs.Expiry.Before(time.Now()) { - svc.sm.Remove(sid) - slog.Warn("ValidateToken. Session has expired", "clientID", clientID) - return "", "", fmt.Errorf("Session has expired") - } - return clientID, sid, nil -} - -// NewJWTAuthenticator returns a new instance of a JWT token authenticator -func NewJWTAuthenticator(authnStore authnstore.IAuthnStore, signingKey keys.IHiveKey, authServerURI string) *JWTAuthenticator { - svc := JWTAuthenticator{ - signingKey: signingKey, - authnStore: authnStore, - authServerURI: authServerURI, - // validity can be changed by user of this service - AgentTokenValidityDays: config.DefaultAgentTokenValidityDays, - ConsumerTokenValidityDays: config.DefaultConsumerTokenValidityDays, - ServiceTokenValidityDays: config.DefaultServiceTokenValidityDays, - sm: sessions.NewSessionmanager(), - signingMethod: jwt.SigningMethodES256, - } - return &svc -} - -// NewJWTAuthenticatorFromFile returns a new instance of a JWT token authenticator -// loading a keypair from file or creating one if it doesn't exist. -// This returns nil if no signing key can be loaded or created -//func NewJWTAuthenticatorFromFile( -// authnStore api.IAuthnStore, -// keysDir string, keyType keys.KeyType) *JWTAuthenticator { -// -// clientID := "authn" -// signingKey, err := keys.LoadCreateKeyPair(clientID, keysDir, keyType) -// if err != nil { -// slog.Error("NewJWTAuthenticatorFromFile failed creating key pair for client", -// "err", err.Error(), "clientID", clientID) -// return nil -// } -// _ = err -// svc := NewJWTAuthenticator(authnStore, signingKey) -// return svc -//} diff --git a/runtime/authn/authenticator/PasetoAuthenticator.go b/runtime/authn/authenticator/PasetoAuthenticator.go deleted file mode 100644 index 97b7c407..00000000 --- a/runtime/authn/authenticator/PasetoAuthenticator.go +++ /dev/null @@ -1,282 +0,0 @@ -package authenticator - -import ( - "crypto/ed25519" - "fmt" - "log/slog" - "time" - - "aidanwoods.dev/go-paseto" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/keys" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/sessions" - "github.com/teris-io/shortid" -) - -// PasetoAuthenticator for generating and validating session tokens. -// This implements the IAuthenticator interface -type PasetoAuthenticator struct { - // key used to create and verify session tokens - signingKey ed25519.PrivateKey - // authentication store for login verification - authnStore authnstore.IAuthnStore - // The URI of the authentication service that provides paseto tokens - authServerURI string - AgentTokenValidityDays int - ConsumerTokenValidityDays int - ServiceTokenValidityDays int - - // sessionmanager tracks session IDs - sm *sessions.SessionManager -} - -// AddSecurityScheme adds this authenticator's security scheme to the given TD. -// This authenticator uses paseto tokens as bearer tokens that can be obtained from -// the login authentication service. -func (srv *PasetoAuthenticator) AddSecurityScheme(tdoc *td.TD) { - - // bearer security scheme for authenticating http and subprotocol connections - format, alg := srv.GetAlg() - - tdoc.AddSecurityScheme("bearer_paseto", td.SecurityScheme{ - //AtType: nil, - Description: "Bearer token authentication", - //Descriptions: nil, - //Proxy: "", - Scheme: "bearer", // nosec, basic, digest, bearer, psk, oauth2, apikey or auto - Authorization: srv.authServerURI, // service to obtain a token - Name: "authorization", - Alg: alg, - Format: format, // jwe, cwt, jws, jwt, paseto - In: "header", // query, body, cookie, uri, auto - }) -} - -// CreateSessionToken creates a new session token for the client -// -// clientID is the account ID of a known client -// sessionID for which this token is valid. Use clientID to allow no session (agents) -// validitySec is the token validity period or 0 for default based on client type -// -// This returns the token -func (svc *PasetoAuthenticator) CreateSessionToken( - clientID string, sessionID string, validity time.Duration) (token string) { - - // TODO: add support for nonce challenge with client pubkey - - // CreateSessionToken creates a signed Paseto session token for a client. - // The token is signed with the given signing key-pair and valid for the given duration. - expiryTime := time.Now().Add(validity) - - pToken := paseto.NewToken() - pToken.SetIssuer("hiveot") - pToken.SetSubject(clientID) - pToken.SetExpiration(expiryTime) - pToken.SetIssuedAt(time.Now()) - pToken.SetNotBefore(time.Now()) - // custom claims - pToken.SetString("sessionID", sessionID) - pToken.SetString("clientID", clientID) - - secretKey, err := paseto.NewV4AsymmetricSecretKeyFromEd25519(svc.signingKey) - if err != nil { - slog.Error("failed making paseto secret key from ED25519") - secretKey = paseto.NewV4AsymmetricSecretKey() - } - signedToken := pToken.V4Sign(secretKey, nil) - - return signedToken -} - -// DecodeSessionToken verifies the given token and returns its claims. -// optionally verify the signed nonce using the client's public key. (todo) -// This returns the auth info stored in the token. -// -// nonce based verification to prevent replay attacks is intended for future version. -// -// token is the token string containing a session token -// This returns the client info reconstructed from the token or an error if invalid -func (svc *PasetoAuthenticator) DecodeSessionToken(sessionKey string, signedNonce string, nonce string) ( - clientID string, sessionID string, err error) { - var pToken *paseto.Token - - pasetoParser := paseto.NewParserForValidNow() - pubKey := svc.signingKey.Public().(ed25519.PublicKey) - v4PubKey, err := paseto.NewV4AsymmetricPublicKeyFromEd25519(pubKey) - if err == nil { - pToken, err = pasetoParser.ParseV4Public(v4PubKey, sessionKey, nil) - } - if err == nil { - clientID, err = pToken.GetString("clientID") - } - if err == nil { - sessionID, err = pToken.GetString("sessionID") - } - if err != nil { - slog.Warn("DecodeSessionToken: the given session token is no longer valid: ", "err", err.Error()) - } - return clientID, sessionID, err -} - -// GetAlg returns the authentication scheme and algorithm -func (svc *PasetoAuthenticator) GetAlg() (string, string) { - return "paseto", "public" -} - -// Login with password and generate a session token -// Intended for end-users that want to establish a session. -// -// clientID is the client to log in -// password to verify -// sessionID of the new session or "" to generate a new session ID -// -// This returns a session token, its session ID, or an error if failed -func (svc *PasetoAuthenticator) Login(clientID string, password string) (token string, err error) { - var sessionID string - // a user login always creates a session token - err = svc.ValidatePassword(clientID, password) - if err != nil { - return "", err - } - - // check if this user has an existing session. Generate the token using its - // existing sessionID. - cs, found := svc.sm.GetSessionByClientID(clientID) - if found { - // use the existing session id and renew the session expiry - sessionID = cs.SessionID - } else { - // password login always uses the consumer token validity - sessionID = shortid.MustGenerate() - } - // create the session to allow token refresh - svc.sm.NewSession(clientID, sessionID) - validity := time.Hour * time.Duration(24*svc.ConsumerTokenValidityDays) - token = svc.CreateSessionToken(clientID, sessionID, validity) - - return token, err -} - -// Logout removes the client session -func (svc *PasetoAuthenticator) Logout(clientID string) { - cs, found := svc.sm.GetSessionByClientID(clientID) - if found { - svc.sm.Remove(cs.SessionID) - } -} - -// RefreshToken requests a new token based on the old token -// This requires that the existing session is still valid -func (svc *PasetoAuthenticator) RefreshToken( - senderID string, oldToken string) (newToken string, err error) { - - // validation only succeeds if there is an active session - tokenClientID, sessionID, err := svc.ValidateToken(oldToken) - if err != nil || senderID != tokenClientID { - return newToken, fmt.Errorf("SenderID mismatch") - } - // must still be a valid client - prof, err := svc.authnStore.GetProfile(senderID) - _ = prof - if err != nil || prof.Disabled { - return newToken, fmt.Errorf("Profile for '%s' is disabled", senderID) - } - validityDays := svc.ConsumerTokenValidityDays - if prof.ClientType == authn.ClientTypeAgent { - validityDays = svc.AgentTokenValidityDays - } else if prof.ClientType == authn.ClientTypeService { - validityDays = svc.ServiceTokenValidityDays - } - validity := time.Duration(validityDays) * 24 * time.Hour - newToken = svc.CreateSessionToken(senderID, sessionID, validity) - return newToken, err -} - -// SetAuthServerURI this sets the server endpoint starting the authorization flow. -// This is included when adding the TD security scheme in AddSecurityScheme() -func (svc *PasetoAuthenticator) SetAuthServerURI(serverURI string) { - svc.authServerURI = serverURI -} - -func (svc *PasetoAuthenticator) ValidatePassword(clientID, password string) (err error) { - clientProfile, err := svc.authnStore.VerifyPassword(clientID, password) - _ = clientProfile - return err -} - -// ValidateToken the session token -// For agents, the sessionID equals the clientID and no session check will take place. (sessions are for consumers only) -func (svc *PasetoAuthenticator) ValidateToken(token string) (clientID string, sessionID string, err error) { - clientID, sid, err := svc.DecodeSessionToken(token, "", "") - if err != nil { - return clientID, sid, err - } - // agents don't require a session - // TBD: if agents do need sessions then the sessions need to be persisted and restored. - // This is a bit of a pain to manage so a future consideration. - if clientID == sid { - return clientID, sid, nil - } - cs, found := svc.sm.GetSessionBySessionID(sid) - if !found { - slog.Warn("ValidateToken. No session found for client", "clientID", clientID) - return clientID, sid, fmt.Errorf("Session is no longer valid") - } - // if the session has expired, remove it - if cs.Expiry.Before(time.Now()) { - svc.sm.Remove(sid) - slog.Warn("ValidateToken. Session has expired", "clientID", clientID) - return clientID, sid, fmt.Errorf("Session has expired") - } - return clientID, sid, nil -} - -// NewPasetoAuthenticator returns a new instance of a Paseto token authenticator using the given signing key -// the session manager is used -func NewPasetoAuthenticator( - authnStore authnstore.IAuthnStore, - signingKey ed25519.PrivateKey, - sm *sessions.SessionManager) *PasetoAuthenticator { - - paseto.NewV4AsymmetricSecretKey() - - svc := PasetoAuthenticator{ - signingKey: signingKey, - authnStore: authnStore, - //authServerURI: authServerURI, use SetAuthServerURI - // validity can be changed by user of this service - AgentTokenValidityDays: config.DefaultAgentTokenValidityDays, - ConsumerTokenValidityDays: config.DefaultConsumerTokenValidityDays, - ServiceTokenValidityDays: config.DefaultServiceTokenValidityDays, - sm: sm, - } - return &svc -} - -// NewPasetoAuthenticatorFromFile returns a new instance of a Paseto token authenticator -// loading a keypair from file or creating one if it doesn't exist. -// This returns nil if no signing key can be loaded or created -// -// The authServerURI is included the TD security scheme to point consumers to the -// endpoint to obtain tokens for this authenticator. -func NewPasetoAuthenticatorFromFile( - authnStore authnstore.IAuthnStore, - keysDir string, - sm *sessions.SessionManager) *PasetoAuthenticator { - - clientID := "authn" - authKey, err := keys.LoadCreateKeyPair(clientID, keysDir, keys.KeyTypeEd25519) - - if err != nil { - slog.Error("NewPasetoAuthenticatorFromFile failed loading or creating a Paseto key pair", - "err", err.Error(), "clientID", clientID) - panic("failed loading or creating Paseto key pair") - } - signingKey := authKey.PrivateKey().(ed25519.PrivateKey) - _ = err - svc := NewPasetoAuthenticator(authnStore, signingKey, sm) - return svc -} diff --git a/runtime/authn/authnstore/AuthnFileStore.go b/runtime/authn/authnstore/AuthnFileStore.go deleted file mode 100644 index cecc3d80..00000000 --- a/runtime/authn/authnstore/AuthnFileStore.go +++ /dev/null @@ -1,382 +0,0 @@ -package authnstore - -import ( - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "path" - "sync" - - "github.com/alexedwards/argon2id" - "github.com/fsnotify/fsnotify" - "github.com/hiveot/hivekit/go/utils" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/config" - jsoniter "github.com/json-iterator/go" - "golang.org/x/crypto/bcrypt" -) - -// AuthnFileStore stores client data, including users, devices and services. -// User passwords are stored using ARGON2id hash -// It includes a file watcher to automatically reload on update. -type AuthnFileStore struct { - entries map[string]AuthnEntry - storePath string - hashAlgo string // hashing algorithm PWHASH_ARGON2id - minPasswordLength int - watcher *fsnotify.Watcher - mutex sync.RWMutex -} - -// Add a new client. -// clientID, clientType are required, the rest is optional -func (store *AuthnFileStore) Add(clientID string, profile authn.ClientProfile) error { - - store.mutex.Lock() - defer store.mutex.Unlock() - - entry, found := store.entries[clientID] - if clientID == "" || clientID != profile.ClientID { - return fmt.Errorf("Add: missing clientID") - } - if profile.ClientType != authn.ClientTypeAgent && - profile.ClientType != authn.ClientTypeConsumer && - profile.ClientType != authn.ClientTypeService { - return fmt.Errorf("Add: invalid clientType '%s' for client '%s'", - profile.ClientType, clientID) - } - - if !found { - slog.Info("Add: New client " + clientID) - entry = AuthnEntry{ClientProfile: profile} - } else { - slog.Info("Add: Updating existing client", slog.String("clientID", clientID)) - entry.ClientProfile = profile - } - entry.Updated = utils.FormatNowUTCMilli() - - store.entries[clientID] = entry - - err := store.save() - return err -} - -// Close the store -func (store *AuthnFileStore) Close() { - store.mutex.Lock() - defer store.mutex.Unlock() - if store.watcher != nil { - _ = store.watcher.Close() - store.watcher = nil - } -} - -// Count nr of entries in the store -func (store *AuthnFileStore) Count() int { - store.mutex.RLock() - defer store.mutex.RUnlock() - - return len(store.entries) -} - -// GetProfile returns the client's profile -func (store *AuthnFileStore) GetProfile( - clientID string) (profile authn.ClientProfile, err error) { - - store.mutex.RLock() - defer store.mutex.RUnlock() - // user must exist - entry, found := store.entries[clientID] - if !found { - err = fmt.Errorf("clientID '%s' does not exist", clientID) - } - return entry.ClientProfile, err -} - -// GetProfiles returns a list of all client profiles in the store -func (store *AuthnFileStore) GetProfiles() (profiles []authn.ClientProfile, err error) { - store.mutex.RLock() - defer store.mutex.RUnlock() - profiles = make([]authn.ClientProfile, 0, len(store.entries)) - for _, entry := range store.entries { - profiles = append(profiles, entry.ClientProfile) - } - return profiles, nil -} - -// GetRole returns the client's stored role -func (store *AuthnFileStore) GetRole(clientID string) (role string, err error) { - store.mutex.RLock() - defer store.mutex.RUnlock() - // user must exist - entry, found := store.entries[clientID] - if !found { - err = fmt.Errorf("clientID '%s' does not exist", clientID) - return "", err - } - return entry.Role, nil -} - -// GetEntries returns a list of all profiles with their hashed passwords -func (store *AuthnFileStore) GetEntries() (entries []AuthnEntry) { - store.mutex.RLock() - defer store.mutex.RUnlock() - entries = make([]AuthnEntry, 0, len(store.entries)) - for _, entry := range store.entries { - entries = append(entries, entry) - } - return entries -} - -// Open the store -// This reads the password file and subscribes to file changes -func (store *AuthnFileStore) Open() (err error) { - if store.watcher != nil { - err = fmt.Errorf("password file store '%s' is already open", store.storePath) - } - if err == nil { - err = store.Reload() - } - if err == nil { - store.watcher, err = utils.WatchFile(store.storePath, store.Reload) - } - if err != nil { - err = fmt.Errorf("AddSession failed %w", err) - } - return err -} - -// Reload the password store from file and subscribe to file changes -// -// If the file does not exist, it will be created. -// Returns an error if the file could not be opened/created. -func (store *AuthnFileStore) Reload() error { - store.mutex.Lock() - defer store.mutex.Unlock() - - entries := make(map[string]AuthnEntry) - dataBytes, err := os.ReadFile(store.storePath) - if errors.Is(err, os.ErrNotExist) { - err = store.save() - } else if err != nil { - err = fmt.Errorf("error reading password file: %w", err) - return err - } else if len(dataBytes) == 0 { - // nothing to do - } else { - - err = jsoniter.Unmarshal(dataBytes, &entries) - if err != nil { - err := fmt.Errorf("error while parsing password file: %w", err) - return err - } - store.entries = entries - } - return err -} - -// Remove a client from the store -func (store *AuthnFileStore) Remove(clientID string) (err error) { - store.mutex.Lock() - defer store.mutex.Unlock() - - _, found := store.entries[clientID] - if found { - delete(store.entries, clientID) - } - err = store.save() - return err -} - -// save the password data to file -// if the storage folder doesn't exist it will be created -// not concurrent save -func (store *AuthnFileStore) save() error { - - folder := path.Dir(store.storePath) - // ensure the location exists - err := os.MkdirAll(folder, 0700) - if err != nil { - return err - } - tmpPath, err := WritePasswordsToTempFile(folder, store.entries) - if err != nil { - err = fmt.Errorf("writing password file to temp failed: %w", err) - return err - } - - err = os.Rename(tmpPath, store.storePath) - if err != nil { - err = fmt.Errorf("rename to password file failed: %w", err) - return err - } - return err -} - -// SetPassword generates and stores the user's password hash. -// -// The hash used is argon2id or bcrypt based on the 'hashAlgo' setting. -// bcrypt limits max password length to 72 bytes. -func (store *AuthnFileStore) SetPassword(loginID string, password string) (err error) { - var hash string - if len(password) < store.minPasswordLength { - return fmt.Errorf("password too short (%d chars)", len(password)) - } - if store.hashAlgo == config.PWHASH_ARGON2id { - params := argon2id.DefaultParams - params.Memory = 16 * 1024 - params.Iterations = 2 - params.Parallelism = 4 // what happens with fewer cores? - hash, err = argon2id.CreateHash(password, params) - } else if store.hashAlgo == config.PWHASH_BCRYPT { - hashBytes, err2 := bcrypt.GenerateFromPassword([]byte(password), 0) - err = err2 - hash = string(hashBytes) - } else { - err = fmt.Errorf("unknown password hash: %s", store.hashAlgo) - } - if err != nil { - return err - } - return store.SetPasswordHash(loginID, hash) -} - -// SetPasswordHash adds/updates the password hash for the given login ID -// Intended for clients to update their own password -func (store *AuthnFileStore) SetPasswordHash(loginID string, hash string) (err error) { - store.mutex.Lock() - defer store.mutex.Unlock() - - entry, found := store.entries[loginID] - if !found { - return fmt.Errorf("client '%s' not found", loginID) - } - entry.PasswordHash = hash - entry.Updated = utils.FormatNowUTCMilli() - store.entries[loginID] = entry - - err = store.save() - return err -} - -// SetRole changes the client's default role -// This returns an error if role is not a known role -func (store *AuthnFileStore) SetRole(clientID string, role string) error { - store.mutex.Lock() - defer store.mutex.Unlock() - entry, found := store.entries[clientID] - if !found { - return fmt.Errorf("SetRole: Client '%s' not found", clientID) - } - - entry.Role = role - store.entries[clientID] = entry - err := store.save() - return err -} - -// UpdateProfile updates the client profile -// The senderID is the connection ID of the sender. -// Authorization should only allow admin level -func (store *AuthnFileStore) UpdateProfile(senderID string, profile authn.ClientProfile) error { - store.mutex.Lock() - defer store.mutex.Unlock() - - entry, found := store.entries[senderID] - if !found { - return fmt.Errorf("UpdateProfile: SenderID='%s' not found", senderID) - } - - entry, found = store.entries[profile.ClientID] - if !found { - return fmt.Errorf("UpdateProfile: SenderID='%s'; Client '%s' not found", - senderID, profile.ClientID) - } - if profile.ClientType != "" { - entry.ClientType = profile.ClientType - } - if profile.DisplayName != "" { - entry.DisplayName = profile.DisplayName - } - if profile.PubKey != "" { - entry.PubKey = profile.PubKey - } - entry.Updated = utils.FormatNowUTCMilli() - store.entries[profile.ClientID] = entry - - err := store.save() - return err -} - -// VerifyPassword verifies the given password with the stored hash -// This returns the matching user's entry or an error if the password doesn't match -func (store *AuthnFileStore) VerifyPassword( - loginID, password string) (profile authn.ClientProfile, err error) { - isValid := false - store.mutex.Lock() - defer store.mutex.Unlock() - - entry, found := store.entries[loginID] - if !found { - // unknown user - isValid = false - } else if store.hashAlgo == config.PWHASH_ARGON2id { - isValid, _ = argon2id.ComparePasswordAndHash(password, entry.PasswordHash) - } else if store.hashAlgo == config.PWHASH_BCRYPT { - err := bcrypt.CompareHashAndPassword([]byte(entry.PasswordHash), []byte(password)) - isValid = err == nil - } - if !isValid { - return profile, fmt.Errorf("invalid login as '%s'", loginID) - } - profile = entry.ClientProfile - return profile, nil -} - -// WritePasswordsToTempFile write the given entries to temp file in the given folder -// This returns the name of the new temp file. -func WritePasswordsToTempFile( - folder string, entries map[string]AuthnEntry) (tempFileName string, err error) { - - file, err := os.CreateTemp(folder, "hub-pwfilestore") - - // file, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - err := fmt.Errorf("failed open temp password file: %s", err) - return "", err - } - tempFileName = file.Name() - - defer file.Close() - pwData, err := json.Marshal(entries) - if err == nil { - _, err = file.Write(pwData) - } - - return tempFileName, err -} - -// NewAuthnFileStore creates a new instance of a file based identity store. -// Call AddSession/Release to start/stop using this store. -// Note: this store is intended for one writer and many readers. -// Multiple concurrent writes are not supported and might lead to one write being ignored. -// -// filepath location of the file store. See also DefaultPasswordFile for the recommended name -// hashAlgo PWHASH_ARGON2id (default) or PWHASH_BCRYPT -func NewAuthnFileStore(filepath string, hashAlgo string) *AuthnFileStore { - if hashAlgo == "" { - hashAlgo = config.PWHASH_ARGON2id - } - if hashAlgo != config.PWHASH_ARGON2id && hashAlgo != config.PWHASH_BCRYPT { - slog.Error("unknown hash algorithm. Falling back to argon2id", "hashAlgo", hashAlgo) - } - store := &AuthnFileStore{ - storePath: filepath, - hashAlgo: hashAlgo, - minPasswordLength: 5, - entries: make(map[string]AuthnEntry), - } - return store -} diff --git a/runtime/authn/authnstore/AuthnFileStore_test.go b/runtime/authn/authnstore/AuthnFileStore_test.go deleted file mode 100644 index 596eb9b4..00000000 --- a/runtime/authn/authnstore/AuthnFileStore_test.go +++ /dev/null @@ -1,396 +0,0 @@ -package authnstore_test - -import ( - "fmt" - "log/slog" - "os" - "path" - "sync" - "testing" - "time" - - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - authz "github.com/hiveot/hub/runtime/authz/api" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" -) - -const unpwFileName = "testunpwstore.passwd" - -var unpwFilePath string - -var tempFolder string -var algo = config.PWHASH_ARGON2id - -// TestMain for all authn tests, setup of default folders and filenames -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - tempFolder = path.Join(os.TempDir(), "hiveot-authn-test") - _ = os.MkdirAll(tempFolder, 0700) - - // Connect without pw file - unpwFilePath = path.Join(tempFolder, unpwFileName) - _ = os.Remove(unpwFilePath) - - res := m.Run() - if res == 0 { - _ = os.RemoveAll(tempFolder) - } - os.Exit(res) -} - -func TestOpenClosePWFile(t *testing.T) { - _ = os.Remove(unpwFilePath) - unpwStore := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := unpwStore.Open() - assert.NoError(t, err) - - // open twice should provide error - err2 := unpwStore.Open() - assert.Error(t, err2) - - time.Sleep(time.Millisecond * 100) - unpwStore.Close() -} - -func TestOpenBadData(t *testing.T) { - // /bin/yes cannot be read - unpwStore := authnstore.NewAuthnFileStore("/bin/yes", "") - err := unpwStore.Open() - assert.Error(t, err) - -} - -func TestGetMissingEntry(t *testing.T) { - _ = os.Remove(unpwFilePath) - // create 2 separate stores - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - defer pwStore1.Close() - - // entry doesn't yet exist - entry, err := pwStore1.GetProfile("iddoesn'texist") - assert.Error(t, err) - assert.Empty(t, entry) -} - -// Add password -func TestAdd(t *testing.T) { - const user1 = "user1" - const pass1 = "pass1" - const user2 = "user2" - const pass2 = "pass2" - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - defer pwStore1.Close() - - // add should succeed - err = pwStore1.Add(user1, authn.ClientProfile{ - ClientID: user1, - ClientType: authn.ClientTypeConsumer, - }) - require.NoError(t, err) - - // adding existing user should update it - err = pwStore1.Add(user1, authn.ClientProfile{ - ClientID: user1, - ClientType: authn.ClientTypeConsumer, - DisplayName: "updated user 1", - }) - assert.NoError(t, err) - prof1, _ := pwStore1.GetProfile(user1) - assert.Equal(t, "updated user 1", prof1.DisplayName) - - // adding missing client should fail - err = pwStore1.Add(user2, authn.ClientProfile{ClientID: "", ClientType: authn.ClientTypeConsumer}) - assert.Error(t, err) - // adding missing type should fail - err = pwStore1.Add(user2, authn.ClientProfile{ClientID: user2, ClientType: ""}) - assert.Error(t, err) - -} - -// verify password -func TestVerifyHashAlgo(t *testing.T) { - const user1 = "user1" - const pass1 = "pass1" - const user2 = "user2" - const pass2 = "pass2" - - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, algo) - err := pwStore1.Open() - require.NoError(t, err) - defer pwStore1.Close() - - err = pwStore1.Add(user1, authn.ClientProfile{ - ClientID: user1, ClientType: authn.ClientTypeConsumer}) - err = pwStore1.Add(user2, authn.ClientProfile{ - ClientID: user2, ClientType: authn.ClientTypeConsumer}) - err = pwStore1.SetPassword(user1, pass1) - require.NoError(t, err) - profile1, err := pwStore1.GetProfile(user1) - require.NoError(t, err) - profile2, err2 := pwStore1.VerifyPassword(user1, pass1) - require.NoError(t, err2, "password verification failed") - assert.Equal(t, user1, profile1.ClientID) - assert.Equal(t, profile1, profile2) - assert.NotEmpty(t, profile1.Updated) - - // verify incorrect password - _, err = pwStore1.VerifyPassword(user1, pass2) - assert.Error(t, err) - - // empty password not allowed - err = pwStore1.SetPassword(user1, "") - assert.Error(t, err) - // empty password verification should fail - _, err = pwStore1.VerifyPassword(user2, "") - assert.Error(t, err) - - // wrong user not found - _, err = pwStore1.VerifyPassword("wronguser", pass1) - assert.Error(t, err) - // wrong password should fail - _, err = pwStore1.VerifyPassword(user1, "wrongpassword") - assert.Error(t, err) - - // after removing user it should fail - - // verify incorrect password - err = pwStore1.Remove(user1) - assert.NoError(t, err) - _, err = pwStore1.VerifyPassword(user1, pass1) - assert.Error(t, err) -} - -// verify password -func TestVerifyBCryptAlgo(t *testing.T) { - algo = config.PWHASH_BCRYPT - TestVerifyHashAlgo(t) - algo = config.PWHASH_ARGON2id -} - -func TestName(t *testing.T) { - const user1 = "user1" - const name1 = "user one" - - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - defer pwStore1.Close() - err = pwStore1.Add(user1, authn.ClientProfile{ - ClientID: user1, ClientType: authn.ClientTypeConsumer, DisplayName: name1}) - - entry, err := pwStore1.GetProfile(user1) - assert.NoError(t, err) - assert.Equal(t, user1, entry.ClientID) - assert.Equal(t, name1, entry.DisplayName) - assert.NotEmpty(t, entry.Updated) -} - -func TestSetPasswordTwoStores(t *testing.T) { - const user1 = "user1" - const user2 = "user2" - const pass1 = "pass1" - const pass2 = "pass2" - - // create 2 separate stores - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - err = pwStore1.Add(user1, - authn.ClientProfile{ClientID: user1, ClientType: authn.ClientTypeConsumer}) - require.NoError(t, err) - // - pwStore2 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err = pwStore2.Open() - require.NoError(t, err) - err = pwStore2.Add(user2, - authn.ClientProfile{ClientID: user2, ClientType: authn.ClientTypeConsumer}) - require.NoError(t, err) - err = pwStore1.Reload() - require.NoError(t, err) - time.Sleep(time.Millisecond) - - // set password in store 1, should appear in store 2 - err = pwStore1.SetPassword(user1, pass1) - assert.NoError(t, err) - // wait before reload - time.Sleep(time.Millisecond * 100) - // check mode of pw file - info, err := os.Stat(unpwFilePath) - require.NoError(t, err) - mode := info.Mode() - assert.Equal(t, 0600, int(mode), "file mode not 0600") - - // read back - // force reload. Don't want to wait - err = pwStore2.Reload() - assert.NoError(t, err) - - // must exist - _, err = pwStore2.GetProfile(user1) - assert.NoError(t, err) - - profile1, err := pwStore2.GetProfile(user1) - assert.NoError(t, err) - profile2, err := pwStore2.VerifyPassword(user1, pass1) - assert.NoError(t, err) - assert.NotEmpty(t, profile1) - assert.Equal(t, profile1, profile2) - - // do it again but in reverse - slog.Info("- do it again in reverse -") - err = pwStore2.SetPassword(user2, pass2) - assert.NoError(t, err) - time.Sleep(time.Millisecond * 100) - - prof2, err := pwStore1.GetProfile(user2) - assert.NoError(t, err) - assert.Equal(t, prof2.ClientID, user2) - prof3, err := pwStore1.VerifyPassword(user2, pass2) - assert.Equal(t, prof2, prof3) - - assert.NoError(t, err) - time.Sleep(time.Millisecond * 100) - pwStore1.Close() - pwStore2.Close() -} - -// Load test if one writer with second reader -func TestConcurrentReadWrite(t *testing.T) { - var wg sync.WaitGroup - var i int - - // start with empty file - fp, _ := os.Create(unpwFilePath) - _ = fp.Close() - - // two stores in parallel - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - assert.NoError(t, err) - pwStore2 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err = pwStore2.Open() - assert.NoError(t, err) - - wg.Add(1) - go func() { - for i = 0; i < 30; i++ { - thingID := fmt.Sprintf("things-%d", i) - err = pwStore1.Add(thingID, - authn.ClientProfile{ClientID: thingID, ClientType: authn.ClientTypeConsumer}) - time.Sleep(time.Millisecond * 1) - if err != nil { - assert.NoError(t, err) - } - } - wg.Done() - }() - wg.Wait() - // time to catch up the file watcher debouncing - time.Sleep(time.Second * 1) - - profiles, err := pwStore1.GetProfiles() - assert.NoError(t, err) - - // both stores should be fully up to date - assert.Equal(t, i, len(profiles)) - assert.Equal(t, i, pwStore1.Count()) - assert.Equal(t, i, pwStore2.Count()) - - // - pwStore1.Close() - pwStore2.Close() -} - -func TestWritePwToBadTempFolder(t *testing.T) { - pws := make(map[string]authnstore.AuthnEntry) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - assert.NoError(t, err) - _, err = authnstore.WritePasswordsToTempFile("/badfolder", pws) - assert.Error(t, err) - pwStore1.Close() -} - -func TestWritePwToReadonlyFile(t *testing.T) { - const user1 = "user1" - const pass1 = "pass1" - // bin/yes cannot be written to - pwStore1 := authnstore.NewAuthnFileStore("/bin/yes", "") - err := pwStore1.Open() - assert.Error(t, err) - err = pwStore1.SetPassword(user1, pass1) - assert.Error(t, err) - pwStore1.Close() -} - -func TestUpdate(t *testing.T) { - const user1 = "user1" - const name1 = "name1" - const key1 = "pubkey1" - const user2 = "user2" - const name2 = "name2" - - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - err = pwStore1.Add(user1, authn.ClientProfile{ClientID: user1, - ClientType: authn.ClientTypeConsumer, DisplayName: name1}) - require.NoError(t, err) - - // update must be of the same user - err = pwStore1.UpdateProfile(user1, authn.ClientProfile{ClientID: user2, ClientType: authn.ClientTypeConsumer}) - assert.Error(t, err) - - // update must succeed - err = pwStore1.UpdateProfile(user1, authn.ClientProfile{ - ClientID: user1, ClientType: authn.ClientTypeConsumer, - DisplayName: name2, PubKey: key1, - }) - assert.NoError(t, err) - prof, err := pwStore1.GetProfile(user1) - assert.NoError(t, err) - assert.Equal(t, name2, prof.DisplayName) - assert.Equal(t, key1, prof.PubKey) - - // update of non-existing user should fail - err = pwStore1.UpdateProfile( - user1, authn.ClientProfile{ClientID: "notauser", ClientType: authn.ClientTypeConsumer}) - assert.Error(t, err) - err = pwStore1.UpdateProfile( - "notauser", authn.ClientProfile{ClientID: user1, ClientType: authn.ClientTypeConsumer}) - assert.Error(t, err) -} - -func TestSetRole(t *testing.T) { - const user1 = "user1" - const role1 string = string(authz.ClientRoleAgent) - - _ = os.Remove(unpwFilePath) - pwStore1 := authnstore.NewAuthnFileStore(unpwFilePath, "") - err := pwStore1.Open() - require.NoError(t, err) - err = pwStore1.Add(user1, authn.ClientProfile{ClientID: user1, - ClientType: authn.ClientTypeConsumer, DisplayName: "test user"}) - require.NoError(t, err) - - err = pwStore1.SetRole(user1, role1) - assert.NoError(t, err) - role2, err := pwStore1.GetRole(user1) - assert.NoError(t, err) - assert.Equal(t, role1, role2) -} diff --git a/runtime/authn/authnstore/IAuthnStore.go b/runtime/authn/authnstore/IAuthnStore.go deleted file mode 100644 index 91223312..00000000 --- a/runtime/authn/authnstore/IAuthnStore.go +++ /dev/null @@ -1,79 +0,0 @@ -package authnstore - -import ( - authn "github.com/hiveot/hub/runtime/authn/api" -) - -// AuthnEntry containing client profile and password hash -// For internal use. -type AuthnEntry struct { - // Client's profile - authn.ClientProfile `yaml:"clientProfile" json:"clientProfile"` - - // PasswordHash password encrypted with argon2id or bcrypt - PasswordHash string `yaml:"passwordHash" json:"passwordHash"` - - // Client 'base role'. Authz can add agent/thing specific roles in the future. - // This is set when creating a user and updated with SetRole. Authz reads it. - Role string `yaml:"role" json:"role"` -} - -// IAuthnStore defined the interface for storing authentication data -type IAuthnStore interface { - // Add adds a device, service or user to the store with authn settings - // If the client already exists, it is updated with the profile. - // - // clientID is the client's identity - // profile to add. Empty fields can receive valid defaults. - Add(clientID string, profile authn.ClientProfile) error - - // Close the store - Close() - - // Count returns the number of clients in the store - Count() int - - // GetEntries returns a list of client profiles including the password hash - // Intended to obtain auth info to apply to the messaging server - // For internal auth usage only. - GetEntries() (entries []AuthnEntry) - - // GetProfile returns the client's profile - // Returns an error if the clientID doesn't exist - GetProfile(clientID string) (profile authn.ClientProfile, err error) - - // GetProfiles returns all client profiles in the store - GetProfiles() (entries []authn.ClientProfile, err error) - - // GetRole returns the client's default role - GetRole(clientID string) (role string, err error) - - // Open the store - Open() error - - // Remove the client from the store - // If the client doesn't exist, no error is returned - Remove(clientID string) (err error) - - // SetPassword stores the hash of the password for the given user. - // If the clientID doesn't exist, this returns an error. - // - // The hashing algorithm is embedded in the store. - // clientID is the login ID of the user whose hash to write - // password is the password whose hash to store - // Returns error if the store isn't writable - SetPassword(clientID string, password string) error - - // SetRole sets the default role of a client - // This returns an error if newRole isn't a known role - SetRole(clientID string, newRole string) error - - // UpdateProfile updates client profile - // If the clientID doesn't exist, this returns an error. - // This fails if the client doesn't exist. - UpdateProfile(clientID string, profile authn.ClientProfile) error - - // VerifyPassword verifies the given password against the stored hash - // Returns the client profile and an error if the verification fails. - VerifyPassword(loginID, password string) (authn.ClientProfile, error) -} diff --git a/runtime/authn/config/AuthnConfig.go b/runtime/authn/config/AuthnConfig.go deleted file mode 100644 index 9d58f1ae..00000000 --- a/runtime/authn/config/AuthnConfig.go +++ /dev/null @@ -1,130 +0,0 @@ -package config - -import ( - "log/slog" - "path" -) - -// Session token validity for client types -const ( - DefaultAgentTokenValidityDays = 90 - DefaultConsumerTokenValidityDays = 30 - DefaultServiceTokenValidityDays = 365 -) - -// supported password hashes -const ( - PWHASH_ARGON2id = "argon2id" - PWHASH_BCRYPT = "bcrypt" // fallback in case argon2id cannot be used -) - -// DefaultAdminUserID is the client ID of the default CLI administrator account -const DefaultAdminUserID = "admin" - -// DefaultLauncherServiceID is the client ID of the launcher service -// auth creates a key and auth token for the launcher on startup -const DefaultLauncherServiceID = "launcher" - -// DefaultPasswordFile is the recommended password filename for Hub authentication -const DefaultPasswordFile = "hub.passwd" - -// AuthnConfig contains the auth service configuration -type AuthnConfig struct { - // PasswordFile with the file based password store. - // Use a relative path for using the default $HOME/stores/authn location - // Use "" for default defined in 'authnstore.DefaultPasswordFile' - PasswordFile string `yaml:"passwordFile,omitempty"` - // Encryption of passwords: "argon2id" (default) or "bcrypt" - Encryption string `yaml:"encryption,omitempty"` - - // Auth token validity for agents in days - AgentTokenValidityDays int `yaml:"agentTokenValidityDays,omitempty"` - // Auth token validity for consumers in days - ConsumerTokenValidityDays int `yaml:"consumerTokenValidityDays,omitempty"` - // Auth token validity for services in days - ServiceTokenValidityDays int `yaml:"serviceTokenValidityDays,omitempty"` - - // NoAutoStart prevents the auth service for auto starting. Intended for testing or custom implementation. - NoAutoStart bool `yaml:"noAutoStart,omitempty"` - - // predefined accounts - // Location of client keys and tokens - KeysDir string `yaml:"certsDir,omitempty"` - AdminAccountID string `yaml:"adminAccountID,omitempty"` - LauncherAccountID string `yaml:"launcherAccountID,omitempty"` - //AdminUserKeyFile string `yaml:"adminUserKeyFile,omitempty"` // default: admin.key - //AdminUserTokenFile string `yaml:"adminUserTokenFile,omitempty"` // default: admin.token - // - //// Setup for an launcher account - //LauncherKeyFile string `yaml:"launcherKeyFile,omitempty"` // default: launcher.key - //LauncherTokenFile string `yaml:"launcherTokenFile,omitempty"` // default: launcher.token -} - -// Setup ensures config is valid -// -// storesDir is the default storage root directory ($HOME/stores) -func (cfg *AuthnConfig) Setup(keysDir, storesDir string) { - - if cfg.PasswordFile == "" { - cfg.PasswordFile = DefaultPasswordFile - } - if !path.IsAbs(cfg.PasswordFile) { - cfg.PasswordFile = path.Join(storesDir, "authn", cfg.PasswordFile) - } - - if cfg.Encryption == "" { - cfg.Encryption = PWHASH_ARGON2id - } - if cfg.Encryption != PWHASH_BCRYPT && cfg.Encryption != PWHASH_ARGON2id { - slog.Error("unknown password encryption method. Reverting to ARGON2id", "Encoding", cfg.Encryption) - cfg.Encryption = PWHASH_ARGON2id - } - - if cfg.AgentTokenValidityDays == 0 { - cfg.AgentTokenValidityDays = DefaultAgentTokenValidityDays - } - if cfg.ServiceTokenValidityDays == 0 { - cfg.ServiceTokenValidityDays = DefaultServiceTokenValidityDays - } - if cfg.ConsumerTokenValidityDays == 0 { - cfg.ConsumerTokenValidityDays = DefaultConsumerTokenValidityDays - } - cfg.KeysDir = keysDir - cfg.AdminAccountID = DefaultAdminUserID - cfg.LauncherAccountID = DefaultLauncherServiceID - - //if cfg.AdminUserKeyFile == "" { - // cfg.AdminUserKeyFile = .DefaultAdminUserID + ".key" - //} - //if !path.IsAbs(cfg.AdminUserKeyFile) { - // cfg.AdminUserKeyFile = path.Join(keysDir, cfg.AdminUserKeyFile) - //} - // - //if cfg.AdminUserTokenFile == "" { - // cfg.AdminUserTokenFile = .DefaultAdminUserID + ".token" - //} - //if !path.IsAbs(cfg.AdminUserTokenFile) { - // cfg.AdminUserTokenFile = path.Join(keysDir, cfg.AdminUserTokenFile) - //} - // - //if cfg.LauncherKeyFile == "" { - // cfg.LauncherKeyFile = .DefaultLauncherServiceID + ".key" - //} - //if !path.IsAbs(cfg.LauncherKeyFile) { - // cfg.LauncherKeyFile = path.Join(keysDir, cfg.LauncherKeyFile) - //} - //if cfg.LauncherTokenFile == "" { - // cfg.LauncherTokenFile = .DefaultLauncherServiceID + ".token" - //} - //if !path.IsAbs(cfg.LauncherTokenFile) { - // cfg.LauncherTokenFile = path.Join(keysDir, cfg.LauncherTokenFile) - //} -} - -func NewAuthnConfig() AuthnConfig { - cfg := AuthnConfig{ - // default password encryption method - Encryption: PWHASH_ARGON2id, - } - return cfg -} diff --git a/runtime/authn/service/AuthnAdminService.go b/runtime/authn/service/AuthnAdminService.go deleted file mode 100644 index b36d9640..00000000 --- a/runtime/authn/service/AuthnAdminService.go +++ /dev/null @@ -1,330 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "os" - "path" - "time" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/sessions" - authz "github.com/hiveot/hub/runtime/authz/api" -) - -// AuthnAdminService handles administration of clients -type AuthnAdminService struct { - authnStore authnstore.IAuthnStore - - cfg *config.AuthnConfig - - // key used to create and verify session tokens - signingKey keys.IHiveKey - - // the authenticator for auth tokens - sessionAuth messaging.IAuthenticator - - // sessions - sm *sessions.SessionManager -} - -// AddConsumer adds a consumer account to the service without a role. -// This updates the client info if the client already exists. -// -// clientID is the ID of the service, agent or user -// displayName is the friendly name for presentation -// password is the optional login password. Intended for users if no other credentials are available. -func (svc *AuthnAdminService) AddConsumer(senderID string, args authn.AdminAddConsumerArgs) (err error) { - //clientID string, displayName string, password string) (err error) { - - slog.Info("AddConsumer", slog.String("clientID", args.ClientID)) - - if args.ClientID == "" { - err = fmt.Errorf("AddConsumer: SenderID is missing") - return err - } - if args.DisplayName == "" { - args.DisplayName = args.ClientID - } - prof, err := svc.authnStore.GetProfile(args.ClientID) - if err != nil { - // new profile - prof = authn.ClientProfile{ - ClientID: args.ClientID, - ClientType: authn.ClientTypeConsumer, - DisplayName: args.DisplayName, - //TokenValiditySec: svc.cfg.ConsumerTokenValiditySec, - } - err = svc.authnStore.Add(args.ClientID, prof) - if args.Password != "" { - err = svc.authnStore.SetPassword(args.ClientID, args.Password) - } - } else { - // client already exists, update password - //err = fmt.Errorf("Client '%s' already exists", args.ClientID) - //prof.TokenValiditySec = svc.cfg.ConsumerTokenValiditySec - if args.Password != "" { - err = svc.authnStore.SetPassword(args.ClientID, args.Password) - } - } - return err -} - -// AddAgent adds or updates a device agent account and assigns the agent role. -// Agents are provided with non-session auth tokens which survive a server restart. -// Agents should store their own key and token files. -func (svc *AuthnAdminService) AddAgent(senderID string, - args authn.AdminAddAgentArgs) (token string, err error) { - - var prof authn.ClientProfile - if args.ClientID == "" { - return token, fmt.Errorf("AddAgent: missing clientID") - } - slog.Info("AddAgent", slog.String("agentID", args.ClientID)) - // agents typically create their own key pair - // services typically don't and have their keys saved on (re)creation - if args.PubKey == "" { - kp, err2 := keys.LoadCreateKeyPair(args.ClientID, svc.cfg.KeysDir, keys.KeyTypeEd25519) - err = err2 - if err == nil { - args.PubKey = kp.ExportPublic() - } - } - if err == nil { - // new profile - prof = authn.ClientProfile{ - ClientID: args.ClientID, - ClientType: authn.ClientTypeAgent, - DisplayName: args.DisplayName, - PubKey: args.PubKey, - //TokenValiditySec: svc.cfg.AgentTokenValiditySec, - } - err = svc.authnStore.Add(args.ClientID, prof) - if err == nil { - err = svc.authnStore.SetRole(args.ClientID, string(authz.ClientRoleAgent)) - } - } - if err == nil { - // agent tokens are not restricted to a session. If sessionID matches clientID then - // no additional session check will take place. - validity := time.Duration(svc.cfg.AgentTokenValidityDays) * time.Hour * 24 - token = svc.sessionAuth.CreateSessionToken(args.ClientID, args.ClientID, validity) - } - return token, err -} - -// AddService adds or updates a service account with the service role and key and auth token files. -// -// Notes: -// * Services are provided with non-session auth tokens which survive a server restart. -// * Service keys and tokens are saved in the certs directory under the service name with -// the .key and .token extension. -func (svc *AuthnAdminService) AddService(senderID string, - args authn.AdminAddServiceArgs) (token string, err error) { - - var prof authn.ClientProfile - if args.ClientID == "" { - return token, fmt.Errorf("missing serviceID") - } - slog.Info("AddService", slog.String("agentID", args.ClientID)) - // agents typically create their own key pair - // services typically don't and have their keys saved on (re)creation - if args.PubKey == "" { - kp, err2 := keys.LoadCreateKeyPair(args.ClientID, svc.cfg.KeysDir, keys.KeyTypeEd25519) - err = err2 - if err == nil { - args.PubKey = kp.ExportPublic() - } - } - if err == nil { - //tokenValiditySec := svc.cfg.ServiceTokenValiditySec - // new profile - prof = authn.ClientProfile{ - ClientID: args.ClientID, - ClientType: authn.ClientTypeService, - DisplayName: args.DisplayName, - PubKey: args.PubKey, - //TokenValiditySec: tokenValiditySec, - } - err = svc.authnStore.Add(args.ClientID, prof) - if err == nil { - err = svc.authnStore.SetRole(args.ClientID, string(authz.ClientRoleService)) - } - } - if err == nil { - // service tokens are not linked to a session (sessionID equals clientID) - validity := time.Duration(svc.cfg.ServiceTokenValidityDays) * time.Hour * 24 - token = svc.sessionAuth.CreateSessionToken(args.ClientID, args.ClientID, validity) - - // remove the readonly token file if it exists, to be able to overwrite - tokenFile := path.Join(svc.cfg.KeysDir, args.ClientID+clients.TokenFileExt) - _ = os.Remove(tokenFile) - err = os.WriteFile(tokenFile, []byte(token), 0400) - } - return token, err -} - -// GetEntries provide a list of known clients. (internal function) -// An entry is a profile with a password hash. -func (svc *AuthnAdminService) GetEntries() (entries []authnstore.AuthnEntry) { - return svc.authnStore.GetEntries() -} - -// GetClientProfile returns a client's profile -func (svc *AuthnAdminService) GetClientProfile( - _ string, clientID string) (prof authn.ClientProfile, err error) { - - prof, err = svc.authnStore.GetProfile(clientID) - return prof, err -} - -// GetProfiles returns a list of all known client profiles -func (svc *AuthnAdminService) GetProfiles( - _ string) (clientProfiles []authn.ClientProfile, err error) { - - profiles, err := svc.authnStore.GetProfiles() - return profiles, err -} - -// GetSessions returns a list of all sessions -func (svc *AuthnAdminService) GetSessions( - _ string) (sessionResp authn.AdminGetSessionsResp, err error) { - - profiles, err := svc.authnStore.GetProfiles() - sessionResp = make([]struct { - ClientID string `json:"clientID,omitempty"` - Created string `json:"created,omitempty"` - Expiry string `json:"expiry,omitempty"` - }, len(profiles)) - i := 0 - for _, prof := range profiles { - sessInfo, found := svc.sm.GetSessionByClientID(prof.ClientID) - if found { - sessionResp[i].Expiry = sessInfo.Expiry.Format(utils.MilliTimeFormat) - sessionResp[i].ClientID = prof.ClientID - sessionResp[i].Created = sessInfo.Created.Format(utils.MilliTimeFormat) - } - } - return sessionResp, err -} - -// NewAgentToken creates a new authentication token for a service or agent. -// This token is not tied to a session so should only be handed out to services or agents -func (svc *AuthnAdminService) NewAgentToken(senderID string, agentID string) (token string, err error) { - _ = senderID - prof, err := svc.authnStore.GetProfile(agentID) - _ = prof - if err == nil { - validityDays := 1 - if prof.ClientType == authn.ClientTypeAgent { - validityDays = svc.cfg.AgentTokenValidityDays - } else if prof.ClientType == authn.ClientTypeService { - validityDays = svc.cfg.ServiceTokenValidityDays - } else { - validityDays = svc.cfg.ConsumerTokenValidityDays - } - validity := time.Duration(validityDays) * time.Hour * 24 - token = svc.sessionAuth.CreateSessionToken(agentID, agentID, validity) - } - return token, err -} - -// RemoveClient removes a client and disables authentication -func (svc *AuthnAdminService) RemoveClient(senderID string, clientID string) error { - slog.Info("RemoveClient", "clientID", clientID, "senderID", senderID) - err := svc.authnStore.Remove(clientID) - return err -} - -// Start the authentication service. -// The provided user store must be opened first. -// This creates accounts for the authn service, the admin user, and launcher if they don't exist. -func (svc *AuthnAdminService) Start() error { - slog.Info("starting AuthnAdminService") - - // ensure the password hash algo is valid - if svc.cfg.Encryption != config.PWHASH_BCRYPT && svc.cfg.Encryption != config.PWHASH_ARGON2id { - return fmt.Errorf("Start: Invalid password hash algo: %s", svc.cfg.Encryption) - } - - // ensure the authn service exists so authz can verify permissions later on - authnID := authn.AdminAgentID - _, err := svc.AddService("", authn.AdminAddServiceArgs{ - ClientID: authnID, DisplayName: "Authn Service", PubKey: ""}) - - // Ensure the launcher service and admin user exist and has a saved key and auth token - launcherID := svc.cfg.LauncherAccountID - _, err = svc.AddService("", authn.AdminAddServiceArgs{ - ClientID: launcherID, DisplayName: "Launcher Service", PubKey: ""}) - if err != nil { - err = fmt.Errorf("failed to setup the launcher account: %w", err) - } - - // ensure the admin user/service exists and has a saved key and auth token - adminID := svc.cfg.AdminAccountID - _, err = svc.AddService("", authn.AdminAddServiceArgs{ - ClientID: adminID, DisplayName: "Administrator", PubKey: ""}) - if err != nil { - err = fmt.Errorf("failed to setup the admin account: %w", err) - } - return err -} - -// SetClientPassword sets a new client password -func (svc *AuthnAdminService) SetClientPassword(senderID string, - args authn.AdminSetClientPasswordArgs) error { - slog.Info("UpdateClientPassword", "clientID", args.ClientID) - err := svc.authnStore.SetPassword(args.ClientID, args.Password) - if err != nil { - slog.Error("Failed changing password", "clientID", args.ClientID, "err", err.Error()) - } - return err -} - -// UpdateClientProfile update the client profile. -// -// profile is the new updated client profile -func (svc *AuthnAdminService) UpdateClientProfile( - senderID string, profile authn.ClientProfile) error { - - slog.Info("UpdateClientProfile", - slog.String("clientID", profile.ClientID), - slog.String("senderID", senderID)) - err := svc.authnStore.UpdateProfile(profile.ClientID, profile) - return err -} - -// Stop the service, unsubscribe and disconnect from the server -func (svc *AuthnAdminService) Stop() { - slog.Info("Stopping AuthnService") - //svc.authnStore.Remove() -} - -// NewAuthnAdminService creates an authentication service instance for use by administrators only. -// -// The provided store should be opened before calling start and closed after calling stop. -// -// authnConfig with the configuration settings for the signing key -// authnStore is the client and credentials store. Must be opened before starting this service. -// sm session manager for viewing active client sessions -// sessionAuth authenticator for new or extended sessions -func NewAuthnAdminService( - authConfig *config.AuthnConfig, - authnStore authnstore.IAuthnStore, - sm *sessions.SessionManager, - sessionAuth messaging.IAuthenticator) *AuthnAdminService { - - authnSvc := &AuthnAdminService{ - cfg: authConfig, - authnStore: authnStore, - sm: sm, - sessionAuth: sessionAuth, - } - return authnSvc -} diff --git a/runtime/authn/service/AuthnAgent.go b/runtime/authn/service/AuthnAgent.go deleted file mode 100644 index 86067e58..00000000 --- a/runtime/authn/service/AuthnAgent.go +++ /dev/null @@ -1,46 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// AuthnAgent agent for the authentication services: -type AuthnAgent struct { - adminHandler messaging.RequestHandler - userHandler messaging.RequestHandler -} - -// HandleRequest authn services action request -func (agent *AuthnAgent) HandleRequest( - req *messaging.RequestMessage, c messaging.IConnection) (resp *messaging.ResponseMessage) { - - _, thingID, _ := digitwin.SplitDigitwinID(req.ThingID) - if thingID == authn.AdminServiceID { - resp = agent.adminHandler(req, c) - } else if thingID == authn.UserServiceID { - resp = agent.userHandler(req, c) - } else { - err := fmt.Errorf("unknown authn service capability '%s'", req.ThingID) - resp = req.CreateResponse(nil, err) - } - return resp -} - -// StartAuthnAgent returns a new instance of the agent for the authentication services. -// This uses the given connected transport for publishing events and subscribing to actions. -// The transport must be closed by the caller after use. -// If the transport is nil then use the HandleMessage method directly to pass methods to the agent, -// for example when testing. -// -// svc is the authentication service whose capabilities to expose -func StartAuthnAgent(svc *AuthnService) *AuthnAgent { - agent := &AuthnAgent{ - adminHandler: authn.NewHandleAdminRequest(svc.AdminSvc), - userHandler: authn.NewHandleUserRequest(svc.UserSvc), - } - return agent -} diff --git a/runtime/authn/service/AuthnService.go b/runtime/authn/service/AuthnService.go deleted file mode 100644 index 5d813701..00000000 --- a/runtime/authn/service/AuthnService.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/runtime/authn/authenticator" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" - "github.com/hiveot/hub/runtime/authn/sessions" -) - -type AuthnService struct { - SessionAuth messaging.IAuthenticator - AuthnStore authnstore.IAuthnStore - AdminSvc *AuthnAdminService - UserSvc *AuthnUserService -} - -// Start the Authentication admin and client services -// This opens the authentication data store and starts the services. -func (svc *AuthnService) Start() error { - err := svc.AuthnStore.Open() - if err == nil { - err = svc.AdminSvc.Start() - if err == nil { - err = svc.UserSvc.Start() - } - } - return err -} - -// Stop the authentication service and close the store -func (svc *AuthnService) Stop() { - svc.AdminSvc.Stop() - svc.UserSvc.Stop() - svc.AuthnStore.Close() -} - -// NewAuthnService creates an instance of the authentication services -func NewAuthnService( - cfg *config.AuthnConfig, - authnStore authnstore.IAuthnStore, - sm *sessions.SessionManager, - sessionAuth messaging.IAuthenticator) *AuthnService { - - svc := &AuthnService{ - AuthnStore: authnStore, - SessionAuth: sessionAuth, - AdminSvc: NewAuthnAdminService(cfg, authnStore, sm, sessionAuth), - UserSvc: NewAuthnUserService(cfg, authnStore, sessionAuth), - } - return svc -} - -// StartAuthnService creates and start the authn administration service -// with the given config. -// This creates a password store and authenticator. -// -// authServerURI is the endpoint the service can be reached at to obtain authentication token -// This is provided by the protocol that gives access to the login method. -func StartAuthnService(cfg *config.AuthnConfig) (*AuthnService, error) { - - authnStore := authnstore.NewAuthnFileStore(cfg.PasswordFile, cfg.Encryption) - //sessionAuth := authenticator.NewJWTAuthenticatorFromFile( - // authnStore, cfg.KeysDir, cfg.DefaultKeyType) - sm := sessions.NewSessionmanager() - sessionAuth := authenticator.NewPasetoAuthenticatorFromFile(authnStore, cfg.KeysDir, sm) - sessionAuth.AgentTokenValidityDays = cfg.AgentTokenValidityDays - sessionAuth.ConsumerTokenValidityDays = cfg.ConsumerTokenValidityDays - sessionAuth.ServiceTokenValidityDays = cfg.ServiceTokenValidityDays - - svc := NewAuthnService(cfg, authnStore, sm, sessionAuth) - err := svc.Start() - return svc, err -} diff --git a/runtime/authn/service/AuthnUserService.go b/runtime/authn/service/AuthnUserService.go deleted file mode 100644 index e45580da..00000000 --- a/runtime/authn/service/AuthnUserService.go +++ /dev/null @@ -1,140 +0,0 @@ -package service - -import ( - "log/slog" - - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - "github.com/hiveot/hub/runtime/authn/config" -) - -// AuthnUserService handles authentication and authorization of regular users -// such as agents, services and end-users. -type AuthnUserService struct { - authnStore authnstore.IAuthnStore - - cfg *config.AuthnConfig - - // the authenticator for jwt tokens - sessionAuth messaging.IAuthenticator -} - -// GetProfile returns a client's profile -func (svc *AuthnUserService) GetProfile( - senderID string) (resp authn.ClientProfile, err error) { - - prof, err := svc.authnStore.GetProfile(senderID) - return prof, err -} - -// Login with password and return a new session token -func (svc *AuthnUserService) Login(_ string, args authn.UserLoginArgs) (token string, err error) { - - token, err = svc.sessionAuth.Login(args.ClientID, args.Password) - return token, err -} - -// Logout and remove the client session -func (svc *AuthnUserService) Logout(senderID string) error { - svc.sessionAuth.Logout(senderID) - return nil -} - -// RefreshToken requests a new token based on the old token -func (svc *AuthnUserService) RefreshToken( - senderID string, args string) (newToken string, err error) { - - newToken, err = svc.sessionAuth.RefreshToken(senderID, args) - return newToken, err -} - -// Start the user facing authentication service. -func (svc *AuthnUserService) Start() error { - slog.Info("starting AuthnService") - return nil -} - -// Stop the service, unsubscribe and disconnect from the server -func (svc *AuthnUserService) Stop() { - slog.Info("Stopping AuthnUserService") -} - -func (svc *AuthnUserService) UpdateName(senderID string, newName string) error { - - slog.Info("UpdateName", "clientID", senderID, "newName", newName) - prof, err := svc.authnStore.GetProfile(senderID) - if err == nil { - prof.DisplayName = newName - err = svc.authnStore.UpdateProfile(senderID, prof) - } - if err != nil { - slog.Error("Failed changing name", - "clientID", senderID, "err", err.Error()) - } - return err -} -func (svc *AuthnUserService) UpdatePassword(senderID string, password string) error { - - slog.Info("SetClientPassword", "senderID", senderID) - err := svc.authnStore.SetPassword(senderID, password) - if err != nil { - slog.Error("Failed changing password", - "senderID", senderID, "err", err.Error()) - } - return err -} -func (svc *AuthnUserService) UpdatePubKey(senderID string, pubKeyPEM string) error { - - slog.Info("UpdatePubKey", "clientID", senderID) - prof, err := svc.authnStore.GetProfile(senderID) - if err == nil { - prof.PubKey = pubKeyPEM - err = svc.authnStore.UpdateProfile(senderID, prof) - } - if err != nil { - slog.Error("Failed updating public key", - "clientID", senderID, "err", err.Error()) - } - return err -} - -// ValidateToken verifies that the given token is valid -//func (svc *AuthnUserService) ValidateToken(senderID string, token string) ( -// resp authn.UserValidateTokenResp, err error) { -// -// clientID, sid, err := svc.sessionAuth.ValidateToken(token) -// if err == nil && clientID != senderID { -// err = fmt.Errorf("ClientID doesn't match senderID") -// } -// resp.ClientID = clientID -// resp.SessionID = sid -// resp.Error = err.Error() -// return resp, nil -//} - -// NewAuthnUserService creates an end-user authentication service instance for -// logging in and managing a user's own profile. This service is accessible by any -// client. -// This service works in conjunction with the authentication store and the -// session authenticator. The latter is created by the admin service or can be instantiated -// using a signing key. -// -// This service does not have start/stop functions. The authnStore and authenticator -// must be operational before using this service. -// -// authnStore is the client and credentials store. Must be opened before starting this service. -// cm is the connection manager used to close all connections on logout -// sessionAuth is the authenticator returned by the admin service. -func NewAuthnUserService( - cfg *config.AuthnConfig, - authnStore authnstore.IAuthnStore, - authenticator messaging.IAuthenticator) *AuthnUserService { - - authnSvc := &AuthnUserService{ - cfg: cfg, - authnStore: authnStore, - sessionAuth: authenticator, - } - return authnSvc -} diff --git a/runtime/authn/service/LoadCreateKey.go b/runtime/authn/service/LoadCreateKey.go deleted file mode 100644 index 48f12fea..00000000 --- a/runtime/authn/service/LoadCreateKey.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -// LoadCreateUserKey loads a user's key from file. -// If the key doesn't exist then create and save a new key. -// -// This supports both ecdsa and nats nkeys (edd25519) -//func (svc *AuthManageClients) LoadCreateUserKey(keyFile string) (serializedKP string, pubKey string, err error) { -// keyData, err := os.ReadFile(keyFile) -// if err != nil { -// // Create a new key. The server dictates the format. -// serializedKP, pubKey = svc.msgServer.CreateKeyPair() -// //ecdsaKey, success := key.(*ecdsa.PrivateKey) -// //if success { -// // save the key -// err = os.WriteFile(keyFile, []byte(serializedKP), 0400) -// return serializedKP, pubKey, err -// } -// // this is an existing key. Try parsing it with ecdsa and nkey formats -// // Is this an ECDSA key? -// ecdsaKey, err := certs.PrivateKeyFromPEM(string(keyData)) -// if err == nil { -// pubKeyData, err := x509.MarshalPKIXPublicKey(&ecdsaKey.PublicKey) -// if err == nil { -// pubKey = base64.StdEncoding.EncodeToString(pubKeyData) -// } -// // if err then the existing public key cannot be serialized.. odd -// return string(keyData), pubKey, err -// } -// // Is this an nkey? -// userKP, err := nkeys.ParseDecoratedNKey(keyData) -// if err == nil { -// pubKey, err = userKP.PublicKey() -// return string(keyData), pubKey, err -// } -// // unknown format -// err = fmt.Errorf("unknown format for key in file '" + keyFile + "'") -// return "", "", err -//} - -// LoadCreateUserToken loads or creates an auth token -// -// This supports both standard jwt and nats jwt tokens -//func (svc *AuthManageClients) LoadCreateUserToken(clientID, tokenFile string) (token string, err error) { -// tokenData, err := os.ReadFile(tokenFile) -// if err != nil { -// // Create a new token. The server dictates the format. -// profile, err := svc.store.GetProfile(clientID) -// if err != nil { -// return "", err -// } -// token, err = svc.msgServer.CreateToken(msgserver.ClientAuthInfo{ -// SenderID: profile.SenderID, -// ClientType: profile.ClientType, -// PubKey: profile.PubKey, -// PasswordHash: "", -// Role: profile.Role, -// }) -// if err == nil && tokenFile != "" { -// err = os.WriteFile(tokenFile, []byte(token), 0400) -// } -// return token, err -// } -// return string(tokenData), nil -//} diff --git a/runtime/authn/sessions/SessionManager.go b/runtime/authn/sessions/SessionManager.go deleted file mode 100644 index 5eee4ada..00000000 --- a/runtime/authn/sessions/SessionManager.go +++ /dev/null @@ -1,167 +0,0 @@ -package sessions - -import ( - "log/slog" - "sync" - "time" -) - -// Default duration of a session -const DefaultSessionDuration = 30 * 24 * time.Hour - -// ClientSession of an authenticated client connected over http. -// -// Each client session has a session-id from the authentication token. -type ClientSession struct { - // ID of this session - SessionID string - - // SenderID is the login ID of the agent or consumer - ClientID string - - // Created time of the session. - Created time.Time - - // Time the session expires. Tokens won't be refreshed after this time. - Expiry time.Time -} - -// SessionManager provides the ability to expire authentication tokens of a client -// through client sessions. -// -// Sessions are directly linked to the client's ID. When a client logs-in successfully, -// a new session is created with a new expiry. The provided session-id is included -// in client tokens. -// -// When the client performs a request, it must provide a valid token. If the token -// is valid, the sessionID in the token must refer to a valid session as a second -// validation factor. -// The session remains valid until it expires or is removed, like when the user logs out. -// -// A session is valid for a limited period of time. Eventually it will expire, -// unless a successful login extends the session. Once expired, token refresh -// will fail and the user is required to login again. -// -// If a client logs-in from multiple places, each will get a token that refers to the -// same session. Therefore there is at most 1 session per client. If the session -// expires or is invalidated, all instances must login again. -// Similarly, logging out, logs the client out on all devices. This too is intentional. -// -// TODO: -// 1. add session expiry -// 2. Persist sessions between restart (to restore login) - config option? -type SessionManager struct { - // existing sessions by sessionID - sidSessions map[string]*ClientSession - // existing sessions by clientID (1 session per client) - clientSessions map[string]*ClientSession - // mutex to access the sessions - mux sync.RWMutex - // default duration a session is valid for after successful login - sessionDuration time.Duration -} - -// NewSession creates a new session for the given clientID and sessionID. -// If a session for the client exists it will be removed. -// If a session for the client and sessionID exists, its expiry will be renewed. -// -// This returns the new session instance -func (sm *SessionManager) NewSession(clientID string, sessionID string) { - - slog.Debug("NewSession", - slog.String("clientID", clientID), - slog.String("sessionID", sessionID)) - if sessionID == "" || clientID == "" { - slog.Error("NewSession: invalid clientID or sessionID") - return - } - sm.mux.Lock() - defer sm.mux.Unlock() - sess, found := sm.clientSessions[clientID] - if !found { - // new session for the client - sess = &ClientSession{ - SessionID: sessionID, - ClientID: clientID, - Created: time.Now(), - Expiry: time.Now().Add(sm.sessionDuration), - } - sm.sidSessions[sessionID] = sess - sm.clientSessions[clientID] = sess - } else if sess.SessionID == sessionID { - // renew expiry of the existing session - sess.Expiry = time.Now().Add(sm.sessionDuration) - } else { - // a different session. Invalidate the existing session - delete(sm.clientSessions, clientID) - delete(sm.sidSessions, sess.SessionID) - // new session for the client - sess = &ClientSession{ - SessionID: sessionID, - ClientID: clientID, - Created: time.Now(), - Expiry: time.Now().Add(sm.sessionDuration), - } - sm.sidSessions[sessionID] = sess - sm.clientSessions[clientID] = sess - } -} - -// GetSessionBySessionID returns the client session if available -// An error is returned if the sessionID is not an existing session -func (sm *SessionManager) GetSessionBySessionID(sessionID string) (sess ClientSession, found bool) { - sm.mux.RLock() - defer sm.mux.RUnlock() - - session, found := sm.sidSessions[sessionID] - if found { - sess = *session - } - return sess, found -} - -// GetSessionByClientID returns the latest client sessions by client's ID. -// An error is returned if the clientID does not have a session -// Intended for lookup of agents or consumers to send directed messages. -func (sm *SessionManager) GetSessionByClientID(clientID string) (sess ClientSession, found bool) { - sm.mux.RLock() - defer sm.mux.RUnlock() - - session, found := sm.clientSessions[clientID] - if found { - sess = *session - } - return sess, found -} - -// Remove the session -func (sm *SessionManager) Remove(sessionID string) { - sm.mux.Lock() - defer sm.mux.Unlock() - - si, found := sm.sidSessions[sessionID] - if found { - delete(sm.clientSessions, si.ClientID) - delete(sm.sidSessions, sessionID) - } -} - -// RemoveAll closes all sessions -func (sm *SessionManager) RemoveAll() { - sm.mux.Lock() - defer sm.mux.Unlock() - - slog.Info("RemoveAll. Closing remaining sessions", "count", len(sm.sidSessions)) - sm.sidSessions = make(map[string]*ClientSession) - sm.clientSessions = make(map[string]*ClientSession) -} - -func NewSessionmanager() *SessionManager { - sm := &SessionManager{ - sidSessions: make(map[string]*ClientSession), - clientSessions: make(map[string]*ClientSession), - mux: sync.RWMutex{}, - sessionDuration: DefaultSessionDuration, - } - return sm -} diff --git a/runtime/authn/sessions/SessionManager_test.go b/runtime/authn/sessions/SessionManager_test.go deleted file mode 100644 index cd8d18ef..00000000 --- a/runtime/authn/sessions/SessionManager_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package sessions_test - -import ( - "github.com/hiveot/hub/runtime/authn/sessions" - "github.com/stretchr/testify/require" - "testing" - "time" -) - -func TestAddRemoveSession(t *testing.T) { - const clientID = "client1" - const session1ID = "sess1" - - sm := sessions.NewSessionmanager() - sm.NewSession(clientID, session1ID) - - // session must exist - s1, found := sm.GetSessionBySessionID(session1ID) - require.True(t, found) - require.Equal(t, clientID, s1.ClientID) - require.Equal(t, session1ID, s1.SessionID) - - s1, found = sm.GetSessionByClientID(clientID) - require.True(t, found) - require.Equal(t, clientID, s1.ClientID) - require.Equal(t, session1ID, s1.SessionID) - - // close the session - sm.Remove(session1ID) - - // session no longer exists - s1, found = sm.GetSessionBySessionID(session1ID) - require.False(t, found) - require.Empty(t, s1) - s1, found = sm.GetSessionByClientID(clientID) - require.False(t, found) - require.Empty(t, s1) -} - -func TestReplaceSession(t *testing.T) { - const clientID = "client1" - const session1ID = "sess1" - const session2ID = "sess2" - - sm := sessions.NewSessionmanager() - sm.NewSession(clientID, session1ID) - sm.NewSession(clientID, session2ID) - - // session1 should not exist - s1, found := sm.GetSessionBySessionID(session1ID) - require.False(t, found) - require.Empty(t, s1) - - // session2 should exist - - // client should be linked to session 2 - s2, found := sm.GetSessionByClientID(clientID) - require.True(t, found) - require.Equal(t, clientID, s2.ClientID) - require.Equal(t, session2ID, s2.SessionID) -} - -func TestRemoveAll(t *testing.T) { - const client1ID = "client1" - const session1ID = "sess1" - const client2ID = "client2" - const session2ID = "sess2" - - sm := sessions.NewSessionmanager() - sm.NewSession(client1ID, session1ID) - sm.NewSession(client2ID, session2ID) - - // sessions must exist - _, found := sm.GetSessionBySessionID(session1ID) - require.True(t, found) - _, found = sm.GetSessionBySessionID(session2ID) - require.True(t, found) - - // after removing all neither must exist - sm.RemoveAll() - _, found = sm.GetSessionBySessionID(session1ID) - require.False(t, found) - _, found = sm.GetSessionBySessionID(session2ID) - require.False(t, found) -} - -func TestSessionRenewal(t *testing.T) { - const clientID = "client1" - const sessionID = "sess1" - sm := sessions.NewSessionmanager() - sm.NewSession(clientID, sessionID) - s1, found := sm.GetSessionBySessionID(sessionID) - - // renew session and must have later expiry - time.Sleep(time.Millisecond * 100) - sm.NewSession(clientID, sessionID) - require.True(t, found) - require.Equal(t, clientID, s1.ClientID) - - // renew session and must have later expiry - s2, found := sm.GetSessionBySessionID(sessionID) - require.Greater(t, s2.Expiry, s1.Expiry) -} - -func TestSessionFail(t *testing.T) { - const clientID = "client1" - const remoteAddr = "remoteAddr" - const sessionID = "sess1" - - sm := sessions.NewSessionmanager() - - // add bad clientID - sm.NewSession("", sessionID) - s1, found := sm.GetSessionByClientID("") - require.False(t, found) - require.Empty(t, s1) - s1, found = sm.GetSessionBySessionID(sessionID) - require.False(t, found) - require.Empty(t, s1) - - // add bad sessionID - sm.NewSession(clientID, "") - s1, found = sm.GetSessionByClientID(clientID) - require.False(t, found) - require.Empty(t, s1) - s1, found = sm.GetSessionBySessionID(sessionID) - require.False(t, found) - require.Empty(t, s1) -} diff --git a/runtime/authn/tm/admin.json b/runtime/authn/tm/admin.json deleted file mode 100644 index b33986f9..00000000 --- a/runtime/authn/tm/admin.json +++ /dev/null @@ -1,261 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - { "hiveot": "https://www.hiveot.net/vocab/v0.1" } - ], - "@type": "Service", - "id": "admin", - "title": "Authentication Administration Service", - "description": "HiveOT runtime service for administration of consumers, Thing agents and services", - "version": { - "instance": "0.1.0" - }, - "created": "2024-06-04T17:00:00.000Z", - "modified": "2025-01-28T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "comment": "TODO: how to reference ClientProfile defined in the user service?", - "links": [], - - "allow": ["admin"], - - "properties": { - "nrClients": { - "title": "Number of clients", - "type": "integer", - "readOnly": true - } - }, - - "events": { - "added": { - "title": "Client Added", - "description": "A new client was added", - "data": { - "title": "Client ID", - "type": "string" - } - }, - - "removed": { - "title": "Client Removed", - "description": "A client was removed", - "data": { - "title": "Client ID", - "type": "string" - } - } - }, - - "actions": { - "addAgent": { - "@type": "hiveot:function", - "title": "Add Agent", - "description": "Create an account for IoT device agents", - "idempotent": true, - "safe": false, - "input": { - "type": "object", - "title": "Agent Info", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "displayName": { - "title": "Display Name", - "type": "string" - }, - "pubKey": { - "title": "Public Key", - "type": "string" - } - } - }, - "output": { - "title": "Token", - "description": "Agent authentication token", - "type": "string" - } - }, - - "addConsumer": { - "@type": "hiveot:function", - "title": "Add Consumer", - "description": "Add an end-user client", - "idempotent": true, - "safe": false, - "input": { - "type": "object", - "title": "Consumer Info", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "displayName": { - "title": "Display Name", - "type": "string" - }, - "password": { - "title": "Password", - "type": "string" - } - } - } - }, - - "addService": { - "@type": "hiveot:function", - "title": "Add Service", - "description": "Add a service account", - "idempotent": true, - "safe": false, - "input": { - "type": "object", - "Title": "Service Info", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "displayName": { - "title": "Display Name", - "type": "string" - }, - "pubKey": { - "title": "Public Key", - "type": "string" - } - } - }, - "output": { - "title": "Token", - "description": "Service authentication token", - "type": "string" - } - }, - - "getClientProfile": { - "@type": "hiveot:function", - "title": "Get Client Profile", - "description": "Get the profile information describing a client", - "idempotent": true, - "safe": true, - "input": { - "title": "Client ID", - "type": "string" - }, - "output": { - "title": "Profile", - "type": "object", - "schema": "ClientProfile" - } - }, - - "getProfiles": { - "@type": "hiveot:function", - "title": "Get Profiles", - "description": "Get a list of all client profiles", - "idempotent": true, - "safe": true, - "output": { - "type": "array", - "title": "Client Profiles", - "items": { - "title": "Client Profile", - "type": "object", - "schema": "ClientProfile" - } - } - }, - - "getSessions": { - "@type": "hiveot:function", - "title": "Get Sessions", - "description": "Get a list of active sessions", - "idempotent": true, - "safe": true, - "output": { - "type": "array", - "title": "Client Sessions", - "items": { - "title": "Client session", - "type": "object", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "created": { - "title": "Session created", - "type": "string" - }, - "expiry": { - "title": "Session expires", - "type": "string" - } - } - } - } - }, - "newAgentToken": { - "@type": "hiveot:function", - "title": "New Agent Token", - "description": "Obtain a new authentication token for an agent or service", - "idempotent": false, - "input": { - "title": "Agent ID", - "type": "string" - }, - "output": { - "title": "Token", - "description": "New service or agent authentication token", - "type": "string" - } - }, - - "removeClient": { - "@type": "hiveot:function", - "title": "Remove Client", - "description": "Remove a client account", - "idempotent": false, - "input": { - "title": "Client ID", - "type": "string" - } - }, - - "setClientPassword": { - "@type": "hiveot:function", - "title": "Set Client Password", - "description": "Update the password of a consumer", - "idempotent": false, - "input": { - "title": "Client Info", - "description": "Client ID and password", - "type": "object", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "password": { - "title": "Password", - "type": "string" - } - } - } - }, - - "updateClientProfile": { - "@type": "hiveot:function", - "title": "Update Client Profile", - "description": "Update the details of a client", - "idempotent": false, - "input": { - "title": "Client Profile", - "type": "ClientProfile" - } - } - } -} diff --git a/runtime/authn/tm/user.json b/runtime/authn/tm/user.json deleted file mode 100644 index d9b7f80d..00000000 --- a/runtime/authn/tm/user.json +++ /dev/null @@ -1,175 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - { "hiveot": "https://www.hiveot.net/vocab/v0.1" } - ], - "@type": "Service", - "id": "user", - "title": "Authentication User Service", - "description": "HiveOT runtime service for users", - "version": { - "instance": "0.1.0" - }, - "created": "2024-06-04T17:00:00.000Z", - "modified": "2024-06-04T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "deny": ["none"], - - "links": [], - "schemaDefinitions": { - "ClientType": { - "title": "Client Type", - "type": "string", - "description": "identifies the client's category", - "oneOf": [ - { - "const": "agent", - "title": "Agent", - "description": "Agents represent one or more devices" - }, - { - "const": "service", - "title": "Service", - "description": "Service enrich information" - }, - { - "const": "consumer", - "title": "Consumer", - "description": "Consumers are end-users of information" - } - ] - }, - "ClientProfile": { - "title": "Client Profile", - "description": "This contains client information of device agents, services and consumers", - "comment": "several actions involve this class, as argument or response", - "type": "object", - "properties": { - "clientID": { - "type": "string", - "title": "Client ID" - }, - "clientType": { - "type": "ClientType", - "title": "Client Type" - }, - "disabled": { - "type": "bool", - "title": "Disabled", - "description": "This client account has been disabled" - }, - "displayName": { - "type": "string", - "title": "Display Name" - }, - "pubKey": { - "type": "string", - "title": "Public Key" - }, - "updated": { - "type": "string", - "title": "Client name or auth updated timestamp" - } - } - } - }, - "properties": {}, - "events": {}, - "actions": { - "getProfile": { - "@type": "hiveot:function", - "title": "Get Client Profile", - "safe": true, - "output": { - "comment": "need a way to reference an external definition", - "type": "object", - "schema": "ClientProfile" - } - }, - - "login": { - "@type": "hiveot:function", - "title": "Login", - "description": "Login with password", - "idempotent": false, - "safe": false, - "deny": [], - "input": { - "type": "object", - "properties": { - "clientID": { - "title": "Login ID", - "type": "string" - }, - "password": { - "title": "Password", - "type": "string" - } - } - }, - "output": { - "title": "Token", - "type": "string" - } - }, - - "logout": { - "@type": "hiveot:function", - "title": "Logout", - "description": "Logout from all devices", - "idempotent": true, - "safe": false - }, - - "refreshToken": { - "@type": "hiveot:function", - "title": "Request a new auth token for the current client", - "idempotent": false, - "safe": false, - "input": { - "title": "Old Token", - "type": "string" - }, - "output": { - "title": "New Token", - "type": "string" - } - }, - - "updateName": { - "@type": "hiveot:function", - "title": "Request changing the display name of the current client", - "idempotent": true, - "safe": false, - "input": { - "title": "New Name", - "type": "string" - } - }, - - "updatePassword": { - "@type": "hiveot:function", - "title": "Update Password", - "description": "Request changing the password of the current client", - "idempotent": true, - "safe": false, - "input": { - "title": "Password", - "type": "string" - } - }, - - "updatePubKey": { - "@type": "hiveot:function", - "title": "Update Public Key", - "description": "Request changing the public key on file of the current client.", - "idempotent": true, - "safe": false, - "input": { - "title": "Public Key PEM", - "description": "Public Key in PEM format", - "type": "string" - } - } - } -} diff --git a/runtime/authz/Authz_test.go b/runtime/authz/Authz_test.go deleted file mode 100644 index 8f43a1a4..00000000 --- a/runtime/authz/Authz_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package authz_test - -import ( - "os" - "path" - "testing" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/runtime/authz/service" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var testDir = path.Join(os.TempDir(), "test-authz") -var passwordFile = path.Join(testDir, "test.passwd") - -// TestMain for all authn tests, setup of default folders and filenames -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - _ = os.RemoveAll(testDir) - res := m.Run() - if res == 0 { - _ = os.RemoveAll(testDir) - } - os.Exit(res) -} - -// Test starting and stopping authorization service -func TestStartStop(t *testing.T) { - cfg := service.NewAuthzConfig() - svc := service.NewAuthzService(&cfg, nil) - err := svc.Start() - require.NoError(t, err) - svc.Stop() -} - -// Test Get/Set role -func TestSetRole(t *testing.T) { - const client1ID = "client1" - const client1Role = authz.ClientRoleAgent - // start the authz server - cfg := service.NewAuthzConfig() - authnStore := authnstore.NewAuthnFileStore(passwordFile, "") - svc := service.NewAuthzService(&cfg, authnStore) - err := svc.Start() - require.NoError(t, err) - defer svc.Stop() - - // add the user whose role to set - err = authnStore.Add(client1ID, authn.ClientProfile{ - ClientID: client1ID, - ClientType: authn.ClientTypeConsumer, - DisplayName: "user 1", - }) - assert.NoError(t, err) - - // connect the client marshaller to the server agent - - //handler, _ := service.StartAuthzAgent(svc, nil) - //hc := embedded.NewEmbeddedClient(client1ID, handler.HandleMessage) - - // set the role - err = svc.SetClientRole(client1ID, authz.AdminSetClientRoleArgs{ - ClientID: client1ID, - Role: client1Role, - }) - //err = authz2.AdminSetClientRole(hc, client1ID, client1Role) - require.NoError(t, err) - - // get the role - role, err := svc.GetClientRole(client1ID, client1ID) - //role, err := authz2.AdminGetClientRole(hc, client1ID) - require.NoError(t, err) - require.Equal(t, client1Role, role) -} - -func TestHasPermission(t *testing.T) { - const operatorID = "operator-1" - const client1Role = authz.ClientRoleOperator - const thingID = "thing1" - const key = "key1" - const correlationID = "req-1" - cfg := service.NewAuthzConfig() - cfg.Setup(testDir) - authnStore := authnstore.NewAuthnFileStore(passwordFile, "") - svc := service.NewAuthzService(&cfg, authnStore) - err := svc.Start() - require.NoError(t, err) - defer svc.Stop() - - err = authnStore.Add(operatorID, - authn.ClientProfile{ClientID: operatorID, ClientType: authn.ClientTypeConsumer}) - require.NoError(t, err) - err = svc.SetClientRole(operatorID, authz.AdminSetClientRoleArgs{operatorID, client1Role}) - assert.NoError(t, err) - // consumers have permission to publish actions and write-property requests - msg := messaging.NewRequestMessage(td.OpInvokeAction, thingID, key, nil, correlationID) - msg.SenderID = operatorID - hasPerm := svc.HasPermission(msg.SenderID, msg.Operation, msg.ThingID) - assert.True(t, hasPerm) - - // operators cannot respond with events updates - //resp := transports.NewResponseMessage(td.OpSubscribeEvent, thingID, key, "eventValue", nil, correlationID) - //resp.SenderID = operatorID - //// haspermission only validates requests and event/property notificates are now subscription responses - //hasPerm = svc.HasPermission(msg.SenderID, msg.Operation, msg.ThingID) - //assert.False(t, hasPerm) -} diff --git a/runtime/authz/README.md b/runtime/authz/README.md deleted file mode 100644 index 816a21b0..00000000 --- a/runtime/authz/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# authz - HiveOT Hub Authorization - -## Objective - -Manage authorization of client requests based on their role. - -## Status - -This service is functional. This changes to include authorization roles in the agent published TD. - -TODOs: -* Define authorization in the TD instead of some hidden mechanism - * Extend vocabulary with an 'authz' field for each action and event - * authz: [list of roles] -* Implementation of custom roles is not yet complete. - -## Summary - -This service provides the following capabilities: - -1. Authorize requests made by agents, consumers and services -2. Retrieve roles -3. Custom role management (future) - -Authorization is built on top of authentication and uses its client profile. - -## Usage - -This service is included in the hub runtime. - - -## Setting Permissions - -All permissions are role based. All clients, eg consumers, agents and services each have a role. - -Permissions are split in permissions to publish actions and permissions to subscribe to events. - -Only agents and services offer Things that can be controlled through actions. Note that Service Things are also called capabilities. - -Only consumers and services are able to subscribe to Thing events. Agents represent IoT devices and are not consumers of events (that would make them a service). - -An agent or service can set the default roles that are allowed to invoke actions on its Things or capabilities using the SetPermission method. This lets the service be used out of the box without configuration. Agents typically do not set permissions for its IoT devices in which case the default role permissions apply. - -The authorization service can override the defaults through configuration. - -## Validating Permissions - -In validating permissions, the authz service first checks whether the requested agent or service has any permissions set for the requested Thing. If set then the service permissions validation is used. If not set then the default role permissions apply. - -This allows each service to control who can use it. It is even possible to apply this to IoT devices themselves and only make them available to certain roles. - -A consumer can validate if it has permissions to invoke an action to control or configure a Thing. This can be useful to enable or disable actions in the user interface. - -### Default Role Permissions - -The default role permissions apply to authenticated clients only: -1. All clients can subscribe to Thing events. -2. Operators can invoke defined actions -3. Managers can in addition invoke the action to set properties -4. Agents (for devices) can not invoke actions, only publish events. -5. Services and administrators roles have no publish or subscribe restrictions. - -### Service Permissions - -Each service can set its own default permissions. If no service permissions are set, then the default rol permissions apply. - -When an agent or service sets usage permissions then it defines the ThingID and the roles that are allowed or denied requesting actions of that Thing. - -Use denied without allowed to allow access to custom roles. -Use allowed without denied to not allow access other than the listed roles. - diff --git a/runtime/authz/api/AdminAgentAPI.go b/runtime/authz/api/AdminAgentAPI.go deleted file mode 100644 index 5f5dfa86..00000000 --- a/runtime/authz/api/AdminAgentAPI.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package authz with the agent request handler for using service 'Admin' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authz - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IAdminService defines the interface of the 'Admin' service -// -// This defines a method for each of the actions in the TD. -// -type IAdminService interface { - - // GetClientRole Get Client Role - // Read the role of a client - // This returns a response with client role - GetClientRole(senderID string, clientID string) (role ClientRole, err error) - - // SetClientRole Set Client Role - // Update the role of a consumer - SetClientRole(senderID string, args AdminSetClientRoleArgs) error -} - -// NewHandleAdminRequest returns an agent handler for Thing 'admin' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleAdminRequest(svc IAdminService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "getClientRole": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.GetClientRole(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "setClientRole": - args := AdminSetClientRoleArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.SetClientRole(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/authz/api/AdminConsumerAPI.go b/runtime/authz/api/AdminConsumerAPI.go deleted file mode 100644 index 30656bd1..00000000 --- a/runtime/authz/api/AdminConsumerAPI.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package authz with the agent request handler for using service 'Admin' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authz - -import "github.com/hiveot/hub/lib/consumer" - - -// AdminGetClientRole client method - Get Client Role. -// Read the role of a client -func AdminGetClientRole(hc *consumer.Consumer, clientID string)(role ClientRole, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminGetClientRoleMethod, &clientID, &role) - return -} - -// AdminSetClientRole client method - Set Client Role. -// Update the role of a consumer -func AdminSetClientRole(hc *consumer.Consumer, clientID string, role ClientRole)(err error){ - var args = AdminSetClientRoleArgs{clientID, role} - err = hc.Rpc("invokeaction", AdminDThingID, AdminSetClientRoleMethod, &args, nil) - return -} diff --git a/runtime/authz/api/AdminTypes.go b/runtime/authz/api/AdminTypes.go deleted file mode 100644 index ab48dcf3..00000000 --- a/runtime/authz/api/AdminTypes.go +++ /dev/null @@ -1,45 +0,0 @@ -// Package authz with types of service 'Admin' from agent 'authz' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package authz - - -//--- Constants --- - -// AdminAgentID is the account ID of the agent managing the Thing. -const AdminAgentID = "authz" - -// AdminServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const AdminServiceID = "Admin" - -// AdminDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const AdminDThingID = "dtw:authz:Admin" - -// Property, Event and Action names -const ( - AdminActionGetClientRole = "getClientRole" - AdminActionSetClientRole = "setClientRole" -) -//--- Argument and Response struct for action of Thing 'admin' --- - -const AdminGetClientRoleMethod = "getClientRole" - -const AdminSetClientRoleMethod = "setClientRole" - -// AdminSetClientRoleArgs defines the arguments of the setClientRole function -// Set Client Role - Update the role of a consumer -type AdminSetClientRoleArgs struct { - - // ClientID with Client ID - ClientID string `json:"clientID,omitempty"` - - // Role with Client Role - Role ClientRole `json:"role,omitempty"` -} - - -// AdminTD contains the raw TD of this service for publication to the Hub -const AdminTD = `{"actions":{"getClientRole":{"@type":"hiveot:function","description":"Read the role of a client","title":"Get Client Role","idempotent":true,"input":{"title":"ClientID","description":"ID of the service, agent or consumer","readOnly":false,"type":"string"},"output":{"title":"Role","description":"Response with client role","readOnly":false,"type":"ClientRole"},"safe":true},"setClientRole":{"@type":"hiveot:function","description":"Update the role of a consumer","title":"Set Client Role","idempotent":true,"input":{"readOnly":false,"type":"object","properties":{"clientID":{"title":"Client ID","readOnly":false,"type":"string"},"role":{"title":"Client Role","readOnly":false,"type":"ClientRole"}}}}},"@context":["https://www.w3.org/2022/wot/td/v1.1",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":"Service","created":"2024-06-04T17:00:00.000Z","description":"HiveOT runtime service for administration of roles and permissions","events":{},"id":"Admin","modified":"2024-06-04T17:00:00.000Z","properties":{},"security":null,"securityDefinitions":null,"title":"Authorization Administration Service","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/authz/api/UserAgentAPI.go b/runtime/authz/api/UserAgentAPI.go deleted file mode 100644 index 28f76446..00000000 --- a/runtime/authz/api/UserAgentAPI.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package authz with the agent request handler for using service 'User' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authz - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IUserService defines the interface of the 'User' service -// -// This defines a method for each of the actions in the TD. -// -type IUserService interface { - - // SetPermissions Set Permissions - // Set the roles that can use a Thing or service - SetPermissions(senderID string, permissions ThingPermissions) error -} - -// NewHandleUserRequest returns an agent handler for Thing 'user' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleUserRequest(svc IUserService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "setPermissions": - var args ThingPermissions - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.SetPermissions(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/authz/api/UserConsumerAPI.go b/runtime/authz/api/UserConsumerAPI.go deleted file mode 100644 index eef5c1fe..00000000 --- a/runtime/authz/api/UserConsumerAPI.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package authz with the agent request handler for using service 'User' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package authz - -import "github.com/hiveot/hub/lib/consumer" - - -// UserSetPermissions client method - Set Permissions. -// Set the roles that can use a Thing or service -func UserSetPermissions(hc *consumer.Consumer, permissions ThingPermissions)(err error){ - - err = hc.Rpc("invokeaction", UserDThingID, UserSetPermissionsMethod, &permissions, nil) - return -} diff --git a/runtime/authz/api/UserTypes.go b/runtime/authz/api/UserTypes.go deleted file mode 100644 index fc3338ac..00000000 --- a/runtime/authz/api/UserTypes.go +++ /dev/null @@ -1,117 +0,0 @@ -// Package authz with types of service 'User' from agent 'authz' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package authz - - -//--- Constants --- - -// UserAgentID is the account ID of the agent managing the Thing. -const UserAgentID = "authz" - -// UserServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const UserServiceID = "User" - -// UserDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const UserDThingID = "dtw:authz:User" - -// Property, Event and Action names -const ( - UserActionSetPermissions = "setPermissions" -) - -//--- Schema definitions of Thing 'user' --- - -// ThingPermissions defines a Thing Permissions data schema. -// -// This defines the roles that have permissions to access this thing -// Used by agents and services to set the roles that can invoke actions on a service. -// These permissions are default recommendations made by the service provider. -// The authz service can override these defaults with another configuration. -type ThingPermissions struct { - - // AgentID with Agent ID - // - // The agent granting the permissions. - AgentID string `json:"agentID,omitempty"` - - // Allow with Roles - // - // Roles allowed access to a Thing - Allow []ClientRole `json:"allow,omitempty"` - - // Deny with - Deny []ClientRole `json:"deny,omitempty"` - - // ThingID with Thing ID - // - // ThingID of the service whose permissions are set. - // This is the ThingID as send by the agent, without the digitwin prefix. - ThingID string `json:"thingID,omitempty"` -} - -// ClientRole enumerator -type ClientRole string -const ( - - // ClientRoleNone for No role - // - // No role means that the user has no permissions. - // It can not do anything until the role is upgraded to viewer or better - // Read permissions: none - // Write permissions: none - ClientRoleNone ClientRole = "none" - - // ClientRoleAdmin for Administrator role - // - // Administrators can publish and subscribe to any sources and invoke all services - // Read permissions: subEvents, subActions - // Write permissions: pubEvents, pubActions, pubConfig - ClientRoleAdmin ClientRole = "admin" - - // ClientRoleAgent for Device agent role - // - // Device agents can publish thing events and subscribe to device actions - // Read permissions: subActions - // Write permissions: pubTDs, pubEvents - ClientRoleAgent ClientRole = "agent" - - // ClientRoleManager for Manager role - // - // Managers can subscribe to Thing TD, events, publish actions and update configuration - // Read permissions: subEvents - // Write permissions: pubActions, pubConfig - ClientRoleManager ClientRole = "manager" - - // ClientRoleOperator for Operator role - // - // Operators can subscribe to events and publish actions - // Read permissions: subEvents - // Write permissions: pubActions - ClientRoleOperator ClientRole = "operator" - - // ClientRoleService for Service role - // - // Services act as an admin user and a device - // Read permissions: subEvents, subActions, subConfig - // Write permissions: pubEvents, pubActions, pubConfig - ClientRoleService ClientRole = "service" - - // ClientRoleViewer for Viewer role - // - // Viewers can read TDs and subscribe to Thing Events - // Read permissions: subEvents - // Write permissions: none - ClientRoleViewer ClientRole = "viewer" -) - -//--- Argument and Response struct for action of Thing 'user' --- - -const UserSetPermissionsMethod = "setPermissions" - - -// UserTD contains the raw TD of this service for publication to the Hub -const UserTD = `{"actions":{"setPermissions":{"@type":"hiveot:function","description":"Set the roles that can use a Thing or service","title":"Set Permissions","idempotent":true,"input":{"title":"Permissions","readOnly":false,"type":"ThingPermissions"}}},"@context":["https://www.w3.org/2022/wot/td/v1.1",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":"Service","created":"2024-06-04T17:00:00.000Z","description":"HiveOT runtime service for services setting permissions","events":{},"id":"User","modified":"2024-06-04T17:00:00.000Z","properties":{},"schemaDefinitions":{"ThingPermissions":{"title":"Thing Permissions","description":"This defines the roles that have permissions to access this thing","comments":["Used by agents and services to set the roles that can invoke actions on a service.","These permissions are default recommendations made by the service provider.","The authz service can override these defaults with another configuration."],"readOnly":false,"type":"object","properties":{"agentID":{"title":"Agent ID","description":"The agent granting the permissions.","readOnly":false,"type":"string"},"allow":{"title":"Roles","description":"Roles allowed access to a Thing","readOnly":false,"type":"array","items":{"title":"Role","description":"Role allowed access to a Thing","readOnly":false,"type":"ClientRole"}},"deny":{"readOnly":false,"type":"array","items":{"title":"Role","description":"Role denied access to the Thing","readOnly":false,"type":"ClientRole"}},"thingID":{"title":"Thing ID","description":"ThingID of the service whose permissions are set.","comments":["This is the ThingID as send by the agent, without the digitwin prefix."],"readOnly":false,"type":"string"}}},"clientRole":{"title":"Client Roles","oneOf":[{"title":"No role","description":"No role means that the user has no permissions.","comments":["It can not do anything until the role is upgraded to viewer or better","Read permissions: none","Write permissions: none"],"const":"none","readOnly":false},{"title":"Administrator role","description":"Administrators can publish and subscribe to any sources and invoke all services","comments":["Read permissions: subEvents, subActions","Write permissions: pubEvents, pubActions, pubConfig"],"const":"admin","readOnly":false},{"title":"Device agent role","description":"Device agents can publish thing events and subscribe to device actions","comments":["Read permissions: subActions","Write permissions: pubTDs, pubEvents"],"const":"agent","readOnly":false},{"title":"Manager role","description":"Managers can subscribe to Thing TD, events, publish actions and update configuration","comments":["Read permissions: subEvents","Write permissions: pubActions, pubConfig"],"const":"manager","readOnly":false},{"title":"Operator role","description":"Operators can subscribe to events and publish actions","comments":["Read permissions: subEvents","Write permissions: pubActions"],"const":"operator","readOnly":false},{"title":"Service role","description":"Services act as an admin user and a device","comments":["Read permissions: subEvents, subActions, subConfig","Write permissions: pubEvents, pubActions, pubConfig"],"const":"service","readOnly":false},{"title":"Viewer role","description":"Viewers can read TDs and subscribe to Thing Events","comments":["Read permissions: subEvents","Write permissions: none"],"const":"viewer","readOnly":false}],"readOnly":false,"type":"string"}},"security":null,"securityDefinitions":null,"title":"Authorization Client Services","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/authz/service/AuthzAgent.go b/runtime/authz/service/AuthzAgent.go deleted file mode 100644 index 8c1be411..00000000 --- a/runtime/authz/service/AuthzAgent.go +++ /dev/null @@ -1,99 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// AuthzAgent serves the message based interface to the authz service API. -// This converts the request messages into API calls and converts the result -// back to a reply message, if any. -// The main entry point is the HandleMessage function. -type AuthzAgent struct { - svc *AuthzService - adminHandler messaging.RequestHandler - userHandler messaging.RequestHandler -} - -// HandleAction authz service action handler -func (agent *AuthzAgent) HandleAction( - req *messaging.RequestMessage, c messaging.IConnection) (resp *messaging.ResponseMessage) { - - // if the message has an authn agent prefix then remove it. - // This can happen if invoked directly through an embedded client - _, thingID, _ := digitwin.SplitDigitwinID(req.ThingID) - if thingID == authz.AdminServiceID { - resp = agent.adminHandler(req, c) - } else if thingID == authz.UserServiceID { - resp = agent.userHandler(req, c) - } else { - err := fmt.Errorf("unknown authz service capability '%s'", req.ThingID) - resp = req.CreateResponse(nil, err) - } - return resp -} - -// HasPermission is a convenience function to check if the sender has permission -// to pub/sub events, actions or properties. This invokes HasPermission on the service. -func (agent *AuthzAgent) HasPermission( - senderID, operation, dThingID string) bool { - return agent.svc.HasPermission(senderID, operation, dThingID) -} - -// StartAuthzAgent creates a new instance of the agent handling authorization service requests -// If hc is nil then use the HandleMessage method directly to pass messages to the agent, -// for example when testing. -// -// svc is the authorization service whose capabilities to expose -// hc is the optional message client used to publish and subscribe -func StartAuthzAgent(svc *AuthzService) (*AuthzAgent, error) { - var err error - agent := AuthzAgent{ - adminHandler: authz.NewHandleAdminRequest(svc), - userHandler: authz.NewHandleUserRequest(svc), - svc: svc, - } - - // FIXME: replace authz with TD based permissions - // set permissions for using the authn services as authz wasn't yet running - err = svc.SetPermissions(authn.AdminAgentID, authz.ThingPermissions{ - AgentID: authn.AdminAgentID, - ThingID: authn.AdminServiceID, - Allow: []authz.ClientRole{authz.ClientRoleService, authz.ClientRoleAdmin, authz.ClientRoleManager}, - }) - if err == nil { - // all users with a role can GetProfile and refresh their token - err = svc.SetPermissions(authn.UserAgentID, authz.ThingPermissions{ - AgentID: authn.UserAgentID, - ThingID: authn.UserServiceID, - Deny: []authz.ClientRole{authz.ClientRoleNone}, - }) - } - - // set permissions for using the authz services - if err == nil { - err = svc.SetPermissions(authz.AdminAgentID, authz.ThingPermissions{ - AgentID: authz.AdminAgentID, - ThingID: authz.AdminServiceID, - Allow: []authz.ClientRole{authz.ClientRoleService, authz.ClientRoleAdmin, authz.ClientRoleManager}, - }) - } - if err == nil { - err = svc.SetPermissions(authz.UserAgentID, authz.ThingPermissions{ - AgentID: authz.UserAgentID, - ThingID: authz.UserServiceID, - Allow: []authz.ClientRole{authz.ClientRoleAgent, authz.ClientRoleService, - authz.ClientRoleAdmin, authz.ClientRoleManager, authz.ClientRoleOperator}, - }) - } - if err != nil { - slog.Error("StartAuthzAgent failed. Continuing anyways", "err", err.Error()) - err = nil - } - return &agent, err -} diff --git a/runtime/authz/service/AuthzConfig.go b/runtime/authz/service/AuthzConfig.go deleted file mode 100644 index b05eda33..00000000 --- a/runtime/authz/service/AuthzConfig.go +++ /dev/null @@ -1,185 +0,0 @@ -package service - -import ( - "path" - "sync" - - "github.com/hiveot/hivekit/go/api/td" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -const DefaultAclFilename = "authz.acl" - -// role and their permissions. This controls whether clients can publish actions, -// publish events and subscribe to events. -// -// Specific role permissions: -// viewer: can publish service actions that allow it -// can not publish agent actions -// can not publish configuration actions -// can subscribe to thing dtw events -// operator: can publish dtw actions -// can not publish configuration actions -// can subscribe to thing dtw events -// manager: can publish thing dtw actions -// can publish thing dtw configuration actions -// can subscribe to thing dtw events -// admin: can publish dtw actions -// can publish dtw configuration actions -// agent: can publish native events (which digitwin turns into digitwin thing events) -// service: can publish native events -// can subscribe to any dtw events -// -// Services set their own default permissions on what roles can use them. Some examples: -// digitwin directory service: -// readTD, readTDs, QueryDTDs methods: all roles -// removeTD: manager, admin, service -// digitwin inbox: (action store) -// readLatest: operator, manager, admin, service -// digitwin outbox: (event store) -// readLatest: all roles -// removeValue: manager, admin, service -// state storage: -// all roles can use this. Service limits it to the client's own data. -// history read: -// all roles can read history -// history manage: -// manager, admin, service - -// RolePermission defines authorization for a role. -// Each permission defines the source/things the user can pub/sub to. -type RolePermission struct { - // device or service publishing the Thing data, or "" for all - //AgentID string - // thingID or capability, or "" for all - ThingID string `yaml:"thingID"` - // OpSubscribeEvent,... action, config, or "" for all message types - Operations []string `yaml:"operations"` - // action name or "" for all actions - //MsgKey string -} - -// re-usable auth permissions to refresh token and logout -var authPermissions = []string{ - //td.HTOpLogout, - //td.HTOpRefresh, -} - -// re-usable permissions to observe and read and observe properties, events, actions, TDs -var readPermissions = []string{ - //td.HTOpReadEvent, td.HTOpReadAllEvents, - //td.HTOpReadTD, td.HTOpReadAllTDs, - td.OpObserveProperty, td.OpObserveAllProperties, - //td.OpQueryAction, td.OpQueryAllActions, // why query actions if you cant invoke? - td.OpReadProperty, td.OpReadAllProperties, - td.OpSubscribeEvent, td.OpSubscribeAllEvents, - td.OpUnobserveProperty, td.OpUnobserveAllProperties, - td.OpUnsubscribeEvent, td.OpUnsubscribeAllEvents, -} - -// Thing agents can refresh tokens and invoke actions on services. -var agentPermissions = RolePermission{ - Operations: append(authPermissions, - td.OpInvokeAction, - ), -} - -// services can do almost anything -var servicePermissions = RolePermission{ - Operations: append( - append(authPermissions, readPermissions...), - td.OpQueryAction, td.OpQueryAllActions, - td.OpInvokeAction, td.OpWriteProperty, - ), -} - -// viewers can authenticate and read properties and events -var viewerPermissions = RolePermission{ - Operations: append(authPermissions, readPermissions...), -} - -// operators can subscribe to events and publish things actions -// operators cannot configure things -var operatorPermissions = RolePermission{ - Operations: append( - append(authPermissions, readPermissions...), - td.OpInvokeAction, - td.OpQueryAction, td.OpQueryAllActions, - ), -} - -// managers are operators that can also configure properties -var managerPermissions = RolePermission{ - Operations: append(operatorPermissions.Operations, - td.OpWriteProperty, - ), -} - -// administrators are like managers. -// Services will add their role authorization on startup -var adminPermissions = RolePermission{ - Operations: append(managerPermissions.Operations), // copy the permissions -} - -// DefaultRolePermissions contains the default pub/sub permissions for each role -var DefaultRolePermissions = map[authz.ClientRole]RolePermission{ - authz.ClientRoleNone: {}, - authz.ClientRoleAgent: agentPermissions, - authz.ClientRoleService: servicePermissions, - authz.ClientRoleViewer: viewerPermissions, - authz.ClientRoleOperator: operatorPermissions, - authz.ClientRoleManager: managerPermissions, - authz.ClientRoleAdmin: adminPermissions, -} - -// AuthzConfig holds the authorization permissions for client roles -type AuthzConfig struct { - // map of role to permissions of that role - RolePermissions map[authz.ClientRole]RolePermission `yaml:"rolePermissions"` - - // map of service dThingID to the allow/deny roles that can invoke it - ThingPermissions map[string]authz.ThingPermissions `yaml:"servicePermissions"` - - // file with configured permissions - aclFile string `yaml:"aclFile"` - - // mutex for accessing configuration - mux sync.RWMutex -} - -func (cfg *AuthzConfig) GetPermissions(dThingID string) (authz.ThingPermissions, bool) { - cfg.mux.Lock() - defer cfg.mux.Unlock() - perm, found := cfg.ThingPermissions[dThingID] - return perm, found -} - -// SetPermissions defines Thing specific permissions -// Intended for storing digital-twin thing permissions -func (cfg *AuthzConfig) SetPermissions(perms authz.ThingPermissions) { - cfg.mux.Lock() - defer cfg.mux.Unlock() - dThingID := digitwin.MakeDigitwinID(perms.AgentID, perms.ThingID) - cfg.ThingPermissions[dThingID] = perms -} - -// Setup ensures config is valid and loaded -// -// storesDir is the default storage root directory ($HOME/stores) -func (cfg *AuthzConfig) Setup(storesDir string) { - if cfg.aclFile == "" { - cfg.aclFile = DefaultAclFilename - } - if !path.IsAbs(cfg.aclFile) { - cfg.aclFile = path.Join(storesDir, "authz", cfg.aclFile) - } -} - -func NewAuthzConfig() AuthzConfig { - cfg := AuthzConfig{ - RolePermissions: DefaultRolePermissions, - ThingPermissions: make(map[string]authz.ThingPermissions), - } - return cfg -} diff --git a/runtime/authz/service/AuthzService.go b/runtime/authz/service/AuthzService.go deleted file mode 100644 index cf9b3875..00000000 --- a/runtime/authz/service/AuthzService.go +++ /dev/null @@ -1,155 +0,0 @@ -package service - -import ( - "fmt" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/runtime/authn/authnstore" - authz "github.com/hiveot/hub/runtime/authz/api" - "log/slog" - "slices" -) - -// AuthzService is the authorization service for authorizing access to devices -type AuthzService struct { - - // configuration with role - cfg *AuthzConfig - - // authz currently uses authn store to persist the user's role - // this is good enough as long as a user only has a single role - authnStore authnstore.IAuthnStore -} - -// CreateCustomRole adds a new custom role -//func (svc *AuthzService) CreateCustomRole(role string) error { -// slog.Error("CreateRole is not yet implemented") -// return nil -//} - -// DeleteCustomRole deletes a custom role -//func (svc *AuthzService) DeleteCustomRole(role string) error { -// slog.Error("DeleteRole is not yet implemented") -// return nil -//} - -// -//// CanPubAction checks if the given client can publish actions -//func (svc *AuthzService) CanPubAction(clientID string) bool { -// hasPerm := svc.HasPermission(clientID, vocab.WotOpInvokeAction, true) -// return hasPerm -//} -// -//// CanPubEvent checks if the given client can publish events -//func (svc *AuthzService) CanPubEvent(clientID string) bool { -// hasPerm := svc.HasPermission(clientID, vocab.HTOpPublishEvent, true) -// return hasPerm -//} -// -//// CanSubAction checks if the given client can subscribe to actions -//func (svc *AuthzService) CanSubAction(clientID string) bool { -// hasPerm := svc.HasPermission(clientID, vocab.WotOpInvokeAction, false) -// return hasPerm -//} -// -//// CanSubEvent checks if the given client can subscribe to events -//func (svc *AuthzService) CanSubEvent(clientID string) bool { -// hasPerm := svc.HasPermission(clientID, vocab.HTOpPublishEvent, false) -// return hasPerm -//} - -// GetClientRole returns the role assigned to the client or an error -func (svc *AuthzService) GetClientRole(senderID string, clientID string) (authz.ClientRole, error) { - // this simply returns the default role stored with the client - // in future more roles could be added in which case authz will need its own store. - role, err := svc.authnStore.GetRole(clientID) - return authz.ClientRole(role), err -} - -// GetRolePermissions returns the permissions for the given role -func (svc *AuthzService) GetRolePermissions(senderID string, role authz.ClientRole) (RolePermission, bool) { - rolePerm, found := svc.cfg.RolePermissions[role] - return rolePerm, found -} - -// SetClientRole sets the role of a client in the authz store -func (svc *AuthzService) SetClientRole(senderID string, args authz.AdminSetClientRoleArgs) error { - // okay, we lied, it uses the authn store - validRoles := []authz.ClientRole{ - authz.ClientRoleViewer, authz.ClientRoleOperator, - authz.ClientRoleManager, authz.ClientRoleAdmin, - authz.ClientRoleAgent, authz.ClientRoleService, - authz.ClientRoleNone, - } - if !slices.Contains(validRoles, args.Role) { - return fmt.Errorf("SetRole: Invalid role '%s'", args.Role) - } - return svc.authnStore.SetRole(args.ClientID, string(args.Role)) -} - -// SetPermissions sets the client roles that are allowed to use an agent's service. -// -// Intended for use by services to set the roles that have access to it. -// This fails if the caller is not an agent and not an admin user. -// Agents can only set permissions for themselves while admin users can set -// permissions for others. -// -// senderID is the client sets the permissions. -// perms are the permissions that apply to using this agent -func (svc *AuthzService) SetPermissions(senderID string, perms authz.ThingPermissions) error { - // the sender must be a service - slog.Info("SetPermissions", - slog.String("senderID", senderID), - slog.String("agentID", perms.AgentID), - slog.String("thingID", perms.ThingID)) - - clientProfile, err := svc.authnStore.GetProfile(senderID) - role, _ := svc.authnStore.GetRole(senderID) - if err != nil { - return err - } else if authz.ClientRole(role) == authz.ClientRoleAdmin { - // administrators can set permissions for others - slog.Info("Administrator setting role") - } else if senderID != perms.AgentID { - // unless the sender is an admin, it cannot set permissions for someone else - return fmt.Errorf( - "sender '%s' cannot set permissions for agent '%s'", senderID, perms.AgentID) - } else if clientProfile.ClientType == authn.ClientTypeConsumer { - return fmt.Errorf( - "'%s' is a consumer and consumers cannot set permissions", senderID) - } - // store the permissions - svc.cfg.SetPermissions(perms) - return nil -} - -// Start starts the authorization service -func (svc *AuthzService) Start() error { - slog.Info("Starting AuthzService") - return nil -} - -// Stop stops the authorization service -func (svc *AuthzService) Stop() { - slog.Info("Stopping AuthzService") -} - -// NewAuthzService creates a new instance of the authorization service with default rules -// -// authnStore is used to store default client roles -func NewAuthzService(cfg *AuthzConfig, authnStore authnstore.IAuthnStore) *AuthzService { - svc := &AuthzService{ - cfg: cfg, - authnStore: authnStore, - } - return svc -} - -// StartAuthzService creates and start the authz administration service -// with the given config. -// This uses the authn store to store the user role -func StartAuthzService(cfg *AuthzConfig, authnStore authnstore.IAuthnStore) (*AuthzService, error) { - - svc := NewAuthzService(cfg, authnStore) - err := svc.Start() - return svc, err -} diff --git a/runtime/authz/service/HasPermission.go b/runtime/authz/service/HasPermission.go deleted file mode 100644 index b07ec185..00000000 --- a/runtime/authz/service/HasPermission.go +++ /dev/null @@ -1,108 +0,0 @@ -package service - -import ( - "golang.org/x/exp/slices" -) - -// HasRolePermission returns whether the client has permission for an -// operation based on their role. -// -// This returns true if the client has permission, false if the client does not have the permission -// See also HasServicePermissions to check if clients can invoke specific services -func (svc *AuthzService) HasRolePermission(senderID, operation string) bool { - role, err := svc.GetClientRole(senderID, senderID) - if err != nil || role == "" { - // unknown client or missing role - return false - } - - // configured role permissions - rolePerms, found := svc.cfg.RolePermissions[role] - if !found { - return false - } - if slices.Contains(rolePerms.Operations, operation) { - return true - } - return false -} - -// HasThingPermission returns whether the client has permission to use a requested thing. -// This only applies if permissions for the Thing (service or device) is set. -// -// senderID contains the sender, thingID and key to validate -// thingID whose permission to check -// -// This returns true if the client has permission, false if the client does not have the permission -func (svc *AuthzService) HasThingPermission(senderID string, thingID string) bool { - sp, found := svc.cfg.GetPermissions(thingID) - if !found { - return false - } - clientRole, err := svc.GetClientRole(senderID, senderID) - if err != nil { - return false - } - // if allow is set then the default is denied - if sp.Allow != nil && len(sp.Allow) > 0 { - if slices.Contains(sp.Allow, clientRole) { - return true - } - return false - } - // if deny list is set then the default is allowed - if sp.Deny != nil && len(sp.Deny) > 0 { - if slices.Contains(sp.Deny, clientRole) { - return false - } - return true - } - return false -} - -// HasPermission returns whether the sender has permission to request an operation -// on a Thing. -// -// This returns true if the client has permission, false if the client does not -// have the permission. -// -// If the Thing is a service and has its permission set then use the service -// set permissions rather than the sender's role permissions. -// -// senderID the login ID of the sender -// messageType WotOpInvokeAction/Event/Property, -func (svc *AuthzService) HasPermission(senderID, operation, dThingID string) (hasPerm bool) { - //If a permission record is set for a service Thing then it has priority. - _, found := svc.cfg.GetPermissions(dThingID) - if found { - hasPerm = svc.HasThingPermission(senderID, dThingID) - } else { - hasPerm = svc.HasRolePermission(senderID, operation) - } - return hasPerm -} - -// HasPubPermission returns whether a sender can publish the given message -// -// This returns an error if the client doesn't have permission -//func (svc *AuthzService) HasPubPermission(msg *transports.ThingMessage) (*transports.IConsumer, error) { -// var hasPerm bool -// //If a thing permission exists then it has priority -// _, found := svc.cfg.GetPermissions(msg.ThingID) -// if found { -// // the publish is for known thing, set with SetPermission() -// hasPerm = svc.HasThingPermission(msg.SenderID, msg.ThingID, true) -// } else { -// hasPerm = svc.HasRolePermission(msg.SenderID, msg.MessageType, true) -// } -// if !hasPerm { -// slog.Warn("Sender has no permissions to publish", -// slog.String("senderID", msg.SenderID), -// slog.String("messageType", msg.MessageType), -// slog.String("thingID", msg.ThingID), -// slog.String("name", msg.Name), -// ) -// return msg, fmt.Errorf("Permission denied") -// } -// return msg, nil -//} diff --git a/runtime/authz/tm/Admin.json b/runtime/authz/tm/Admin.json deleted file mode 100644 index b750b35b..00000000 --- a/runtime/authz/tm/Admin.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - { "hiveot": "https://www.hiveot.net/vocab/v0.1" } - ], - "@type": "Service", - "id": "admin", - "title": "Authorization Administration Service", - "description": "HiveOT runtime service for administration of roles and permissions", - "version": { - "instance": "0.1.0" - }, - "created": "2024-06-04T17:00:00.000Z", - "modified": "2024-06-04T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "allow": ["admin"], - - "schemaDefinitions": {}, - "properties": {}, - "events": {}, - "actions": { - "getClientRole": { - "@type": "hiveot:function", - "title": "Get Client Role", - "description": "Read the role of a client", - "safe": true, - "idempotent": true, - "input": { - "title": "ClientID", - "type": "string", - "description": "ID of the service, agent or consumer" - }, - "output": { - "title": "Role", - "description": "Response with client role", - "type": "ClientRole" - } - }, - "setClientRole": { - "@type": "hiveot:function", - "title": "Set Client Role", - "description": "Update the role of a consumer", - "idempotent": true, - "input": { - "type": "object", - "properties": { - "clientID": { - "title": "Client ID", - "type": "string" - }, - "role": { - "title": "Client Role", - "type": "ClientRole" - } - } - } - } - }, - "links": [] -} diff --git a/runtime/authz/tm/User.json b/runtime/authz/tm/User.json deleted file mode 100644 index ec65533c..00000000 --- a/runtime/authz/tm/User.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - { "hiveot": "https://www.hiveot.net/vocab/v0.1" } - ], - "@type": "Service", - "id": "user", - "title": "Authorization Client Services", - "description": "HiveOT runtime service for services setting permissions", - "version": { - "instance": "0.1.0" - }, - "created": "2024-06-04T17:00:00.000Z", - "modified": "2024-06-04T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "schemaDefinitions": { - "clientRole": { - "type": "string", - "title": "Client Roles", - "oneOf": [ - { - "const": "none", - "title": "No role", - "description": "No role means that the user has no permissions.", - "comments": [ - "It can not do anything until the role is upgraded to viewer or better", - "Read permissions: none", - "Write permissions: none" - ] - }, - { - "const": "admin", - "title": "Administrator role", - "description": "Administrators can publish and subscribe to any sources and invoke all services", - "comments": [ - "Read permissions: subEvents, subActions", - "Write permissions: pubEvents, pubActions, pubConfig" - ] - }, - { - "const": "agent", - "title": "Device agent role", - "description": "Device agents can publish thing events and subscribe to device actions", - "comments": [ - "Read permissions: subActions", - "Write permissions: pubTDs, pubEvents" - ] - }, - { - "const": "manager", - "title": "Manager role", - "description": "Managers can subscribe to Thing TD, events, publish actions and update configuration", - "comments": [ - "Read permissions: subEvents", - "Write permissions: pubActions, pubConfig" - ] - }, - { - "const": "operator", - "title": "Operator role", - "description": "Operators can subscribe to events and publish actions", - "comments": [ - "Read permissions: subEvents", - "Write permissions: pubActions" - ] - }, - { - "const": "service", - "title": "Service role", - "description": "Services act as an admin user and a device", - "comments": [ - "Read permissions: subEvents, subActions, subConfig", - "Write permissions: pubEvents, pubActions, pubConfig" - ] - }, - { - "const": "viewer", - "title": "Viewer role", - "description": "Viewers can read TDs and subscribe to Thing Events", - "comments": ["Read permissions: subEvents", "Write permissions: none"] - } - ] - }, - "ThingPermissions": { - "title": "Thing Permissions", - "description": "This defines the roles that have permissions to access this thing", - "comments": [ - "Used by agents and services to set the roles that can invoke actions on a service.", - "These permissions are default recommendations made by the service provider.", - "The authz service can override these defaults with another configuration." - ], - "type": "object", - "properties": { - "agentID": { - "type": "string", - "title": "Agent ID", - "description": "The agent granting the permissions." - }, - "thingID": { - "type": "string", - "title": "Thing ID", - "description": "ThingID of the service whose permissions are set.", - "comments": [ - "This is the ThingID as send by the agent, without the digitwin prefix." - ] - }, - "allow": { - "type": "array", - "title": "Roles", - "description": "Roles allowed access to a Thing", - "items": { - "type": "ClientRole", - "title": "Role", - "description": "Role allowed access to a Thing" - } - }, - "deny": { - "type": "array", - "items": { - "type": "ClientRole", - "title": "Role", - "description": "Role denied access to the Thing" - } - } - } - } - }, - "deny": ["none"], - - "properties": {}, - "events": {}, - "actions": { - "setPermissions": { - "@type": "hiveot:function", - "title": "Set Permissions", - "description": "Set the roles that can use a Thing or service", - "idempotent": true, - "input": { - "type": "ThingPermissions", - "title": "Permissions" - } - } - }, - "links": [] -} diff --git a/runtime/cmd/main.go b/runtime/cmd/main.go index d384c093..0e40271f 100644 --- a/runtime/cmd/main.go +++ b/runtime/cmd/main.go @@ -6,8 +6,8 @@ import ( "os" "time" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" + "github.com/hiveot/hivekit/go/modules/factory" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/runtime" ) @@ -25,8 +25,8 @@ func main() { flag.PrintDefaults() fmt.Println() } - env := plugin.GetAppEnvironment("", true) - logging.SetLogging(env.LogLevel, "") + env := factory.NewAppEnvironment("", true) + utils.SetLogging(env.LogLevel, "") fmt.Println("home: ", env.HomeDir) if len(flag.Args()) > 0 { println("ERROR: No arguments expected.") @@ -36,9 +36,8 @@ func main() { } // Initialize the runtime configuration, directories and load keys and certificates - cfg := runtime.NewRuntimeConfig() - r := runtime.NewRuntime(cfg) - err := r.Start(&env) + r := runtime.NewRuntime(env) + err := r.Start() if err != nil { println("Starting hivot runtime failed: ", err.Error()) os.Exit(1) @@ -46,7 +45,7 @@ func main() { // Startup Successful // wait until signal - plugin.WaitForSignal() + utils.WaitForSignal() println("Graceful shutdown of Runtime") diff --git a/runtime/digitwin/DigitwinAction_test.go b/runtime/digitwin/DigitwinAction_test.go deleted file mode 100644 index 0c3ec543..00000000 --- a/runtime/digitwin/DigitwinAction_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package digitwin_test - -import ( - "encoding/json" - "testing" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - digitwinapi "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/runtime/digitwin/service" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestActionFlow(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - const title1 = "title1" - const consumerID = "user1" - const actionName = "action1" - const actionValue = 25 - const correlationID = "req-1" - dThingID := digitwinapi.MakeDigitwinID(agentID, thingID) - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // Create the native TD for invoking an action to - tdDoc1 := createTDDoc(thingID, 5, 4, 3) - actionSchema := &td.DataSchema{Type: td.DataTypeInteger, Title: "Position"} - tdDoc1.AddAction(actionName, "action 1", "", actionSchema) - tddjson, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tddjson)) - require.NoError(t, err) - - // create the action - req := messaging.NewRequestMessage( - td.OpInvokeAction, dThingID, actionName, actionValue, correlationID) - as, stored, err := dtwStore.NewActionStart(req) - require.NoError(t, err) - require.True(t, stored) - require.Equal(t, messaging.StatusPending, as.State) - - // check progress - as, err = svc.ValuesSvc.QueryAction(consumerID, digitwinapi.ThingValuesQueryActionArgs{ - ThingID: dThingID, - Name: actionName}) - require.NoError(t, err) - inputVal := utils.DecodeAsInt(as.Input) - require.Equal(t, actionValue, inputVal) - require.Equal(t, correlationID, as.ActionID) - require.Equal(t, messaging.StatusPending, as.State) - - // complete the action - resp := messaging.NewResponseMessage( - td.OpInvokeAction, dThingID, actionName, actionValue, nil, correlationID) - as, err = dtwStore.UpdateActionWithResponse(resp) - require.NoError(t, err) - require.Equal(t, correlationID, as.ActionID) - assert.Equal(t, "completed", as.State) - - // read action status - as, err = svc.ValuesSvc.QueryAction(consumerID, digitwinapi.ThingValuesQueryActionArgs{ - ThingID: dThingID, - Name: actionName}) - - require.NoError(t, err) - outputInt := utils.DecodeAsInt(as.Output) - require.Equal(t, actionValue, outputInt) - require.Equal(t, messaging.StatusCompleted, as.State) - - // read all actions - //actList, err := svc.ValuesSvc.QueryAllActions(consumerID, dThingID) - //require.NoError(t, err) - //require.NotZero(t, len(actList)) -} - -func TestActionReadFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - var dThingID = digitwinapi.MakeDigitwinID(agentID, thingID) - - svc, _, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an action - tdDoc1 := createTDDoc(thingID, 4, 2, 1) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tdDoc1Json)) - require.NoError(t, err) - - _, err = svc.ValuesSvc.QueryAction("itsme", digitwinapi.ThingValuesQueryActionArgs{ - ThingID: "badthingid", - Name: "someevent"}) - assert.Error(t, err) - - // query non-existing action is allowed if strict is set to false - _, err = svc.ValuesSvc.QueryAction("itsme", digitwinapi.ThingValuesQueryActionArgs{ - ThingID: dThingID, - Name: "badeventname"}) - require.NoError(t, err) -} - -func TestInvokeActionErrors(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - const title1 = "title1" - const consumerID = "user1" - const actionName = "action1" - const actionValue = 25 - const correlationID = "request-1" - dThingID := digitwinapi.MakeDigitwinID(agentID, thingID) - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // Create the native TD for invoking an action to - tdDoc1 := createTDDoc(thingID, 5, 4, 3) - actionSchema := &td.DataSchema{Type: td.DataTypeInteger, Title: "Position"} - tdDoc1.AddAction(actionName, "action 1", "", actionSchema) - tddjson, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tddjson)) - require.NoError(t, err) - - // invoke the action with the wrong thing - req := messaging.NewRequestMessage( - td.OpInvokeAction, "badThingID", actionName, actionValue, correlationID) - _, stored, err := dtwStore.NewActionStart(req) - - // unknown thingIDs are still allowed for now. - assert.NoError(t, err) - assert.False(t, stored) - - // invoke the action with the wrong name - req = messaging.NewRequestMessage( - td.OpInvokeAction, dThingID, "badName", actionValue, correlationID) - _, stored, err = dtwStore.NewActionStart(req) - // same as above - assert.NoError(t, err) - assert.False(t, stored) - - // complete the action on wrong thing - resp := messaging.NewResponseMessage( - td.OpInvokeAction, "badThingID", actionName, actionValue, nil, correlationID) - - _, err = dtwStore.UpdateActionWithResponse(resp) - assert.Error(t, err) - - // complete the action on wrong action name - resp.ThingID = thingID - resp.Name = "badName" - _, err = dtwStore.UpdateActionWithResponse(resp) - assert.Error(t, err) -} - -func TestDigitwinAgentAction(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - const title1 = "title1" - const consumerID = "user1" - const actionName = "action1" - const actionValue = 25 - const correlationID = "request-1" - - dThingID := digitwinapi.MakeDigitwinID(agentID, thingID) - - svc, _, stopFunc := startService(true) - defer stopFunc() - - // Create the native TD for invoking an action to - tdDoc1 := createTDDoc(thingID, 5, 4, 3) - actionSchema := &td.DataSchema{Type: td.DataTypeInteger, Title: "Position"} - tdDoc1.AddAction(actionName, "action 1", "", actionSchema) - tddJSON1, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tddJSON1)) - require.NoError(t, err) - - // read back should succeed - tddJson2, err := svc.DirSvc.RetrieveThing(consumerID, dThingID) - require.NoError(t, err) - require.NotEmpty(t, tddJson2) - - // next, invoke the action to read the thing from the directory. - ag := service.NewDigitwinAgent(svc) - req := messaging.NewRequestMessage(td.OpInvokeAction, - digitwinapi.ThingDirectoryDThingID, digitwinapi.ThingDirectoryRetrieveThingMethod, dThingID, consumerID) - req.CorrelationID = correlationID - resp := ag.HandleRequest(req, nil) - - require.Empty(t, resp.Error) - require.NotEmpty(t, resp.Value) - - // a non-existing TD should fail - req = messaging.NewRequestMessage(td.OpInvokeAction, - digitwinapi.ThingDirectoryDThingID, digitwinapi.ThingDirectoryRetrieveThingMethod, "badid", consumerID) - req.CorrelationID = correlationID - resp = ag.HandleRequest(req, nil) - require.NotEmpty(t, resp.Error) - - // a non-existing method name should fail - req = messaging.NewRequestMessage(td.OpInvokeAction, - digitwinapi.ThingDirectoryDThingID, "badMethod", dThingID, consumerID) - req.CorrelationID = correlationID - resp = ag.HandleRequest(req, nil) - require.NotEmpty(t, resp.Error) - - // a non-existing serviceID should fail - req = messaging.NewRequestMessage(td.OpInvokeAction, - "badservicename", digitwinapi.ThingDirectoryRetrieveThingMethod, dThingID, consumerID) - req.CorrelationID = correlationID - resp = ag.HandleRequest(req, nil) - require.NotEmpty(t, resp.Error) - -} diff --git a/runtime/digitwin/DigitwinBench_test.go b/runtime/digitwin/DigitwinBench_test.go deleted file mode 100644 index b4ac5019..00000000 --- a/runtime/digitwin/DigitwinBench_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package digitwin_test - -import ( - "fmt" - "testing" - - "github.com/hiveot/hub/lib/logging" - jsoniter "github.com/json-iterator/go" - "github.com/stretchr/testify/assert" -) - -func BenchmarkUpdateDTW(b *testing.B) { - b.Logf("---%s---\n", b.Name()) - const agent1ID = "agent1" - const thing1ID = "thing1" - const title1 = "title1" - const count = 1000 - - // create the test TDs - tddocs := make([]string, 0) - for m := range 1000 { - thingID := fmt.Sprintf("%s-%d", thing1ID, m) - tdDoc := createTDDoc(thingID, 20, 10, 3) - tdDocJson, _ := jsoniter.MarshalToString(tdDoc) - tddocs = append(tddocs, tdDocJson) - } - logging.SetLogging("warning", "") - svc, store, stopFn := startService(true) - store2 := store - dirSvc := svc.DirSvc - defer stopFn() - - var err error - - // 140 msec to update 1000 things - b.Run("update DTW", - func(b *testing.B) { - for n := 0; n < b.N; n++ { - for m := range 1000 { - tdDocJson := tddocs[m] - err = dirSvc.UpdateThing(agent1ID, string(tdDocJson)) - assert.NoError(b, err) - } - } - }) - - // 78 msec to reload a cache with 1000 things (using json-iterator) - b.Run("LoadCacheFromStore", - func(b *testing.B) { - for n := 0; n < b.N; n++ { - err = store2.LoadCacheFromStore() - assert.NoError(b, err) - } - }) - assert.NoError(b, err) -} diff --git a/runtime/digitwin/DigitwinDirectory_test.go b/runtime/digitwin/DigitwinDirectory_test.go deleted file mode 100644 index b7cedbe6..00000000 --- a/runtime/digitwin/DigitwinDirectory_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package digitwin_test - -import ( - "encoding/json" - "testing" - - digitwinapi "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddRemoveTD(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thing1ID = "thing1" - const title1 = "title1" - const consumerID = "user1" - var dThing1ID = digitwinapi.MakeDigitwinID(agentID, thing1ID) - - svc, _, stopFunc := startService(true) - defer stopFunc() - dirSvc := svc.DirSvc - - // use the native thingID for this TD doc as the directory converts it to - // the digital twin ID using the given agent that owns the TD. - tdd1 := createTDDoc(thing1ID, 5, 4, 3) - tdd1JSON, _ := json.Marshal(tdd1) - err := dirSvc.UpdateThing(agentID, string(tdd1JSON)) - require.NoError(t, err) - - dThingID := digitwinapi.MakeDigitwinID(agentID, thing1ID) - tdd2JSON, err := dirSvc.RetrieveThing(consumerID, dThingID) - require.NoError(t, err) - require.NotEmpty(t, tdd2JSON) - - dtdList, err := dirSvc.RetrieveAllThings(consumerID, - digitwinapi.ThingDirectoryRetrieveAllThingsArgs{Limit: 10}) - assert.NoError(t, err) - assert.NotEmpty(t, dtdList) - - // after removal, getTD should return nil - err = dirSvc.DeleteThing("senderID", dThing1ID) - assert.NoError(t, err) - - td1c, err := dirSvc.RetrieveThing(consumerID, dThingID) - assert.Error(t, err) - assert.Empty(t, td1c) - -} - -func TestGetTDsFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const clientID = "client1" - - svc, _, stopFunc := startService(true) - defer stopFunc() - dirSvc := svc.DirSvc - - // bad clientID - td1, err := dirSvc.RetrieveThing("", "badid") - require.Error(t, err) - require.Empty(t, td1) - - _ = svc - defer stopFunc() - tdList, err := svc.ReadAllTDs("", 0, 10) - require.NoError(t, err) - require.Empty(t, tdList) - -} - -//func TestQueryTDs(t *testing.T) { -//t.Logf("---%s---\n", t.Name()) -// _ = os.Remove(testStoreFile) -// const senderID = "agent1" -// const thing1ID = "agent1:thing1" -// const title1 = "title1" -// -// svc, stopFunc := startDirectory() -// defer stopFunc() -// -// tdDoc1 := createTDDoc(thing1ID, title1) -// err := svc.UpdateDTD(senderID, thing1ID, tdDoc1) -// require.NoError(t, err) -// -// jsonPathQuery := `$[?(@.id=="agent1:thing1")]` -// tdList, err := svc.QueryDTDs(jsonPathQuery) -// require.NoError(t, err) -// assert.NotNil(t, tdList) -// assert.True(t, len(tdList) > 0) -// el0 := things.TD{} -// json.Decode([]byte(tdList[0]), &el0) -// assert.Equal(t, thing1ID, el0.ID) -// assert.Equal(t, title1, el0.Title) -//} diff --git a/runtime/digitwin/DigitwinEvent_test.go b/runtime/digitwin/DigitwinEvent_test.go deleted file mode 100644 index 232b94ba..00000000 --- a/runtime/digitwin/DigitwinEvent_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package digitwin_test - -import ( - "encoding/json" - "testing" - - "github.com/hiveot/hivekit/go/api/td" - digitwinapi "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestAddReadEvent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agent1ID = "agent1" - const thing1ID = "thing1" - const eventName = "event1" - const eventValue = 25 - dThing1ID := digitwinapi.MakeDigitwinID(agent1ID, thing1ID) - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - tdDoc1 := createTDDoc(thing1ID, 5, 3, 1) - tdDoc1.AddEvent(eventName, "event1", "Descr 1", - &td.DataSchema{ - Title: "type1", - Type: td.DataTypeInteger, - }) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agent1ID, string(tdDoc1Json)) - - // provide an event value - evVal := digitwinapi.ThingValue{Data: eventValue, Name: eventName, ThingID: dThing1ID} - err = dtwStore.UpdateEventValue(evVal) - assert.NoError(t, err) - - // Read the event value and all events - ev2, err := svc.ValuesSvc.ReadEvent("user1", digitwinapi.ThingValuesReadEventArgs{ - ThingID: dThing1ID, - Name: eventName, - }) - assert.NoError(t, err) - assert.Equal(t, eventValue, ev2.Data) - evList, err := svc.ValuesSvc.ReadAllEvents("user1", dThing1ID) - assert.Equal(t, 1, len(evList)) - assert.NoError(t, err) -} - -func TestEventReadFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - var dThingID = digitwinapi.MakeDigitwinID(agentID, thingID) - - svc, _, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - - tdDoc1 := createTDDoc(thingID, 4, 2, 1) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tdDoc1Json)) - require.NoError(t, err) - - _, err = svc.ValuesSvc.ReadEvent("itsme", digitwinapi.ThingValuesReadEventArgs{ - ThingID: "badthingid", - Name: "someevent", - }) - assert.Error(t, err) - // if strict is set to false this can succeed with nil output - _, err = svc.ValuesSvc.ReadEvent("itsme", digitwinapi.ThingValuesReadEventArgs{ - ThingID: dThingID, - Name: "badeventname", - }) - assert.NoError(t, err) - _, err = svc.ValuesSvc.ReadAllEvents("itsme", "badthingid") - assert.Error(t, err) -} - -func TestEventUpdateFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - const EventName = "event1" - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - - tdDoc1 := createTDDoc(thingID, 4, 2, 1) - tdDoc1.AddEvent(EventName, "event1", "Descr 1", - &td.DataSchema{ - Title: "type1", - Type: td.DataTypeInteger, - }) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tdDoc1Json)) - require.NoError(t, err) - - evVal := digitwinapi.ThingValue{Data: 123, Name: EventName, ThingID: "notathing"} - err = dtwStore.UpdateEventValue(evVal) - assert.Error(t, err) - - //event names not in the TD are accepted - dThingID2 := digitwinapi.MakeDigitwinID(agentID, thingID) - evVal = digitwinapi.ThingValue{Data: 123, Name: "notanevent", ThingID: dThingID2} - err = dtwStore.UpdateEventValue(evVal) - assert.NoError(t, err) - assert.NotEmpty(t, dThingID2) -} diff --git a/runtime/digitwin/DigitwinProperty_test.go b/runtime/digitwin/DigitwinProperty_test.go deleted file mode 100644 index 08717831..00000000 --- a/runtime/digitwin/DigitwinProperty_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package digitwin_test - -import ( - "encoding/json" - "testing" - - digitwinapi "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestUpdateReadProperty(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agent1ID = "agent1" - const user1ID = "user1" - const thing1ID = "thing1" - const title1 = "title1" - const propName = "prop1" - const propValue = 25 - const propValue2 = 52 - const correlationID = "request-1" - dThing1ID := digitwinapi.MakeDigitwinID(agent1ID, thing1ID) - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - tdDoc1 := createTDDoc(thing1ID, 5, 4, 3) - tdDoc1.AddPropertyAsInt(propName, "", title1) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agent1ID, string(tdDoc1Json)) - - // - - // agent has provided a new property value - changed, err := dtwStore.UpdatePropertyValue( - digitwinapi.ThingValue{ThingID: dThing1ID, Name: propName, Data: propValue}) - assert.NoError(t, err) - assert.True(t, changed) - - // Read the property value and all values - v2, err := svc.ValuesSvc.ReadProperty(user1ID, digitwinapi.ThingValuesReadPropertyArgs{ - ThingID: dThing1ID, - Name: propName}) - assert.NoError(t, err) - assert.Equal(t, propValue, v2.Data) - - propList, err := svc.ValuesSvc.ReadAllProperties(user1ID, dThing1ID) - assert.Equal(t, 1, len(propList)) - assert.NoError(t, err) - - // next write a new value - changed, err = dtwStore.UpdatePropertyValue( - digitwinapi.ThingValue{ThingID: dThing1ID, Name: propName, Data: propValue2}) - - assert.NoError(t, err) - assert.True(t, changed) - v3, err := svc.ValuesSvc.ReadProperty(user1ID, digitwinapi.ThingValuesReadPropertyArgs{ - ThingID: dThing1ID, - Name: propName}) - assert.Equal(t, propValue2, v3.Data) -} - -func TestPropertyReadFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - - svc, _, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - - tdDoc1 := createTDDoc(thingID, 4, 2, 1) - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tdDoc1Json)) - require.NoError(t, err) - - _, err = svc.ValuesSvc.ReadProperty("itsme", digitwinapi.ThingValuesReadPropertyArgs{ - ThingID: "badthingid", - Name: "someprop"}) - assert.Error(t, err) - _, err = svc.ValuesSvc.ReadAllProperties("consumer", "badthingid") - assert.Error(t, err) -} - -func TestPropertyUpdateFail(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - const thingID = "thing1" - const propName = "prop1" - dThing1ID := digitwinapi.MakeDigitwinID(agentID, thingID) - dBadThingID := digitwinapi.MakeDigitwinID(agentID, "notathing") - - svc, dtwStore, stopFunc := startService(true) - defer stopFunc() - - // add a TD with an event - - tdDoc1 := createTDDoc(thingID, 4, 2, 1) - tdDoc1.AddPropertyAsInt(propName, "", "property 1") - tdDoc1Json, _ := json.Marshal(tdDoc1) - err := svc.DirSvc.UpdateThing(agentID, string(tdDoc1Json)) - require.NoError(t, err) - - changed, err := dtwStore.UpdatePropertyValue( - digitwinapi.ThingValue{ThingID: dBadThingID, Name: propName, Data: 123}) - assert.Error(t, err) - assert.False(t, changed) - //property names not in the TD are accepted - changed, err = dtwStore.UpdatePropertyValue( - digitwinapi.ThingValue{ThingID: dThing1ID, Name: "unknownprop", Data: 123}) - assert.NoError(t, err) - assert.True(t, changed) - - //can't update a property that doesn't exist - //dThingID := digitwin.MakeDigitwinID(agentID, thingID) - //err = dtwStore.WriteProperty(dThingID, digitwin.ThingValue{ - // Name: "unknownprop", - // Data: 123, - // SenderID: "user1", - //}) - //assert.NoError(t, err) -} diff --git a/runtime/digitwin/DigitwinRouter_test.go b/runtime/digitwin/DigitwinRouter_test.go deleted file mode 100644 index d369d232..00000000 --- a/runtime/digitwin/DigitwinRouter_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package digitwin_test - -import ( - "os" - "path" - "testing" - - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/runtime/digitwin/router" -) - -var testDir = path.Join(os.TempDir(), "test-router") - -// runtime tests the router - -// TestMain for all authn tests, setup of default folders and filenames -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - _ = os.RemoveAll(testDir) - res := m.Run() - if res == 0 { - _ = os.RemoveAll(testDir) - } - os.Exit(res) -} - -// just a compile check -func TestStartStop(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - // API match check - var _ router.IDigitwinRouter = &router.DigitwinRouter{} - //r := hubrouter.NewDigitwinRouter(dtwService, dirAgent, authnAction, authzAction) - -} diff --git a/runtime/digitwin/DigitwinService_test.go b/runtime/digitwin/DigitwinService_test.go deleted file mode 100644 index 2c40a35d..00000000 --- a/runtime/digitwin/DigitwinService_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package digitwin_test - -import ( - "encoding/json" - "log/slog" - "os" - "path" - "testing" - - "github.com/dchest/uniuri" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/connections" - "github.com/hiveot/hub/runtime/digitwin/service" - "github.com/hiveot/hub/runtime/digitwin/store" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var testDirFolder = path.Join(os.TempDir(), "test-directory") -var dirStorePath = path.Join(testDirFolder, "directory.data") -var cm *connections.ConnectionManager - -// startService initializes a service and a client -// This doesn't use any transport. -func startService(clean bool) ( - svc *service.DigitwinService, - dirStore *store.DigitwinStore, - stopFn func()) { - - if cm == nil { - cm = connections.NewConnectionManager() - } - if clean { - _ = os.RemoveAll(testDirFolder) - cm.CloseAll() - } - notifHandler := func(notif *messaging.NotificationMessage) { - slog.Info("Received notification", "op", notif.Operation) - } - svc, dirStore, err := service.StartDigitwinService(dirStorePath, notifHandler, true) - if err != nil { - panic("unable to start the digitwin service") - } - - // use direct transport to pass messages to the service - //msgHandler := digitwin.NewDirectoryHandler(svc) - //cl = embedded.NewEmbeddedClient(digitwin.DirectoryAgentID, msgHandler) - - return svc, dirStore, func() { - svc.Stop() - } -} - -// generate a TD document with properties, events and actions -func createTDDoc(thingID string, nrProps, nrEvents, nrActions int) *td.TD { - title := CreateRandomName("title-", 0) - tdi := td.NewTD(thingID, title, vocab.Device) - for range nrProps { - name := CreateRandomName("prop-", 0) - tdi.AddProperty(name, "", name, td.DataTypeInteger) - } - for range nrEvents { - name := CreateRandomName("ev-", 0) - tdi.AddEvent(name, name, "", - &td.DataSchema{ - Type: td.DataTypeInteger, - }) - } - for range nrActions { - name := CreateRandomName("act-", 0) - tdi.AddAction(name, name, "", - &td.DataSchema{ - Type: td.DataTypeBool, - }) - } - return tdi -} - -// Create a random text -func CreateRandomName(prefix string, length int) string { - if length > 0 { - return prefix + uniuri.NewLen(length) - } - return prefix + uniuri.New() -} -func TestStartStopService(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - var thingIDs = []string{"thing1", "thing2", "thing3", "thing4"} - svc, hc, stopFunc := startService(true) - _ = hc - // add TDs - for _, thingID := range thingIDs { - tdi := createTDDoc(thingID, 1, 1, 1) - tddjson, _ := json.Marshal(tdi) - err := svc.DirSvc.UpdateThing("test", string(tddjson)) - require.NoError(t, err) - } - tds1, err := svc.ReadAllTDs("", 0, 10) - require.NoError(t, err) - require.Greater(t, len(tds1), 1) - - // stop and start again, the update should be reloaded - stopFunc() - - svc, hc, stopFunc = startService(false) - defer stopFunc() - tds2, err := svc.ReadAllTDs("", 0, 10) - assert.Equal(t, len(tds1), len(tds2)) - -} diff --git a/runtime/digitwin/DummyTransportBinding_test.go b/runtime/digitwin/DummyTransportBinding_test.go deleted file mode 100644 index 751cc1b4..00000000 --- a/runtime/digitwin/DummyTransportBinding_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package digitwin_test - -// dummy transport for testing with the digitwin service -// This implements the ITransportServer interface. -//type DummyTransportServer struct { -//} - -//func (dummy *DummyTransportSe -//func (dummy *DummyTransportServer) GetConnectionByConnectionID(cid string) transports2.IServerConnection { -// return nil -//} -//func (dummy *DummyTransportServer) GetProtocolType() string { -// return transports2.ProtocolTypeEmbedded -//} - -//func (dummy *DummyTransportServer) InvokeAction( -// agentID string, thingID string, name string, value any, correlationID string, consumerID string) ( -// status string, output any, err error) { -// -// return vocab.RequestPending, nil, nil -//} -// -//func (dummy *DummyTransportServer) PublishEvent( -// dThingID string, name string, value any, correlationID string, agentID string) { -//} -// -//func (dummy *DummyTransportServer) PublishProperty( -// dThingID string, name string, value any, correlationID string, agentID string) { -//} -//func (dummy *DummyTransportServer) PublishProgressUpdate( -// connectionID string, stat transports2.ActionStatus, agentID string) (bool, error) { -// return false, nil -//} -// -//func (dummy *DummyTransportServer) WriteProperty( -// agentID string, thingID string, name string, value any, msgID string, senderID string) ( -// found bool, status string, err error) { -// -// return false, vocab.RequestPending, nil -//} - -//func NewDummyTransportServer() *DummyTransportServer { -// dummy := DummyTransportServer{} -// return &dummy -//} diff --git a/runtime/digitwin/api/DigitwinID.go b/runtime/digitwin/api/DigitwinID.go deleted file mode 100644 index 8e64672e..00000000 --- a/runtime/digitwin/api/DigitwinID.go +++ /dev/null @@ -1,26 +0,0 @@ -package digitwin - -import ( - "fmt" - "strings" - - digitwinapi "github.com/hiveot/hivekit/go/modules/digitwin/api" -) - -// Create a digital twin ID from the agent and device thingID -// this joins the digitwin prefix with the agent and thingID -func MakeDigitwinID(agentID string, thingID string) string { - digitwinThingID := fmt.Sprintf("%s%s:%s", - digitwinapi.DigitwinIDPrefix, agentID, thingID) - return digitwinThingID -} - -// Split the digital twin ID into the agent and device thingID -// This returns an error if the given ID is not a digitwin ID -func SplitDigitwinID(digitwinID string) (agentID string, thingID string, err error) { - parts := strings.Split(digitwinID, ":") - if len(parts) != 3 || !strings.HasPrefix(digitwinID, digitwinapi.DigitwinIDPrefix) { - return "", "", fmt.Errorf("The given id '%s' is not a digital twin thingID", digitwinID) - } - return parts[1], parts[2], nil -} diff --git a/runtime/digitwin/api/DirectoryAgentAPI.go b/runtime/digitwin/api/DirectoryAgentAPI.go deleted file mode 100644 index dc95f7e5..00000000 --- a/runtime/digitwin/api/DirectoryAgentAPI.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package digitwin with the agent request handler for using service 'ThingDirectory' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IThingDirectoryService defines the interface of the 'ThingDirectory' service -// -// This defines a method for each of the actions in the TD. -// -type IThingDirectoryService interface { - - // CreateThing Create Thing TD - // Update the Thing TD in the directory. For use by Thing agents only. - CreateThing(senderID string, tDJSON string) error - - // DeleteThing Delete Thing - // Remove a digital twin TD from the directory - DeleteThing(senderID string, dThingID string) error - - // RetrieveAllThings Read all TDs - // Read a batch of TD documents - // This returns a list of JSON encoded TD documents - RetrieveAllThings(senderID string, args ThingDirectoryRetrieveAllThingsArgs) (tDlist []string, err error) - - // RetrieveThing Retrieve a TD - // Return a JSON encoded TD document - // This returns a a JSON encoded Thing Description Document - RetrieveThing(senderID string, thingID string) (tD string, err error) - - // UpdateThing Update Thing TD - // Update the Thing TD in the directory. For use by agents only. - UpdateThing(senderID string, tD string) error -} - -// NewHandleThingDirectoryRequest returns an agent handler for Thing 'ThingDirectory' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleThingDirectoryRequest(svc IThingDirectoryService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "createThing": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.CreateThing(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "deleteThing": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.DeleteThing(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "retrieveThing": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.RetrieveThing(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "retrieveAllThings": - args := ThingDirectoryRetrieveAllThingsArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.RetrieveAllThings(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "updateThing": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.UpdateThing(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/digitwin/api/DirectoryConsumerAPI.go b/runtime/digitwin/api/DirectoryConsumerAPI.go deleted file mode 100644 index 665bc169..00000000 --- a/runtime/digitwin/api/DirectoryConsumerAPI.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package digitwin with the agent request handler for using service 'ThingDirectory' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - -import "github.com/hiveot/hub/lib/consumer" - - -// ThingDirectoryCreateThing client method - Create Thing TD. -// Update the Thing TD in the directory. For use by Thing agents only. -func ThingDirectoryCreateThing(hc *consumer.Consumer, tDJSON string)(err error){ - - err = hc.Rpc("invokeaction", ThingDirectoryDThingID, ThingDirectoryCreateThingMethod, &tDJSON, nil) - return -} - -// ThingDirectoryDeleteThing client method - Delete Thing. -// Remove a digital twin TD from the directory -func ThingDirectoryDeleteThing(hc *consumer.Consumer, dThingID string)(err error){ - - err = hc.Rpc("invokeaction", ThingDirectoryDThingID, ThingDirectoryDeleteThingMethod, &dThingID, nil) - return -} - -// ThingDirectoryRetrieveAllThings client method - Read all TDs. -// Read a batch of TD documents -func ThingDirectoryRetrieveAllThings(hc *consumer.Consumer, limit int64, offset int64)(tDlist []string, err error){ - var args = ThingDirectoryRetrieveAllThingsArgs{limit, offset} - err = hc.Rpc("invokeaction", ThingDirectoryDThingID, ThingDirectoryRetrieveAllThingsMethod, &args, &tDlist) - return -} - -// ThingDirectoryRetrieveThing client method - Retrieve a TD. -// Return a JSON encoded TD document -func ThingDirectoryRetrieveThing(hc *consumer.Consumer, thingID string)(tD string, err error){ - - err = hc.Rpc("invokeaction", ThingDirectoryDThingID, ThingDirectoryRetrieveThingMethod, &thingID, &tD) - return -} - -// ThingDirectoryUpdateThing client method - Update Thing TD. -// Update the Thing TD in the directory. For use by agents only. -func ThingDirectoryUpdateThing(hc *consumer.Consumer, tD string)(err error){ - - err = hc.Rpc("invokeaction", ThingDirectoryDThingID, ThingDirectoryUpdateThingMethod, &tD, nil) - return -} diff --git a/runtime/digitwin/api/DirectoryTypes.go b/runtime/digitwin/api/DirectoryTypes.go deleted file mode 100644 index 8a82d6d4..00000000 --- a/runtime/digitwin/api/DirectoryTypes.go +++ /dev/null @@ -1,75 +0,0 @@ -// Package digitwin with types of service 'ThingDirectory' from agent 'digitwin' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - - -//--- Constants --- - -// ThingDirectoryAgentID is the account ID of the agent managing the Thing. -const ThingDirectoryAgentID = "digitwin" - -// ThingDirectoryServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const ThingDirectoryServiceID = "ThingDirectory" - -// ThingDirectoryDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const ThingDirectoryDThingID = "dtw:digitwin:ThingDirectory" - -// Property, Event and Action names -const ( - ThingDirectoryPropThings = "things" - ThingDirectoryEventThingUpdated = "thingUpdated" - ThingDirectoryEventThingDeleted = "thingDeleted" - ThingDirectoryActionUpdateThing = "updateThing" - ThingDirectoryActionCreateThing = "createThing" - ThingDirectoryActionDeleteThing = "deleteThing" - ThingDirectoryActionRetrieveThing = "retrieveThing" - ThingDirectoryActionRetrieveAllThings = "retrieveAllThings" -) -//--- Argument and Response struct for action of Thing 'ThingDirectory' --- - -const ThingDirectoryCreateThingMethod = "createThing" - -const ThingDirectoryDeleteThingMethod = "deleteThing" - -const ThingDirectoryRetrieveAllThingsMethod = "retrieveAllThings" - -// ThingDirectoryRetrieveAllThingsArgs defines the arguments of the retrieveAllThings function -// Read all TDs - Read a batch of TD documents -type ThingDirectoryRetrieveAllThingsArgs struct { - - // Limit with Limit - // - // Maximum number of documents to return - Limit int64 `json:"limit,omitempty"` - - // Offset with Offset - // - // Start index in the list of TD documents - Offset int64 `json:"offset,omitempty"` -} - -// ThingDirectoryRetrieveAllThingsResp defines the response of the retrieveAllThings function -// Read all TDs - Read a batch of TD documents -// -// List of JSON encoded TD documents -// ThingDirectoryRetrieveAllThingsResp defines a TD list data schema. -// -// List of JSON encoded TD documents -type ThingDirectoryRetrieveAllThingsResp []struct { - - // ThingDirectoryRetrieveAllThingsResp with - ThingDirectoryRetrieveAllThingsResp string `json:"ThingDirectoryRetrieveAllThingsResp,omitempty"` -} - - -const ThingDirectoryRetrieveThingMethod = "retrieveThing" - -const ThingDirectoryUpdateThingMethod = "updateThing" - - -// ThingDirectoryTD contains the raw TD of this service for publication to the Hub -const ThingDirectoryTD = `{"actions":{"createThing":{"description":"Update the Thing TD in the directory. For use by Thing agents only.","forms":[{"additionalResponses":[{"contentType":"application/problem+json","description":"Invalid serialization or TD","htv:statusCodeValue":400}],"href":"/things/{id}","htv:methodName":"PUT","response":{"description":"Success response","htv:statusCodeValue":201}}],"title":"Create Thing TD","uriVariables":{"id":{"readOnly":false,"type":"string"}},"idempotent":true,"input":{"title":"TD JSON","description":"Device TD document in JSON format","readOnly":false,"type":"string"}},"deleteThing":{"description":"Remove a digital twin TD from the directory","forms":[{"additionalResponses":[{"contentType":"application/problem+json","description":"TD with the given id not found","htv:statusCodeValue":404}],"href":"/things/{id}","htv:methodName":"DELETE","response":{"description":"Success response","htv:statusCodeValue":204}}],"title":"Delete Thing","uriVariables":{"id":{"readOnly":false,"type":"string"}},"idempotent":true,"input":{"title":"dThing ID","readOnly":false,"type":"string"}},"retrieveAllThings":{"description":"Read a batch of TD documents","forms":[{"additionalResponses":[{"contentType":"application/problem+json","description":"Invalid serialization or TD","htv:statusCodeValue":400}],"contentType":"application/td+json","href":"/things","htv:methodName":"GET","response":{"description":"Success response","htv:statusCodeValue":204}}],"title":"Read all TDs","idempotent":true,"input":{"readOnly":false,"type":"object","properties":{"limit":{"title":"Limit","description":"Maximum number of documents to return","default":100,"readOnly":false,"type":"integer","minimum":1},"offset":{"title":"Offset","description":"Start index in the list of TD documents","default":0,"readOnly":false,"type":"integer"}}},"output":{"title":"TD list","description":"List of JSON encoded TD documents","readOnly":false,"type":"array","items":{"readOnly":false,"type":"string"}},"safe":true},"retrieveThing":{"description":"Return a JSON encoded TD document","forms":[{"comments":["Additional http endpoint as per spec to read a TD.","Use URI variable as second method of input","The alternative endpoint is /things/invokeaction/retrieveThing with payload the thingID"],"href":"/things/{id}","htv:methodName":"GET"}],"title":"Retrieve a TD","uriVariables":{"id":{"title":"Thing Description ID","readOnly":false,"type":"string"}},"idempotent":true,"input":{"title":"Thing ID","description":"Digital-Twin Thing ID of the Thing to read","readOnly":false,"type":"string"},"output":{"title":"TD","description":"A JSON encoded Thing Description Document","readOnly":false,"type":"string"},"safe":true},"updateThing":{"description":"Update the Thing TD in the directory. For use by agents only.","forms":[{"additionalResponses":[{"contentType":"application/problem+json","description":"Invalid serialization or TD","htv:statusCodeValue":400}],"contentType":"application/td+json","href":"/things/{id}","htv:methodName":"PUT","response":{"description":"Success response","htv:statusCodeValue":204}}],"title":"Update Thing TD","uriVariables":{"id":{"@type":"ThingID","title":"Thing Description ID","readOnly":false,"type":"string"}},"idempotent":true,"input":{"title":"TD","description":"Device TD document in JSON format","readOnly":false,"type":"string"}}},"@context":["https://www.w3.org/2022/wot/td/v1.1","https://www.w3.org/2022/wot/discovery",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":["tm:ThingModel","ThingDirectory"],"base":"{{DIRECTORY_BASE_URL}}","created":"2025-09-22T17:00:00.000Z","description":"HiveOT digital twin directory service","events":{"thingDeleted":{"description":"A Thing TD was removed from the directory","title":"Thing Deleted","data":{"title":"Thing ID","description":"ID of the digital twin Thing that was deleted","readOnly":false,"type":"string"}},"thingUpdated":{"description":"A digital twin Thing TD was updated in the directory","title":"Thing Updated","data":{"title":"TD","description":"JSON encoded TD of the digital twin Thing","readOnly":false,"type":"string"}}},"id":"ThingDirectory","modified":"2025-09-22T17:00:00.000Z","properties":{"things":{"description":"Retrieve all Thing Descriptions","readOnly":false,"type":"array","items":{"description":"Thing Description","readOnly":false,"type":"object"},"forms":[{"additionalResponses":[{"contentType":"application/problem+json","description":"Invalid query arguments","htv:statusCodeValue":400}],"href":"/things{?offset,limit}","htv:methodName":"GET","response":{"contentType":"application/ld+json","description":"Success response","htv:headers":[{"htv:fieldName":"Link"}],"htv:statusCodeValue":200}}],"uriVariables":{"limit":{"title":"Number of TDs in a page","default":1,"readOnly":false,"type":"number"},"offset":{"title":"Number of TDs to skip before the page","default":0,"readOnly":false,"type":"number"}}}},"security":null,"securityDefinitions":null,"title":"DigiTwin Directory Service","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/digitwin/api/ValuesAgentAPI.go b/runtime/digitwin/api/ValuesAgentAPI.go deleted file mode 100644 index 11e59b79..00000000 --- a/runtime/digitwin/api/ValuesAgentAPI.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package digitwin with the agent request handler for using service 'ThingValues' -// This builds a service agent that calls the service. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" - - -// IThingValuesService defines the interface of the 'ThingValues' service -// -// This defines a method for each of the actions in the TD. -// -type IThingValuesService interface { - - // QueryAction Action status - // Read the current action status of a Thing - // This returns a the latest action value - QueryAction(senderID string, args ThingValuesQueryActionArgs) (actionvalue ActionStatus, err error) - - // QueryAllActions Action statuses - // Read all action statuses of a Thing - // This returns a map of ActionStatus objects by action name - QueryAllActions(senderID string, thingID string) (actionvalue map[string]ActionStatus, err error) - - // ReadAllEvents Read all event values - // Read the latest known event values of a Thing - // This returns a map with the most recent event ThingValue objects - ReadAllEvents(senderID string, thingID string) (eventvalues map[string]ThingValue, err error) - - // ReadAllProperties Read all properties - // Read the latest known property values - // This returns a map of event name:ThingValue objects - ReadAllProperties(senderID string, thingID string) (propertyvalues map[string]ThingValue, err error) - - // ReadEvent Read event value - // Read the latest event value of a Thing - // This returns a most recent event value - ReadEvent(senderID string, args ThingValuesReadEventArgs) (eventvalue ThingValue, err error) - - // ReadProperty Read property value - // Read the latest property value - // This returns a most recent property value - ReadProperty(senderID string, args ThingValuesReadPropertyArgs) (propertyvalue ThingValue, err error) -} - -// NewHandleThingValuesRequest returns an agent handler for Thing 'thingValues' requests. -// -// This unmarshalls the request payload into an args struct and passes it to the service -// that implements the corresponding interface method. -// -// This returns the marshalled response data or an error. -func NewHandleThingValuesRequest(svc IThingValuesService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "readAllEvents": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.ReadAllEvents(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "readAllProperties": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.ReadAllProperties(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "readEvent": - args := ThingValuesReadEventArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.ReadEvent(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "readProperty": - args := ThingValuesReadPropertyArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.ReadProperty(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "queryAction": - args := ThingValuesQueryActionArgs{} - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.QueryAction(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "queryAllActions": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.QueryAllActions(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file diff --git a/runtime/digitwin/api/ValuesConsumerAPI.go b/runtime/digitwin/api/ValuesConsumerAPI.go deleted file mode 100644 index 793f7d86..00000000 --- a/runtime/digitwin/api/ValuesConsumerAPI.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package digitwin with the agent request handler for using service 'ThingValues' -// This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - -import "github.com/hiveot/hub/lib/consumer" - - -// ThingValuesQueryAction client method - Action status. -// Read the current action status of a Thing -func ThingValuesQueryAction(hc *consumer.Consumer, name string, thingID string)(actionvalue ActionStatus, err error){ - var args = ThingValuesQueryActionArgs{name, thingID} - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesQueryActionMethod, &args, &actionvalue) - return -} - -// ThingValuesQueryAllActions client method - Action statuses. -// Read all action statuses of a Thing -func ThingValuesQueryAllActions(hc *consumer.Consumer, thingID string)(actionvalue map[string]ActionStatus, err error){ - - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesQueryAllActionsMethod, &thingID, &actionvalue) - return -} - -// ThingValuesReadAllEvents client method - Read all event values. -// Read the latest known event values of a Thing -func ThingValuesReadAllEvents(hc *consumer.Consumer, thingID string)(eventvalues map[string]ThingValue, err error){ - - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesReadAllEventsMethod, &thingID, &eventvalues) - return -} - -// ThingValuesReadAllProperties client method - Read all properties. -// Read the latest known property values -func ThingValuesReadAllProperties(hc *consumer.Consumer, thingID string)(propertyvalues map[string]ThingValue, err error){ - - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesReadAllPropertiesMethod, &thingID, &propertyvalues) - return -} - -// ThingValuesReadEvent client method - Read event value. -// Read the latest event value of a Thing -func ThingValuesReadEvent(hc *consumer.Consumer, name string, thingID string)(eventvalue ThingValue, err error){ - var args = ThingValuesReadEventArgs{name, thingID} - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesReadEventMethod, &args, &eventvalue) - return -} - -// ThingValuesReadProperty client method - Read property value. -// Read the latest property value -func ThingValuesReadProperty(hc *consumer.Consumer, name string, thingID string)(propertyvalue ThingValue, err error){ - var args = ThingValuesReadPropertyArgs{name, thingID} - err = hc.Rpc("invokeaction", ThingValuesDThingID, ThingValuesReadPropertyMethod, &args, &propertyvalue) - return -} diff --git a/runtime/digitwin/api/ValuesTypes.go b/runtime/digitwin/api/ValuesTypes.go deleted file mode 100644 index c2763753..00000000 --- a/runtime/digitwin/api/ValuesTypes.go +++ /dev/null @@ -1,262 +0,0 @@ -// Package digitwin with types of service 'ThingValues' from agent 'digitwin' -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Intended for use by both service agents and consumer. -// Generated 09 Apr 2026 13:24 PDT. -package digitwin - - -//--- Constants --- - -// ThingValuesAgentID is the account ID of the agent managing the Thing. -const ThingValuesAgentID = "digitwin" - -// ThingValuesServiceID is the thingID of the device/service as used by agents. -// Agents use this to publish events and subscribe to actions -const ThingValuesServiceID = "ThingValues" - -// ThingValuesDThingID is the Digitwin thingID as used by consumers. Digitwin adds the dtw:{agent} prefix to the serviceID -// Consumers use this to publish actions and subscribe to events -const ThingValuesDThingID = "dtw:digitwin:ThingValues" - -// Property, Event and Action names -const ( - ThingValuesActionReadEvent = "readEvent" - ThingValuesActionReadProperty = "readProperty" - ThingValuesActionQueryAction = "queryAction" - ThingValuesActionQueryAllActions = "queryAllActions" - ThingValuesActionReadAllEvents = "readAllEvents" - ThingValuesActionReadAllProperties = "readAllProperties" -) - -//--- Schema definitions of Thing 'thingValues' --- - -// ActionStatus defines a Action Status data schema. -// -// Status of the an action -type ActionStatus struct { - - // ActionID with Action ID - // - // Unique identifier of the action - ActionID string `json:"actionID,omitempty"` - - // AgentID with Agent ID - // - // The agent handling the action - AgentID string `json:"agentID,omitempty"` - - // Error with Error - // - // Object with error value when failed - Error *ErrorValue `json:"error,omitempty"` - - // Input with Action input - // - // Action input value - Input any `json:"input,omitempty"` - - // Name with Action name - // - // name of the action or property - Name string `json:"name,omitempty"` - - // Output with Action output - Output any `json:"output,omitempty"` - - // SenderID with Sender client ID - // - // ID of the client requesting the action - SenderID string `json:"senderID,omitempty"` - - // State with Action Status - // - // Status of the action's progress - State string `json:"state,omitempty"` - - // ThingID with Action Thing - // - // Digital twin ThingID the action applies to - ThingID string `json:"thingID,omitempty"` - - // TimeRequested with Requested Time - // - // Time the action request was received - TimeRequested string `json:"timeRequested,omitempty"` - - // TimeUpdated with Updated Time - // - // Time the action status was last updated - TimeUpdated string `json:"timeUpdated,omitempty"` -} - -// ErrorValue defines a Error Value data schema. -// -// Error response value -type ErrorValue struct { - - // Detail with Details - Detail string `json:"detail,omitempty"` - - // Status with Http code - Status int64 `json:"status,omitempty"` - - // Title with Title - Title string `json:"title,omitempty"` - - // Type with Type - Type string `json:"type,omitempty"` -} - -// ThingValue defines a Thing Value data schema. -// -// Property or event value -type ThingValue struct { - - // AffordanceType with Type of value - // - // Affordance this is a value of, eg 'property', 'event', or 'action' - AffordanceType string `json:"affordanceType,omitempty"` - - // Data with Payload - // - // Data in format as described by the thing's property affordance - Data any `json:"data,omitempty"` - - // Name with Name - // - // Name of the property holding the value - Name string `json:"name,omitempty"` - - // ThingID with Thing ID - // - // Digital twin Thing ID - ThingID string `json:"thingID,omitempty"` - - // Timestamp with Updated time - // - // Time the value was last updated - Timestamp string `json:"timestamp,omitempty"` -} - -//--- Argument and Response struct for action of Thing 'thingValues' --- - -const ThingValuesQueryActionMethod = "queryAction" - -// ThingValuesQueryActionArgs defines the arguments of the queryAction function -// Action status - Read the current action status of a Thing -type ThingValuesQueryActionArgs struct { - - // Name with Action Name - // - // The action name to query - Name string `json:"name,omitempty"` - - // ThingID with Thing ID - // - // ID of the Thing to query - ThingID string `json:"thingID,omitempty"` -} - -// ThingValuesQueryActionResp defines the response of the queryAction function -// Action status - Read the current action status of a Thing -// -// The latest action value -// ThingValuesQueryActionResp defines a Action value data schema. -// -// The latest action value -type ThingValuesQueryActionResp ActionStatus - - -const ThingValuesQueryAllActionsMethod = "queryAllActions" - -// ThingValuesQueryAllActionsResp defines the response of the queryAllActions function -// Action statuses - Read all action statuses of a Thing -// -// Map of ActionStatus objects by action name -// ThingValuesQueryAllActionsResp defines a Action value data schema. -// -// Map of ActionStatus objects by action name -type ThingValuesQueryAllActionsResp map[string]ActionStatus - - -const ThingValuesReadAllEventsMethod = "readAllEvents" - -// ThingValuesReadAllEventsResp defines the response of the readAllEvents function -// Read all event values - Read the latest known event values of a Thing -// -// Map with the most recent event ThingValue objects -// ThingValuesReadAllEventsResp defines a Event values data schema. -// -// Map with the most recent event ThingValue objects -type ThingValuesReadAllEventsResp map[string]ThingValue - - -const ThingValuesReadAllPropertiesMethod = "readAllProperties" - -// ThingValuesReadAllPropertiesResp defines the response of the readAllProperties function -// Read all properties - Read the latest known property values -// -// Map of event name:ThingValue objects -// ThingValuesReadAllPropertiesResp defines a Property values data schema. -// -// Map of event name:ThingValue objects -type ThingValuesReadAllPropertiesResp map[string]ThingValue - - -const ThingValuesReadEventMethod = "readEvent" - -// ThingValuesReadEventArgs defines the arguments of the readEvent function -// Read event value - Read the latest event value of a Thing -type ThingValuesReadEventArgs struct { - - // Name with Event name - // - // Name of the event to read the latest values - Name string `json:"name,omitempty"` - - // ThingID with Thing ID - // - // ID of the Thing to read - ThingID string `json:"thingID,omitempty"` -} - -// ThingValuesReadEventResp defines the response of the readEvent function -// Read event value - Read the latest event value of a Thing -// -// Most recent event value -// ThingValuesReadEventResp defines a Event value data schema. -// -// Most recent event value -type ThingValuesReadEventResp ThingValue - - -const ThingValuesReadPropertyMethod = "readProperty" - -// ThingValuesReadPropertyArgs defines the arguments of the readProperty function -// Read property value - Read the latest property value -type ThingValuesReadPropertyArgs struct { - - // Name with Property name - // - // The property name whose value to read - Name string `json:"name,omitempty"` - - // ThingID with Thing ID - // - // ID of the Thing to read - ThingID string `json:"thingID,omitempty"` -} - -// ThingValuesReadPropertyResp defines the response of the readProperty function -// Read property value - Read the latest property value -// -// Most recent property value -// ThingValuesReadPropertyResp defines a Property value data schema. -// -// Most recent property value -type ThingValuesReadPropertyResp ThingValue - - - -// ThingValuesTD contains the raw TD of this service for publication to the Hub -const ThingValuesTD = `{"actions":{"queryAction":{"@type":"hiveot:function","description":"Read the current action status of a Thing","title":"Action status","idempotent":true,"input":{"readOnly":false,"type":"object","properties":{"name":{"title":"Action Name","description":"The action name to query","readOnly":false,"type":"string"},"thingID":{"title":"Thing ID","description":"ID of the Thing to query","readOnly":false,"type":"string"}},"required":["thingID","name"]},"output":{"title":"Action value","description":"The latest action value","readOnly":false,"type":"object","schema":"ActionStatus"},"safe":true},"queryAllActions":{"@type":"hiveot:function","description":"Read all action statuses of a Thing","title":"Action statuses","idempotent":true,"input":{"title":"Thing ID","description":"ID of the Thing to query","readOnly":false,"type":"string"},"output":{"title":"Action value","description":"Map of ActionStatus objects by action name","readOnly":false,"type":"object","properties":{"":{"title":"Action Status","readOnly":false,"type":"object","schema":"ActionStatus"}}},"safe":true},"readAllEvents":{"@type":"hiveot:function","description":"Read the latest known event values of a Thing","forms":[{"href":"/{operation}"}],"title":"Read all event values","idempotent":true,"input":{"title":"Thing ID","description":"ID of the Thing to read","readOnly":false,"type":"string"},"output":{"title":"Event values","description":"Map with the most recent event ThingValue objects","readOnly":false,"type":"object","properties":{"":{"title":"Event Value","readOnly":false,"type":"object","schema":"ThingValue"}}},"safe":true},"readAllProperties":{"@type":"hiveot:function","description":"Read the latest known property values","forms":[{"href":"/{operation}"}],"title":"Read all properties","idempotent":true,"input":{"title":"Thing ID","description":"Digital twin ID of the Thing to read","readOnly":false,"type":"string"},"output":{"title":"Property values","description":"Map of event name:ThingValue objects","readOnly":false,"type":"object","properties":{"":{"title":"Property Value","readOnly":false,"type":"object","schema":"ThingValue"}}},"safe":true},"readEvent":{"@type":"hiveot:function","description":"Read the latest event value of a Thing","forms":[{"href":"/{operation}"}],"title":"Read event value","idempotent":true,"input":{"readOnly":false,"type":"object","properties":{"name":{"title":"Event name","description":"Name of the event to read the latest values","readOnly":false,"type":"string"},"thingID":{"title":"Thing ID","description":"ID of the Thing to read","readOnly":false,"type":"string"}},"required":["thingID","name"]},"output":{"title":"Event value","description":"Most recent event value","readOnly":false,"type":"object","schema":"ThingValue"},"safe":true},"readProperty":{"@type":"hiveot:function","description":"Read the latest property value","forms":[{"href":"/{operation}"}],"title":"Read property value","idempotent":true,"input":{"readOnly":false,"type":"object","properties":{"name":{"title":"Property name","description":"The property name whose value to read","readOnly":false,"type":"string"},"thingID":{"title":"Thing ID","description":"ID of the Thing to read","readOnly":false,"type":"string"}},"required":["thingID","name"]},"output":{"title":"Property value","description":"Most recent property value","readOnly":false,"type":"object","schema":"ThingValue"},"safe":true}},"@context":["https://www.w3.org/2022/wot/td/v1.1",{"hiveot":"https://www.hiveot.net/vocab/v0.1"}],"@type":"Service","base":"https://localhost/","created":"2024-10-04T17:00:00.000Z","description":"Service for providing the last known property, event and action values","events":{},"id":"ThingValues","modified":"2024-10-04T17:00:00.000Z","properties":null,"schemaDefinitions":{"ActionStatus":{"title":"Action Status","description":"Status of the an action","readOnly":false,"type":"object","properties":{"actionID":{"title":"Action ID","description":"Unique identifier of the action","readOnly":false,"type":"string"},"agentID":{"title":"Agent ID","description":"The agent handling the action","readOnly":false,"type":"string"},"error":{"title":"Error","description":"Object with error value when failed","readOnly":false,"type":"object","schema":"ErrorValue"},"input":{"title":"Action input","description":"Action input value","readOnly":false,"type":"any"},"name":{"title":"Action name","description":"name of the action or property","readOnly":false,"type":"string"},"output":{"title":"Action output","readOnly":false,"type":"any"},"senderID":{"title":"Sender client ID","description":"ID of the client requesting the action","readOnly":false,"type":"string"},"state":{"title":"Action Status","description":"Status of the action's progress","enum":["pending","running","completed","failed"],"readOnly":false,"type":"string"},"thingID":{"title":"Action Thing","description":"Digital twin ThingID the action applies to","readOnly":false,"type":"string"},"timeRequested":{"title":"Requested Time","description":"Time the action request was received","readOnly":false,"type":"string"},"timeUpdated":{"title":"Updated Time","description":"Time the action status was last updated","readOnly":false,"type":"string"}}},"ErrorValue":{"title":"Error Value","description":"Error response value","readOnly":false,"type":"object","properties":{"detail":{"title":"Details","readOnly":false,"type":"string"},"status":{"title":"Http code","readOnly":false,"type":"integer"},"title":{"title":"Title","readOnly":false,"type":"string"},"type":{"title":"Type","readOnly":false,"type":"string"}}},"ThingValue":{"title":"Thing Value","description":"Property or event value","readOnly":false,"type":"object","properties":{"affordanceType":{"title":"Type of value","description":"Affordance this is a value of, eg 'property', 'event', or 'action'","readOnly":false,"type":"string"},"data":{"title":"Payload","description":"Data in format as described by the thing's property affordance","readOnly":false,"type":"any"},"name":{"title":"Name","description":"Name of the property holding the value","readOnly":false,"type":"string"},"thingID":{"title":"Thing ID","description":"Digital twin Thing ID","readOnly":false,"type":"string"},"timestamp":{"title":"Updated time","description":"Time the value was last updated","readOnly":false,"type":"string"}}}},"security":null,"securityDefinitions":null,"title":"DigiTwin Values Service","support":"https://www.github.com/hiveot/hub"}` \ No newline at end of file diff --git a/runtime/digitwin/dthing/DigitwinThing.go b/runtime/digitwin/dthing/DigitwinThing.go deleted file mode 100644 index 5f8c4ec6..00000000 --- a/runtime/digitwin/dthing/DigitwinThing.go +++ /dev/null @@ -1,41 +0,0 @@ -package dthing - -import ( - "github.com/hiveot/hivekit/go/api/td" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// DigitwinThing This is the digital twin Thing -// This contains the digital twin TD and the TD of the device it represents. -// It handles incoming requests and is a consumer of the actual device. -// -// In development. This will take over from the router. -type DigitwinThing struct { - // Agent that manages access to the exposed thing - AgentID string `json:"agentID" ` - ID string `json:"id" ` - - // AgentTD as exposed by the agent - AgentTD *td.TD `json:"agTD"` - // DigitwinTD as exposed by the hub - DigitwinTD *td.TD `json:"dtwTD" ` - - // PropValues with latest digital twin properties - PropValues map[string]digitwin.ThingValue `json:"pv"` - // EventValues with latest digital twin events - EventValues map[string]digitwin.ThingValue `json:"ev"` - // ActionStatuses with latest 'unsafe' digital twin action status - ActionStatuses map[string]digitwin.ActionStatus `json:"av"` -} - -// Handle request (from any client) -// - invoke action -// - write property -// - read property -// - read event -// - read action status - -// Handle response (from device) -// - receive property update -// - receive event -// - receive action status diff --git a/runtime/digitwin/router/AuthFlow.go b/runtime/digitwin/router/AuthFlow.go deleted file mode 100644 index 98eba126..00000000 --- a/runtime/digitwin/router/AuthFlow.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package service with digital twin event handling functions -package router - -import ( - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" -) - -// HandleLogin passes the request as an action to the authn service -func (r *DigitwinRouter) HandleLogin( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - // Data {login,password} - req.ThingID = authn.UserDThingID - req.Name = authn.UserLoginMethod - resp := r.authnAction(req, c) - return resp -} - -// HandleLoginRefresh passes the request as an action to the authn service -func (r *DigitwinRouter) HandleLoginRefresh( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - // Data {oldToken} - req.ThingID = authn.UserDThingID - req.Name = authn.UserRefreshTokenMethod - - resp := r.authnAction(req, c) - return resp -} - -// HandleLogout converts the logout operation into an authn service action -// and closes all connections from the sender. -func (r *DigitwinRouter) HandleLogout( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - req.ThingID = authn.UserDThingID - req.Name = authn.UserLogoutMethod - resp := r.authnAction(req, c) - r.transportServer.CloseAllClientConnections(req.SenderID) - return resp -} diff --git a/runtime/digitwin/router/DigitwinRouter.go b/runtime/digitwin/router/DigitwinRouter.go deleted file mode 100644 index a41f7e07..00000000 --- a/runtime/digitwin/router/DigitwinRouter.go +++ /dev/null @@ -1,101 +0,0 @@ -package router - -import ( - "log/slog" - "sync" - - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers" - "github.com/hiveot/hub/runtime/digitwin/service" - "github.com/hiveot/hub/runtime/digitwin/store" -) - -// ActionHandler is the API for service action handling -//type ActionHandler func(msg *transports.ThingMessage) (stat transports.ActionStatus) - -// PermissionHandler is the handler that authorizes the sender to perform an operation -// -// senderID is the account ID of the consumer or agent -// operation is one of the predefined operations, eg WotOpReadEvent -// dThingID is the ID of the digital twin Thing the request applies to -type PermissionHandler func(senderID, operation, dThingID string) bool - -// DigitwinRouter implements the action, event and property flows to and from the -// digital twin. -// -// This uses the transport binding to forward actions and write property requests -// to the agents, and event and property updates to the consumer. -type DigitwinRouter struct { - dtwStore *store.DigitwinStore - - // internal services are directly invoked - digitwinAction messaging.RequestHandler - dtwService *service.DigitwinService - authnAction messaging.RequestHandler - authzAction messaging.RequestHandler - hasPermission PermissionHandler - - // in-memory cache of active actions lookup by correlationID - activeCache map[string]ActiveRequestRecord - // cache map usage mux - mux sync.Mutex - // connection manager for forwarding messages to agents or consumers - transportServer servers.IMessageServer - - // logging of requests and response - requestLogger *slog.Logger - // logging of notifications - notifLogger *slog.Logger -} - -// SetNotifLogger replaces the default logger for notifications -func (r *DigitwinRouter) SetNotifLogger(logger *slog.Logger) { - r.mux.Lock() - defer r.mux.Unlock() - r.notifLogger = logger -} - -// SetRequestLogger replaces the default logger for requests -func (r *DigitwinRouter) SetRequestLogger(logger *slog.Logger) { - r.mux.Lock() - defer r.mux.Unlock() - r.requestLogger = logger -} - -// SetTransportServer sets the transport server for routing outgoing messages -func (r *DigitwinRouter) SetTransportServer(srv servers.IMessageServer) { - r.transportServer = srv -} - -// NewDigitwinRouter instantiates a new hub messaging router -// Use SetTransportServer for forwarding messages to non-local agents and consumers. -// -// dtwService is used to update the digital twin status -// digitwinAction build-in handler for digital twin directory and value requests -// authnAgent built-in handler for authentication service requests -// authzAgent built-in handler for authorization service requests -// permissionHandler built-in handler for authorizing requests -// transportServer is the transport binding for forwarding service requests -func NewDigitwinRouter( - dtwService *service.DigitwinService, - digitwinAction messaging.RequestHandler, - authnAgent messaging.RequestHandler, - authzAgent messaging.RequestHandler, - permissionHandler PermissionHandler, - transportServer servers.IMessageServer, -) *DigitwinRouter { - r := &DigitwinRouter{ - dtwStore: dtwService.DtwStore, - //cm: cm, - transportServer: transportServer, - authnAction: authnAgent, - authzAction: authzAgent, - hasPermission: permissionHandler, - digitwinAction: digitwinAction, - dtwService: dtwService, - activeCache: make(map[string]ActiveRequestRecord), - requestLogger: slog.Default(), - notifLogger: slog.Default(), - } - return r -} diff --git a/runtime/digitwin/router/IDigitwinRouter.go b/runtime/digitwin/router/IDigitwinRouter.go deleted file mode 100644 index 4318bea8..00000000 --- a/runtime/digitwin/router/IDigitwinRouter.go +++ /dev/null @@ -1,17 +0,0 @@ -package router - -import ( - "github.com/hiveot/hub/lib/messaging" -) - -// IDigitwinRouter is the interface for routing the action,event and property messages -// received from consumers and agents. It handles the flow for TD level operations. -type IDigitwinRouter interface { - - // HandleRequest with the message to handle. - // If a result is immediately available it is returned, otherwise it is sent - // separately to the connection with the ID of replyTo - HandleRequest(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage - - HandleResponse(resp *messaging.ResponseMessage) error -} diff --git a/runtime/digitwin/router/NotificationFlow.go b/runtime/digitwin/router/NotificationFlow.go deleted file mode 100644 index 0899fdd4..00000000 --- a/runtime/digitwin/router/NotificationFlow.go +++ /dev/null @@ -1,125 +0,0 @@ -// Package router with digital twin event routing functions -package router - -import ( - "fmt" - "log/slog" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// HandleNotification handles receiving a notification from an agent (event, property, action) -// This updates the digital twin property or event value -func (r *DigitwinRouter) HandleNotification(notif *messaging.NotificationMessage) { - var err error - r.notifLogger.Info("<- NOTIF: HandleNotification", - slog.String("senderID", notif.SenderID), - slog.String("operation", notif.Operation), - slog.String("thingID", notif.ThingID), - slog.String("name", notif.Name), - slog.String("value", utils.DecodeAsString(notif.Value, 30)), - ) - // Convert the agent ThingID to that of the digital twin - dtwNotif := *notif - dThingID := digitwin.MakeDigitwinID(notif.SenderID, notif.ThingID) - dtwNotif.ThingID = dThingID - if dtwNotif.Timestamp == "" { - dtwNotif.Timestamp = utils.FormatUTCMilli(time.Now()) - } - - // Update the digital twin with this event or property value - if dtwNotif.Operation == td.OpSubscribeEvent || - dtwNotif.Operation == td.OpSubscribeAllEvents { - tv := digitwin.ThingValue{ - Name: dtwNotif.Name, - Data: dtwNotif.Value, - ThingID: dtwNotif.ThingID, - Timestamp: dtwNotif.Timestamp, - AffordanceType: string(messaging.AffordanceTypeEvent), - } - err = r.dtwStore.UpdateEventValue(tv) - if err == nil { - // broadcast the event to subscribers of the digital twin - r.transportServer.SendNotification(&dtwNotif) - } - } else if dtwNotif.Operation == td.OpObserveProperty { - tv := digitwin.ThingValue{ - Name: dtwNotif.Name, - Data: dtwNotif.Value, - ThingID: dtwNotif.ThingID, - Timestamp: dtwNotif.Timestamp, - AffordanceType: string(messaging.AffordanceTypeProperty), - } - changed, _ := r.dtwStore.UpdatePropertyValue(tv) - // unchanged values are still updated in the store but not published - // should this be configurable? - if changed { - r.transportServer.SendNotification(&dtwNotif) - } - } else if dtwNotif.Operation == td.OpObserveAllProperties { - // output is a key-value map - var propMap map[string]any - err := utils.DecodeAsObject(dtwNotif.Value, &propMap) - if err == nil { - for k, v := range propMap { - tv := digitwin.ThingValue{ - AffordanceType: string(messaging.AffordanceTypeProperty), - Name: k, - Data: v, - ThingID: dtwNotif.ThingID, - Timestamp: dtwNotif.Timestamp, - } - changed, _ := r.dtwStore.UpdatePropertyValue(tv) - // unchanged values are still updated in the store but not published - // should this be configurable? - if changed { - // notify the consumer with individual updates instead of a map - // this seems more correct than sending a map. - notifCpy2 := dtwNotif - notifCpy2.Operation = td.OpObserveProperty - notifCpy2.Name = k - notifCpy2.Value = v - r.transportServer.SendNotification(¬ifCpy2) - } - } - } - } else if dtwNotif.Operation == td.OpInvokeAction { - // action progress update. Forward to sender of the request - var cc messaging.IConnection - r.mux.Lock() - actRec, found := r.activeCache[dtwNotif.CorrelationID] - r.mux.Unlock() - if !found { - // no associated action for this notification - return - } - // the sender (agents) must be the agent hat handled the action - if dtwNotif.SenderID != actRec.AgentID { - // notification wasn't sent by the correct sender - slog.Warn("Notification with correlationID '%s' from sender '%s' is "+ - "not sent by agent '%s' for action '%s'", - dtwNotif.CorrelationID, dtwNotif.SenderID, actRec.AgentID, dtwNotif.Operation, - ) - return - } - // update the action status - r.dtwStore.UpdateActionWithNotification(&dtwNotif) - - // forward the notification to the sender of the request only - cc = r.transportServer.GetConnectionByConnectionID(actRec.SenderID, actRec.ReplyTo) - if cc != nil { - _ = cc.SendNotification(&dtwNotif) - } - } else { - err = fmt.Errorf("Unknown notification '%s'", dtwNotif.Operation) - } - if err != nil { - slog.Warn(err.Error()) - // Other notifications are not supported at this moment - //svc.cm.PublishNotification(notif) - } -} diff --git a/runtime/digitwin/router/RequestFlow.go b/runtime/digitwin/router/RequestFlow.go deleted file mode 100644 index 8d44b8f9..00000000 --- a/runtime/digitwin/router/RequestFlow.go +++ /dev/null @@ -1,349 +0,0 @@ -// Package router with digital twin action flow handling functions -package router - -import ( - "fmt" - "log/slog" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// ActiveRequestRecord holds active requests -type ActiveRequestRecord struct { - Operation string // The operation being tracked, invoke action or write property - CorrelationID string // CorrelationID of the ongoing action - AgentID string // Agent that is handling the action request - ThingID string // thingID as provided by the agent the action is for - Name string // name of the action as described in the TD - SenderID string // action sender that will receive progress messages - Progress string // current progress of the action: - Updated time.Time // timestamp to handle expiry - ReplyTo string // Action reply address. Typically, the sender's connection-id -} - -// ForwardRequestToRemoteAgent forwards the request to an external agent. -// -// This tracks the action in the digitwin store, locates the agent connection, -// forwards the request to the agent. -// -// This sends a notification with an ActionStatus message to the sender (sc), or with an error if -// something went wrong. -// -// req is the request to forward -// sc is the server connection endpoint of the client sending the request -// -// If the agent is not connected status is failed. -func (r *DigitwinRouter) ForwardRequestToRemoteAgent( - req *messaging.RequestMessage, sc messaging.IConnection) ( - resp *messaging.ResponseMessage) { - - agentID, agThingID, _ := digitwin.SplitDigitwinID(req.ThingID) - - // Determine the agent to forward the request to. - // Agents only have a single connection instance so the agentID can be used. - agentConn := r.transportServer.GetConnectionByClientID(agentID) - - if agentConn == nil { - // The request cannot be delivered as the agent is not reachable - // For now return an error. - // TODO: determine the rules and use-cases for queuing a request - err := fmt.Errorf("ForwardRequestToRemoteAgent: Agent '%s' not reachable. Ignored", agentID) - return req.CreateResponse(nil, err) - } - replyTo := "" - if sc != nil { - replyTo = sc.GetConnectionInfo().ConnectionID - } - - // track the request progress so async responses can be returned to the client (replyTo) - requestRecord := ActiveRequestRecord{ - AgentID: agentID, - Name: req.Name, - Operation: req.Operation, - ReplyTo: replyTo, - Updated: time.Now().UTC(), - CorrelationID: req.CorrelationID, - Progress: messaging.StatusPending, - SenderID: req.SenderID, - ThingID: agThingID, - } - r.mux.Lock() - r.activeCache[requestRecord.CorrelationID] = requestRecord - r.mux.Unlock() - - // forward the request to the agent using the ThingID of the agent, not the - // digital twin agThingID. - req2 := *req - req2.ThingID = agThingID // agent uses the local message ID - err := agentConn.SendRequest(&req2) - - // if forwarding the request to the agent failed, then remove the tracking, - // update the action status, and return an error response - if err != nil { - slog.Warn("ForwardRequestToRemoteAgent - failed", - slog.String("dThingID", req.ThingID), - slog.String("actionName", req.Name), - slog.String("correlationID", req.CorrelationID), - slog.String("err", err.Error())) - - // cleanup as the record is no longer needed - r.mux.Lock() - delete(r.activeCache, req.CorrelationID) - r.mux.Unlock() - - resp = req.CreateResponse(nil, err) - //if stored { - // _, _ = svc.dtwStore.UpdateActionWithResponse(resp) - //} - return resp - } - - // no immediate result so return nil - return nil -} - -// HandleRequest routes requests from clients to agents. -// -// This handles all requests for the digital twin and forwards other request -// to the connected agent. -// -// This returns a response if available. replyTo is used to store the sender's -// reply-to address for handling responses to pending requests. -func (r *DigitwinRouter) HandleRequest( - req *messaging.RequestMessage, c messaging.IConnection) (resp *messaging.ResponseMessage) { - - if req.Created == "" { - req.Created = utils.FormatNowUTCMilli() - } - - r.requestLogger.Info("-> REQ:", - slog.String("correlationID", req.CorrelationID), - slog.String("operation", req.Operation), - slog.String("dThingID", req.ThingID), - slog.String("name", req.Name), - slog.String("Input", req.ToString(20)), - slog.String("senderID", req.SenderID), - ) - - // middleware: authorize the request. (TODO: use a middleware chain) - if !r.hasPermission(req.SenderID, req.Operation, req.ThingID) { - err := fmt.Errorf("unauthorized. client '%s' does not have permission"+ - " to invoke operation '%s' on Thing '%s'", req.SenderID, req.Operation, req.ThingID) - r.requestLogger.Warn(err.Error()) - return req.CreateResponse(nil, err) - } - switch req.Operation { - // Thing actions status are tracked and stored. - // Responses are send asynchronously to the replyTo address. - case td.OpInvokeAction: - resp = r.HandleInvokeAction(req, c) - case td.OpWriteProperty: - resp = r.HandleWriteProperty(req, c) - - // digital twin requests are handled immediately and return a response - case td.OpQueryAction: - resp = r.HandleQueryAction(req, c) - case td.OpQueryAllActions: - resp = r.HandleQueryAllActions(req, c) - case td.OpReadProperty: - resp = r.HandleReadProperty(req, c) - case td.OpReadAllProperties: - resp = r.HandleReadAllProperties(req, c) - - default: - err := fmt.Errorf("unknown request operation '%s' from client '%s'", - req.Operation, req.SenderID) - slog.Warn(err.Error()) - resp = req.CreateResponse(nil, err) - } - // direct responses are optional - if resp != nil { - errMsg := "" - if resp.Error != nil { - errMsg = resp.Error.String() - } - r.requestLogger.Info("<- RESP", - slog.String("correlationID", resp.CorrelationID), - slog.String("operation", resp.Operation), - slog.String("dThingID", resp.ThingID), - slog.String("name", resp.Name), - // slog.String("value", resp.ValueAsString(20)), - slog.String("err", errMsg), - ) - } - return resp -} - -// HandleInvokeAction handles the request to invoke an action on a Thing. -// -// This tracks the progress in the digital twin store and passes the action request -// to the thing agent. -// -// The digitwin acts as a proxy for the action and forwards the request to the agent -// identified by the dThingID. This can lead to one of these flows: -// 1: The agent is offline => return an error and status Failed -// 2: The agent is online and rejects the request => return an error and status Failed -// 3: The agent is online and accepts the request but has no result yet -// => return a delivery status of 'pending' and no output -// when result is available: -// => update the corresponding property; or send an event with the output -// 4: The agent is online and accepts the request and has a result -// => return a delivery status of 'completed' with output -// -// Pre-requisite: The transport protocol must wait for a response from the agent, -// even thought it uses a uni-directional channel for sending the request. -// SSE, WS, MQTT bindings must use a correlation-id to match request-response messages. -// this is not well-defined in the WoT specs and up to the protocol binding implementation. -func (r *DigitwinRouter) HandleInvokeAction( - req *messaging.RequestMessage, c messaging.IConnection) (resp *messaging.ResponseMessage) { - - // Forward the action to the built-in services - agentID, thingID, _ := digitwin.SplitDigitwinID(req.ThingID) - _ = thingID - - // internal services return instant result. - // There is no good use-case to record these actions. - switch agentID { - case digitwin.ThingDirectoryAgentID: - resp = r.digitwinAction(req, c) - case authn.AdminAgentID: - resp = r.authnAction(req, c) - case authz.AdminAgentID: - resp = r.authzAction(req, c) - default: - // forward action to external service - // Store the request progress to be able to respond to queryAction. Only - // unsafe (stateful) actions are stored. - actionStatus, stored, err := r.dtwStore.NewActionStart(req) - _ = stored - if err != nil { - return req.CreateResponse(nil, err) - } - - // Forward the action to external agents - // Depending on how the agent is connection this can provide an immediate response - // or no immediate response, in which case a notification is returned. - resp = r.ForwardRequestToRemoteAgent(req, c) - - // the request has been sent successfully - // actions return a response or a notification with ActionStatus record with status pending. - // other requests simply don't return anything until an async response is received. - if stored && resp != nil { - // in case of immediately available response and the action was stored. - _, _ = r.dtwStore.UpdateActionWithResponse(resp) - } else if resp == nil && c != nil { - // send an async notification if no response is available yet - notif := req.CreateNotification() - notif.Value = actionStatus - _ = c.SendNotification(notif) - } - } - return resp -} - -// HandleQueryAction returns the action status -func (r *DigitwinRouter) HandleQueryAction( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - av, err := r.dtwService.ValuesSvc.QueryAction(req.SenderID, - digitwin.ThingValuesQueryActionArgs{ThingID: req.ThingID, Name: req.Name}) - return req.CreateResponse(av, err) -} -func (r *DigitwinRouter) HandleQueryAllActions( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - av, err := r.dtwService.ValuesSvc.QueryAllActions(req.SenderID, req.ThingID) - return req.CreateResponse(av, err) -} - -// HandleReadEvent consumer requests a digital twin thing's event value -func (r *DigitwinRouter) HandleReadEvent( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - output, err := r.dtwService.ValuesSvc.ReadEvent(req.SenderID, - digitwin.ThingValuesReadEventArgs{ThingID: req.ThingID, Name: req.Name}) - return req.CreateResponse(output, err) -} - -// HandleReadAllEvents consumer requests all digital twin thing event values -func (r *DigitwinRouter) HandleReadAllEvents( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - output, err := r.dtwService.ValuesSvc.ReadAllEvents(req.SenderID, req.ThingID) - return req.CreateResponse(output, err) -} - -// HandleReadProperty consumer requests a digital twin thing's property value -func (r *DigitwinRouter) HandleReadProperty( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - output, err := r.dtwService.ValuesSvc.ReadProperty(req.SenderID, - digitwin.ThingValuesReadPropertyArgs{ThingID: req.ThingID, Name: req.Name}) - return req.CreateResponse(output, err) -} - -// HandleReadAllProperties consumer requests reading all digital twin's property values -func (r *DigitwinRouter) HandleReadAllProperties( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - output, err := r.dtwService.ValuesSvc.ReadAllProperties(req.SenderID, req.ThingID) - return req.CreateResponse(output, err) -} - -// HandleReadTD consumer reads a TD -// This converts the operation in an action for the directory service. -//func (svc *DigitwinRouter) HandleReadTD( -// req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { -// -// // the thingID in the request becomes the argument for the directory service, if any -// req2 := *req -// req2.Input = req.ThingID -// req2.ThingID = digitwin.ThingDirectoryDThingID -// req2.Name = digitwin.ThingDirectoryRetrieveThingMethod -// resp := svc.digitwinAction(&req2, c) -// return resp -//} - -// HandleReadAllTDs consumer reads all TDs -// This converts the operation in an action for the directory service. -//func (svc *DigitwinRouter) HandleReadAllTDs( -// req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { -// -// req2 := *req -// req2.ThingID = digitwin.ThingDirectoryDThingID -// req2.Name = digitwin.ThingDirectoryRetrieveAllThingsMethod -// resp := svc.digitwinAction(&req2, c) -// return resp -//} - -// HandleUpdateTD agent updates a TD -// This converts the operation in an action for the directory service. -//func (svc *DigitwinRouter) HandleUpdateTD( -// req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { -// -// // the thingID in the request becomes the argument for the directory service, if any -// //rework the request -// req2 := *req -// req2.Input = req.Input -// req2.ThingID = digitwin.ThingDirectoryDThingID -// req2.Name = digitwin.ThingDirectoryUpdateThingMethod -// resp := svc.digitwinAction(&req2, c) -// return resp -//} - -// HandleWriteProperty A consumer requests to write a new value to a property. -// -// This follows the same process as invoking an action but without tracking progress. -// The request is forwarded to the agent which is expected to send a response. -func (r *DigitwinRouter) HandleWriteProperty( - req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - - // Note: if internal services have writable properties (currently they don't) - // then add forwarding it here similar to invoking actions. - - return r.ForwardRequestToRemoteAgent(req, c) -} diff --git a/runtime/digitwin/router/ResponseFlow.go b/runtime/digitwin/router/ResponseFlow.go deleted file mode 100644 index e81ff8a4..00000000 --- a/runtime/digitwin/router/ResponseFlow.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package service with digital twin action flow handling functions -package router - -import ( - "fmt" - "log/slog" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -// HandleActionResponse handles receiving a response to an action -// This updates the action status if it was recorded. -// -// This: -// 1. Validates the request is still active. -// 2: Updates the status fields of the current digital twin action record to completed. -// 3: Forwards the update to the sender of the request. -// 4: Remove the active request from the cache. -func (r *DigitwinRouter) HandleActionResponse(resp *messaging.ResponseMessage) (err error) { - - // Action response - errMsg := "" - if resp.Error != nil { - errMsg = resp.Error.String() - } - // log the response - r.requestLogger.Info("<- RESP", - slog.String("correlationID", resp.CorrelationID), - slog.String("operation", resp.Operation), - slog.String("dThingID", resp.ThingID), - slog.String("name", resp.Name), - slog.String("error", errMsg), - slog.String("Value", resp.ToString(20)), - slog.String("senderID", resp.SenderID), - ) - - // 1: The response must be an active request - // Note that event and property subscriptions are active - r.mux.Lock() - as, found := r.activeCache[resp.CorrelationID] - r.mux.Unlock() - if !found { - err = fmt.Errorf( - // FIXME: this happens with writeproperty operations. These should also be in the activeCache - "HandleResponse: Message '%s' from agent '%s' not in action cache. It is ignored", - resp.CorrelationID, resp.SenderID) - - r.requestLogger.Error("Response Failed - correlationID not in action cache", - slog.String("correlationID", resp.CorrelationID), - ) - return nil - } - - // the sender (agents) must be the agent hat handled the action - if resp.SenderID != as.AgentID { - err = fmt.Errorf("HandleActionResponse: response ID '%s' of thing '%s' "+ - "does not come from agent '%s' but from '%s'. Response ignored", - resp.CorrelationID, resp.ThingID, as.AgentID, resp.SenderID) - r.requestLogger.Warn(err.Error()) - return nil - } - - // 2: Update the response status in the digital twin action record and log errors - // only action requests are tracked. - _, _ = r.dtwStore.UpdateActionWithResponse(resp) - - // 3: Forward the response to the sender of the request - c := r.transportServer.GetConnectionByConnectionID(as.SenderID, as.ReplyTo) - if c != nil { - err = c.SendResponse(resp) - } else { - // can't reach the consumer - err = fmt.Errorf("client connection-id (replyTo) '%s' not found for client '%s'", - as.ReplyTo, as.SenderID) - } - - if err != nil { - r.requestLogger.Error("Response Failed - Forwarding to sender failed", - slog.String("correlationID", resp.CorrelationID), - slog.String("operation", resp.Operation), - slog.String("dThingID", resp.ThingID), - slog.String("name", resp.Name), - slog.String("senderID", resp.SenderID), - slog.String("err", err.Error()), - ) - err = nil - } - - // 4: Remove the action when the response is received - // (notifications can provide intermediate status updates) - r.mux.Lock() - defer r.mux.Unlock() - delete(r.activeCache, as.CorrelationID) - return err -} - -// HandleResponse update the action status with the agent response. -// -// This converts the ThingID from the agent to that of the digital twin for whom -// the response is intended. The digital twin in turn sends this to the client -// that requested the action on the digital twin. -// -// If the message is no longer in the active cache then it is ignored. -func (r *DigitwinRouter) HandleResponse(resp *messaging.ResponseMessage) error { - var err error - - // Convert the agent ThingID to that of the digital twin - dThingID := digitwin.MakeDigitwinID(resp.SenderID, resp.ThingID) - resp.ThingID = dThingID - // ensure the updated time is set - if resp.Timestamp == "" { - resp.Timestamp = utils.FormatNowUTCMilli() - } - - // for now the only external response message is an action response - // (how about write property?) - err = r.HandleActionResponse(resp) - return err -} diff --git a/runtime/digitwin/service/DigitwinAgent.go b/runtime/digitwin/service/DigitwinAgent.go deleted file mode 100644 index 3f3c8bed..00000000 --- a/runtime/digitwin/service/DigitwinAgent.go +++ /dev/null @@ -1,43 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -type DigitwinAgent struct { - svc *DigitwinService - dirHandler messaging.RequestHandler - valuesHandler messaging.RequestHandler -} - -// HandleRequest handles digitwin services requests -// Including reading events, properties, actions from the digital twin. -func (agent *DigitwinAgent) HandleRequest( - req *messaging.RequestMessage, c messaging.IConnection) (resp *messaging.ResponseMessage) { - - if req.ThingID == digitwin.ThingDirectoryDThingID { - resp = agent.dirHandler(req, c) - } else if req.ThingID == digitwin.ThingValuesDThingID { - resp = agent.valuesHandler(req, c) - } else { - slog.Warn("HandleRequest: dThingID is not a service capability", "dThingID", req.ThingID) - err := fmt.Errorf("%s is not a digitwin service capability", req.ThingID) - resp = req.CreateResponse(nil, err) - } - return resp -} - -// NewDigitwinAgent creates the agent that passes messages to the service api. -// This uses the tdd2go generated service action handlers. -func NewDigitwinAgent(svc *DigitwinService) *DigitwinAgent { - agent := &DigitwinAgent{ - svc: svc, - dirHandler: digitwin.NewHandleThingDirectoryRequest(svc.DirSvc), - valuesHandler: digitwin.NewHandleThingValuesRequest(svc.ValuesSvc), - } - return agent -} diff --git a/runtime/digitwin/service/DigitwinService.go b/runtime/digitwin/service/DigitwinService.go deleted file mode 100644 index 786be650..00000000 --- a/runtime/digitwin/service/DigitwinService.go +++ /dev/null @@ -1,106 +0,0 @@ -package service - -import ( - "log/slog" - "os" - "path" - "sync" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/kvbtree" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/runtime/digitwin/store" -) - -// The DigitwinService stores digital twin things, property values and provide -// the latest event and action values. -type DigitwinService struct { - // persistent store for the digital twin objects - bucketStore buckets.IBucketStore - - // in-memory store with digital twin instances - DtwStore *store.DigitwinStore - - // Directory service for reading and updating TDs - DirSvc *DirectoryService - - // service for reading latest values - ValuesSvc *ValuesService - - mux sync.RWMutex -} - -// ReadAllTDs returns a list of digital twin TDs in the store -func (svc *DigitwinService) ReadAllTDs( - clientID string, offset int64, limit int64) ([]*td.TD, error) { - dtlist, err := svc.DtwStore.ReadTDs(offset, limit) - return dtlist, err -} - -//// ReadThing returns the digitwin TD of a Thing -//func (svc *DigitwinService) ReadThing( -// consumerID string, dThingID string) (tdd.TD, error) { -// dtd, err := svc.DtwStore.ReadDThing(dThingID) -// return dtd, err -//} - -// SetFormsHook sets the transport hook for adding forms and securityScheme entries to TDs -func (svc *DigitwinService) SetFormsHook(addFormsHandler func(*td.TD, bool)) { - svc.DirSvc.addFormsHandler = addFormsHandler -} - -// Stop the service -func (svc *DigitwinService) Stop() { - slog.Info("Stopping DigitwinService") - svc.DtwStore.Close() - svc.bucketStore.Close() -} - -// Start starts the digitwin services. -// This creates a bucket store for the directory, inbox, and outbox. -// -// storesDir is the directory where to create the digitwin storage -// notifHandler is notifies of changes to digital twin state. -// includeAffordanceForms to include forms for affordances in digital twin TDs. -func (svc *DigitwinService) Start( - storesDir string, notifHandler messaging.NotificationHandler, includeAffordanceForms bool) ( - digitwinStore *store.DigitwinStore, err error) { - - sPath := path.Join(storesDir, "digitwin") - err = os.MkdirAll(sPath, 0700) - storePath := path.Join(sPath, "digitwinStore") - - bucketStore := kvbtree.NewKVStore(storePath) - err = bucketStore.Open() - if err != nil { - slog.Error("Unable to open digital twin storage bucket", "err", err.Error()) - return nil, err - } - - digitwinStore, err = store.OpenDigitwinStore(bucketStore, false) - if err != nil { - slog.Error("Unable to open digital twin store itself", "err", err.Error()) - return nil, err - } - svc.bucketStore = bucketStore - svc.DtwStore = digitwinStore - svc.DirSvc = NewDigitwinDirectoryService(digitwinStore, notifHandler, includeAffordanceForms) - svc.ValuesSvc = NewDigitwinValuesService(digitwinStore) - return digitwinStore, err -} - -// StartDigitwinService creates and start the digitwin services. -// This creates a bucket store for the directory, inbox, and outbox. -// -// storesDir is the directory where to create the digitwin storage -// notifHandler is notifies of changes to digital twin state. -// includeAffordanceForms to include forms for affordances in digital twin TDs. -func StartDigitwinService( - storesDir string, notifHandler messaging.NotificationHandler, includeAffordanceForms bool) ( - svc *DigitwinService, digitwinStore *store.DigitwinStore, err error) { - - svc = &DigitwinService{} - digitwinStore, err = svc.Start(storesDir, notifHandler, includeAffordanceForms) - return svc, digitwinStore, err -} diff --git a/runtime/digitwin/service/DirectoryService.go b/runtime/digitwin/service/DirectoryService.go deleted file mode 100644 index 52fe294f..00000000 --- a/runtime/digitwin/service/DirectoryService.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "log/slog" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/runtime/digitwin/store" - jsoniter "github.com/json-iterator/go" -) - -// DirectoryService provides the digital twin directory service -// This is based on the W3C WoT Discovery draft specification: https://w3c.github.io/wot-discovery -// Currently being revised to be compatible. -type DirectoryService struct { - dtwStore *store.DigitwinStore - - // include forms in each affordance when generating the digitwin TD - includeAffordanceForms bool - - // notifications on directory changes - notifHandler messaging.NotificationHandler - addFormsHandler func(*td.TD, bool) -} - -// CreateThing uses updateThing -func (svc *DirectoryService) CreateThing(agentID string, tdJson string) error { - return svc.UpdateThing(agentID, tdJson) -} - -// DeleteThing removes a Thing TD document from the digital twin directory -func (svc *DirectoryService) DeleteThing(senderID string, dThingID string) error { - err := svc.dtwStore.RemoveDTW(dThingID, senderID) - if err == nil && svc.notifHandler != nil { - // Publish an event notifying subscribers that the Thing was removed from the directory - // Those subscribing to directory event will be notified - notif := messaging.NewNotificationMessage(td.OpSubscribeEvent, - digitwin.ThingDirectoryDThingID, digitwin.ThingDirectoryEventThingDeleted, - dThingID) - go svc.notifHandler(notif) - } - return err -} - -// MakeDigitalTwinTD returns the digital twin from an agent provided TD -// This modifies the TD as follows: -// 1. Make a deep copy of the original TD -// 2. Change the ThingID to the digitwin thing ID: dtw:{agentID}:{thingID} -// 3. Reset forms and security definitions -// 4. Use the AddForms hook to populate the digitin TD instance with the forms and security -// definitions available through the enabled protocols. -// 5. Add a writable 'title' property if it doesn't exist -func (svc *DirectoryService) MakeDigitalTwinTD( - agentID string, tdJSON string) (thingTD *td.TD, dtwTD *td.TD, err error) { - - err = jsoniter.UnmarshalFromString(tdJSON, &thingTD) - if err != nil { - slog.Error("MakeDigitalTwinTD. Bad TD", "err", err.Error()) - return thingTD, dtwTD, err - } - // 1. make a deep copy for the digital twin - _ = jsoniter.UnmarshalFromString(tdJSON, &dtwTD) - - // 2. Change the ThingID to the digital twins ID by prefixing the agent ID - dtwTD.ID = digitwin.MakeDigitwinID(agentID, thingTD.ID) - - // 3. reset all existing forms and auth info - dtwTD.Forms = make([]td.Form, 0) - dtwTD.Security = nil - dtwTD.SecurityDefinitions = make(map[string]td.SecurityScheme) - - for _, aff := range dtwTD.Properties { - aff.Forms = make([]td.Form, 0) - } - for _, aff := range dtwTD.Events { - aff.Forms = make([]td.Form, 0) - } - for _, aff := range dtwTD.Actions { - aff.Forms = make([]td.Form, 0) - } - - // 4. populate the TD with forms and security definitions of the available protocols - // See messaging.servers.TransportManager.AddTDForms() for more details - if svc.addFormsHandler != nil { - svc.addFormsHandler(dtwTD, svc.includeAffordanceForms) - } - return thingTD, dtwTD, err -} - -//func (svc *DirectoryService) QueryDTDs( -// senderID string, args digitwin.DirectoryQueryTDsArgs) (tdDocuments []string, err error) { -// //svc.DtwStore.QueryDTDs(args) -// return nil, fmt.Errorf("Not yet implemented") -//} - -// RetrieveThing returns a JSON encoded TD document -func (svc *DirectoryService) RetrieveThing(senderID string, dThingID string) (tdJSON string, err error) { - dtd, err := svc.dtwStore.ReadDThing(dThingID) - if err == nil { - tdJSON, err = jsoniter.MarshalToString(dtd) - } - return tdJSON, err -} - -// RetrieveAllThings returns a batch of TD documents -// This returns a list of JSON encoded digital twin TD documents -func (svc *DirectoryService) RetrieveAllThings( - senderID string, args digitwin.ThingDirectoryRetrieveAllThingsArgs) (tdList []string, err error) { - - dtdList, err := svc.dtwStore.ReadTDs(args.Offset, args.Limit) - if err == nil { - tdList = make([]string, 0, len(dtdList)) - for _, dtd := range dtdList { - tdJSON, err2 := jsoniter.MarshalToString(dtd) - if err2 == nil { - tdList = append(tdList, tdJSON) - } - } - } - return tdList, err -} - -// UpdateThing updates the digitwin TD from an agent supplied TD -// This transforms the given TD into the digital twin instance, stores it in the directory -// store and sends a thing-updated event as described in the TD. -// This returns true when the TD has changed or an error -func (svc *DirectoryService) UpdateThing(agentID string, tdJson string) error { - - // Transform the original TD into a digital twin's TD. This replaces forms and - // adds the security info for accessing the digital twin Thing. - thingTD, digitalTwinTD, err := svc.MakeDigitalTwinTD(agentID, tdJson) - if err != nil { - return err - } - slog.Info("UpdateDTD", - slog.String("agentID", agentID), slog.String("thingID", thingTD.ID)) - // Store both the original and digitwin TD documents - svc.dtwStore.UpdateTD(agentID, thingTD, digitalTwinTD) - - // notify subscribers of TD updates - if svc.notifHandler != nil { - dtdJSON, _ := jsoniter.MarshalToString(digitalTwinTD) - - // todo: only send notification on changes - // publish an event that the directory TD has updated with a new TD - notif := messaging.NewNotificationMessage(td.OpSubscribeEvent, - digitwin.ThingDirectoryDThingID, digitwin.ThingDirectoryEventThingUpdated, - dtdJSON) - go svc.notifHandler(notif) - } - return err -} - -// NewDigitwinDirectoryService creates a new instance of the directory service -// using the given store. -// This is based on the W3C WoT Discovery draft specification: https://w3c.github.io/wot-discovery -// Currently being revised to be compatible. -// The transport binding can be supplied directly or set later by the parent service -func NewDigitwinDirectoryService( - dtwStore *store.DigitwinStore, notifHandler messaging.NotificationHandler, - includeAffordanceForms bool) *DirectoryService { - - dirSvc := &DirectoryService{ - dtwStore: dtwStore, - notifHandler: notifHandler, - includeAffordanceForms: includeAffordanceForms, - } - - // verify service interface matches the TD generated interface - var s digitwin.IThingDirectoryService = dirSvc - _ = s - - return dirSvc -} diff --git a/runtime/digitwin/service/ValuesService.go b/runtime/digitwin/service/ValuesService.go deleted file mode 100644 index 88b6fdf5..00000000 --- a/runtime/digitwin/service/ValuesService.go +++ /dev/null @@ -1,76 +0,0 @@ -package service - -import ( - "github.com/hiveot/hivekit/go/utils" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/runtime/digitwin/store" -) - -// ValuesService provides access to digital thing values by consumers -// This implements the IValuesSvcService interface -type ValuesService struct { - // underlying store for the digital twin objects - dtwStore *store.DigitwinStore -} - -// QueryAction returns the current ActionValue status of the action -func (svc *ValuesService) QueryAction(clientID string, - args digitwin.ThingValuesQueryActionArgs) (av digitwin.ActionStatus, err error) { - //convert action status to action value, because ... need generated agent code - as, err := svc.dtwStore.QueryAction(args.ThingID, args.Name) - if err == nil { - err = utils.Decode(as, &av) - } - return av, err -} - -// QueryAllActions returns the current status of all thing actions -func (svc *ValuesService) QueryAllActions(clientID string, - thingID string) (av map[string]digitwin.ActionStatus, err error) { - - //convert action status to action value, because ... need generated agent code - as, err := svc.dtwStore.QueryAllActions(thingID) - if err == nil { - err = utils.Decode(as, &av) - } - return av, err -} - -// ReadAllEvents returns a list of known digitwin instance event values -func (svc *ValuesService) ReadAllEvents(clientID string, - dThingID string) (map[string]digitwin.ThingValue, error) { - - evMap, err := svc.dtwStore.ReadAllEvents(dThingID) - return evMap, err -} - -// ReadAllProperties returns a map of known digitwin instance property values -func (svc *ValuesService) ReadAllProperties(clientID string, - dThingID string) (map[string]digitwin.ThingValue, error) { - - propMap, err := svc.dtwStore.ReadAllProperties(dThingID) - return propMap, err -} - -// ReadEvent returns the latest event of a digitwin instance -func (svc *ValuesService) ReadEvent(clientID string, - args digitwin.ThingValuesReadEventArgs) (digitwin.ThingValue, error) { - - return svc.dtwStore.ReadEvent(args.ThingID, args.Name) -} - -// ReadProperty returns the last known property value of the given name, -// or an empty value if no value is known. -// This returns an error if the dThingID doesn't exist. -func (svc *ValuesService) ReadProperty(clientID string, - args digitwin.ThingValuesReadPropertyArgs) (p digitwin.ThingValue, err error) { - - return svc.dtwStore.ReadProperty(args.ThingID, args.Name) -} - -func NewDigitwinValuesService(dtwStore *store.DigitwinStore) *ValuesService { - svc := &ValuesService{ - dtwStore: dtwStore, - } - return svc -} diff --git a/runtime/digitwin/store/DigitwinInstance.go b/runtime/digitwin/store/DigitwinInstance.go deleted file mode 100644 index 9185b72a..00000000 --- a/runtime/digitwin/store/DigitwinInstance.go +++ /dev/null @@ -1,35 +0,0 @@ -package store - -import ( - "github.com/hiveot/hivekit/go/api/td" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -type ValueQueue struct { - values []digitwin.ThingValue - maxDepth int -} - -// DigitalTwinInstance contains the digital twin of a device -type DigitalTwinInstance struct { - // Agent that manages access to the exposed thing - AgentID string `json:"agentID" ` - ID string `json:"id" ` - - // AgentTD as exposed by the agent - AgentTD *td.TD `json:"agTD"` - // DigitwinTD as exposed by the hub - DigitwinTD *td.TD `json:"dtwTD" ` - - // PropValues with latest digital twin properties - PropValues map[string]digitwin.ThingValue `json:"pv"` - // EventValues with latest digital twin events - EventValues map[string]digitwin.ThingValue `json:"ev"` - // ActionStatuses with latest 'unsafe' digital twin action status - ActionStatuses map[string]digitwin.ActionStatus `json:"av"` - - // TBD: queue actions in the inbox of this device for timed delivery - //Inbox ActionQueue `json:"inbox"` - // TBD: queue events in the outbox to allow reading recent events on connecting - //Outbox ValueQueue `json:"outbox"` -} diff --git a/runtime/digitwin/store/DigitwinStore.go b/runtime/digitwin/store/DigitwinStore.go deleted file mode 100644 index e2e423ff..00000000 --- a/runtime/digitwin/store/DigitwinStore.go +++ /dev/null @@ -1,680 +0,0 @@ -package store - -import ( - "fmt" - "log/slog" - "sync" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - jsoniter "github.com/json-iterator/go" -) - -// DTWBucketName contains the name of the digital twin instances storage bucket -const DTWBucketName = "dtw" - -type DigitwinStore struct { - // The digital twin storage bucket - dtwBucket buckets.IBucket - - // in-memory cache of the digital twin Things by dThingID - dtwCache map[string]*DigitalTwinInstance - - // map of changed digital thing IDs values - changedThings map[string]any - - // list of DTW thingIDs used as a consistent iterator for reading batches - thingKeys []string - - // mutex for read/writing the cache - cacheMux sync.RWMutex // mutex for the following two fields - - // strict forces event and property checks against the TD - strict bool -} - -// Close the digitwin store -func (svc *DigitwinStore) Close() { - slog.Info("Closing DigitwinStore") - _ = svc.SaveChanges(false) - if svc.dtwBucket != nil { - _ = svc.dtwBucket.Close() - svc.dtwBucket = nil - } -} - -// GetDigitwinInfo returns the digital twin info containing both the digitwin and agent TD -// This returns nil if no such digital twin exists -func (store *DigitwinStore) GetDigitwinInfo(dThingID string) *DigitalTwinInstance { - dti := store.dtwCache[dThingID] - return dti -} - -// LoadCacheFromStore saves the current changes and reloads the cache from -// store into memory. -func (store *DigitwinStore) LoadCacheFromStore() error { - _ = store.SaveChanges(false) - - store.cacheMux.Lock() - defer store.cacheMux.Unlock() - cursor, err := store.dtwBucket.Cursor() - if err != nil { - return err - } - store.thingKeys = make([]string, 0, 1000) - store.dtwCache = make(map[string]*DigitalTwinInstance) - store.changedThings = make(map[string]any) - for { - // read in batches of 300 TD documents - tdmap, itemsRemaining := cursor.NextN(300) - for dThingID, dtwSer := range tdmap { - dtwInstance := &DigitalTwinInstance{} - err = jsoniter.Unmarshal(dtwSer, &dtwInstance) - //err = msgpack.Unmarshal(dtwSer, &dtwInstance) - if err == nil { - store.thingKeys = append(store.thingKeys, dThingID) - store.dtwCache[dThingID] = dtwInstance - } - } - if !itemsRemaining { - break - } - } - return nil -} - -// NewActionStart updates the action with a new start and pending status. -// -// This stores the action request for use with query actions. -// -// Note 'safe' actions are not stored as they don't affect the state of a Thing. -// The output is just a transformation of the input. -// -// This returns true if the request is stored or false if the request is safe. -func (svc *DigitwinStore) NewActionStart(req *messaging.RequestMessage) ( - actionStatus digitwin.ActionStatus, stored bool, err error) { - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - // A digital twin must exist. They are created when a TD is received. - dtw, found := svc.dtwCache[req.ThingID] - if !found { - // for now just warn for unknown things, in case the TD is not yet known. - // This might become an error in the future once the use-cases that can - // trigger this are better understood. - err := fmt.Errorf( - "NewActionStart: Action requested on an unknown Thing with ID '%s'. "+ - "Action is not recorded", req.ThingID) - slog.Warn(err.Error()) - return actionStatus, false, nil - } - // action affordance should exist - aff, found := dtw.DigitwinTD.Actions[req.Name] - _ = aff - if !found { - // FIXME:The request might not be an action but could also be a Thing level operation - // Need to understand the use-cases this might occur before changing this - // into an error or info. Most operations should be handled by the digital twin, - // not by remote agents. - err := fmt.Errorf("NewActionStart: Action '%s' not found in "+ - "digital twin '%s'. Action is not recorded", req.Name, req.ThingID) - slog.Warn(err.Error()) - // this is currently only a warning - return actionStatus, false, nil - } - if aff.Safe { - // Safe actions do not affect the Thing state. The response is a function - // if the input parameters so no use storing these. - slog.Info("Not recording a safe action as it doesnt affect Thing state") - return actionStatus, false, nil - } - // this is an action that affects the Thing state (not safe). Record it. - actionStatus, found = dtw.ActionStatuses[req.Name] - if !found { - actionStatus = digitwin.ActionStatus{} - } - actionStatus.Name = req.Name - actionStatus.ThingID = req.ThingID - actionStatus.SenderID = req.SenderID - actionStatus.Input = req.Input - actionStatus.State = messaging.StatusPending - actionStatus.TimeRequested = req.Created - actionStatus.ActionID = req.CorrelationID - dtw.ActionStatuses[req.Name] = actionStatus - svc.changedThings[req.ThingID] = true - - return actionStatus, true, nil -} - -// QueryAction returns the current status of the action -// This returns an empty value if no action value is available -func (svc *DigitwinStore) QueryAction( - dThingID string, name string) (v digitwin.ActionStatus, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("ReadAction: dThing with ID '%s' not found", dThingID) - return v, err - } - if svc.strict { - // affordance must exist - aff, found := dtw.DigitwinTD.Actions[name] - _ = aff - if !found { - return v, fmt.Errorf("QueryAction: Action '%s' not found in digital twin '%s'", name, dThingID) - } - } - v, found = dtw.ActionStatuses[name] - _ = found - return v, nil -} - -// QueryAllActions returns all last known action invocation status of the given thing -func (svc *DigitwinStore) QueryAllActions(dThingID string) ( - v map[string]digitwin.ActionStatus, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("QueryAllActions: dThing with ID '%s' not found", dThingID) - return v, err - } - // shallow copy - actMap := make(map[string]digitwin.ActionStatus) - for k, v := range dtw.ActionStatuses { - actMap[k] = v - } - return actMap, err -} - -// ReadAllEvents returns all last received events of the given thing -func (svc *DigitwinStore) ReadAllEvents(dThingID string) ( - v map[string]digitwin.ThingValue, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("ReadAllEvents: dThing with ID '%s' not found", dThingID) - return v, err - } - - // shallow copy - evMap := make(map[string]digitwin.ThingValue) - for k, v := range dtw.EventValues { - evMap[k] = v - } - return evMap, err -} - -// ReadAllProperties returns a shallow copy of all last known property values of the given thing -func (svc *DigitwinStore) ReadAllProperties(dThingID string) ( - v map[string]digitwin.ThingValue, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - - if !found { - err = fmt.Errorf("GetAllProperties: dThing with ID '%s' not found", dThingID) - return v, err - } - // shallow copy - propMap := make(map[string]digitwin.ThingValue) - for k, v := range dtw.PropValues { - propMap[k] = v - } - return propMap, err -} - -// ReadEvent returns the last known event status of the given name -// this returns an empty value if no last known event is found -// If 'strict' is set then this fails with an error if the event is not in the TD. -func (svc *DigitwinStore) ReadEvent( - dThingID string, name string) (v digitwin.ThingValue, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("ReadEvent: dThing with ID '%s' not found", dThingID) - return v, err - } - if svc.strict { - // affordance must exist - aff, found := dtw.DigitwinTD.Events[name] - _ = aff - if !found { - return v, fmt.Errorf("ReadEvent: Event '%s' not found in digital twin '%s'", name, dThingID) - } - } - // event value might not exist - v, found = dtw.EventValues[name] - _ = found - return v, nil -} - -// ReadProperty returns the last known property value of the given name, -// or an empty value if no value is known. -// This returns an error if the dThingID doesn't exist. -func (svc *DigitwinStore) ReadProperty( - dThingID string, name string) (v digitwin.ThingValue, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("ReadProperty: dThing with ID '%s' not found", dThingID) - return v, err - } - if svc.strict { - // affordance must exist - aff, found := dtw.DigitwinTD.Properties[name] - _ = aff - if !found { - return v, fmt.Errorf("ReadProperty: Property '%s' not found in digital twin '%s'", name, dThingID) - } - } - // value might not exist is optional - v, found = dtw.PropValues[name] - _ = found - return v, nil -} - -// ReadDThing returns the Digitwin TD document in the store. -func (svc *DigitwinStore) ReadDThing(dThingID string) (dtd *td.TD, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - dtw, found := svc.dtwCache[dThingID] - if !found { - err = fmt.Errorf("dThing with ID '%s' not found", dThingID) - return dtd, err - } - return dtw.DigitwinTD, err -} - -// ReadTDs returns a list of digital twin TDs -// -// limit is the maximum number of records to return -// offset is the offset of the first record to return -func (svc *DigitwinStore) ReadTDs(offset int64, limit int64) (resp []*td.TD, err error) { - - svc.cacheMux.RLock() - defer svc.cacheMux.RUnlock() - // Use the thingKeys index to ensure consistent iteration and to quickly - // skip offset items (maps are not consistent between iterations) - if offset >= int64(len(svc.thingKeys)) { - // empty result - return resp, nil - } - if offset+limit > int64(len(svc.thingKeys)) { - limit = int64(len(svc.thingKeys)) - offset - } - tdKeys := svc.thingKeys[offset:limit] - resp = make([]*td.TD, 0, len(tdKeys)) - - // add the documents - for _, k := range tdKeys { - dtw := svc.dtwCache[k] - resp = append(resp, dtw.DigitwinTD) - } - return resp, nil -} - -// RemoveDTW deletes the digitwin instance of an agent with the given ThingID -func (svc *DigitwinStore) RemoveDTW(dThingID string, senderID string) error { - // TBD: should we mark this as deleted instead? retain historical things? - slog.Debug("RemoveTD", - slog.String("dThingID", dThingID), - slog.String("senderID", senderID)) - - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - // remove from both cache and bucket - err := svc.dtwBucket.Delete(dThingID) - delete(svc.dtwCache, dThingID) - delete(svc.changedThings, dThingID) - // delete from the index array. A bit primitive but it is rarely used - for i, key := range svc.thingKeys { - if key == dThingID { - svc.thingKeys[i] = svc.thingKeys[len(svc.thingKeys)-1] - svc.thingKeys = svc.thingKeys[:len(svc.thingKeys)-1] - break - } - } - return err -} - -// SaveChanges persists digital twins that have been modified since the -// last call to this function. -// -// background save in the background -func (svc *DigitwinStore) SaveChanges(background bool) error { - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - // Serialize the changed things for persisting to the bucket store in the background - changedDtwJson := make(map[string][]byte) - for dThingID := range svc.changedThings { - var dtwSer []byte - dtw, found := svc.dtwCache[dThingID] - // if the thing is no longer there it has been deleted. - if found { - dtwSer, _ = jsoniter.Marshal(dtw) - } - changedDtwJson[dThingID] = dtwSer - } - - // Don't block the digital twins. Update the store in the background. - saveit := func() { - for dThingID, dThingJSON := range changedDtwJson { - var err error - if dThingJSON != nil { - err = svc.dtwBucket.Set(dThingID, dThingJSON) - } else { - err = svc.dtwBucket.Delete(dThingID) - } - if err != nil { - slog.Error("SaveChanges. Writing to bucket failed. Skipped", - "dThingID", dThingID, "err", err) - continue - } - } - } - if background { - go saveit() - } else { - saveit() - } - - // continue with a clean list of changed things - svc.changedThings = make(map[string]any) - return nil -} - -// UpdateTD update the provided TD and the derived digital twin TD in the -// stored digital twin record. -// -// The given TD is provided by the agent and has the agent's ThingID. -// If no digital twin exists yet, it is created. -// -// This returns false if the given TD is the same as the one on record. -func (svc *DigitwinStore) UpdateTD( - agentID string, thingTD *td.TD, digitwinTD *td.TD) { - - slog.Debug("UpdateDTD", - slog.String("agentID", agentID), - slog.String("thingID", thingTD.ID)) - - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - dThingID := digitwinTD.ID - dtw, found := svc.dtwCache[dThingID] - if !found { - dtw = &DigitalTwinInstance{ - AgentID: agentID, - ID: dThingID, - PropValues: make(map[string]digitwin.ThingValue), - EventValues: make(map[string]digitwin.ThingValue), - ActionStatuses: make(map[string]digitwin.ActionStatus), - } - svc.dtwCache[dThingID] = dtw - svc.thingKeys = append(svc.thingKeys, dThingID) - } - dtw.AgentTD = thingTD - dtw.DigitwinTD = digitwinTD - svc.changedThings[dThingID] = true -} - -// UpdateActionWithNotification (by agent) updates the action with a notification. -// -// notif is a action notification with progress status -func (svc *DigitwinStore) UpdateActionWithNotification(notif *messaging.NotificationMessage) { - var actionStatus digitwin.ActionStatus - var rxStatus digitwin.ActionStatus - - err := utils.DecodeAsObject(notif.Value, &rxStatus) - if err != nil || rxStatus.State == "" { - slog.Warn("UpdateActionWithNotification: Notification does not contain an ActionStatus") - return - } - - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - dtw, found := svc.dtwCache[notif.ThingID] - if !found { - // not a known thing - slog.Warn("UpdateActionWithNotification: not a known Thing", - "thingID", notif.ThingID, - ) - return - } - actionStatus, found = dtw.ActionStatuses[notif.Name] - if !found { - slog.Warn("UpdateActionWithNotification: no status for the action", - "actionID", rxStatus.ActionID, - "thingID", notif.ThingID, - "name", notif.Name, - ) - return - } - if actionStatus.State == messaging.StatusCompleted { - slog.Warn("UpdateActionWithNotification: Action is already completed", - "actionID", rxStatus.ActionID, - "thingID", notif.ThingID, - "name", notif.Name, - ) - return - } - actionStatus.TimeUpdated = rxStatus.TimeUpdated - actionStatus.State = rxStatus.State - dtw.ActionStatuses[actionStatus.Name] = actionStatus - svc.changedThings[actionStatus.ThingID] = true -} - -// UpdateActionWithResponse (by agent) updates the action with a response. -// -// Note that a response means that the action is completed. -// -// resp is a response from invokeaction containing an ActionStatus as value -func (svc *DigitwinStore) UpdateActionWithResponse( - resp *messaging.ResponseMessage) (actionStatus digitwin.ActionStatus, err error) { - - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - dtw, found := svc.dtwCache[resp.ThingID] - if !found { - err := fmt.Errorf("dThing with ID '%s' not found", resp.ThingID) - return actionStatus, err - } - // action affordance must exist - _, found = dtw.DigitwinTD.Actions[resp.Name] - if !found { - return actionStatus, fmt.Errorf( - "UpdateActionWithResponse: Action '%s' not found in digital twin '%s'", - resp.Name, resp.ThingID) - } - - // action response contains a messaging.ActionStatus object - var respStatus messaging.ActionStatus - err = utils.DecodeAsObject(resp.Value, &respStatus) - - if err != nil { - // the response does not hold an ActionStatus object, - // assume it is completed and contains the output directly. - respStatus.Output = resp.Value - respStatus.TimeUpdated = resp.Timestamp - respStatus.State = messaging.StatusCompleted - respStatus.Error = resp.Error - // respStatus.ActionID=resp.CorrelationID - - // The protocol binding didn't decode the action response correctly - // because in hiveot all responses should hold the action status - slog.Error("UpdateActionWithResponse: Invalid ActionStatus in response. Recover by using the value as the output", - "thingID", resp.ThingID, - "actionName", resp.Name, - "value", resp.ToString(20), - "err", err.Error(), - ) - } - - // this is a progress update; update only the status fields. - // action value should exist. recover if it doesn't - actionStatus, found = dtw.ActionStatuses[resp.Name] - if !found { - // an existing action status is expected. Recover by creating a new one. - // convert messaging.ActionStatus to api.ActionStatus - err = utils.Decode(respStatus, &actionStatus) - if err != nil { - slog.Error("UpdateActionWithResponse: Cannot decode ActionStatus from response", - "thingID", resp.ThingID, - "actionName", resp.Name, - "err", err.Error(), - ) - return actionStatus, err - } - actionStatus.Input = nil // input is not in the response - } - actionStatus.TimeUpdated = utils.FormatNowUTCMilli() - if resp.Error != nil { - // if the response itself holds an error - actionStatus.Error = &digitwin.ErrorValue{ - Detail: resp.Error.Detail, - Status: int64(resp.Error.Status), - Title: resp.Error.Title, - Type: resp.Error.Type, - } - actionStatus.State = messaging.StatusFailed - } else if respStatus.Error != nil { - // if the response actionStatus holds the error - actionStatus.Error = &digitwin.ErrorValue{ - Detail: respStatus.Error.Detail, - Status: int64(respStatus.Error.Status), - Title: respStatus.Error.Title, - Type: respStatus.Error.Type, - } - actionStatus.State = respStatus.State - } else { - // no error - actionStatus.Output = respStatus.Output - actionStatus.TimeUpdated = respStatus.TimeUpdated - actionStatus.State = respStatus.State - } - dtw.ActionStatuses[resp.Name] = actionStatus - svc.changedThings[resp.ThingID] = true - - return actionStatus, nil - -} - -// UpdateEventValue updates the last known thing event value. -// -// This does accept event values that are not defined in the TD. -// This is intentional. -// -// ev is the received notification of the event update -func (svc *DigitwinStore) UpdateEventValue(ev digitwin.ThingValue) error { - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - ev.AffordanceType = string(messaging.AffordanceTypeEvent) - dtw, found := svc.dtwCache[ev.ThingID] - if !found { - err := fmt.Errorf("dThing with ID '%s' not found", ev.ThingID) - slog.Warn("UpdateEventValue Unknown Thing. Event ignored.", "dThingID", ev.ThingID) - return err - } - dtw.EventValues[ev.Name] = ev - svc.changedThings[ev.ThingID] = true - - return nil -} - -// UpdatePropertyValue updates the last known thing property value. -// -// newValue of the property -// -// This returns a flag indicating whether the property value has changed. -func (svc *DigitwinStore) UpdatePropertyValue(newValue digitwin.ThingValue) ( - hasChanged bool, err error) { - - svc.cacheMux.Lock() - defer svc.cacheMux.Unlock() - - newValue.AffordanceType = string(messaging.AffordanceTypeProperty) - dtw, found := svc.dtwCache[newValue.ThingID] - if !found { - err := fmt.Errorf("dThing with ID '%s' not found", newValue.ThingID) - return false, err - } - if svc.strict { - aff := dtw.DigitwinTD.GetProperty(newValue.Name) - if aff == nil { - return false, - fmt.Errorf("UpdatePropertyValue: unknown property '%s' for thing '%s'", - newValue.Name, newValue.ThingID) - } - } - // TODO: value timestamp sanity check. Is it worth it? - hasChanged = true - dtw.PropValues[newValue.Name] = newValue - svc.changedThings[newValue.ThingID] = true - //propValue, found := dtw.PropValues[newValue.Name] - //if !found { - // hasChanged = true - // dtw.PropValues[newValue.Name] = newValue - // svc.changedThings[newValue.ThingID] = true - //} else if newValue.Timestamp < propValue.Timestamp { - // slog.Warn("Timestamp of new property value is before last value", - // "thingID", propValue.ThingID, - // "name", propValue.Name, - // "last timestamp", propValue.Timestamp, - // "new timestamp", newValue.Timestamp, - // ) - // hasChanged = false - //} else if newValue.Timestamp > now { - // slog.Warn("Timestamp of new value is in the future") - // hasChanged = false - //} else { - // hasChanged = true - // dtw.PropValues[newValue.Name] = newValue - // svc.changedThings[newValue.ThingID] = true - //} - //_ = propValue - - return hasChanged, nil -} - -// OpenDigitwinStore initializes the digitwin store using the given storage bucket. -// This will load the digitwin directory into a memory cache. -// The storage bucket will be closed when the store is closed. -// -// store is the bucket store to store the data. This is opened by 'Start' and closed by 'Stop' -func OpenDigitwinStore(bucketStore buckets.IBucketStore, strict bool) (*DigitwinStore, error) { - bucket := bucketStore.GetBucket(DTWBucketName) - - svc := &DigitwinStore{ - dtwBucket: bucket, - dtwCache: make(map[string]*DigitalTwinInstance), - changedThings: make(map[string]any), - thingKeys: make([]string, 0), - strict: strict, - } - // fill the in-memory cache - err := svc.LoadCacheFromStore() - if err != nil { - slog.Error("OpenDigitwinStore failed", "err", err.Error()) - return svc, err - } - return svc, nil -} diff --git a/runtime/digitwin/store/DigitwinStore_test.go b/runtime/digitwin/store/DigitwinStore_test.go deleted file mode 100644 index 6260fd1c..00000000 --- a/runtime/digitwin/store/DigitwinStore_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package store_test - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets/kvbtree" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/runtime/digitwin/store" - - "math/rand" - "os" - "path" - "testing" - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var testValueFolder = path.Join(os.TempDir(), "test-values") -var valueStorePath = path.Join(testValueFolder, "values.store") -var valueNames = []string{"temperature", "humidity", "pressure", "wind", "speed", "switch", "location", "sensor-A", "sensor-B", "sensor-C"} - -func startLatestStore(clean bool) ( - svc *store.DigitwinStore, - stopFn func()) { - - if clean { - _ = os.Remove(valueStorePath) - } - kvstore := kvbtree.NewKVStore(valueStorePath) - err := kvstore.Open() - if err != nil { - panic("unable to open the digital twin bucket store") - } - svc, err = store.OpenDigitwinStore(kvstore, false) - if err != nil { - panic("unable to start the latest store service") - } - - return svc, func() { - svc.Close() - _ = kvstore.Close() - } -} - -// generate a random batch of values for testing -func addValues(svc *store.DigitwinStore, - nrValues int, agentID string, thingIDs []string, timespanSec int) { - - for j := 0; j < nrValues; j++ { - thingIndex := rand.Intn(len(thingIDs)) - thingID := thingIDs[thingIndex] - randomName := rand.Intn(10) - randomValue := rand.Float64() * 100 - //randomSeconds := time.Duration(rand.Intn(timespanSec)) * time.Second - //randomTime := time.Now().Add(-randomSeconds) - //ev.Created = randomTime.Format(time.RFC3339) - - // add a TD - dThingID := digitwin.MakeDigitwinID(agentID, thingID) - _, err := svc.ReadDThing(dThingID) - if err != nil { - title := fmt.Sprintf("Test thing %d", j) - thingTD := td.NewTD(thingID, title, "randomdevice") - dtwTD := *thingTD - dtwTD.ID = dThingID - svc.UpdateTD(agentID, thingTD, &dtwTD) - } - - value := fmt.Sprintf("%2.3f", randomValue) - name := valueNames[randomName] - tv := digitwin.ThingValue{ - Timestamp: utils.FormatNowUTCMilli(), - Data: value, - Name: name, - ThingID: dThingID, - AffordanceType: string(messaging.AffordanceTypeProperty), - } - _ = svc.UpdateEventValue(tv) - } -} - -func TestMain(m *testing.M) { - var err error - logging.SetLogging("info", "") - // clean start - _ = os.RemoveAll(testValueFolder) - err = os.MkdirAll(testValueFolder, 0700) - - if err != nil { - panic(err) - } - - res := m.Run() - os.Exit(res) -} - -func TestStartStop(t *testing.T) { - var agentID = "agent1" - var thingIDs = []string{"thing1", "thing2", "thing3", "thing4"} - svc, stopFunc := startLatestStore(true) - - addValues(svc, 100, agentID, thingIDs, 100) - - //valList1, err := svc.ReadLatest(vocab.HTOpPublishEvent, thingIDs[1], nil, "") - dthingID := digitwin.MakeDigitwinID(agentID, thingIDs[1]) - valList1, err := svc.ReadAllEvents(dthingID) - assert.NoError(t, err) - assert.True(t, len(valList1) > 1) - - // stop and start again, the update should be reloaded - stopFunc() - - svc, stopFunc = startLatestStore(false) - defer stopFunc() - //tdList2, err := directory.ReadThings(mt, 0, 10) - //assert.Equal(t, len(thingIDs), len(tdList2)) - - dThingID1 := digitwin.MakeDigitwinID(agentID, thingIDs[1]) - valList2a, err := svc.ReadAllEvents(dThingID1) - assert.NoError(t, err) - assert.Equal(t, len(valList1), len(valList2a)) -} - -func TestGetEvents(t *testing.T) { - const count = 100 - const agent1ID = "agent1" - const thing1ID = "thing1" // matches a percentage of the random things - const valueName = "temperature" // from the list with names above - var dThingID1 = digitwin.MakeDigitwinID(agent1ID, thing1ID) - - svc, closeFn := startLatestStore(true) - defer closeFn() - - // add events using various names including temperature - addValues(svc, count, agent1ID, []string{thing1ID}, 3600*24*30) - - t0 := time.Now() - - values, err := svc.ReadAllEvents(dThingID1) - require.NoError(t, err) - require.NotNil(t, values) - d0 := time.Now().Sub(t0) - // warmup - _, _ = svc.ReadAllEvents(dThingID1) - - // 2nd time from cache should be faster - t1 := time.Now() - values2, err := svc.ReadAllEvents(dThingID1) - require.NoError(t, err) - require.NotNil(t, values2) - d1 := time.Now().Sub(t1) - - assert.Less(t, d1, d0, "reading from cache is not faster?") - - // reading a single event must have the same result - value3, err := svc.ReadEvent(dThingID1, valueName) - require.NoError(t, err) - require.Equal(t, values2[valueName], value3) - - // save and reload props - closeFn() - //svc.Stop() - svc, closeFn = startLatestStore(false) - //err = svc.Start() - assert.NoError(t, err) - - // LoadLatest should load and not find a cached value - v, err := svc.ReadAllEvents(dThingID1) - require.NoError(t, err) - require.NotNil(t, v) -} - -func TestUpdateProps(t *testing.T) { - agent1ID := "agent1" - thing1ID := "thing1" - var dThingID1 = digitwin.MakeDigitwinID(agent1ID, thing1ID) - const prop1Name = "temperature" - const prop1Value = 10.5 - - svc, closeFn := startLatestStore(true) - defer closeFn() - addValues(svc, 10, agent1ID, []string{thing1ID}, 3600*24*30) - - tv := digitwin.ThingValue{ - Timestamp: utils.FormatNowUTCMilli(), - Data: prop1Value, - Name: prop1Name, - ThingID: dThingID1, - AffordanceType: string(messaging.AffordanceTypeProperty), - } - changed, err := svc.UpdatePropertyValue(tv) - require.NoError(t, err) - require.True(t, changed) - - p1val, err := svc.ReadProperty(dThingID1, prop1Name) - require.NoError(t, err) - require.Equal(t, prop1Value, p1val.Data) -} - -func TestAddPropsFail(t *testing.T) { - agent1ID := "agent1" - thing1ID := "badthingid" - dThingID := digitwin.MakeDigitwinID(agent1ID, thing1ID) - svc, closeFn := startLatestStore(true) - _ = svc - defer closeFn() - - tv := digitwin.ThingValue{ - Timestamp: utils.FormatNowUTCMilli(), - Data: "val1", - Name: "prop1", - ThingID: dThingID, - AffordanceType: string(messaging.AffordanceTypeProperty), - } - changed, err := svc.UpdatePropertyValue(tv) - - require.Error(t, err) - require.False(t, changed) -} diff --git a/runtime/digitwin/tm/Directory.json b/runtime/digitwin/tm/Directory.json deleted file mode 100644 index 11fb1b0e..00000000 --- a/runtime/digitwin/tm/Directory.json +++ /dev/null @@ -1,305 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - "https://www.w3.org/2022/wot/discovery", - { - "hiveot": "https://www.hiveot.net/vocab/v0.1" - } - ], - "@type": ["tm:ThingModel", "ThingDirectory"], - "id": "ThingDirectory", - "title": "DigiTwin Directory Service", - "description": "HiveOT digital twin directory service", - "version": { - "instance": "0.1.0" - }, - "created": "2025-09-22T17:00:00.000Z", - "modified": "2025-09-22T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "base": "{{DIRECTORY_BASE_URL}}", - "deny": ["none"], - "properties": { - "things": { - "description": "Retrieve all Thing Descriptions", - "comment": "Define for spec compatibility. An action (retrieveAllThings) should be used instead.", - "type": "array", - "items": { - "description": "Thing Description", - "type": "object" - }, - "uriVariables": { - "offset": { - "title": "Number of TDs to skip before the page", - "type": "number", - "default": 0 - }, - "limit": { - "title": "Number of TDs in a page", - "type": "number", - "default": 1 - } - }, - "forms": [ - { - "href": "/things{?offset,limit}", - "htv:methodName": "GET", - "response": { - "description": "Success response", - "htv:statusCodeValue": 200, - "contentType": "application/ld+json", - "htv:headers": [ - { - "htv:fieldName": "Link" - } - ] - }, - "additionalResponses": [ - { - "description": "Invalid query arguments", - "contentType": "application/problem+json", - "htv:statusCodeValue": 400 - } - ] - } - ] - } - }, - - "events": { - "thingUpdated": { - "title": "Thing Updated", - "description": "A digital twin Thing TD was updated in the directory", - "data": { - "title": "TD", - "description": "JSON encoded TD of the digital twin Thing", - "type": "string" - } - }, - "thingDeleted": { - "title": "Thing Deleted", - "description": "A Thing TD was removed from the directory", - "data": { - "title": "Thing ID", - "description": "ID of the digital twin Thing that was deleted", - "type": "string" - } - } - }, - - "actions": { - "createThing": { - "title": "Create Thing TD", - "description": "Update the Thing TD in the directory. For use by Thing agents only.", - "idempotent": true, - "allow": ["agent", "admin"], - "input": { - "title": "TD JSON", - "description": "Device TD document in JSON format", - "comment": "The spec defines type object but there is no scheme associated with it.", - "type": "string" - }, - "uriVariables": { - "id": { - "type": "string", - "comment": "uri 'id' is in the spec, this is ignored as the TD contains the ID." - } - }, - "forms": [ - { - "href": "/things/{id}", - "htv:methodName": "PUT", - "response": { - "description": "Success response", - "htv:statusCodeValue": 201 - }, - "additionalResponses": [ - { - "description": "Invalid serialization or TD", - "contentType": "application/problem+json", - "htv:statusCodeValue": 400 - } - ] - } - ] - }, - - "deleteThing": { - "title": "Delete Thing", - "description": "Remove a digital twin TD from the directory", - "safe": false, - "idempotent": true, - "allow": ["admin"], - "comment": "only admin can remove TDs", - "input": { - "title": "dThing ID", - "type": "string" - }, - "uriVariables": { - "id": { - "type": "string", - "comment": "uri 'id' maps to input.", - "hiveot:input": "" - } - }, - "forms": [ - { - "href": "/things/{id}", - "htv:methodName": "DELETE", - "response": { - "description": "Success response", - "htv:statusCodeValue": 204 - }, - "additionalResponses": [ - { - "description": "TD with the given id not found", - "contentType": "application/problem+json", - "htv:statusCodeValue": 404 - } - ] - } - ] - }, - - "retrieveThing": { - "title": "Retrieve a TD", - "description": "Return a JSON encoded TD document", - "safe": true, - "idempotent": true, - "uriVariables": { - "id": { - "title": "Thing Description ID", - "type": "string", - "comment": "uri 'id' maps to input.", - "hiveot:input": "" - } - }, - "input": { - "title": "Thing ID", - "type": "string", - "description": "Digital-Twin Thing ID of the Thing to read", - "comment": "URI variables can be used as input where supported" - }, - "output": { - "title": "TD", - "description": "A JSON encoded Thing Description Document", - "type": "string" - }, - "forms": [ - { - "href": "/things/{id}", - "htv:methodName": "GET", - "comments": [ - "Additional http endpoint as per spec to read a TD.", - "Use URI variable as second method of input", - "The alternative endpoint is /things/invokeaction/retrieveThing with payload the thingID" - ] - } - ] - }, - - "retrieveAllThings": { - "title": "Read all TDs", - "description": "Read a batch of TD documents", - "safe": true, - "idempotent": true, - "input": { - "type": "object", - "properties": { - "offset": { - "title": "Offset", - "description": "Start index in the list of TD documents", - "type": "integer", - "minimum": 0, - "default": 0 - }, - "limit": { - "title": "Limit", - "description": "Maximum number of documents to return", - "type": "integer", - "minimum": 1, - "default": 100 - } - }, - "uriVariables": { - "offset": { - "title": "Offset", - "type": "number", - "hiveot:input": "offset" - }, - "limit": { - "title": "Max nr of responses", - "type": "number", - "hiveot:input": "limit" - } - } - }, - "output": { - "title": "TD list", - "description": "List of JSON encoded TD documents", - "type": "array", - "items": { - "type": "string" - } - }, - "forms": [ - { - "href": "/things", - "htv:methodName": "GET", - "contentType": "application/td+json", - "response": { - "description": "Success response", - "htv:statusCodeValue": 204 - }, - "additionalResponses": [ - { - "description": "Invalid serialization or TD", - "contentType": "application/problem+json", - "htv:statusCodeValue": 400 - } - ] - } - ] - }, - - "updateThing": { - "title": "Update Thing TD", - "description": "Update the Thing TD in the directory. For use by agents only.", - "idempotent": true, - "allow": ["agent", "admin"], - "comment": "Only agents can update their TD", - "input": { - "title": "TD", - "description": "Device TD document in JSON format", - "comment": "The spec defines type object but there is no scheme associated with it.", - "type": "string" - }, - "uriVariables": { - "id": { - "@type": "ThingID", - "title": "Thing Description ID", - "type": "string", - "comment": "uri 'id' is in the spec, bit ignored as the TD contains the ID." - } - }, - "forms": [ - { - "href": "/things/{id}", - "htv:methodName": "PUT", - "contentType": "application/td+json", - "response": { - "description": "Success response", - "htv:statusCodeValue": 204 - }, - "additionalResponses": [ - { - "description": "Invalid serialization or TD", - "contentType": "application/problem+json", - "htv:statusCodeValue": 400 - } - ] - } - ] - } - }, - "links": [] -} diff --git a/runtime/digitwin/tm/Values.json b/runtime/digitwin/tm/Values.json deleted file mode 100644 index b920db9c..00000000 --- a/runtime/digitwin/tm/Values.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/2022/wot/td/v1.1", - { - "hiveot": "https://www.hiveot.net/vocab/v0.1" - } - ], - "@type": "Service", - "id": "thingValues", - "title": "DigiTwin Values Service", - "description": "Service for providing the last known property, event and action values", - "version": { - "instance": "0.1.0" - }, - "created": "2024-10-04T17:00:00.000Z", - "modified": "2024-10-04T17:00:00.000Z", - "support": "https://www.github.com/hiveot/hub", - "base": "https://localhost/", - "deny": ["none"], - "schemaDefinitions": { - "ActionStatus": { - "title": "Action Status", - "description": "Status of the an action", - "type": "object", - "properties": { - "actionID": { - "title": "Action ID", - "description": "Unique identifier of the action", - "type": "string" - }, - "agentID": { - "title": "Agent ID", - "description": "The agent handling the action", - "type": "string" - }, - "error": { - "title": "Error", - "description": "Object with error value when failed", - "type": "object", - "schema": "ErrorValue" - }, - "input": { - "title": "Action input", - "description": "Action input value", - "type": "any" - }, - "name": { - "title": "Action name", - "description": "name of the action or property", - "type": "string" - }, - "output": { - "title": "Action output", - "description": "", - "type": "any" - }, - "senderID": { - "title": "Sender client ID", - "description": "ID of the client requesting the action", - "type": "string" - }, - "state": { - "title": "Action Status", - "description": "Status of the action's progress", - "type": "string", - "enum": ["pending", "running", "completed", "failed"] - }, - "thingID": { - "title": "Action Thing", - "description": "Digital twin ThingID the action applies to", - "type": "string" - }, - "timeRequested": { - "title": "Requested Time", - "description": "Time the action request was received", - "type": "string" - }, - "timeUpdated": { - "title": "Updated Time", - "description": "Time the action status was last updated", - "type": "string" - } - } - }, - "ErrorValue": { - "title": "Error Value", - "description": "Error response value", - "type": "object", - "properties": { - "status": { "title": "Http code", "type": "integer" }, - "type": { "title": "Type", "type": "string" }, - "title": { "title": "Title", "type": "string" }, - "detail": { "title": "Details", "type": "string" } - } - }, - "ThingValue": { - "title": "Thing Value", - "description": "Property or event value", - "type": "object", - "comment": "This has the same fields as a transports ThingValue", - "properties": { - "affordanceType": { - "title": "Type of value", - "description": "Affordance this is a value of, eg 'property', 'event', or 'action'", - "type": "string" - }, - "data": { - "title": "Payload", - "description": "Data in format as described by the thing's property affordance", - "type": "any" - }, - "name": { - "title": "Name", - "description": "Name of the property holding the value", - "type": "string" - }, - "thingID": { - "title": "Thing ID", - "description": "Digital twin Thing ID", - "type": "string" - }, - "timestamp": { - "title": "Updated time", - "description": "Time the value was last updated", - "type": "string" - } - } - } - }, - "events": {}, - "actions": { - "queryAction": { - "@type": "hiveot:function", - "title": "Action status", - "description": "Read the current action status of a Thing", - "safe": true, - "idempotent": true, - "input": { - "type": "object", - "required": ["thingID", "name"], - "properties": { - "thingID": { - "title": "Thing ID", - "description": "ID of the Thing to query", - "type": "string" - }, - "name": { - "title": "Action Name", - "description": "The action name to query", - "type": "string" - } - } - }, - "output": { - "title": "Action value", - "description": "The latest action value", - "comment": "defined in schemadefinitions", - "type": "object", - "schema": "ActionStatus" - } - }, - - "queryAllActions": { - "@type": "hiveot:function", - "title": "Action statuses", - "description": "Read all action statuses of a Thing", - "safe": true, - "idempotent": true, - "input": { - "type": "string", - "title": "Thing ID", - "description": "ID of the Thing to query" - }, - "output": { - "title": "Action value", - "description": "Map of ActionStatus objects by action name", - "type": "object", - "properties": { - "": { - "type": "object", - "title": "Action Status", - "schema": "ActionStatus" - } - } - } - }, - - "readAllEvents": { - "@type": "hiveot:function", - "title": "Read all event values", - "description": "Read the latest known event values of a Thing", - "safe": true, - "idempotent": true, - "input": { - "title": "Thing ID", - "description": "ID of the Thing to read", - "type": "string" - }, - "output": { - "title": "Event values", - "description": "Map with the most recent event ThingValue objects", - "type": "object", - "properties": { - "": { - "title": "Event Value", - "type": "object", - "schema": "ThingValue", - "comment": "defined in schemadefinitions" - } - } - }, - "forms": [{ "href": "/{operation}" }] - }, - - "readAllProperties": { - "@type": "hiveot:function", - "title": "Read all properties", - "description": "Read the latest known property values", - "safe": true, - "idempotent": true, - "input": { - "title": "Thing ID", - "description": "Digital twin ID of the Thing to read", - "type": "string" - }, - "output": { - "title": "Property values", - "description": "Map of event name:ThingValue objects", - "type": "object", - "properties": { - "": { - "title": "Property Value", - "type": "object", - "schema": "ThingValue", - "comment": "defined in schemadefinitions" - } - } - }, - "forms": [{ "href": "/{operation}" }] - }, - - "readEvent": { - "@type": "hiveot:function", - "title": "Read event value", - "description": "Read the latest event value of a Thing", - "safe": true, - "idempotent": true, - "input": { - "type": "object", - "required": ["thingID", "name"], - "properties": { - "thingID": { - "title": "Thing ID", - "description": "ID of the Thing to read", - "type": "string" - }, - "name": { - "title": "Event name", - "description": "Name of the event to read the latest values", - "type": "string" - } - } - }, - "output": { - "title": "Event value", - "description": "Most recent event value", - "comment": "defined in schemadefinitions", - "type": "object", - "schema": "ThingValue" - }, - "forms": [{ "href": "/{operation}" }] - }, - - "readProperty": { - "@type": "hiveot:function", - "title": "Read property value", - "description": "Read the latest property value", - "safe": true, - "idempotent": true, - "input": { - "type": "object", - "required": ["thingID", "name"], - "properties": { - "thingID": { - "title": "Thing ID", - "description": "ID of the Thing to read", - "type": "string" - }, - "name": { - "title": "Property name", - "description": "The property name whose value to read", - "type": "string" - } - } - }, - "output": { - "title": "Property value", - "description": "Most recent property value", - "comment": "defined in schemadefinitions", - "type": "object", - "schema": "ThingValue" - }, - "forms": [{ "href": "/{operation}" }] - } - } -} diff --git a/runtime/runtime.go b/runtime/runtime.go new file mode 100644 index 00000000..628d9990 --- /dev/null +++ b/runtime/runtime.go @@ -0,0 +1,362 @@ +package runtime + +import ( + "github.com/hiveot/hivekit/go/modules" + "github.com/hiveot/hivekit/go/modules/certs" + certspkg "github.com/hiveot/hivekit/go/modules/certs/pkg" + digitwin "github.com/hiveot/hivekit/go/modules/digitwin" + digitwinpkg "github.com/hiveot/hivekit/go/modules/digitwin/pkg" + "github.com/hiveot/hivekit/go/modules/directory" + "github.com/hiveot/hivekit/go/modules/factory" + factorypkg "github.com/hiveot/hivekit/go/modules/factory/pkg" + router "github.com/hiveot/hivekit/go/modules/router" + routerpkg "github.com/hiveot/hivekit/go/modules/router/pkg" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/modules/transport/addforms" + addformspkg "github.com/hiveot/hivekit/go/modules/transport/addforms/pkg" + "github.com/hiveot/hivekit/go/modules/transport/discovery" + discoverypkg "github.com/hiveot/hivekit/go/modules/transport/discovery/pkg" + tlsserverpkg "github.com/hiveot/hivekit/go/modules/transport/tlsserver/pkg" + "github.com/hiveot/hivekit/go/modules/transport/wss" + wsspkg "github.com/hiveot/hivekit/go/modules/transport/wss/pkg" +) + +// Recipe for a simple device that provides a standalone server for a counter module. +var recipe = factorypkg.FactoryRecipe{ + // modules used in the recipe in alpha order + ModuleDefs: map[string]factory.ModuleDefinition{ + // add forms to published TDs + addforms.AddFormsModuleType: { + Constructor: addformspkg.NewAddFormsServiceFactory, + }, + // create a self-signed CA and server cert if none exist + certs.InitFactoryCertsModuleType: { + Constructor: certspkg.NewInitFactoryCerts, + }, + // digitwin service + digitwin.DigitwinModuleType: { + Constructor: digitwinpkg.NewDigitwinServiceFactory, + }, + // discovery server for publishing the counter TD + discovery.DiscoveryServerModuleType: { + Constructor: discoverypkg.NewDiscoveryServerFactory, + }, + // router module for connecting to agents and devices + router.RouterModuleType: { + Constructor: routerpkg.NewRouterServiceFactory, + }, + // http server module is used by transports and discovery + transport.TLSServerModuleType: { + Constructor: tlsserverpkg.NewTLSServerFactory, + }, + // websockets is the main communication transport + wss.WotWebsocketServerModuleType: { + Constructor: wsspkg.NewWotWssServerFactory, + }, + }, + + // This chain defines the complete application + ModuleChain: []string{ + // 1. initialize the environment with certificates - run-once + certs.InitFactoryCertsModuleType, + // 2. run a websocket server (this loads the http transport) + wss.WotWebsocketServerModuleType, + // 3. add forms to the published TDs + addforms.AddFormsModuleType, + // 4. run discovery server to publish the directory + discovery.DiscoveryServerModuleType, + // 5. run directory server to collect and publish TDs + // * agents publish the TD of their devices to the hub + // * TDs can be manually uploaded to the directory import folder (todo) + // * the digitwin service hooks into the directory to convert device TDs to digital twins + directory.DirectoryModuleType, + // 6. The digitwin server module + // * TDs of devices written to the directory are converted to digital twins + // * Non-device TDs are not convert + // * Read requests for digital twins are handled by the digitwin vcache + // * Other requests for digital twins are converted and forwarded to the actual device + digitwin.DigitwinModuleType, + // ? the router forwards Thing requests to agents or devices + // * this uses the directory to determine where the Thing lives and how to connect to it + // * requests to Things served by agents are forwarded to the corresponding transport server + // * requests to network devices are connected to on demand + router.RouterModuleType, + }, +} + +// Runtime is the Hub runtime. This is the bare-bone core of the hub that operates the +// communication protocols with services for auth, inbox, outbox and directory. +type Runtime struct { + modules.HiveModuleBase + + // cfg *RuntimeConfig + env *factory.AppEnvironment + + // AuthnSvc *service.AuthnService + // AuthzSvc *service2.AuthzService + // AuthnAgent *service.AuthnAgent + // AuthzAgent *service2.AuthzAgent + // DigitwinSvc *service4.DigitwinService + // DigitwinRouter *router.DigitwinRouter + // TransportsMgr *transportmanager.TransportManager + + // // logging of request and response messages + // requestLogger *slog.Logger + // requestLogFile *os.File + + // // logging of notification messages + // notifLogger *slog.Logger + // notifLogFile *os.File + + // // logging of all other runtime messages + // runtimeLogger *slog.Logger + // runtimeLogFile *os.File +} + +// GetForm returns the form for an operation using a transport protocol binding +// These forms point to the use of a digital twin via the hub runtime. +// If the protocol is not found this returns a nil and might cause a panic +//func (r *Runtime) GetForm(op string, protocol string) (f *td.Form) { +// srv := r.TransportsMgr.GetServer(protocol) +// if srv != nil { +// return srv.GetForm(op, "", "") +// } +// return nil +//} + +// GetConnectURL returns the URL for connecting with the given protocol type. +// If the protocol is not available, the https fallback is returned. +// func (r *Runtime) GetConnectURL() string { +// return r.TransportsMgr.GetConnectURL() +// } + +// GetTD returns the TD with the given digitwin ID. +// This passes the request to the digitwin directory store. +// Intended for testing to get a TD. +// func (r *Runtime) GetTD(dThingID string) (td *td.TD) { +// tdJSON, err := r.DigitwinSvc.DirSvc.RetrieveThing("", dThingID) +// if err != nil { +// return nil +// } +// _ = jsoniter.UnmarshalFromString(tdJSON, &td) +// return td +// } + +func (r *Runtime) Start() error { + return nil +} + +// Start the Hub runtime. +// This starts the runtime authn, authz, digitwin and transport services. +// func (r *Runtime) Start(env *plugin.AppEnvironment) error { +// slog.Warn("Starting HiveOT runtime") +// err := r.cfg.Setup(env) +// if err != nil { +// return err +// } +// // setup logging sinks +// if r.cfg.RuntimeLog != "" { +// runtimeLogfileName := path.Join(env.LogsDir, r.cfg.RuntimeLog) +// utils.SetLogging(env.LogLevel, runtimeLogfileName) +// } + +// // startup + +// // 1: setup the Authentication service +// // This creates an authenticator for authenticating connections and adding security scheme to TD. +// // The transport manager calls SetAuthServerURI() to set the authentication endpoint for including in the TD. +// r.AuthnSvc, err = service.StartAuthnService(&r.cfg.Authn) +// if err != nil { +// return err +// } +// r.AuthnAgent = service.StartAuthnAgent(r.AuthnSvc) + +// // 2: start authorization service and add its account +// r.AuthzSvc, err = service2.StartAuthzService(&r.cfg.Authz, r.AuthnSvc.AuthnStore) +// if err != nil { +// return err +// } +// // provide admin access to the authz agent +// prof := authn.ClientProfile{ +// ClientID: authz.AdminAgentID, +// ClientType: authn.ClientTypeService, +// } +// _ = r.AuthnSvc.AuthnStore.Add(authz.AdminAgentID, prof) +// _ = r.AuthnSvc.AuthnStore.SetRole(authz.AdminAgentID, string(authz.ClientRoleService)) +// r.AuthzAgent, err = service2.StartAuthzAgent(r.AuthzSvc) +// if err != nil { +// return err +// } + +// // 3: The digitwin service provides a directory for digital twin Things and notifies +// // of changes to digital twin state. +// prof = authn.ClientProfile{ +// ClientID: digitwin.ThingDirectoryAgentID, +// ClientType: authn.ClientTypeService, +// } +// _ = r.AuthnSvc.AuthnStore.Add(digitwin.ThingDirectoryAgentID, prof) +// _ = r.AuthnSvc.AuthnStore.SetRole(digitwin.ThingDirectoryAgentID, string(authz.ClientRoleService)) +// r.DigitwinSvc, _, err = service4.StartDigitwinService( +// env.StoresDir, r.SendNotification, r.cfg.ProtocolsConfig.IncludeForms) +// if err != nil { +// return err +// } +// dtwAgent := service4.NewDigitwinAgent(r.DigitwinSvc) + +// // 4: The digitwin router receives all incoming requests, responses and notifications +// // from agents and consumers. +// // +// // Digital twin requests are forward to the digital twin service. +// // Authentication requests are forwarded to the authn service +// // Authorization requests are forwarded to the authz service +// // +// // Actions for remote Things are forwarded to the Things using the transport +// // manager, if set. The transport manager can be set with 'SetTransportServer'. +// // +// // Internal services are authn,authz and the digital twin directory and value service. +// r.DigitwinRouter = router.NewDigitwinRouter( +// r.DigitwinSvc, +// dtwAgent.HandleRequest, +// r.AuthnAgent.HandleRequest, +// r.AuthzAgent.HandleAction, +// r.AuthzAgent.HasPermission, +// nil) + +// // 5: Create the transports but do not start yet. +// // +// // The authenticator provided by the authn service (authnSvc.SessionAuth) is used to +// // both authenticate connections and to add the securityScheme to TDs in TransportManager.AddTDForms. +// // +// // Note if you're trying to find out where the digital twin TDs get their forms and +// // security fields then the answer is in TransportManager.AddTDForms. +// // On startup the TransportManager is given the AuthnSvc.SessionAuth authenticator and +// // it updates it with the login URI. In AddTDForms it uses the authenticator to set the +// // security scheme and uses each of the protocols to set the forms for affordances. +// // Look for AddTDForms in each protocol. This is a rather roundabout way of getting +// // the security scheme in the TD but necessary as it is pluggable. +// // See AuthnSvc above +// r.TransportsMgr = transportmanager.NewTransportManager( +// &r.cfg.ProtocolsConfig, +// r.cfg.ServerCert, +// r.cfg.CaCert, +// r.AuthnSvc.SessionAuth, +// r.DigitwinRouter.HandleNotification, +// r.DigitwinRouter.HandleRequest, +// r.DigitwinRouter.HandleResponse, +// ) +// // the router needs the transport to forward requests and send responses +// r.DigitwinRouter.SetTransportServer(r.TransportsMgr) + +// // generated digitwin TDs include forms produced by transport protocols +// r.DigitwinSvc.SetFormsHook(r.TransportsMgr.AddTDForms) + +// // 6. Setup logging of requests and notifications +// if r.cfg.RequestLog != "" { +// requestLogfileName := path.Join(env.LogsDir, r.cfg.RequestLog) +// r.requestLogger, r.requestLogFile = logging.NewFileLogger( +// requestLogfileName, r.cfg.LogfileInJson) +// r.DigitwinRouter.SetRequestLogger(r.requestLogger) +// } +// if r.cfg.NotifLog != "" { +// notifLogfileName := path.Join(env.LogsDir, r.cfg.NotifLog) +// r.notifLogger, r.notifLogFile = logging.NewFileLogger( +// notifLogfileName, r.cfg.LogfileInJson) +// r.DigitwinRouter.SetNotifLogger(r.notifLogger) +// } +// if err != nil { +// return err +// } + +// // 7: Setup the directory with TDs and permissions for agents and consumers +// // Add the TDs of the built-in services (authn,authz,directory,values) to the directory +// _ = r.DigitwinSvc.DirSvc.UpdateThing(authn.AdminAgentID, authn.AdminTD) +// _ = r.DigitwinSvc.DirSvc.UpdateThing(authn.UserAgentID, authn.UserTD) +// _ = r.DigitwinSvc.DirSvc.UpdateThing(authz.AdminAgentID, authz.AdminTD) +// // The digital twin of the directoryTD will be used by discovery (below) to publish the .well-know/wot +// _ = r.DigitwinSvc.DirSvc.UpdateThing(digitwin.ThingDirectoryAgentID, digitwin.ThingDirectoryTD) +// _ = r.DigitwinSvc.DirSvc.UpdateThing(digitwin.ThingValuesAgentID, digitwin.ThingValuesTD) + +// // set agent permissions to update the directory +// // agents can update the directory with their TDs +// err = r.AuthzSvc.SetPermissions(digitwin.ThingDirectoryAgentID, authz.ThingPermissions{ +// AgentID: digitwin.ThingDirectoryAgentID, +// ThingID: digitwin.ThingDirectoryServiceID, +// Allow: []authz.ClientRole{authz.ClientRoleAgent}, +// }) +// if err != nil { +// slog.Error("failed SetPermissions. Continuing...", "err", err.Error()) +// } +// // anyone else can read the directory, except those with no role +// err = r.AuthzSvc.SetPermissions(digitwin.ThingDirectoryAgentID, authz.ThingPermissions{ +// AgentID: digitwin.ThingDirectoryAgentID, +// ThingID: digitwin.ThingDirectoryServiceID, +// Deny: []authz.ClientRole{authz.ClientRoleNone}, +// }) +// if err != nil { +// slog.Error("failed SetPermissions. Continuing...", "err", err.Error()) +// } +// // with the router in place activate the transport server to receive and send messages +// err = r.TransportsMgr.Start() +// if err != nil { +// return err +// } + +// // 8: last, start discovery and exploration of the digital twin directory +// if r.cfg.ProtocolsConfig.EnableDiscovery { +// // the directory TD was added above (r.DigitwinSvc.DirSvc.UpdateThing) +// dirTDJson, err := r.DigitwinSvc.DirSvc.RetrieveThing(digitwin.ThingDirectoryAgentID, digitwin.ThingDirectoryDThingID) +// if err == nil { +// protocolsCfg := r.cfg.ProtocolsConfig +// err = r.TransportsMgr.StartDiscovery( +// r.cfg.ProtocolsConfig.DiscoveryInstanceName, protocolsCfg.DirectoryTDPath, dirTDJson, +// ) +// } +// if err != nil { +// slog.Error("failed starting discovery. Continuing anyways...", "err", err.Error()) +// } +// } +// return err +// } + +// // SendNotification sends an event or property response message to subscribers. +// // This simply forwards the notification to the transport manager. +// func (r *Runtime) SendNotification(notif *msg.NotificationMessage) { +// r.TransportsMgr.SendNotification(notif) +// } + +// func (r *Runtime) Stop() { +// slog.Warn("Stopping the HiveOT runtime") +// // wait a little to allow ongoing connection closure to complete +// time.Sleep(time.Millisecond * 10) +// //nrConnections, _ := r.CM.GetNrConnections() + +// if r.AuthnSvc != nil { +// r.AuthnSvc.Stop() +// } +// if r.AuthzSvc != nil { +// r.AuthzSvc.Stop() +// } +// if r.DigitwinSvc != nil { +// r.DigitwinSvc.Stop() +// } +// if r.TransportsMgr != nil { +// r.TransportsMgr.Stop() +// } +// if r.notifLogFile != nil { +// _ = r.notifLogFile.Close() +// r.notifLogFile = nil +// } +// if r.requestLogFile != nil { +// _ = r.requestLogFile.Close() +// r.requestLogFile = nil +// } +// if r.runtimeLogFile != nil { +// _ = r.runtimeLogFile.Close() +// r.runtimeLogFile = nil +// } +// } + +func NewRuntime(env *factory.AppEnvironment) *Runtime { + r := &Runtime{env: env} + return r +} diff --git a/services/certs/Certs_test.go b/services/certs/Certs_test.go deleted file mode 100644 index 8503161c..00000000 --- a/services/certs/Certs_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package certs_test - -import ( - "crypto/x509" - "os" - "testing" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/testenv" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/services/certs/certsapi" - "github.com/hiveot/hub/services/certs/certsclient" - "github.com/hiveot/hub/services/certs/service/selfsigned" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ts *testenv.TestServer - -const agentUsesWSS = true - -// Factory for creating service instance. Currently the only implementation is selfsigned. -func startService() (cl *certsclient.CertsClient, stopFunc func()) { - ts = testenv.StartTestServer(true) - - // the service needs a server connection - // FIXME: certs service need to register their service - hc1, token1 := ts.AddConnectService(certsapi.CertsAdminAgentID) - _ = token1 - - //storeDir := path.Join(ts.TestDir, "test-certs") - - svc := selfsigned.NewSelfSignedCertsService(ts.Certs.CaCert, ts.Certs.CaKey) - err := svc.Start(hc1) - if err != nil { - panic(err) - } - - //--- connect the certs client as admin - co1, _, _ := ts.AddConnectConsumer("admin1", authz.ClientRoleAdmin) - certAdmin := certsclient.NewCertsClient(co1) - - return certAdmin, func() { - co1.Disconnect() - hc1.Disconnect() - svc.Stop() - ts.Stop() - } -} - -// TestMain clears the certs folder for clean testing -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - - res := m.Run() - if res == 0 { - } - os.Exit(res) -} - -func TestCreateDeviceCert(t *testing.T) { - t.Log("--- TestCreateDeviceCert ---") - agentID := "agent1" - agThingID := "thing1" - - certAdmin, stopFunc := startService() - defer stopFunc() - - k := keys.NewKey(keys.KeyTypeECDSA) - pubKeyPEM := k.ExportPublic() - - // a TD is needed first - td1 := td.NewTD(agThingID, "Title", vocab.DeviceSensorMulti) - ts.AddTD(agentID, td1) - - deviceCertPEM, caCertPEM, err := certAdmin.CreateDeviceCert( - agentID, pubKeyPEM, 1) - require.NoError(t, err) - - deviceCert, err := certs.X509CertFromPEM(deviceCertPEM) - require.NoError(t, err) - require.NotNil(t, deviceCert) - caCert2, err := certs.X509CertFromPEM(caCertPEM) - require.NoError(t, err) - require.NotNil(t, caCert2) - - // verify certificate - err = certAdmin.VerifyCert(agentID, deviceCertPEM) - assert.NoError(t, err) - err = certAdmin.VerifyCert("notanid", deviceCertPEM) - assert.Error(t, err) - - // verify certificate against CA - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert2) - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - _, err = deviceCert.Verify(opts) - assert.NoError(t, err) - t.Log("--- TestCreateDeviceCert ended ---") -} - -// test device cert with bad parameters -func TestDeviceCertBadParms(t *testing.T) { - t.Log("--- TestDeviceCertBadParms ---") - agentID := "agent1" - agThingID := "thing1" - - // test creating hub certificate - certAdmin, stopFunc := startService() - defer stopFunc() - // a TD is needed first - td1 := td.NewTD(agThingID, "Title", vocab.DeviceSensorMulti) - ts.AddTD(agentID, td1) - - k := keys.NewKey(keys.KeyTypeECDSA) - pubKeyPEM := k.ExportPublic() - - // missing device ID - certPEM, _, err := certAdmin.CreateDeviceCert("", pubKeyPEM, 0) - require.Error(t, err) - assert.Empty(t, certPEM) - - // missing public key - certPEM, _, err = certAdmin.CreateDeviceCert(agentID, "", 1) - require.Error(t, err) - assert.Empty(t, certPEM) - t.Log("--- TestDeviceCertBadParms ended ---") - -} - -func TestCreateServiceCert(t *testing.T) { - t.Log("--- TestCreateServiceCert ---") - // test creating hub certificate - const serviceID = "testService" - names := []string{"127.0.0.1", "localhost"} - - certAdmin, stopFunc := startService() - defer stopFunc() - k := keys.NewKey(keys.KeyTypeECDSA) - pubKeyPEM := k.ExportPublic() - - serviceCertPEM, caCertPEM, err := certAdmin.CreateServiceCert( - serviceID, pubKeyPEM, names, 0) - require.NoError(t, err) - serviceCert, err := certs.X509CertFromPEM(serviceCertPEM) - require.NoError(t, err) - caCert2, err := certs.X509CertFromPEM(caCertPEM) - require.NoError(t, err) - - // verify service certificate against CA - err = certAdmin.VerifyCert(serviceID, serviceCertPEM) - assert.NoError(t, err) - - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert2) - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - } - _, err = serviceCert.Verify(opts) - assert.NoError(t, err) - t.Log("--- TestCreateServiceCert ended ---") -} - -// test with bad parameters -func TestServiceCertBadParms(t *testing.T) { - t.Log("--- TestServiceCertBadParms ---") - const serviceID = "testService" - hostnames := []string{"127.0.0.1"} - - certAdmin, stopFunc := startService() - defer stopFunc() - - caCert, caKey, _ := certs.CreateCA("Test CA", 1) - k := keys.NewKey(keys.KeyTypeECDSA) - pubKeyPEM := k.ExportPublic() - - // Bad CA certificate - badCa := x509.Certificate{} - assert.Panics(t, func() { - selfsigned.NewSelfSignedCertsService(&badCa, caKey) - }) - - // missing CA private key - assert.Panics(t, func() { - selfsigned.NewSelfSignedCertsService(caCert, nil) - }) - - // missing service ID - //certAdmin := selfsigned.NewSelfSignedCertsService(caCert, caKey, nil) - - serviceCertPEM, _, err := certAdmin.CreateServiceCert( - "", pubKeyPEM, hostnames, 1) - - require.Error(t, err) - require.Empty(t, serviceCertPEM) - - // missing public key - serviceCertPEM, _, err = certAdmin.CreateServiceCert( - serviceID, "", hostnames, 1) - require.Error(t, err) - require.Empty(t, serviceCertPEM) - t.Log("--- TestServiceCertBadParms ended ---") - -} - -func TestCreateUserCert(t *testing.T) { - t.Log("--- TestCreateUserCert ---") - userID := "bob" - // test creating hub certificate - cl, cancelFunc := startService() - defer cancelFunc() - k := keys.NewKey(keys.KeyTypeECDSA) - pubKeyPEM := k.ExportPublic() - - userCertPEM, caCertPEM, err := cl.CreateUserCert(userID, pubKeyPEM, 0) - require.NoError(t, err) - - userCert, err := certs.X509CertFromPEM(userCertPEM) - require.NoError(t, err) - require.NotNil(t, userCert) - caCert2, err := certs.X509CertFromPEM(caCertPEM) - require.NoError(t, err) - require.NotNil(t, caCert2) - - // verify service certificate against CA - err = cl.VerifyCert(userID, userCertPEM) - assert.NoError(t, err) - - // verify client certificate against CA - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert2) - opts := x509.VerifyOptions{ - Roots: caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - _, err = userCert.Verify(opts) - assert.NoError(t, err) - t.Log("--- TestCreateUserCert ended ---") -} diff --git a/services/certs/README.md b/services/certs/README.md deleted file mode 100644 index e5066629..00000000 --- a/services/certs/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# Certs Service - -## Objective - -Manage certificates for authentication of hub clients. - -## Status - -This service is reworked to work with the digital twin runtime. It is functional. - -Future: - -- Consider defining and publishing a TD for this service. -- Support for lets encrypt to obtain client certificates - -## Summary - -This service manages authentication certificates for use by services, IoT devices and end-users on the local network: - -1. A self-signed Hub CA certificate for generating signed certificates. This is usually done by the administrator using the CLI or the admin web console if such is included. -2. Hub service certificates for each Hub service -3. IoT device client certificates for each IoT device. -4. User client certificates for end-users - -This service currently uses self-signed certificates and has no external dependencies. Support for lets-encrypt and 3rd party certificate import are possible for the future. - -## Certificate Revocation - -When certificates are renewed the old certificate must no longer be used. Certificate revocation checks are currently not implemented but might be added in the future. - -## Hub CA Certificate - -By default, the Hub utilizes a self-signed CA certificate, created during first install. Use of a 3rd party generated signing certificate is typically not necessary as the Hub is only using these certificates on the local network to identify local IoT devices and users. - -The CA certificate has a default 2 year validity period. A new CA certificate is generated every year using the same public/private key pair. The new CA certificate then has a year to be automatically distributed to the IoT devices when they renew their own certificate. - -It is recommended to import the CA certificate in the browser to avoid unnecessary warnings when accessing the Hub. The alternative is to accept the browser warning. - -## Hub Service Certificates - -Hub services can request a new certificate for use with TCP connections. This certificate is signed by the CA. It is intended for authentication of the service to its clients and as a client to authenticate to other services. - -Service certificates are only used with TLS connections over TCP. UDS connections do not use TLS. UDS connections are secured through their socket file permissions. MiM attacks are not possible without root access to the device, in which case all security has already been broken. - -A service certificate is an organization certificate and not a domain certificate. They are only intended to be used on the local network (domain is local). The certificate CN contains the service ID, its OU identifies it as a Hub service and SAN names contain both the hostname and the ip address of the device the service is running on. - -Service certificates have a short validity period. They are renewed by the service on each service restart. - -It is recommended to NOT expose any service to the internet unless a proxy server is used. The proxy server can use a certificate from Lets Encrypt. - -Proposal: bridge services connect to each other over the internet. They are effectively a proxy between Hubs and utilize a - preferably Lets Encrypt - certificate. Bridge services use a manual adoption process where the administrator adds the remote bridge address and accept or reject the offered certificate. Certificate renewal will re-use the keys used to generate the certificate and does not require redistribution. - -## IoT Device Certificates - -IoT devices that register themselves with the Hub to publish 'Things' receive a client certificate upon registration. This certificate is unique to the device and signed by the CA. - -The provisioning service can be used to automatically provision IoT devices using out-of-band secrets. The administrator can upload a batch of device ID's and corresponding secrets. The device can request a certificate by providing its ID and secret. If the secret is recognized, the certificate is issued. - -Provisioning secrets are optional. Requests without a secret will be stored and can be manually approved by the administrator. - -The default device certificates has a validity period of 30 days, before which they can be automatically renewed using the provisioning service. Certificates can also be installed manually onto devices that support it. In this case any validity period can be used when generating the certificate. - -The certificate CN contains the device ID, while its OU identifies it as an IoT Device. The device ID is its unique identifier for the local network. - -IoT device certificates are used by the Hub to authenticate the device. This allows the device to publish the TD documents of its 'Things', publish events and receive actions. Security only allows for a device to publish/subscribe for Things of which it is the publisher. - -## User Authentication Using Certificates - -Users can be issued a client certificate so they don't have to login using the login name and password. The certificate CN contains the user login name while the OU identifies it as a User. - -Consumer client certificates have a validity of 1 year, before they must be renewed. - -The Hub CLI provide a command to create user certificates manually. See the CLI for more information. - -## Build and Installation - -This package is built as part of the Hub. - -See [hub's README.md](https://github.com/hiveot/hub/blob/main/README.md) for more details on building and installation. - -### Configuration - -When launched from the installation folder this service works out of the box without requiring configuration. - -The service has its configuration in the application 'config' directory. This is either the './config' subdirectory of the application installation directory, or the /etc/hiveot/config directory as described in the hub README.md - -The following files are used: - -- config/certs.yaml - Service configuration -- certs/caCert.pem - CA public certificate in PEM format -- certs/caKey.pem - CA private key. Read-only for hub process only. - -Issued service certificates are stored: - -- certs/Cert.pem - Issued service public certificate in PEM format -- certs/Key.pem - Issued service public/private key in PEM format - -IoT device certificates and user certificates are not stored on the Hub. - -## Usage - -This service is intended to be started by the launcher. For testing purposes a manual startup is also possible. In this case the configuration file can be specified using the -c commandline option. - -The Hub CLI provides a commandline interface for managing certificates. For more information see: - -> hubcli certs -h - -The service API is defined with capnproto IDL at: - -> github.com/hiveot/hub/api/capnp/hubapi/certs.capnp - -A golang interface at: - -> github.com/hiveot/hub/pkg/certs/ICerts - -### Golang Clients - -An easy to use golang POGS (plain old Golang Struct) client can be found at: - -> github.com/hiveot/hub/pkg/certs/capnpclient - -To use this client, first obtain the capability (see below) and create the POGS instance. For example, to create a device certificate the provisioning service does this: - -``` - deviceCap := GetCapability() // from an authorized source - deviceCert := NewDeviceCertsCapnpClient(deviceCap) // returns IDeviceCerts - certPEM, caCertPEM, err := deviceCert.CreateDeviceCert(ctx, deviceID, pubKeyPEM, validityDays) -``` - -where GetCapability() provides the authorized capability, and NewDeviceCertsCapnpClient provides an the POGS implementation with the IDeviceCerts interface. - -To obtain the capability to use the ICerts apis, proper authentication and authorization must be obtained first. In this case, the certs package is intended for internal use by the CLI and select services such as the provisioning service. These leverage a bootstrap mechanism such as the socket the service is listening on. diff --git a/services/certs/certsapi/AdminCertsAPI.go b/services/certs/certsapi/AdminCertsAPI.go deleted file mode 100644 index c7326d27..00000000 --- a/services/certs/certsapi/AdminCertsAPI.go +++ /dev/null @@ -1,53 +0,0 @@ -package certsapi - -// CertsAdminAgentID is the connect ID of the agent connecting to the Hub -const CertsAdminAgentID = "certs" - -// CertsAdminThingID is the ID of the service exposed by the agent -const CertsAdminThingID = "admin" - -// Management methods -const ( - CreateDeviceCertMethod = "createDeviceCert" - CreateServiceCertMethod = "createServiceCert" - CreateUserCertMethod = "createUserCert" -) - -// DefaultServiceCertValidityDays with validity of generated service certificates -const DefaultServiceCertValidityDays = 100 - -// DefaultUserCertValidityDays with validity of generated client certificates -const DefaultUserCertValidityDays = 100 - -// DefaultDeviceCertValidityDays with validity of generated device certificates -const DefaultDeviceCertValidityDays = 100 - -type CreateDeviceCertArgs struct { - DeviceID string `json:"deviceID"` - PubKeyPEM string `json:"pubKeyPEM"` - ValidityDays int `json:"validityDays"` -} -type CreateCertResp struct { - CertPEM string - CaCertPEM string -} - -type CreateServiceCertArgs struct { - ServiceID string `json:"serviceID"` - PubKeyPEM string `json:"pubKeyPEM"` - Names []string `json:"names"` - ValidityDays int `json:"validityDays"` -} - -type CreateUserCertArgs struct { - UserID string `json:"userID"` - PubKeyPEM string `json:"pubKeyPEM"` - ValidityDays int `json:"validityDays"` -} - -const VerifyCertMethod = "verifyCert" - -type VerifyCertArgs struct { - ClientID string `json:"clientID"` - CertPEM string `json:"certPEM"` -} diff --git a/services/certs/certsclient/ManageCertsClient.go b/services/certs/certsclient/ManageCertsClient.go deleted file mode 100644 index 4dac7f3b..00000000 --- a/services/certs/certsclient/ManageCertsClient.go +++ /dev/null @@ -1,106 +0,0 @@ -package certsclient - -import ( - "github.com/hiveot/hub/lib/consumer" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/certs/certsapi" -) - -// CertsClient is a marshaller for cert service messages using a provided hub connection. -// This uses the default serializer to marshal and unmarshal messages. -type CertsClient struct { - // dThingID digital twin service ID of the certificate management - dThingID string - // Connection to the hub - co *consumer.Consumer -} - -//// helper for publishing a rpc request to the certs service -//func (cl *CertsClient) pubReq(action string, req interface{}, resp interface{}) error { -// var msg []byte -// if req != nil { -// msg, _ = jsoniter.Marshal(req) -// } -// -// data, err := cl.hc.PubRPCRequest( -// cl.serviceID, certs.ManageCertsCapability, action, msg) -// if err != nil { -// return err -// } -// if data.ErrorReply != nil { -// return data.ErrorReply -// } -// err = cl.hc.ParseResponse(data.Payload, resp) -// return err -//} - -// CreateDeviceCert generates or renews IoT device certificate for access hub IoT gateway -func (cl *CertsClient) CreateDeviceCert(deviceID string, pubKeyPEM string, validityDays int) ( - certPEM string, caCertPEM string, err error) { - - req := certsapi.CreateDeviceCertArgs{ - DeviceID: deviceID, - PubKeyPEM: pubKeyPEM, - ValidityDays: validityDays, - } - resp := certsapi.CreateCertResp{} - err = cl.co.InvokeAction(cl.dThingID, certsapi.CreateDeviceCertMethod, req, &resp) - return resp.CertPEM, resp.CaCertPEM, err -} - -// CreateServiceCert generates or renews service certificates for access hub IoT gateway -func (cl *CertsClient) CreateServiceCert( - serviceID string, pubKeyPEM string, names []string, validityDays int) ( - certPEM string, caCertPEM string, err error) { - - req := certsapi.CreateServiceCertArgs{ - ServiceID: serviceID, - PubKeyPEM: pubKeyPEM, - Names: names, - ValidityDays: validityDays, - } - resp := certsapi.CreateCertResp{} - err = cl.co.InvokeAction(cl.dThingID, certsapi.CreateServiceCertMethod, req, &resp) - - return resp.CertPEM, resp.CaCertPEM, err -} - -// CreateUserCert generates or renews user certificates for access hiveot hub -func (cl *CertsClient) CreateUserCert( - userID string, pubKeyPEM string, validityDays int) ( - certPEM string, caCertPEM string, err error) { - - req := certsapi.CreateUserCertArgs{ - UserID: userID, - PubKeyPEM: pubKeyPEM, - ValidityDays: validityDays, - } - resp := certsapi.CreateCertResp{} - err = cl.co.InvokeAction(cl.dThingID, certsapi.CreateUserCertMethod, req, &resp) - return resp.CertPEM, resp.CaCertPEM, err -} - -// VerifyCert verifies if the certificate is valid for the Hub -func (cl *CertsClient) VerifyCert( - clientID string, certPEM string) (err error) { - - req := certsapi.VerifyCertArgs{ - ClientID: clientID, - CertPEM: certPEM, - } - err = cl.co.InvokeAction(cl.dThingID, certsapi.VerifyCertMethod, req, nil) - return err -} - -// NewCertsClient returns a certs service client for managing certificates -// -// hc is the hub client connection to use -func NewCertsClient(co *consumer.Consumer) *CertsClient { - agentID := certsapi.CertsAdminAgentID - - cl := CertsClient{ - co: co, - dThingID: digitwin.MakeDigitwinID(agentID, certsapi.CertsAdminThingID), - } - return &cl -} diff --git a/services/certs/cmd/main.go b/services/certs/cmd/main.go deleted file mode 100644 index f50a0503..00000000 --- a/services/certs/cmd/main.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "crypto/x509" - "log/slog" - "os" - "path" - - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/services/certs/service/selfsigned" -) - -// Connect the certs service -// -// commandline options: -// --certs -func main() { - var caCert *x509.Certificate - var err error - - env := plugin.GetAppEnvironment("", true) - logging.SetLogging(env.LogLevel, "") - slog.Warn("Starting certs service", "clientID", env.ClientID, "loglevel", env.LogLevel) - - // This service needs the CA certificate and key to operate - caCertPath := path.Join(env.CertsDir, certs.DefaultCaCertFile) - caKeyPath := path.Join(env.CertsDir, certs.DefaultCaKeyFile) - - slog.Info("Loading CA certificate and key", "dir", env.CertsDir) - caCert, err = certs.LoadX509CertFromPEM(caCertPath) - if err != nil { - slog.Error("Failed loading CA certificate", - "caCertPath", caCertPath, "err", err) - os.Exit(1) - } - caKey, err := keys.NewKeyFromFile(caKeyPath) - if err != nil { - slog.Error("Error loading CA key", - "caKeyPath", caKeyPath, "err", err) - os.Exit(1) - } - - svc := selfsigned.NewSelfSignedCertsService(caCert, caKey) - - plugin.StartPlugin(svc, env.ClientID, env.CertsDir, env.ServerURL) -} diff --git a/services/certs/service/letsencrypt/README.md b/services/certs/service/letsencrypt/README.md deleted file mode 100644 index 381d7352..00000000 --- a/services/certs/service/letsencrypt/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# LetsEncrypt - -placeholder for future certificate service using Lets encrypt instead of a self signed certificate. - -Intended for use by Hubs that are internet accessible. diff --git a/services/certs/service/selfsigned/CertsAdminService.go b/services/certs/service/selfsigned/CertsAdminService.go deleted file mode 100644 index c5fba00c..00000000 --- a/services/certs/service/selfsigned/CertsAdminService.go +++ /dev/null @@ -1,272 +0,0 @@ -package selfsigned - -import ( - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "log/slog" - "math/big" - "net" - "time" - - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/keys" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/services/certs/certsapi" -) - -// SelfSignedCertsService creates certificates for use by services, devices and admin users. -// -// Note that this service does not support certificate revocation. -// -// * See also: https://www.imperialviolet.org/2014/04/19/revchecking.html -// -// Issued certificates are short-lived and must be renewed before they expire. -type SelfSignedCertsService struct { - caCert *x509.Certificate - caCertPEM string - caKey keys.IHiveKey - caCertPool *x509.CertPool -} - -// _createDeviceCert internal function to create a CA signed certificate for mutual authentication by IoT devices -func (svc *SelfSignedCertsService) _createDeviceCert( - deviceID string, pubKey keys.IHiveKey, validityDays int) ( - cert *x509.Certificate, err error) { - if validityDays == 0 { - validityDays = certsapi.DefaultDeviceCertValidityDays - } - - cert, err = createClientCert( - deviceID, - certs.OUIoTDevice, - pubKey, - svc.caCert, - svc.caKey, - validityDays) - - return cert, err -} - -// createServiceCert internal function to create a CA signed service certificate for mutual authentication between services -func (svc *SelfSignedCertsService) _createServiceCert( - serviceID string, pubKey keys.IHiveKey, names []string, validityDays int) ( - cert *x509.Certificate, err error) { - - if serviceID == "" || pubKey == nil || names == nil { - err := fmt.Errorf("missing argument serviceID, servicePubKey, or names") - slog.Error(err.Error()) - return nil, err - } - if validityDays == 0 { - validityDays = certsapi.DefaultServiceCertValidityDays - } - - // firefox complains if serial is the same as that of the CA. So generate a unique one based on timestamp. - serial := time.Now().Unix() - 3 - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{ - Country: []string{"CA"}, - Province: []string{"BC"}, - Locality: []string{"local"}, - Organization: []string{"HiveOT"}, - OrganizationalUnit: []string{certs.OUService}, - CommonName: serviceID, - }, - NotBefore: time.Now().Add(-time.Second), - NotAfter: time.Now().AddDate(0, 0, validityDays), - //NotBefore: time.Now(), - //NotAfter: time.Now().AddDate(0, 0, config.DefaultServiceCertDurationDays), - - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - //ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - IsCA: false, - MaxPathLenZero: true, - // BasicConstraintsValid: true, - // IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - IPAddresses: []net.IP{}, - } - // determine the hosts for this hub - for _, h := range names { - if ip := net.ParseIP(h); ip != nil { - template.IPAddresses = append(template.IPAddresses, ip) - } else { - template.DNSNames = append(template.DNSNames, h) - } - } - // Create the service private key - //certKey := certs.CreateECDSAKeys() - // and the certificate itself - certDer, err := x509.CreateCertificate(rand.Reader, template, - svc.caCert, pubKey.PublicKey(), svc.caKey.PrivateKey()) - if err == nil { - cert, err = x509.ParseCertificate(certDer) - } - - return cert, err -} - -// _createUserCert internal function to create a client certificate for end-users -func (svc *SelfSignedCertsService) _createUserCert(userID string, pubKey keys.IHiveKey, validityDays int) ( - cert *x509.Certificate, err error) { - if validityDays == 0 { - validityDays = certsapi.DefaultUserCertValidityDays - } - - cert, err = createClientCert( - userID, - certs.OUUser, - pubKey, - svc.caCert, - svc.caKey, - validityDays) - return cert, err -} - -// CreateDeviceCert creates a CA signed certificate for mutual authentication by IoT devices in PEM format -func (svc *SelfSignedCertsService) CreateDeviceCert( - args certsapi.CreateDeviceCertArgs) (certsapi.CreateCertResp, error) { - //deviceID string, pubKeyPEM string, durationDays int) ( - //certPEM string, caCertPEM string, err error) { - var cert *x509.Certificate - - slog.Info("CreateDeviceCert", "deviceID", args.DeviceID, "pubKey", args.PubKeyPEM) - k := keys.NewKey(keys.KeyTypeECDSA) - err := k.ImportPublic(args.PubKeyPEM) - if err != nil { - err = fmt.Errorf("public key for '%s' is invalid: %s", args.DeviceID, err) - } else { - cert, err = svc._createDeviceCert(args.DeviceID, k, args.ValidityDays) - } - resp := certsapi.CreateCertResp{} - if err == nil { - resp.CertPEM = certs.X509CertToPEM(cert) - resp.CaCertPEM = svc.caCertPEM - } - return resp, err -} - -// CreateServiceCert creates a CA signed service certificate for mutual authentication between services -func (svc *SelfSignedCertsService) CreateServiceCert( - args certsapi.CreateServiceCertArgs) (certsapi.CreateCertResp, error) { - var cert *x509.Certificate - - slog.Info("Creating service certificate", - "serviceID", args.ServiceID, "names", args.Names) - k := keys.NewKey(keys.KeyTypeECDSA) - err := k.ImportPublic(args.PubKeyPEM) - if err == nil { - cert, err = svc._createServiceCert(args.ServiceID, k, args.Names, args.ValidityDays) - } - resp := certsapi.CreateCertResp{} - if err == nil { - resp.CertPEM = certs.X509CertToPEM(cert) - resp.CaCertPEM = svc.caCertPEM - } - // TODO: publish an event when a new cert is created. Only admin/manager/service roles - // can subscribe to these. - return resp, err -} - -// CreateUserCert creates a client certificate for end-users -func (svc *SelfSignedCertsService) CreateUserCert( - args certsapi.CreateUserCertArgs) (certsapi.CreateCertResp, error) { - //userID string, pubKeyPEM string, validityDays int) ( - //certPEM string, caCertPEM string, err error) { - var cert *x509.Certificate - - slog.Info("CreateUserCert", - "userID", args.UserID, "pubKey", args.PubKeyPEM) - k := keys.NewKey(keys.KeyTypeECDSA) - err := k.ImportPublic(args.PubKeyPEM) - if err == nil { - - cert, err = svc._createUserCert(args.UserID, k, args.ValidityDays) - } - resp := certsapi.CreateCertResp{} - if err == nil { - resp.CertPEM = certs.X509CertToPEM(cert) - resp.CaCertPEM = svc.caCertPEM - } - return resp, err -} - -// Start the service and listen for requests -// -// hc is the connection to the hub with a service role. For testing it can be nil. -func (svc *SelfSignedCertsService) Start(ag *agent.Agent) (err error) { - slog.Info("Starting certs service", "serviceID", ag.GetClientID()) - - // permissions for using this service are for admin only - err = authz.UserSetPermissions(ag.Consumer, authz.ThingPermissions{ - AgentID: ag.GetClientID(), - ThingID: certsapi.CertsAdminThingID, - Allow: []authz.ClientRole{authz.ClientRoleAdmin}, - Deny: nil, - }) - - // FIXME: add the service TD with the actions - - StartCertsAgent(svc, ag) - return err -} - -// Stop the service and remove subscription -func (svc *SelfSignedCertsService) Stop() { - slog.Info("Stopping the certs service") -} - -// VerifyCert verifies whether the given certificate is a valid client certificate -func (svc *SelfSignedCertsService) VerifyCert(args certsapi.VerifyCertArgs) error { - - opts := x509.VerifyOptions{ - Roots: svc.caCertPool, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - cert, err := certs.X509CertFromPEM(args.CertPEM) - if err == nil { - if cert.Subject.CommonName != args.ClientID { - err = fmt.Errorf("client ID '%s' doesn't match certificate name '%s'", - args.ClientID, cert.Subject.CommonName) - } - } - //if err == nil { - // x509Cert, err := x509.ParseCertificate(clientCert.Certificate[0]) - //} - if err == nil { - _, err = cert.Verify(opts) - } - return err -} - -// NewSelfSignedCertsService returns a new instance of the selfsigned certificate service -// -// caCert is the CA certificate used to created certificates -// caKey is the CA private key used to created certificates -func NewSelfSignedCertsService( - caCert *x509.Certificate, - caKey keys.IHiveKey, -) *SelfSignedCertsService { - - caCertPool := x509.NewCertPool() - caCertPool.AddCert(caCert) - - // Use one service instance per capability. - // This does open the door to creating an instance per client session with embedded constraints, - // although this is not needed at the moment. - service := &SelfSignedCertsService{ - caCert: caCert, - caKey: caKey, - caCertPEM: certs.X509CertToPEM(caCert), - caCertPool: caCertPool, - } - if caCert == nil || caKey == nil || caCert.PublicKey == nil { - panic("Missing CA certificate or key") - } - - return service -} diff --git a/services/certs/service/selfsigned/CertsAgent.go b/services/certs/service/selfsigned/CertsAgent.go deleted file mode 100644 index 284c3ca5..00000000 --- a/services/certs/service/selfsigned/CertsAgent.go +++ /dev/null @@ -1,27 +0,0 @@ -package selfsigned - -import ( - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/hubagent" - "github.com/hiveot/hub/services/certs/certsapi" -) - -// StartCertsAgent returns a new instance of the agent for the certificate management service. -// This uses the given connected hubclient for publishing events and subscribing to actions. -// The client must be closed by the caller after use. -// -// svc is the service whose capabilities to expose -// hc is the hub client connected to the server protocol -func StartCertsAgent(svc *SelfSignedCertsService, ag *agent.Agent) *hubagent.AgentHandler { - - methods := map[string]interface{}{ - certsapi.CreateDeviceCertMethod: svc.CreateDeviceCert, - certsapi.CreateServiceCertMethod: svc.CreateServiceCert, - certsapi.CreateUserCertMethod: svc.CreateUserCert, - certsapi.VerifyCertMethod: svc.VerifyCert, - } - - ah := hubagent.NewAgentHandler(certsapi.CertsAdminThingID, methods) - ag.SetRequestHandler(ah.HandleRequest) - return ah -} diff --git a/services/certs/service/selfsigned/createClientCert.go b/services/certs/service/selfsigned/createClientCert.go deleted file mode 100644 index e6df2b0f..00000000 --- a/services/certs/service/selfsigned/createClientCert.go +++ /dev/null @@ -1,80 +0,0 @@ -package selfsigned - -import ( - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "fmt" - "log/slog" - "math/big" - "net" - "time" - - "github.com/hiveot/hub/lib/keys" -) - -// createClientCert is the internal function to create a client certificate -// for IoT devices, administrator -// -// The client ouRole is intended to for role based authorization. It is stored in the -// certificate OrganizationalUnit. See OUxxx -// -// This generates a TLS client certificate with keys -// -// clientID used as the CommonName, eg pluginID or deviceID -// ouRole with type of client: OUNone, OUAdmin, OUClient, OUIoTDevice -// ownerPubKey the public key of the certificate holder -// caCert CA's certificate for signing -// caPrivKey CA's ECDSA key for signing -// validityDays nr of days the certificate will be valid -// -// Returns the signed certificate with the corresponding CA used to sign, or an error -func createClientCert( - clientID string, ouRole string, ownerPubKey keys.IHiveKey, - caCert *x509.Certificate, caPrivKey keys.IHiveKey, validityDays int) ( - clientCert *x509.Certificate, err error) { - - var newCert *x509.Certificate - - if clientID == "" || ownerPubKey == nil { - err := fmt.Errorf("missing clientID or client public key") - slog.Error(err.Error()) - return nil, err - } - // firefox complains if serial is the same as that of the CA. So generate a unique one based on timestamp. - serial := time.Now().Unix() - 2 - template := &x509.Certificate{ - SerialNumber: big.NewInt(serial), - Subject: pkix.Name{ - Country: []string{"CA"}, - Province: []string{"BC"}, - Locality: []string{"local"}, - Organization: []string{"HiveOT"}, - OrganizationalUnit: []string{ouRole}, - CommonName: clientID, - Names: make([]pkix.AttributeTypeAndValue, 0), - }, - NotBefore: time.Now().Add(-time.Second), - NotAfter: time.Now().AddDate(0, 0, validityDays), - - //KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment | x509.KeyUsageKeyEncipherment, - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - - BasicConstraintsValid: true, - IsCA: false, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - } - // clientKey := certs.CreateECDSAKeys() - certDer, err := x509.CreateCertificate(rand.Reader, template, caCert, ownerPubKey.PublicKey(), caPrivKey.PrivateKey()) - if err == nil { - newCert, err = x509.ParseCertificate(certDer) - } - - // // combined them into a TLS certificate - // tlscert := &tls.Certificate{} - // tlscert.Certificate = append(tlscert.Certificate, certDer) - // tlscert.PrivateKey = clientKey - - return newCert, err -} diff --git a/services/certs/tm/certs.json.todo b/services/certs/tm/certs.json.todo deleted file mode 100644 index cdd2e2e3..00000000 --- a/services/certs/tm/certs.json.todo +++ /dev/null @@ -1 +0,0 @@ -//TODO: define the TD for the certificate service and generate the agent and client handlers using tm2go \ No newline at end of file diff --git a/services/history/HistoryBench_test.go b/services/history/HistoryBench_test.go deleted file mode 100644 index 79e84354..00000000 --- a/services/history/HistoryBench_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package history_test - -import ( - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" -) - -const timespanHour = 3600 -const timespanDay = timespanHour * 24 -const timespanWeek = timespanDay * 7 -const timespanMonth = timespanDay * 31 -const timespanYear = timespanDay * 365 - -// $go test -bench=BenchmarkAddEvents -benchtime=3s -run ^# -// --- WOT protocol bindings (reading) --- -// --------------- MQTT/NATS BROKER -------------- HTTPSSE runtime WSS runtime -// DBSize #Things kvbtree (msec) pebble (msec) bbolt (msec) pebble (msec) pebble (msec) -// 10K 10 add 1K single (*) 2.5 4.7 4600/4600 5.6 6.1 <- increase of 40% due to internal changes -// 10K 10 add 1K batch (*) 1.2 2.4 76/72 5.6 3.5 -// 10K 10 get 1K single 330/125 324/130 300/130 770 (!! ouch) 190 -// 10K 10 get 1K batch 5.5/4.3 7 5.5/4.3 17 (!!) 15 -// -// 100K 10 add 1K single (*) 2.9 4.3 4900/4900 6.7 5.5 -// 100K 10 add 1K batch (*) 1.4 2.4 84/82 5.8 3.1 -// 100K 10 get 1K single 340/130 320/128 325/130 750 (!! ouch) 190 -// 100K 10 get 1K batch 6.0/4.2 7 5.2/4.3 15 16 -// -// 1M 100 add 1K single (*) 2.9 5.7 5500 -// 1M 100 add 1K batch (*) 1.4 3.1 580 -// 1M 100 get 1K single 310 330 303 -// 1M 100 get 1K batch 8.6 5.6 5.1 -// -// 10M 3 add 1K single (*) 3.0 10 5460 -// 10M 3 add 1K batch (*) 1.4 4.1 550 -// 10M 3 get 1K single 310 326 304 -// 10M 3 get 1K batch 6.0/4.2 6.7 5.1 -// -// (*) NOTE1: adding events without message bus -// NOTE2: get 1K single/batch test uses message bus RPC calls and are thus much higher -// NOTE3: the new digitwin runtime using HTTP/SSE is half as fast, as expected, but the get 1K single is horrendous! -// looks like crypto is taking the bulk of it. Is it the re-auth? - -var DataSizeTable = []struct { - dataSize int - nrThings int - nrSets int // repeatedly set the same event -}{ - {dataSize: 10000, nrThings: 10, nrSets: 1000}, - {dataSize: 100000, nrThings: 10, nrSets: 1000}, - //{dataSize: 1000000, nrThings: 100, nrSets: 1000}, - //{dataSize: 10000000, nrThings: 100, nrSets: 1000}, -} - -func BenchmarkAddEvents(b *testing.B) { - const agentID = "agent1" - const thing0ID = thingIDPrefix + "0" - const timespanSec = 3600 * 24 * 10 - var dThing0ID = digitwin.MakeDigitwinID(agentID, thing0ID) - - logging.SetLogging("error", "") - - for _, tbl := range DataSizeTable { - testData, _ := makeValueBatch("device1", tbl.dataSize, tbl.nrThings, timespanMonth) - svc, readHist, stopFn := startHistoryService(true) - time.Sleep(time.Millisecond) - // build a dataset in the store - addBulkHistory(svc, agentID, tbl.dataSize, 10, timespanSec) - - addHist := svc.GetAddHistory() - - // test adding records one by one - // add history directly access the history store. No comms protocol is used. - b.Run(fmt.Sprintf("[dbsize:%d] #things:%d add-single:%d", tbl.dataSize, tbl.nrThings, tbl.nrSets), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - - for i := 0; i < tbl.nrSets; i++ { - ev := testData[i] - err := addHist.AddValue(agentID, ev) - require.NoError(b, err) - } - - } - }) - // test adding records using the ThingID batch add for a single ThingID - // add history directly access the history store. No comms protocol is used. - b.Run(fmt.Sprintf("[dbsize:%d] #things:%d add-batch:%d", tbl.dataSize, tbl.nrThings, tbl.nrSets), - func(b *testing.B) { - bulk := testData[0:tbl.nrSets] - for n := 0; n < b.N; n++ { - for _, v := range bulk { - err := addHist.AddValue(agentID, v) - require.NoError(b, err) - } - } - }) - - // test reading records - // readHist uses the hubclient library - time.Sleep(time.Millisecond * 300) // let the add settle - b.Run(fmt.Sprintf("[dbsize:%d] #things:%d get-single:%d", tbl.dataSize, tbl.nrThings, tbl.nrSets), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - - cursor, releaseFn, _ := readHist.GetCursor(dThing0ID, "") - v, valid, _ := cursor.First() - for i := 0; i < tbl.nrSets-1; i++ { - v, valid, _ = cursor.Next() - if !assert.True(b, valid, - fmt.Sprintf("counting only '%d' records. Expected at least '%d'.", i, tbl.nrSets)) { - break - } - assert.NotEmpty(b, v) - } - releaseFn() - } - }) - // test reading records - // readHist uses the hubclient library - b.Run(fmt.Sprintf("[dbsize:%d] #things:%d get-batch:%d", tbl.dataSize, tbl.nrThings, tbl.nrSets), - func(b *testing.B) { - for n := 0; n < b.N; n++ { - - cursor, releaseFn, _ := readHist.GetCursor(dThing0ID, "") - require.NotNil(b, cursor) - tv, _, _ := cursor.First() - assert.NotEmpty(b, tv) - if tbl.nrSets > 1 { - tvBatch, _, _ := cursor.NextN(tbl.nrSets-1, "") - if !assert.True(b, len(tvBatch) > 0, - fmt.Sprintf("counting only '%d' records. Expected at least '%d'.", len(tvBatch), tbl.nrSets)) { - break - } - } - releaseFn() - } - }) - - stopFn() - time.Sleep(time.Second) // cleanup delay - fmt.Println("--- next round ---") - } -} diff --git a/services/history/History_test.go b/services/history/History_test.go deleted file mode 100644 index 80aee927..00000000 --- a/services/history/History_test.go +++ /dev/null @@ -1,736 +0,0 @@ -package history_test - -import ( - "fmt" - "log/slog" - "math/rand" - "os" - "strconv" - "testing" - "time" - - "github.com/araddon/dateparse" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/api/vocab" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/bucketstore" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/testenv" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/history/historyapi" - "github.com/hiveot/hub/services/history/historyclient" - "github.com/hiveot/hub/services/history/service" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" -) - -const thingIDPrefix = "things-" - -// recommended store for history is Pebble -// const historyStoreBackend = buckets.BackendPebble -const historyStoreBackend = buckets.BackendKVBTree - -const testClientID = "operator1" - -// the following are set by the testmain -var ts *testenv.TestServer - -var names = []string{"temperature", "humidity", "pressure", "wind", "speed", "switch", "location", "sensor-A", "sensor-B", "sensor-C"} - -// Create a new store, delete if it already exists -func startHistoryService(clean bool) ( - svc *service.HistoryService, - r *historyclient.ReadHistoryClient, - stopFn func()) { - - ts = testenv.StartTestServer(clean) - - histStore, err := bucketstore.NewBucketStore( - ts.TestDir, "hist", historyStoreBackend) - if err == nil { - err = histStore.Open() - } - if err != nil { - panic("can't open history bucket store") - } - - // the service needs an agent connection - agentConn, _ := ts.AddConnectService(historyapi.AgentID) - svc = service.NewHistoryService(histStore) - - err = svc.Start(agentConn) - if err != nil { - panic("Failed starting the state service: " + err.Error()) - } - - // create an end user client for testing - co1, _, _ := ts.AddConnectConsumer(testClientID, authz.ClientRoleOperator) - histCl := historyclient.NewReadHistoryClient(co1) - - return svc, histCl, func() { - co1.Disconnect() - svc.Stop() - _ = histStore.Close() - agentConn.Disconnect() - ts.Stop() - // give it some time to shut down before the next test - time.Sleep(time.Millisecond) - } -} - -//func stopStore(store client.IHistory) error { -// return store.(*mongohs.MongoHistoryServer).Stop() -//} - -// generate a random batch of property and event values for testing -// timespanSec is the range of timestamps up until now -func makeValueBatch(agentID string, nrValues, nrThings, timespanSec int) ( - batch []messaging.ThingValue, highest map[string]messaging.ThingValue) { - - highest = make(map[string]messaging.ThingValue) - valueBatch := make([]messaging.ThingValue, 0, nrValues) - for j := 0; j < nrValues; j++ { - randomID := rand.Intn(nrThings) - randomName := rand.Intn(10) - randomValue := rand.Float64() * 100 - randomSeconds := time.Duration(rand.Intn(timespanSec)) * time.Second - randomTime := time.Now().Add(-randomSeconds) - // - thingID := thingIDPrefix + strconv.Itoa(randomID) - dThingID := digitwin.MakeDigitwinID(agentID, thingID) - - randomMsgType := rand.Intn(2) - affType := messaging.AffordanceTypeEvent - if randomMsgType == 1 { - affType = messaging.AffordanceTypeProperty - } - - tv := messaging.ThingValue{ - //ID: fmt.Sprintf("%d", randomID), - Name: names[randomName], - Data: fmt.Sprintf("%2.3f", randomValue), - ThingID: dThingID, - Timestamp: utils.FormatUTCMilli(randomTime), - AffordanceType: affType, - } - - // track the actual most recent event for the name for things 3 - if randomID == 0 { - if _, exists := highest[tv.Name]; !exists || - highest[tv.Name].Timestamp < tv.Timestamp { - highest[tv.Name] = tv - } - } - valueBatch = append(valueBatch, tv) - } - return valueBatch, highest -} - -// add some history to the store. This bypasses the check for thingID to exist. -func addBulkHistory(svc *service.HistoryService, agentID string, count int, nrThings int, - timespanSec int) (highest map[string]messaging.ThingValue) { - - var batchSize = 1000 - if batchSize > count { - batchSize = count - } - - evBatch, highest := makeValueBatch(agentID, count, nrThings, timespanSec) - addHist := svc.GetAddHistory() - - // use add multiple in 100's - for i := 0; i < count/batchSize; i++ { - // no thingID constraint allows adding events from any things - start := batchSize * i - end := batchSize * (i + 1) - for j := start; j < end; j++ { - err := addHist.AddValue(agentID, evBatch[j]) - if err != nil { - slog.Error("Problem adding events.", "err", err) - } - } - } - return highest -} - -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - res := m.Run() - os.Exit(res) -} - -// Test creating and deleting the history database -// This requires a local unsecured MongoDB instance -func TestStartStop(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - - store, readHist, stopFn := startHistoryService(true) - defer stopFn() - _ = store - _ = readHist -} - -func TestAddGetEvent(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const id1 = "thing1" - const id2 = "thing2" - const agent1ID = "agent1" - const thing1ID = id1 - const thing2ID = id2 - const evTemperature = "temperature" - const evHumidity = "humidity" - - // add history with two specific things to test against - svc, readHist, stopFn := startHistoryService(true) - // do not defer cancel as it will be closed and reopened in the test - fivemago := time.Now().Add(-time.Minute * 5) - fiftyfivemago := time.Now().Add(-time.Minute * 55) - addBulkHistory(svc, agent1ID, 20, 3, 3600) - - // add thing1 temperature from 5 minutes ago - addHist := svc.GetAddHistory() - dThing1ID := digitwin.MakeDigitwinID(agent1ID, thing1ID) - ev1_1 := &messaging.NotificationMessage{ - Operation: td.OpSubscribeEvent, - SenderID: agent1ID, ThingID: dThing1ID, Name: evTemperature, - Value: "12.5", Timestamp: utils.FormatUTCMilli(fivemago), - } - err := addHist.AddMessage(ev1_1) - assert.NoError(t, err) - // add thing1 humidity from 55 minutes ago - ev1_2 := &messaging.NotificationMessage{ - Operation: td.OpSubscribeEvent, - SenderID: agent1ID, ThingID: dThing1ID, Name: evHumidity, - Value: "70", Timestamp: utils.FormatUTCMilli(fiftyfivemago), - } - err = addHist.AddMessage(ev1_2) - assert.NoError(t, err) - - // add thing2 humidity from 5 minutes ago - dThing2ID := digitwin.MakeDigitwinID(agent1ID, thing2ID) - ev2_1 := &messaging.NotificationMessage{ - Operation: td.OpSubscribeEvent, - SenderID: agent1ID, ThingID: dThing2ID, Name: evHumidity, - Value: "50", Timestamp: utils.FormatUTCMilli(fivemago), - } - err = addHist.AddMessage(ev2_1) - assert.NoError(t, err) - - // add thing2 temperature from 55 minutes ago - ev2_2 := &messaging.NotificationMessage{ - Operation: td.OpSubscribeEvent, - SenderID: agent1ID, ThingID: dThing2ID, Name: evTemperature, - Value: "17.5", Timestamp: utils.FormatUTCMilli(fiftyfivemago), - } - err = addHist.AddMessage(ev2_2) - assert.NoError(t, err) - - // Test 1: get events of thing1 older than 300 minutes ago - expect 1 humidity from 55 minutes ago - cursor1, c1Release, err := readHist.GetCursor(dThing1ID, "") - require.NoError(t, err) - - // seek must return the things humidity added 55 minutes ago, not 5 minutes ago - timeAfter := time.Now().Add(-time.Minute * 300) - tv1, valid, err := cursor1.Seek(timeAfter) - if assert.NoError(t, err) && assert.True(t, valid) { - assert.Equal(t, dThing1ID, tv1.ThingID) - assert.Equal(t, evHumidity, tv1.Name) - // next finds the temperature from 5 minutes ago - tv2, valid, err := cursor1.Next() - assert.NoError(t, err) - if assert.True(t, valid) { - assert.Equal(t, evTemperature, tv2.Name) - } - } - - // Test 2: get events of things 1 newer than 30 minutes ago - expect 1 temperature - timeAfter = time.Now().Add(-time.Minute * 30) - - // do we need to get a new cursor? - //readHistory = svc.CapReadHistory() - tv3, valid, _ := cursor1.Seek(timeAfter) - if assert.True(t, valid) { - assert.Equal(t, dThing1ID, tv3.ThingID) // must match the filtered id1 - assert.Equal(t, evTemperature, tv3.Name) // must match evTemperature from 5 minutes ago - assert.Equal(t, utils.FormatUTCMilli(fivemago), tv3.Timestamp) - } - c1Release() - // Stop the service before phase 2 - stopFn() - - // PHASE 2: after closing and reopening the svc the event should still be there - svc, readHist, stopFn = startHistoryService(false) - defer stopFn() - - // Test 3: get first temperature of things 2 - expect 1 result - time.Sleep(time.Second) - cursor2, releaseFn, err := readHist.GetCursor(dThing2ID, "") - require.NoError(t, err) - tv4, valid, err := cursor2.First() - require.NoError(t, err) - require.True(t, valid) - assert.Equal(t, evTemperature, tv4.Name) - releaseFn() -} - -func TestAddProperties(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - //const clientID = "device0" - const thing1ID = thingIDPrefix + "0" // matches a percentage of the random things - const agent1 = "device1" - const temp1 = 55 - const battTemp = 50 - - svc, readHist, closeFn := startHistoryService(true) - defer closeFn() - - dThing1ID := digitwin.MakeDigitwinID(agent1, thing1ID) - action1 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: dThing1ID, - Name: vocab.ActionSwitchOnOff, - Value: "on", - Operation: td.OpInvokeAction, - } - event1 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: dThing1ID, - Name: vocab.PropEnvTemperature, - Value: temp1, - Operation: td.OpSubscribeEvent, - } - badEvent1 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: dThing1ID, - Name: "", // missing name - Operation: td.OpSubscribeEvent, - } - // dThing1ID identifies the publisher so not an error - //badEvent2 := &transports.IConsumer{ - // SenderID: "", // missing publisher - // ThingID: dThing1ID, - // Name: "name", - // Operation: td.OpSubscribeEvent, - //} - badEvent3 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: dThing1ID, - Name: "baddate", - Timestamp: "-1", - Operation: td.OpSubscribeEvent, - } - badEvent4 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: "", // missing ID - Name: "temperature", - } - propsList := make(map[string]interface{}) - propsList[vocab.PropDeviceBattery] = battTemp - propsList[vocab.PropEnvCpuload] = 30 - propsList[vocab.PropSwitchOnOff] = "off" - props1 := &messaging.NotificationMessage{ - SenderID: agent1, - ThingID: dThing1ID, - Name: "", // property list - Value: propsList, - Operation: td.OpObserveAllProperties, - } - - // in total add 5 properties - addHist := svc.GetAddHistory() - err := addHist.AddMessage(action1) - assert.NoError(t, err) - err = addHist.AddMessage(event1) - assert.NoError(t, err) - err = addHist.AddMessage(props1) - assert.NoError(t, err) - - // and some bad values - err = addHist.AddMessage(badEvent1) - assert.Error(t, err) - //err = addHist.AddMessage(badEvent2) - //assert.Error(t, err) - err = addHist.AddMessage(badEvent3) // bad date is recovered - assert.NoError(t, err) - err = addHist.AddMessage(badEvent4) - assert.Error(t, err) - err = addHist.AddMessage(badEvent1) - assert.Error(t, err) - - c, releaseFn, err := readHist.GetCursor(dThing1ID, "") - defer releaseFn() - require.NoError(t, err) - msg, valid, err := c.First() - require.True(t, valid) - assert.NotEmpty(t, msg) - hasProps := false - for valid && err == nil { - if msg.AffordanceType == messaging.AffordanceTypeProperty { - hasProps = true - require.NotEmpty(t, msg.Name) - require.NotEmpty(t, msg.Data) - if msg.Name == vocab.PropDeviceBattery { - assert.Equal(t, float64(battTemp), msg.Data) - } - //props := make(map[string]interface{}) - //err = utils.DecodeAsObject(msg.Data, &props) - //require.NoError(t, err) - } else if msg.Name == vocab.PropEnvTemperature { - dataInt := utils.DecodeAsInt(msg.Data) - require.Equal(t, temp1, dataInt) - } - msg, valid, err = c.Next() - } - require.NoError(t, err) - require.True(t, hasProps) -} - -func TestGetInfo(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const agentID = "agent1" - //const thing0ID = thingIDPrefix + "0" - //var dThing0ID = things.MakeDigiTwinThingID(agentID, thing0ID) - - store, readHist, stopFn := startHistoryService(true) - defer stopFn() - _ = readHist - addBulkHistory(store, agentID, 1000, 5, 1000) - - // TODO: add GetInfo for store statistics - //info := store.Info() - //t.Logf("Store ID:%s, records:%d", info.Id, info.NrRecords) - - //info := readHistory.Info(ctx) - //assert.NotEmpty(t, info.Engine) - //assert.NotEmpty(t, info.Id) - //t.Logf("ID:%s records:%d", info.Id, info.NrRecords) -} - -func TestPrevNext(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const count = 1000 - const agentID = "agent1" - const thing0ID = thingIDPrefix + "0" // matches a percentage of the random things - var dThing0ID = digitwin.MakeDigitwinID(agentID, thing0ID) - - store, readHist, closeFn := startHistoryService(true) - defer closeFn() - - // 10 sensors -> 1 sample per minute, 60 per hour -> 600 - _ = addBulkHistory(store, agentID, count, 1, 3600*24*30) - - cursor, releaseFn, _ := readHist.GetCursor(dThing0ID, "") - defer releaseFn() - assert.NotNil(t, cursor) - - // go forward - item0, valid, err := cursor.First() - require.NoError(t, err) - assert.True(t, valid) - assert.NotEmpty(t, item0) - item1, valid, err := cursor.Next() - require.NoError(t, err) - assert.True(t, valid) - assert.NotEmpty(t, item1) - items2to11, itemsRemaining, err := cursor.NextN(10, "") - require.NoError(t, err) - assert.True(t, itemsRemaining) - assert.Equal(t, 10, len(items2to11)) - - // go backwards - item10to1, itemsRemaining, err := cursor.PrevN(10, "") - require.NoError(t, err) - assert.True(t, valid) - assert.Equal(t, 10, len(item10to1)) - - // reached first item - item0b, valid, err := cursor.Prev() - require.NoError(t, err) - assert.True(t, valid) - assert.Equal(t, item0.Timestamp, item0b.Timestamp) - - // can't skip before the beginning of time - iteminv, valid, err := cursor.Prev() - require.NoError(t, err) - _ = iteminv - assert.False(t, valid) - - // seek to item11 should succeed - item11 := items2to11[9] - timeStamp, _ := dateparse.ParseAny(item11.Timestamp) - item11b, valid, err := cursor.Seek(timeStamp) - require.NoError(t, err) - assert.True(t, valid) - assert.Equal(t, item11.Name, item11b.Name) -} - -// filter on property name -func TestPrevNextFiltered(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const count = 1000 - const agentID = "agent1" - const thing0ID = thingIDPrefix + "0" // matches a percentage of the random things - var dThing0ID = digitwin.MakeDigitwinID(agentID, thing0ID) - - svc, readHist, closeFn := startHistoryService(true) - defer closeFn() - - // 10 sensors -> 1 sample per minute, 60 per hour -> 600 - _ = addBulkHistory(svc, agentID, count, 1, 3600*24*30) - propName := names[2] // names was used to generate the history - - // A cursor with a filter on propName should only return results of propName - cursor, releaseFn, err := readHist.GetCursor(dThing0ID, propName) - require.NoError(t, err) - defer releaseFn() - assert.NotNil(t, cursor) - item0, valid, err := cursor.First() - require.NoError(t, err) - assert.True(t, valid) - assert.Equal(t, propName, item0.Name) - - // further steps should still only return propName - item1, valid, err := cursor.Next() - assert.True(t, valid) - require.Nil(t, err) - assert.Equal(t, propName, item1.Name) - items2to11, itemsRemaining, err := cursor.NextN(10, "") - assert.True(t, itemsRemaining) - assert.Equal(t, 10, len(items2to11)) - assert.Equal(t, propName, items2to11[9].Name) - - // go backwards - item10to1, itemsRemaining, err := cursor.PrevN(10, "") - assert.True(t, valid) - assert.Equal(t, 10, len(item10to1)) - - // reached first item - item0b, valid, err := cursor.Prev() - assert.True(t, valid) - require.Nil(t, err) - assert.Equal(t, item0.Timestamp, item0b.Timestamp) - assert.Equal(t, propName, item0b.Name) - - // can't skip before the beginning of time - iteminv, valid, err := cursor.Prev() - _ = iteminv - assert.False(t, valid) - - // seek to item11 should succeed - item11 := items2to11[9] - timeStamp, _ := dateparse.ParseAny(item11.Timestamp) - item11b, valid, err := cursor.Seek(timeStamp) - assert.True(t, valid) - require.Nil(t, err) - assert.Equal(t, item11.Name, item11b.Name) - - // last item should be of the name - lastItem, valid, err := cursor.Last() - assert.True(t, valid) - require.Nil(t, err) - assert.Equal(t, propName, lastItem.Name) - - cursor.Release() -} - -func TestNextPrevUntil(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const count = 1000 - const agentID = "agent1" - const thing0ID = thingIDPrefix + "0" // matches a percentage of the random things - var dThing0ID = digitwin.MakeDigitwinID(agentID, thing0ID) - - store, readHist, closeFn := startHistoryService(true) - defer closeFn() - - // 1 sensor -> 1000/24 hours is approx 41/hour - _ = addBulkHistory(store, agentID, count, 1, 3600*24) - - cursor, releaseFn, _ := readHist.GetCursor(dThing0ID, "") - defer releaseFn() - assert.NotNil(t, cursor) - - // start 20 hours ago - startTime := time.Now().Add(-20 * time.Hour) - item0, valid, err := cursor.Seek(startTime) - require.NoError(t, err) - assert.True(t, valid) - assert.NotEmpty(t, item0) - - // read an hour's worth. Expect around 41 results - endTime := startTime.Add(time.Hour).Format(time.RFC3339) - // note, batch1 doesn't have item0 - batch, itemsRemaining, err := cursor.NextN(100, endTime) - require.NoError(t, err) - assert.False(t, itemsRemaining) - assert.True(t, len(batch) > 20) - assert.True(t, len(batch) < 60) - - // read backwards again. note batch2 ends with item0 - batch2, itemsRemaining, err := cursor.PrevN(100, startTime.Format(time.RFC3339)) - require.NoError(t, err) - assert.False(t, itemsRemaining) - assert.Equal(t, len(batch), len(batch2)) -} - -func TestReadHistory(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const count = 1000 - const agentID = "device1" - const thing0ID = thingIDPrefix + "0" // matches a percentage of the random things - var dThing0ID = digitwin.MakeDigitwinID(agentID, thing0ID) - - store, readHist, closeFn := startHistoryService(true) - defer closeFn() - - // 1 sensors -> 1000/24 hours is approx 41/hour - _ = addBulkHistory(store, agentID, count, 1, 3600*24) - - // start 20 hours ago and read an hour's worth - startTime := time.Now().Add(-20 * time.Hour) - duration := time.Hour - items, remaining, err := readHist.ReadHistory(dThing0ID, "", startTime, duration, 60) - require.NoError(t, err) - assert.False(t, remaining) - assert.NotEmpty(t, items) - assert.True(t, len(items) > 20) - - // start 19 hours ago and read back in time - startTime = time.Now().Add(-19 * time.Hour) - duration = -1 * time.Hour - items, remaining, err = readHist.ReadHistory(dThing0ID, "", startTime, duration, 60) - require.NoError(t, err) - assert.False(t, remaining) - assert.NotEmpty(t, items) - assert.True(t, len(items) > 20) - -} - -func TestPubEvents(t *testing.T) { - const agent1ID = "device1" - - t.Logf("---%s---\n", t.Name()) - - _, readHist, stopFn := startHistoryService(true) - defer stopFn() - ag1, _ := ts.AddConnectService(agent1ID) - defer ag1.Disconnect() - - // Add the thing who is publishing events - td1 := ts.CreateTestTD(0) - ts.AddTD(agent1ID, td1) - thing0ID := td1.ID - dThing0ID := digitwin.MakeDigitwinID(agent1ID, thing0ID) - - // publish events - names := []string{ - vocab.PropEnvTemperature, vocab.PropSwitchOnOff, - vocab.PropSwitchOnOff, vocab.PropDeviceBattery, - vocab.PropAlarmStatus, "noname", - "tttt", vocab.PropEnvTemperature, - vocab.PropSwitchOnOff, vocab.PropEnvTemperature} - _ = names - - // only valid names should be added - for i := 0; i < 10; i++ { - val := strconv.Itoa(i + 1) - // events are published by the agent using their native thingID - name := names[i] - err := ag1.PubEvent(thing0ID, name, val) - //err := ag1.PubEvent(f1, thing0ID, name, val, "") - assert.NoError(t, err) - // make sure timestamp differs - time.Sleep(time.Millisecond * 3) - } - - time.Sleep(time.Millisecond * 1000) - // read back - // consumers read events see the digital twin representation - cursor, releaseFn, err := readHist.GetCursor(dThing0ID, "") - require.NoError(t, err) - ev, valid, err := cursor.First() - require.NoError(t, err) - assert.True(t, valid) - assert.NotEmpty(t, ev) - - // store - - batched, _, _ := cursor.NextN(10, "") - // expect 3 entries total from valid events (9 when retention manager isn't used) - assert.Equal(t, 9, len(batched)) - releaseFn() -} - -func TestManageRetention(t *testing.T) { - t.Logf("---%s---\n", t.Name()) - const client1ID = "admin" - const agentID = "agent1" // should not match existing test devices - const event1Name = "event1" - const event2Name = "notRetainedEvent" - - // setup with some history - store, readHist, closeFn := startHistoryService(true) - defer closeFn() - addBulkHistory(store, agentID, 1000, 5, 1000) - - // make sure the TD whose retention rules are added exist - td0 := ts.CreateTestTD(0) - ts.AddTD(agentID, td0) - dThing0ID := digitwin.MakeDigitwinID(agentID, td0.ID) - - // connect as an admin user - co1, _, _ := ts.AddConnectConsumer(client1ID, authz.ClientRoleAdmin) - mngHist := historyclient.NewManageHistoryClient(co1) - - // should be able to read the current retention rules. Expect the default rules. - rules1, err := mngHist.GetRetentionRules() - require.NoError(t, err) - assert.Greater(t, 1, len(rules1)) - - // Add two retention rules to retain temperature and our test event from device1 - rules1[vocab.PropEnvTemperature] = append(rules1[vocab.PropEnvTemperature], - &historyapi.RetentionRule{Retain: true}) - rules1[event1Name] = append(rules1[event1Name], - &historyapi.RetentionRule{Retain: true}) - err = mngHist.SetRetentionRules(rules1) - require.NoError(t, err) - - // The new retention rule should now exist and accept our custom event - rules2, err := mngHist.GetRetentionRules() - require.NoError(t, err) - assert.Equal(t, len(rules1), len(rules2)) - rule, err := mngHist.GetRetentionRule(dThing0ID, event1Name) - assert.NoError(t, err) - if assert.NotNil(t, rule) { - assert.Equal(t, "", rule.ThingID) - assert.Equal(t, event1Name, rule.Name) - } - - // connect as agent-1 and publish two events for thing0, one to be retained - ag1, _, _ := ts.AddConnectAgent(agentID) - require.NoError(t, err) - defer ag1.Disconnect() - err = ag1.PubEvent(td0.ID, event1Name, "event one") - assert.NoError(t, err) - err = ag1.PubEvent(td0.ID, event2Name, "event two") - assert.NoError(t, err) - // give it some time to persist the bucket - time.Sleep(time.Millisecond * 100) - - // read the history of device 1 and expect the event to be retained - cursor, releaseFn, err := readHist.GetCursor(dThing0ID, "") - require.NoError(t, err) - histEv1, valid, _ := cursor.First() - require.True(t, valid, "missing the first event") - assert.Equal(t, event1Name, histEv1.Name) - histEv2, valid2, _ := cursor.Next() - assert.False(t, valid2, "second event should not be there") - _ = histEv2 - releaseFn() - -} diff --git a/services/history/README.md b/services/history/README.md deleted file mode 100644 index 42eacdbf..00000000 --- a/services/history/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Thing Value History - -## Objective - -Provide historical reading of thing events and actions using the bucket store. - -## Status - -This service is functional but breaking changes can still be expected. - -TODO: -1. Capture actions -2. Query support -3. Selective retention of events and actions with hubcli support -4. Support auto expiry of events -5. Support for removal of outlier values - - -## Summary - -The History service provides capture and retrieval of past events and actions. - -Data ingress at a continuous rate of 1000 events per second is readily supported on small to medium sized systems with 500MB of RAM or more and plenty of disk space. More sensors can be supported if storage space, memory and CPU are available. For smaller environments this can be dialed down further with some experimentation. The use of retention rules can reduce the required disk space. - -Basic data queries are provided through the API for the purpose to display and compare historical information. - -Data retention rules must be set to start storing events. To run out of the box, a default rule set is included with the history.yaml configuration file. The rules can be modified through the retention API. Changes to the configuration file require a restart of the service. - -**Limitations:** - -* The history store is designed to use the bucket store and is thus limited by the storage capabilities and query capabilities of the bucket store API. - -* Complex analytics of historical information is out of scope for the history store. The recommended approach is to periodically export a batch of new data to a time series database such as Prometheus, InfluxDB, or others that support clustering and can be integrated with 3rd party analytics engines. - -* There are no tools yet to monitor the bucket store health, and track its resource usage. - - -## Backend Storage - -This service uses the bucket-store for the storage backend. The bucket-store supports several embedded backend implementations that run out of the box without the need for any setup and configuration. - -Extending the bucket store with external databases such as Mongodb, SQLite, PostgresSQL and possibly others is under consideration. - -The bucket-store API provides a cursor with key-ranged seek capability which can be used for time-based queries. All bucket store implementations support this range query through cursors. - -Currently, the Pebble bucket store is the default for the history store. It provides a good balance between storage size and resource usage for smaller systems. Pebble should be able to handle a TB of data or even more. - -More testing is needed to determine the actual limitations and improve performance. - -### Performance - -Performance is mostly limited by the message bus. The bench test shows an average read/write duration of 1-2 ms per 1000 calls, except for bbolt which is much slower on write but faster on read. As read times are also limited by messaging, the use of bbolt only make sense for reading lots of data locally, for example when analyzing, which currently has no use-case. - - -### Data Size - -Data size of event samples depends strongly on the type of sensor, actuator or service that captures the data. Below some example cases and the estimated memory to get an idea of the required space. - -Since the store uses a bucket per thingID, the thingID itself does not add significant size. The key is the msec timestamp since epoc, approx 15 characters. - -The following estimates are based on a sample size of 100 bytes uncompressed (key:20, event name:10, value: 60, json: 10). These are worst case numbers as deduplication and compression can reduce the sizes significantly. - -Case 1: sensors with a 1 minute average sample interval. - -* A single sensor -> 500K samples => 50MB/year (uncompressed) -* A small system with 10 sensors -> 5M samples => 500MB/year -* A medium system with 100 sensors -> 50M samples => 5GB/year -* A larger system with 1000 sensors -> 500M samples => 50GB/year - -Case 2: sensors with a 1 second average sample interval. -* A single sensor -> 32M samples => 3.2GB/year (uncompressed) -* A small system with 10 sensors -> 320M samples => 32GB/year -* A larger system with 1000 sensors -> 32000 M samples => 3.2TB/year - -In reality these numbers will be lower depending on the chosen store. - -Case 3: image timelapse snapshot with 5 minute interval -An image is 720i compressed, around 100K/image. - -* A single image -> 100K snapshots/year => 10 GB/year -* A system with 10 cameras -> 1000K snapshots/year => 100 GB/year -* A larger system with 100 cameras -> 10M snapshots/year => 1 TB/year - -Backend Recommendations: -1. Use of kvstore backend is recommended for smallish datasets up to 100GB or so. Beyond this the read/write performance starts to suffer. -2. Of the embedded stores, Pebble scales best for large datasets of 100GB-10TB. Beyond that a stand-alone clustering database server should be used. -3. Bbolt works best when using a service that analyzes the data locally with heavy read operations. Write is slow but read is faster than the other stores when processing a lot of data. - -### Retention - -Data that loses a meaningful use after time can be removed using retention rules. The retention engine periodically removes records of events and actions from the store that meet the criteria. Rule criteria include the publishing agent, thingID and event names. \ No newline at end of file diff --git a/services/history/cmd/main.go b/services/history/cmd/main.go deleted file mode 100644 index 4c327dd0..00000000 --- a/services/history/cmd/main.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package main with the history store -package main - -import ( - "fmt" - "log/slog" - "path" - - "github.com/hiveot/hub/lib/buckets/bucketstore" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/services/history/config" - "github.com/hiveot/hub/services/history/service" -) - -// Connect the history store service -func main() { - env := plugin.GetAppEnvironment("", true) - logging.SetLogging(env.LogLevel, "") - slog.Warn("Starting history service", "clientID", env.ClientID, "loglevel", env.LogLevel) - - storesDir := path.Join(env.StoresDir, env.ClientID) - cfg := config.NewHistoryConfig(storesDir) - _ = env.LoadConfig(&cfg) - - // the service uses the bucket store to store history - store, err := bucketstore.NewBucketStore(storesDir, "history", cfg.Backend) - if err == nil { - err = store.Open() - } - if err != nil { - err = fmt.Errorf("can't open history bucket store: %w", err) - slog.Error(err.Error()) - panic(err.Error()) - } - svc := service.NewHistoryService(store) - plugin.StartPlugin(svc, env.ClientID, env.CertsDir, env.ServerURL) -} diff --git a/services/history/config/HistoryConfig.go b/services/history/config/HistoryConfig.go deleted file mode 100644 index 18030d3b..00000000 --- a/services/history/config/HistoryConfig.go +++ /dev/null @@ -1,27 +0,0 @@ -package config - -import ( - "github.com/hiveot/hub/lib/buckets" -) - -// HistoryConfig with history store database configuration -type HistoryConfig struct { - // Bucket store ID of the backend to store - // kvbtree, pebble (default), bbolt. See IBucketStore for details. - Backend string `yaml:"backend"` - - // Bucket store location where to store the history - //StoreDirectory string `yaml:"storeDirectory"` - - // Default retention from config by event name - //Retention []history.EventRetention `yaml:"retention"` -} - -// NewHistoryConfig creates a new config with default values -func NewHistoryConfig(storeDirectory string) HistoryConfig { - cfg := HistoryConfig{ - Backend: buckets.BackendPebble, - //StoreDirectory: storeDirectory, - } - return cfg -} diff --git a/services/history/config/history.yaml b/services/history/config/history.yaml deleted file mode 100644 index c02036a7..00000000 --- a/services/history/config/history.yaml +++ /dev/null @@ -1,63 +0,0 @@ -# history.yaml - configuration file for the state storage. - - -# backend storage to use. Options are: 'pebble' (default), "bbolt" and "kvbtree" -# backend: pebble - -# storage directory. Default is the hub's stores subdirectory. -#directory: /var/lib/history - - -# serviceID is the service instance and thingID of the service itself -# Default is history -#serviceID: history - -# retain all unlisted events, eg events not in the retention map below -retainUnlisted: false - -# FIXME: these should be derived from the vocabulary -# default event names to retain. -# see the vocab package for property/event names -retention: - - name: alarm - - name: atmosphericPressure - - name: battery - - name: coLevel - - name: oDetector - - name: co2Level - - name: cpuLevel - - name: current - - name: dewpoint - - name: dimmer - - name: energy - - name: errors - - name: hostname - - name: heatindex - - name: humidity - - name: latency - - name: latitude - - name: latlon - - name: longitude - - name: lock - - name: location - - name: luminance - - name: memory - - name: motion - - name: power - - name: pushButton - - name: rain - - name: relay - - name: signalStrength - - name: smokeDetector - - name: soundDetector - - name: snow - - name: switch - - name: temperature - - name: value - - name: vibration - - name: voltage - - name: volume - - name: waterLevel - - name: weather - - name: windHeading - - name: windSpeed diff --git a/services/history/historyapi/ManageHistoryAPI.go b/services/history/historyapi/ManageHistoryAPI.go deleted file mode 100644 index 79fd2eb1..00000000 --- a/services/history/historyapi/ManageHistoryAPI.go +++ /dev/null @@ -1,64 +0,0 @@ -package historyapi - -// AgentID is the agentID of the history services -const AgentID = "history" - -// ManageHistoryServiceID is the ID of the service exposed by the agent -const ManageHistoryServiceID = "manage" - -// ManageHistoryThingID is the ThingID of the manage history service -//var ManageHistoryThingID = things.MakeDigiTwinThingID(HistoryAgentID, ManageHistoryCap) - -// Management methods -const ( - // GetRetentionRuleMethod returns the first retention rule that applies - // to the given value. - GetRetentionRuleMethod = "getRetentionRule" - - // GetRetentionRulesMethod returns the collection of retention configurations - GetRetentionRulesMethod = "getRetentionRules" - - // SetRetentionRulesMethod updates the set of retention rules - SetRetentionRulesMethod = "setRetentionRules" -) - -// RetentionRule with a retention rule for an event (or action) -type RetentionRule struct { - // Optional, the rule applies to data from this (digital twin) Thing - ThingID string `yaml:"thingID,omitempty" json:"thingID,omitempty"` - - // Optional, the rule applies to events or actions with this name - Name string `yaml:"name,omitempty"` - - // TODO: class of value, eg @type in the TD (eg, temperature, humidity) - //Type string `yaml:"type",json:"type"` - - // Retain or not retain based on this rule - Retain bool `yaml:"retain" json:"retain"` - - // Retention age after which to remove the value. 0 to retain indefinitely - MaxAge uint64 `yaml:"maxAge"` -} - -// RetentionRuleSet is a map by event/action name with one or more rules for agent/things. -type RetentionRuleSet map[string][]*RetentionRule - -type GetRetentionRuleArgs struct { - // ThingID whose rule to get (digital twin ID) is optional - ThingID string `json:"thingID,omitempty"` - // Name of the event whose retention settings to get - Name string `json:"name,omitempty"` - // Retention for events,properties,actions or empty for all - AffordanceType string `json:"affordanceType,omitempty"` -} -type GetRetentionRuleResp struct { - Rule *RetentionRule `json:"rule"` -} - -type GetRetentionRulesResp struct { - Rules RetentionRuleSet `json:"rules"` -} - -type SetRetentionRulesArgs struct { - Rules RetentionRuleSet `json:"rules"` -} diff --git a/services/history/historyapi/ReadHistoryAPI.go b/services/history/historyapi/ReadHistoryAPI.go deleted file mode 100644 index f40a2a3a..00000000 --- a/services/history/historyapi/ReadHistoryAPI.go +++ /dev/null @@ -1,135 +0,0 @@ -package historyapi - -import ( - "github.com/hiveot/hub/lib/messaging" -) - -// ReadHistoryServiceID is the ID of the service exposed by the agent -const ReadHistoryServiceID = "read" - -// DefaultLimit nr items of none provided -const DefaultLimit = 100 - -// Read history methods -const ( - // CursorNextNMethod returns a batch of next N historical values - CursorNextNMethod = "cursorNextN" - - // CursorPrevNMethod returns a batch of prior N historical values - CursorPrevNMethod = "cursorPrevN" - - // CursorReleaseMethod releases the cursor and resources - // This MUST be called after the cursor is not longer used. - CursorReleaseMethod = "cursorRelease" - - // CursorSeekMethod seeks the starting point in time for iterating the history - // This returns a single value response with the value at timestamp or next closest - // if it doesn't exist. - // Returns empty value when there are no values at or past the given timestamp - CursorSeekMethod = "cursorSeek" - - // GetCursorMethod returns a cursor to iterate the history of a things - // The cursor MUST be released after use. - // The cursor will expire after not being used for the default expiry period. - GetCursorMethod = "getCursor" - - // ReadHistoryMethod reads the history up to a limit. - ReadHistoryMethod = "readHistory" -) - -// cursor methods that take the key as arg and returns a single value -const ( - // CursorFirstMethod return the oldest value in the history - CursorFirstMethod = "cursorFirst" - // CursorLastMethod return the newest value in the history - CursorLastMethod = "cursorLast" - // CursorNextMethod returns the next value in the history - CursorNextMethod = "cursorNext" - // CursorPrevMethod returns the previous value in the history - CursorPrevMethod = "cursorPrev" -) - -// CursorArgs contain the cursor request for use in First,Last,Next,Prev -type CursorArgs struct { - // Cursor identifier obtained with GetCursor - CursorKey string `json:"cursorKey"` -} - -// CursorSingleResp contains a single response value to a cursor request -type CursorSingleResp struct { - // The value at the new cursor position or nil if not valid - Value *messaging.ThingValue `json:"value"` - // The current position holds a valid value - Valid bool `json:"valid"` -} - -// CursorNArgs contains the request for use in NextN and PrevN -type CursorNArgs struct { - // Cursor identifier obtained with GetCursor - CursorKey string `json:"cursorKey"` - // Maximum number of results to return - Limit int `json:"limit,omitempty"` - // Time until to keep reading or "" for up to 1 year - Until string `json:"until,omitempty"` -} - -// CursorNResp contains the batch response to a cursor request -type CursorNResp struct { - // Returns up to 'Limit' iterated values. - // This will be an empty list when trying to read past the last value. - Values []*messaging.ThingValue `json:"values"` - // There are still items remaining. - ItemsRemaining bool `json:"itemsRemaining"` -} - -type CursorReleaseArgs struct { - // Cursor identifier obtained with GetCursor - CursorKey string `json:"cursorKey"` -} - -type CursorSeekArgs struct { - // Cursor identifier obtained with GetCursor - CursorKey string `json:"cursorKey"` - // timestamp in rfc8601 format - TimeStamp string `json:"timeStamp"` -} - -// returns CursorSingleResp - -// GetCursorArgs request arguments: -type GetCursorArgs struct { - // Digitwin thing providing the event to get (required) - ThingID string `json:"thingID"` - // Optional filter value to search for a specific key - FilterOnName string `json:"filterOnName,omitempty"` - // optional lifespan. Default is 1 minute - LifespanSec int `json:"lifespan"` -} -type GetCursorResp struct { - // Cursor identifier - // The cursor MUST be released after use. - CursorKey string `json:"cursorKey"` -} - -// ReadHistoryArgs arguments for reading a batch of historical values -type ReadHistoryArgs struct { - // Thing to read values from - ThingID string `json:"thingID"` - // Optional filter value to search for a specific event name - FilterOnName string `json:"filterOnName,omitempty"` - // Timestamp in RFC3339 format or 'now' for default - Timestamp string `json:"timeStamp"` - // Duration to read or 0 for previous 24 hours (-3600*24) - Duration int `json:"duration"` - // limit nr of results or 0 for default of 1000 - Limit int `json:"limit"` -} - -// ReadHistoryResp contains the batch response to a reading history values -type ReadHistoryResp struct { - // Returns up to 'Limit' iterated values. - // This will be an empty list when trying to read past the last value. - Values []*messaging.ThingValue `json:"values"` - // There are still items remaining. - ItemsRemaining bool `json:"itemsRemaining"` -} diff --git a/services/history/historyclient/HistoryCursorClient.go b/services/history/historyclient/HistoryCursorClient.go deleted file mode 100644 index 52aa06c9..00000000 --- a/services/history/historyclient/HistoryCursorClient.go +++ /dev/null @@ -1,147 +0,0 @@ -package historyclient - -import "C" -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/history/historyapi" -) - -// HistoryCursorClient provides iterator client for iterating the history -type HistoryCursorClient struct { - // the key identifying this cursor - cursorKey string - - // history cursor service ID - dThingID string - co *consumer.Consumer -} - -// First positions the cursor at the first key in the ordered list -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) First() (thingValue *messaging.ThingValue, valid bool, err error) { - req := historyapi.CursorArgs{ - CursorKey: cl.cursorKey, - } - resp := historyapi.CursorSingleResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorFirstMethod, &req, &resp) - //err = cl.co.SendRequest(cl.dThingID, historyapi.CursorFirstMethod, &req, &resp) - return resp.Value, resp.Valid, err -} - -// Last positions the cursor at the last key in the ordered list -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) Last() ( - thingValue *messaging.ThingValue, valid bool, err error) { - - req := historyapi.CursorArgs{ - CursorKey: cl.cursorKey, - } - resp := historyapi.CursorSingleResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorLastMethod, &req, &resp) - return resp.Value, resp.Valid, err -} - -// Next moves the cursor to the next key from the current cursor -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) Next() ( - thingValue *messaging.ThingValue, valid bool, err error) { - - req := historyapi.CursorArgs{ - CursorKey: cl.cursorKey, - } - resp := historyapi.CursorSingleResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorNextMethod, &req, &resp) - return resp.Value, resp.Valid, err -} - -// NextN moves the cursor to the next N steps from the current cursor -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) NextN( - limit int, until string) (batch []*messaging.ThingValue, itemsRemaining bool, err error) { - - req := historyapi.CursorNArgs{ - CursorKey: cl.cursorKey, - Until: until, - Limit: limit, - } - resp := historyapi.CursorNResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorNextNMethod, &req, &resp) - return resp.Values, resp.ItemsRemaining, err -} - -// Prev moves the cursor to the previous key from the current cursor -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) Prev() ( - thingValue *messaging.ThingValue, valid bool, err error) { - - req := historyapi.CursorArgs{ - CursorKey: cl.cursorKey, - } - resp := historyapi.CursorSingleResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorPrevMethod, &req, &resp) - return resp.Value, resp.Valid, err -} - -// PrevN moves the cursor to the previous N steps from the current cursor -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) PrevN( - limit int, until string) (batch []*messaging.ThingValue, itemsRemaining bool, err error) { - - req := historyapi.CursorNArgs{ - CursorKey: cl.cursorKey, - Until: until, - Limit: limit, - } - resp := historyapi.CursorNResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorPrevNMethod, &req, &resp) - return resp.Values, resp.ItemsRemaining, err -} - -// Release the cursor capability -func (cl *HistoryCursorClient) Release() { - req := historyapi.CursorReleaseArgs{ - CursorKey: cl.cursorKey, - } - err := cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorReleaseMethod, &req, nil) - _ = err - return -} - -// Seek the starting point for iterating the history -// timeStamp in ISO8106 format -// This returns an error if the cursor has expired or is not found. -func (cl *HistoryCursorClient) Seek(timeStamp time.Time) ( - thingValue *messaging.ThingValue, valid bool, err error) { - timeStampStr := utils.FormatUTCMilli(timeStamp) - req := historyapi.CursorSeekArgs{ - CursorKey: cl.cursorKey, - TimeStamp: timeStampStr, - } - resp := historyapi.CursorSingleResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.CursorSeekMethod, &req, &resp) - return resp.Value, resp.Valid, err -} - -// NewHistoryCursorClient returns a read cursor client -// Intended for internal use. -// -// co client connection to the Hub -// serviceID of the read capability -// cursorKey is the iterator key obtain when requesting the cursor -func NewHistoryCursorClient(co *consumer.Consumer, cursorKey string) *HistoryCursorClient { - agentID := historyapi.AgentID - serviceID := historyapi.ReadHistoryServiceID - cl := &HistoryCursorClient{ - cursorKey: cursorKey, - // history cursor serviceID - dThingID: digitwin.MakeDigitwinID(agentID, serviceID), - co: co, - } - return cl -} diff --git a/services/history/historyclient/ManageHistoryClient.go b/services/history/historyclient/ManageHistoryClient.go deleted file mode 100644 index 102c5461..00000000 --- a/services/history/historyclient/ManageHistoryClient.go +++ /dev/null @@ -1,57 +0,0 @@ -package historyclient - -import ( - "github.com/hiveot/hub/lib/consumer" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/history/historyapi" -) - -// ManageHistoryClient client for managing retention of the history service -type ManageHistoryClient struct { - // service providing the history management capability - dThingID string - //co transports.IClientConnection - co *consumer.Consumer -} - -// GetRetentionRule returns the retention configuration of an event by name -// This applies to events from any publishers and things -// returns nil if there is no retention rule for the event -// -// dThingID -// eventName whose retention to return -func (cl *ManageHistoryClient) GetRetentionRule( - dThingID string, name string) (*historyapi.RetentionRule, error) { - - args := historyapi.GetRetentionRuleArgs{ - ThingID: dThingID, - Name: name, - } - resp := historyapi.GetRetentionRuleResp{} - err := cl.co.InvokeAction(cl.dThingID, historyapi.GetRetentionRuleMethod, &args, &resp) - return resp.Rule, err -} - -// GetRetentionRules returns the list of retention rules -func (cl *ManageHistoryClient) GetRetentionRules() (historyapi.RetentionRuleSet, error) { - resp := historyapi.GetRetentionRulesResp{} - err := cl.co.InvokeAction(cl.dThingID, historyapi.GetRetentionRulesMethod, nil, &resp) - return resp.Rules, err -} - -// SetRetentionRules configures the retention of a Thing event -func (cl *ManageHistoryClient) SetRetentionRules(rules historyapi.RetentionRuleSet) error { - args := historyapi.SetRetentionRulesArgs{Rules: rules} - err := cl.co.InvokeAction(cl.dThingID, historyapi.SetRetentionRulesMethod, &args, nil) - return err -} - -// NewManageHistoryClient creates a new instance of the manage history client for use by authorized clients -func NewManageHistoryClient(co *consumer.Consumer) *ManageHistoryClient { - agentID := historyapi.AgentID - mngCl := &ManageHistoryClient{ - dThingID: digitwin.MakeDigitwinID(agentID, historyapi.ManageHistoryServiceID), - co: co, - } - return mngCl -} diff --git a/services/history/historyclient/ReadHistoryClient.go b/services/history/historyclient/ReadHistoryClient.go deleted file mode 100644 index 901b6d99..00000000 --- a/services/history/historyclient/ReadHistoryClient.go +++ /dev/null @@ -1,75 +0,0 @@ -package historyclient - -import ( - "time" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/history/historyapi" -) - -// ReadHistoryClient for talking to the history service -type ReadHistoryClient struct { - // ThingID of the service providing the read history capability - dThingID string - co *consumer.Consumer -} - -// GetCursor returns an iterator for ThingMessage objects containing historical events,tds or actions -// This returns a release function that MUST be called after completion. -// -// thingID the event or action belongs to -// filterOnName option filter on a specific event or action name -func (cl *ReadHistoryClient) GetCursor(thingID string, filterOnName string) ( - cursor *HistoryCursorClient, releaseFn func(), err error) { - - args := historyapi.GetCursorArgs{ - ThingID: thingID, - FilterOnName: filterOnName, - } - resp := historyapi.GetCursorResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.GetCursorMethod, &args, &resp) - cursor = NewHistoryCursorClient(cl.co, resp.CursorKey) - return cursor, cursor.Release, err -} - -// ReadHistory returns a list of historical messages in time order. -// -// thingID the event or action belongs to -// FilterOnName option filter on a specific event or action name -// timestamp to start/end -// duration number of seconds to return. Use negative number to go back in time. -// limit max nr of items to return. Use 0 for max limit -// -// This returns a list of messages and a flag indicating of all duration was returned -// or whether items were remaining. If items were remaining them use the last entry -// to continue reading the next page. -func (cl *ReadHistoryClient) ReadHistory(thingID string, filterOnName string, - timestamp time.Time, duration time.Duration, limit int) ( - batch []*messaging.ThingValue, itemsRemaining bool, err error) { - - args := historyapi.ReadHistoryArgs{ - ThingID: thingID, - FilterOnName: filterOnName, - Timestamp: timestamp.Format(time.RFC3339), - Duration: int(duration.Seconds()), - Limit: limit, - } - resp := historyapi.ReadHistoryResp{} - err = cl.co.Rpc(td.OpInvokeAction, cl.dThingID, historyapi.ReadHistoryMethod, &args, &resp) - return resp.Values, resp.ItemsRemaining, err -} - -// NewReadHistoryClient returns an instance of the read history client using the given connection -// -// invokeAction is the TD invokeAction for the invoke-action operation of the history service -func NewReadHistoryClient(co *consumer.Consumer) *ReadHistoryClient { - agentID := historyapi.AgentID - histCl := ReadHistoryClient{ - co: co, - dThingID: digitwin.MakeDigitwinID(agentID, historyapi.ReadHistoryServiceID), - } - return &histCl -} diff --git a/services/history/service/AddHistory.go b/services/history/service/AddHistory.go deleted file mode 100644 index 8d9dde41..00000000 --- a/services/history/service/AddHistory.go +++ /dev/null @@ -1,179 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "strconv" - "time" - - "github.com/araddon/dateparse" - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/messaging" - jsoniter "github.com/json-iterator/go" -) - -const DefaultMaxMessageSize = 30 - -// AddHistory adds events and actions of any Thing -type AddHistory struct { - // store with a bucket for each Thing - store buckets.IBucketStore - // onAddedValue is a callback to invoke after a value is added. Intended for tracking most recent values. - //onAddedValue func(ev *transports.IConsumer) - // - retentionMgr *ManageHistory - // Maximum message size in bytes. - MaxMessageSize int -} - -// encode a ResponseMessage into a single storage key value pair for easy storage and filtering. -// Encoding generates a key as: timestampMsec/name/a|e|p/sender, -// where a|e|p indicates message type "action", "event" or "property" -func (svc *AddHistory) encodeValue(senderID string, tv *messaging.ThingValue) (storageKey string, data []byte) { - var err error - createdTime := time.Now().UTC() - if tv.Timestamp != "" { - createdTime, err = dateparse.ParseAny(tv.Timestamp) - if err != nil { - slog.Warn("Invalid Timestamp time. Using current time instead", "created", tv.Timestamp) - createdTime = time.Now().UTC() - } - } - - // the index uses milliseconds for timestamp - timestamp := createdTime.UnixMilli() - storageKey = strconv.FormatInt(timestamp, 10) + "/" + tv.Name - if tv.AffordanceType == messaging.AffordanceTypeAction { - // TODO: actions subscriptions are currently not supported. This would be useful though. - storageKey = storageKey + "/a" - } else if tv.AffordanceType == messaging.AffordanceTypeProperty { - storageKey = storageKey + "/p" - } else { // treat everything else as events - storageKey = storageKey + "/e" - } - storageKey = storageKey + "/" + senderID - //if msg.Data != nil { - data, _ = jsoniter.Marshal(tv.Data) - //} - return storageKey, data -} - -// AddValue adds a Thing value from a sender to the action history -func (svc *AddHistory) AddValue(senderID string, tv messaging.ThingValue) error { - //slog.Info("AddValue", - // slog.String("senderID", senderID), - // slog.String("ID", tv.ID), - // slog.String("thingID", tv.ThingID), - // slog.String("name", tv.Name), - // slog.String("affordance", tv.AffordanceType), - //) - retain, err := svc.validateValue(senderID, &tv) - if err != nil { - slog.Info("AddValue value error", "err", err.Error()) - return err - } - if !retain { - slog.Debug("AddValue value not retained", - slog.String("name", tv.Name)) - return nil - } - storageKey, val := svc.encodeValue(senderID, &tv) - bucket := svc.store.GetBucket(tv.ThingID) - err = bucket.Set(storageKey, val) - _ = bucket.Close() - //if svc.onAddedValue != nil { - // svc.onAddedValue(actionValue) - //} - return err -} - -// AddMessage adds the value of an event, action or property notification to the history store -func (svc *AddHistory) AddMessage(msg *messaging.NotificationMessage) error { - // FIXME: store the action request or response? - // How to obtain the action request? - // How to subscribe to action responses? - // Option1: digitwin allows subscribing to action responses (HTOpSubscribeAction?) - not compatible, but thats okay? - // Option2: digitwin publishes an actionstatus event (OpSubscribeEvent) - thingID won't match - // Option3: digitwin publishes the last action response as notification - not a TD event - tv := messaging.ThingValue{ - //ID: msg.CorrelationID, - Name: msg.Name, - Data: msg.Value, - ThingID: msg.ThingID, - Timestamp: msg.Timestamp, - } - switch msg.Operation { - case td.OpInvokeAction: - tv.AffordanceType = messaging.AffordanceTypeAction - return svc.AddValue(msg.SenderID, tv) // response of an action - case td.OpObserveProperty: - tv.AffordanceType = messaging.AffordanceTypeProperty - return svc.AddValue(msg.SenderID, tv) - case td.OpObserveAllProperties: - // output is a key:value map - tv.AffordanceType = messaging.AffordanceTypeProperty - propMap := make(map[string]any) - err := utils.DecodeAsObject(msg.Value, &propMap) - if err != nil { - return err - } - for k, v := range propMap { - tv.Name = k - tv.Data = v - _ = svc.AddValue(msg.SenderID, tv) - } - return err - case td.OpSubscribeEvent, td.OpSubscribeAllEvents: - tv.AffordanceType = messaging.AffordanceTypeEvent - return svc.AddValue(msg.SenderID, tv) - default: - // anything else is added as an event - tv.AffordanceType = messaging.AffordanceTypeEvent - return svc.AddValue(msg.SenderID, tv) - } -} - -// validateValue checks the event has the right things address, adds a timestamp if missing and returns if it is retained -// an error will be returned if the agentID, thingID or name are empty. -// retained returns true if the value is valid and passes the retention rules -func (svc *AddHistory) validateValue(senderID string, tv *messaging.ThingValue) (retained bool, err error) { - if tv.ThingID == "" { - return false, fmt.Errorf("missing thingID in value with value name '%s'", tv.Name) - } - if tv.Name == "" { - return false, fmt.Errorf("missing name for event or action for things '%s'", tv.ThingID) - } - if senderID == "" && tv.AffordanceType == messaging.AffordanceTypeAction { - return false, fmt.Errorf("missing sender for action on thing '%s'", tv.ThingID) - } - if tv.Timestamp == "" { - tv.Timestamp = utils.FormatNowUTCMilli() - } - if svc.retentionMgr != nil { - retain, rule := svc.retentionMgr._IsRetained(tv) - if rule == nil { - slog.Debug("no retention rule found for event", - slog.String("name", tv.Name), slog.Bool("retain", retain)) - } - return retain, nil - } - - return true, nil -} - -// NewAddHistory provides the capability to add values to Thing history buckets -// -// store with a bucket for each Thing -// retentionMgr is optional and used to apply constraints to the events to add -func NewAddHistory( - store buckets.IBucketStore, retentionMgr *ManageHistory) *AddHistory { - svc := &AddHistory{ - store: store, - retentionMgr: retentionMgr, - MaxMessageSize: DefaultMaxMessageSize, - } - - return svc -} diff --git a/services/history/service/HistoryAgent.go b/services/history/service/HistoryAgent.go deleted file mode 100644 index 5fdc0dc3..00000000 --- a/services/history/service/HistoryAgent.go +++ /dev/null @@ -1,68 +0,0 @@ -package service - -import ( - "fmt" - - "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/hubagent" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyapi" -) - -// StartHistoryAgent returns a new instance of the agent for the history services. -// This uses the given connected transport for publishing events and subscribing to actions. -// The transport must be closed by the caller after use. -// If the transport is nil then use the HandleMessage method directly to pass methods to the agent, -// for example when testing. -// -// svc is the history service whose capabilities to expose -// ag is the optional connected agent connected to the server protocol -func StartHistoryAgent(svc *HistoryService, ag *agent.Agent) { - - // TODO: load latest retention rules from state store - manageHistoryMethods := map[string]interface{}{ - historyapi.GetRetentionRuleMethod: svc.manageHistSvc.GetRetentionRule, - historyapi.GetRetentionRulesMethod: svc.manageHistSvc.GetRetentionRules, - historyapi.SetRetentionRulesMethod: svc.manageHistSvc.SetRetentionRules, - } - readHistoryMethods := map[string]interface{}{ - historyapi.CursorFirstMethod: svc.readHistSvc.First, - historyapi.CursorLastMethod: svc.readHistSvc.Last, - historyapi.CursorNextMethod: svc.readHistSvc.Next, - historyapi.CursorNextNMethod: svc.readHistSvc.NextN, - historyapi.CursorPrevMethod: svc.readHistSvc.Prev, - historyapi.CursorPrevNMethod: svc.readHistSvc.PrevN, - historyapi.CursorReleaseMethod: svc.readHistSvc.Release, - historyapi.CursorSeekMethod: svc.readHistSvc.Seek, - historyapi.GetCursorMethod: svc.readHistSvc.GetCursor, - historyapi.ReadHistoryMethod: svc.readHistSvc.ReadHistory, - } - rah := hubagent.NewAgentHandler(historyapi.ReadHistoryServiceID, readHistoryMethods) - mah := hubagent.NewAgentHandler(historyapi.ManageHistoryServiceID, manageHistoryMethods) - - // receive subscribed updates for events and properties - ag.Consumer.SetNotificationHandler(func(notif *messaging.NotificationMessage) { - if notif.Operation == td.OpSubscribeEvent { - _ = svc.addHistory.AddMessage(notif) - } else if notif.Operation == td.OpObserveProperty { - _ = svc.addHistory.AddMessage(notif) - } - //ignore the rest - return - }) - - // handle service requests - ag.SetRequestHandler(func(req *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - if req.Operation == td.OpInvokeAction { - if req.ThingID == historyapi.ReadHistoryServiceID { - return rah.HandleRequest(req, c) - } else if req.ThingID == historyapi.ManageHistoryServiceID { - return mah.HandleRequest(req, c) - } - } - return req.CreateResponse(nil, fmt.Errorf("Unhandled message")) - }) - - // TODO: publish the TD -} diff --git a/services/history/service/HistoryService.go b/services/history/service/HistoryService.go deleted file mode 100644 index b2d57833..00000000 --- a/services/history/service/HistoryService.go +++ /dev/null @@ -1,117 +0,0 @@ -package service - -import ( - "log/slog" - - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/buckets" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/services/history/historyapi" -) - -// HistoryService provides storage for action and event history using the bucket store -// Each Thing has a bucket with events and actions. -// This implements the IHistoryService interface -type HistoryService struct { - - // The history service bucket store with a bucket for each Thing - bucketStore buckets.IBucketStore - // Storage of the latest properties of a things - //propsStore *LatestPropertiesStore - // the manage history sub-service - manageHistSvc *ManageHistory - // the read-history sub-service - readHistSvc *ReadHistory - - agentID string - // the messaging agent used to pubsub service to subscribe to event - ag *agent.Agent - // optional handling of pubsub events. nil if not used - //subEventHandler *PubSubEventHandler - // handler that adds history to the store - addHistory *AddHistory -} - -// GetAddHistory returns the handler for adding history. -// Intended for testing. -func (svc *HistoryService) GetAddHistory() *AddHistory { - return svc.addHistory -} - -// Start the history service -func (svc *HistoryService) Start(ag *agent.Agent) (err error) { - - slog.Info("Starting HistoryService", "clientID", ag.GetClientID()) - - // setup - svc.ag = ag - svc.agentID = ag.GetClientID() - svc.manageHistSvc = NewManageHistory(nil) - err = svc.manageHistSvc.Start() - if err == nil { - svc.readHistSvc = NewReadHistory(svc.bucketStore) - err = svc.readHistSvc.Start() - } - if err != nil { - return err - } - - // Set the required permissions for using this service - // any user roles can view the history - permissions := authz.ThingPermissions{ - AgentID: ag.GetClientID(), - ThingID: historyapi.ReadHistoryServiceID, - Deny: []authz.ClientRole{authz.ClientRoleNone}, - } - err = authz.UserSetPermissions(ag.Consumer, permissions) - - //if err == nil { - // // only admin role can manage the history - // err = myProfile.SetServicePermissions(historyapi.ManageHistoryThingID, []string{api.ClientRoleAdmin}) - //} - - // subscribe to events to add to the history store - if err == nil && svc.ag != nil { - - // handler of adding events to the history - svc.addHistory = NewAddHistory(svc.bucketStore, svc.manageHistSvc) - - // register the history service methods and listen for requests - StartHistoryAgent(svc, svc.ag) - - // TODO: add actions to the history, filtered through retention manager - // subscribe to receive the events to add to the history, filtered through the retention manager - err = svc.ag.Subscribe("", "") - err = svc.ag.ObserveProperty("", "") - } - - return err -} - -// Stop using the history service and release resources -func (svc *HistoryService) Stop() { - slog.Info("Stopping HistoryService") - if svc.readHistSvc != nil { - svc.readHistSvc.Stop() - svc.readHistSvc = nil - } - if svc.manageHistSvc != nil { - svc.manageHistSvc.Stop() - svc.manageHistSvc = nil - } - _ = svc.bucketStore.Close() -} - -// NewHistoryService creates a new instance for the history service using the given -// storage bucket. -// -// config optional configuration or nil to use defaults -// store contains an opened bucket store to use. This will be closed on Stop. -// hc connection with the hub -func NewHistoryService(store buckets.IBucketStore) *HistoryService { - - svc := &HistoryService{ - bucketStore: store, - } - return svc -} diff --git a/services/history/service/ManageHistory.go b/services/history/service/ManageHistory.go deleted file mode 100644 index b247e6c4..00000000 --- a/services/history/service/ManageHistory.go +++ /dev/null @@ -1,119 +0,0 @@ -package service - -import ( - "log/slog" - - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyapi" -) - -// ManageHistory provides the capability to manage how history is captured -type ManageHistory struct { - // retention rules grouped by event ID - rules historyapi.RetentionRuleSet -} - -// return the first retention rule that applies to the given value or nil if no rule applies -func (svc *ManageHistory) _FindFirstRule(tv *messaging.ThingValue) *historyapi.RetentionRule { - // two sets of rules apply, those that match the name and those that don't filter by name - // rules with specified event names take precedence - rules1, found := svc.rules[tv.Name] - if found { - // there is a potential to optimize this for a lot of rules by - // include a nested map of agentIDs and ThingIDs for fast lookup. - // before going down that road some performance analysis needs to be done first - for _, rule := range rules1 { - if rule.ThingID == "" || rule.ThingID == tv.ThingID { - return rule - } - } - } - // rules that apply to any event/action names - rules2, found := svc.rules[""] - if found { - for _, rule := range rules2 { - if rule.ThingID == "" || rule.ThingID == tv.ThingID { - return rule - } - } - } - // no applicable rule found - return nil -} - -// _IsRetained returns the rule 'Retain' flag if a matching rule is found -// If no retention rules are defined this returns true -// If rules are defined but not found this returns false -func (svc *ManageHistory) _IsRetained(tv *messaging.ThingValue) (bool, *historyapi.RetentionRule) { - if svc.rules == nil || len(svc.rules) == 0 { - return true, nil - } - rule := svc._FindFirstRule(tv) - if rule == nil { - return false, nil - } - return rule.Retain, rule -} - -// GetRetentionRule returns the first retention rule that applies -// to the given value. -// This returns nil without error if no retention rules are defined. -// -// eventName whose retention to return -func (svc *ManageHistory) GetRetentionRule(senderID string, args *historyapi.GetRetentionRuleArgs) (resp *historyapi.GetRetentionRuleResp, err error) { - - tv := messaging.ThingValue{ - //AgentID: args.AgentID, - ThingID: args.ThingID, - Name: args.Name, - AffordanceType: messaging.AffordanceType(args.AffordanceType), - } - rule := svc._FindFirstRule(&tv) - resp = &historyapi.GetRetentionRuleResp{Rule: rule} - return resp, err -} - -// GetRetentionRules returns all retention rules -func (svc *ManageHistory) GetRetentionRules(senderID string) (*historyapi.GetRetentionRulesResp, error) { - resp := &historyapi.GetRetentionRulesResp{Rules: svc.rules} - return resp, nil -} - -// SetRetentionRules updates the retention rules set -func (svc *ManageHistory) SetRetentionRules(senderID string, args *historyapi.SetRetentionRulesArgs) error { - ruleCount := 0 - // ensure that the name in the rule matches the key in the map - for key, nameRules := range args.Rules { - for _, rule := range nameRules { - rule.Name = key - ruleCount++ - } - } - - slog.Info("SetRetentionRules", slog.Int("nr-rules", ruleCount)) - svc.rules = args.Rules - return nil -} - -// Start the history management handler. -func (svc *ManageHistory) Start() (err error) { - return nil -} - -// Stop using the history manager -func (svc *ManageHistory) Stop() { - // nothing to do here -} - -// NewManageHistory creates a new instance that implements IManageRetention -// -// defaultRules with rules from config -func NewManageHistory(defaultRules historyapi.RetentionRuleSet) *ManageHistory { - if defaultRules == nil { - defaultRules = make(historyapi.RetentionRuleSet) - } - svc := &ManageHistory{ - rules: defaultRules, - } - return svc -} diff --git a/services/history/service/ReadHistory.go b/services/history/service/ReadHistory.go deleted file mode 100644 index a9cbd743..00000000 --- a/services/history/service/ReadHistory.go +++ /dev/null @@ -1,126 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "time" - - "github.com/araddon/dateparse" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyapi" -) - -// ReadHistory provides read access to the history of things values. -type ReadHistory struct { - // routing address of the things to read history of - bucketStore buckets.IBucketStore - // cache of remote cursors - cursorCache *buckets.CursorCache - - isRunning bool -} - -// GetCursor returns an iterator for ThingMessage objects. -func (svc *ReadHistory) GetCursor( - senderID string, args historyapi.GetCursorArgs) (*historyapi.GetCursorResp, error) { - - lifespan := time.Minute - if args.LifespanSec != 0 { - lifespan = time.Duration(args.LifespanSec) * time.Second - } - if args.ThingID == "" { - return nil, fmt.Errorf("missing thingID") - } - thingAddr := args.ThingID - slog.Info("GetCursor for bucket: ", "addr", thingAddr) - bucket := svc.bucketStore.GetBucket(thingAddr) - cursor, err := bucket.Cursor() - // - if err != nil { - return nil, err - } - key := svc.cursorCache.Add(cursor, bucket, senderID, args.FilterOnName, lifespan) - resp := &historyapi.GetCursorResp{CursorKey: key} - return resp, nil -} - -// internal read history function -func (svc *ReadHistory) readHistory( - thingID string, filterOnKey string, timestamp string, durationSec int, limit int) ( - values []*messaging.ThingValue, itemsRemaining bool, err error) { - - values = make([]*messaging.ThingValue, 0) - - if limit <= 0 { - limit = historyapi.DefaultLimit - } - if thingID == "" { - return nil, false, fmt.Errorf("missing thingID") - } - bucket := svc.bucketStore.GetBucket(thingID) - cursor, err := bucket.Cursor() - if err != nil { - return nil, false, err - } - defer cursor.Release() - - ts, _ := dateparse.ParseAny(timestamp) - item0, valid := svc.seek(cursor, ts, filterOnKey) - if valid { - // item0 is nil when seek after the last available item - values = append(values, item0) - } - var batch []*messaging.ThingValue - until := ts.Add(time.Duration(durationSec) * time.Second) - if durationSec > 0 { - // read forward in time - batch, itemsRemaining = svc.nextN(cursor, filterOnKey, until, limit) - } else { - // read backwards in time - batch, itemsRemaining = svc.prevN(cursor, filterOnKey, until, limit) - } - values = append(values, batch...) - return values, itemsRemaining, err -} - -// ReadHistory the history for the given time, duration and limit -// For more extensive result use the cursor -// To go back in time use the negative duration. -func (svc *ReadHistory) ReadHistory( - _ string, args historyapi.ReadHistoryArgs) (resp historyapi.ReadHistoryResp, err error) { - - items, remaining, err := svc.readHistory( - args.ThingID, args.FilterOnName, args.Timestamp, args.Duration, args.Limit) - - resp.Values = items - resp.ItemsRemaining = remaining - return resp, err -} - -// Start the read history handler. -// This starts the cursor cache -func (svc *ReadHistory) Start() (err error) { - svc.cursorCache.Start() - return nil -} - -// Stop the read history capability -// this unsubscribes from requests and stops the cursor cleanup task. -func (svc *ReadHistory) Stop() { - svc.isRunning = false - svc.cursorCache.Stop() -} - -// NewReadHistory starts the capability to read from a things's history -// -// hc with the message bus connection. Its ID will be used as the agentID that provides the capability. -// thingBucket is the open bucket used to store history data -func NewReadHistory(bucketStore buckets.IBucketStore) (svc *ReadHistory) { - - svc = &ReadHistory{ - bucketStore: bucketStore, - cursorCache: buckets.NewCursorCache(), - } - return svc -} diff --git a/services/history/service/ReadHistoryCursor.go b/services/history/service/ReadHistoryCursor.go deleted file mode 100644 index 0aa4fa66..00000000 --- a/services/history/service/ReadHistoryCursor.go +++ /dev/null @@ -1,431 +0,0 @@ -package service - -import ( - "log/slog" - "strconv" - "strings" - "time" - - "github.com/araddon/dateparse" - "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyapi" - jsoniter "github.com/json-iterator/go" -) - -// key of filter by event/action name, stored in context -//const filterContextKey = "name" - -// The HistoryCursor contains the bucket instance created for a cursor. -// It is created when a cursor is requested, stored in the cursorCache and -// released when the cursor is released or expires. -//type HistoryCursor struct { -// // agentID -// agentID string // -// thingID string -// filterName string // optional event name to filter on -// bucket buckets.IBucket // bucket being iterator -// bc buckets.IBucketCursor // the iteration -//} - -// decodeValue convert the storage key and raw data to a things value object -// this must match the encoding done in AddHistory -// -// If this returns an error with valid true, then the caller should ignore -// this entry and continue with the next value (if any). -// -// bucketID is the ID of the bucket, which is the digital twin thingID -// storageKey is the value's key, which is defined as timestamp/valueKey -// raw is the serialized message data -// -// This returns the value, or nil if the key is invalid -// If the json in the store is invalid this returns an error -func decodeValue(bucketID string, storageKey string, raw []byte) ( - thingValue *messaging.ThingValue, valid bool, err error) { - - var senderID string - // key is constructed as timestamp/name/{a|e|c}/sender, where sender can be omitted - parts := strings.Split(storageKey, "/") - if len(parts) < 2 { - // the key is invalid so return no-more-data - return thingValue, false, nil - } - createdMsec, _ := strconv.ParseInt(parts[0], 10, 64) - createdTime := time.UnixMilli(createdMsec) - name := parts[1] - valueType := messaging.AffordanceTypeEvent - if len(parts) >= 2 { - if parts[2] == "a" { - valueType = messaging.AffordanceTypeAction - } else if parts[2] == "p" { - valueType = messaging.AffordanceTypeProperty - } - } - if len(parts) > 3 { - senderID = parts[3] - } - // FIXME: keep the correlationID? serialize the ResponseMessage - var data interface{} - err = jsoniter.Unmarshal(raw, &data) - if err != nil { - // the stored data cannot be unmarshalled. This is unexpected! - // the caller should continue with the next record as the rest of the - // history might still be valid. - slog.Error("decodeValue, stored data cannot be unmarshalled", - "thingID", bucketID, "name", name, "err", err.Error()) - } - - thingValue = &messaging.ThingValue{ - ThingID: bucketID, // digital twin thingID that includes the agent prefix - Name: name, - Data: data, - Timestamp: utils.FormatUTCMilli(createdTime), - AffordanceType: valueType, - } - _ = senderID - return thingValue, true, err -} - -// First returns the oldest value in the history -func (svc *ReadHistory) First(senderID string, args historyapi.CursorArgs) (*historyapi.CursorSingleResp, error) { - until := time.Now().UTC() - - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - k, raw, valid := cursor.First() - if !valid { - // bucket is empty - return nil, nil - } - - tm, valid, err := decodeValue(cursor.BucketID(), k, raw) - filterName := ci.Filter - if valid && filterName != "" && tm.Name != filterName { - tm, valid = svc.next(cursor, filterName, until) - } - resp := historyapi.CursorSingleResp{ - Value: tm, - Valid: valid, - } - return &resp, err -} - -// Last positions the cursor at the last key in the ordered list -func (svc *ReadHistory) Last(senderID string, args historyapi.CursorArgs) (*historyapi.CursorSingleResp, error) { - - // the beginning of time? - until := time.Time{} - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - k, raw, valid := cursor.Last() - - resp := &historyapi.CursorSingleResp{ - Value: nil, - Valid: valid, - } - - if !valid { - // bucket is empty - return resp, nil - } - thingValue, valid, err := decodeValue(cursor.BucketID(), k, raw) - filterName := ci.Filter - - // search back to the last valid value without an error - if (valid || err != nil) && filterName != "" && thingValue.Name != filterName { - thingValue, valid = svc.prev(cursor, filterName, until) - } - resp.Value = thingValue - resp.Valid = valid - return resp, nil -} - -// next iterates the cursor until the next value containing 'name' is found and the -// timestamp doesn't exceed untilTime. -// A successive call with an increased timestamp should return the next batch of results. Intended -// to iterated an hours/day/week at a time. -// This returns the next value, or nil if the value was not found. -// -// cursor to iterate -// name is the event name to match -// until is the time not to exceed in the result. Intended to avoid unnecessary iteration in range queries -func (svc *ReadHistory) next( - cursor buckets.IBucketCursor, name string, until time.Time) ( - thingValue *messaging.ThingValue, found bool) { - - untilMilli := until.UnixMilli() - found = false - for { - k, raw, valid := cursor.Next() - if !valid { - // key is invalid. This means we reached the end of cursor - return nil, false - } - // key is constructed as {timestamp}/{name}/{a|e|c}/{sender} - parts := strings.Split(k, "/") - if len(parts) != 4 { - // key exists but is invalid. skip this entry - slog.Warn("findNextName: invalid name", "name", k) - } else { - // check timestamp and name must match - timestampmsec, _ := strconv.ParseInt(parts[0], 10, 64) - if untilMilli > 0 && timestampmsec > untilMilli { - // we passed the given time limit - // undo the last step so that followup requests with a new time limit can include this result - cursor.Prev() - return thingValue, false - } - if name == "" || name == parts[1] { - // found a match. Decode and return it - thingValue, found, err := decodeValue(cursor.BucketID(), k, raw) - if err == nil { - return thingValue, found - } - // the data was invalid. ignore this entry - } - // name doesn't match. Skip this entry - } - } -} - -// Next moves the cursor to the next key from the current cursor -// First() or Seek must have been called first. -// This returns an error if the cursor is not found. -func (svc *ReadHistory) Next(senderID string, args historyapi.CursorArgs) (*historyapi.CursorSingleResp, error) { - - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - until := time.Now() - value, valid := svc.next(cursor, ci.Filter, until) - resp := historyapi.CursorSingleResp{ - Value: value, - Valid: valid, - } - - return &resp, nil -} - -// Read the next number of items until time or count limit is reached -func (svc *ReadHistory) nextN( - cursor buckets.IBucketCursor, filterKey string, endTime time.Time, limit int) ( - items []*messaging.ThingValue, itemsRemaining bool) { - - items = make([]*messaging.ThingValue, 0, limit) - itemsRemaining = true - - for i := 0; i < limit; i++ { - value, valid := svc.next(cursor, filterKey, endTime) - if !valid { - itemsRemaining = false - break - } - items = append(items, value) - } - return items, itemsRemaining -} - -// NextN moves the cursor to the next N places from the current cursor -// and return a list with N values in incremental time order. -// itemsRemaining is false if the iterator has reached the end. -// Intended to speed up with batch iterations over rpc. -func (svc *ReadHistory) NextN(senderID string, args historyapi.CursorNArgs) (*historyapi.CursorNResp, error) { - - until := time.Now() - if args.Until != "" { - until, _ = dateparse.ParseAny(args.Until) - } - limit := args.Limit - if limit <= 0 { - limit = historyapi.DefaultLimit - } - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - values, itemsRemaining := svc.nextN(cursor, ci.Filter, until, limit) - - resp := &historyapi.CursorNResp{} - resp.Values = values - resp.ItemsRemaining = itemsRemaining - return resp, nil -} - -// prev iterates the cursor until the previous value passes the filters and the -// timestamp is not before 'until' time. -// -// This supports 2 filters, a key of the value and a timestamp. -// Since key and timestamp are part of the bucket key these checks are fast. -// -// This returns the previous value, or nil if the value was not found. -// -// cursor is a valid bucket cursor -// name is the value name (event,prop,action) to match or "" for all keys -// until is the limit of the time to read. Intended for time-range queries and -// to avoid unnecessary iteration in range queries -func (svc *ReadHistory) prev( - cursor buckets.IBucketCursor, name string, until time.Time) ( - thingValue *messaging.ThingValue, found bool) { - - untilMilli := until.UnixMilli() - found = false - for { - k, raw, valid := cursor.Prev() - if !valid { - // key is invalid. This means we reached the beginning of cursor - return thingValue, false - } - // key is constructed as {timestamp}/{valueName}/{a|e|c}/sender - parts := strings.Split(k, "/") - if len(parts) != 4 { - // key exists but is invalid. skip this entry - } else { - // check timestamp and name must match - timestampmsec, _ := strconv.ParseInt(parts[0], 10, 64) - if timestampmsec < untilMilli { - // we passed the given time limit - // undo the last step so that followup requests with a new time limit can include this result - cursor.Next() - return nil, false - } - - if name == "" || name == parts[1] { - // found a match. Decode and return it - thingValue, found, err := decodeValue(cursor.BucketID(), k, raw) - if err == nil { - return thingValue, found - } - // the data was invalid for unknown reason. Skip this entry. - } - // filter doesn't match. Skip this entry - } - } -} - -// Read the previous number of items until time or count limit is reached -func (svc *ReadHistory) prevN( - cursor buckets.IBucketCursor, filterKey string, endTime time.Time, limit int) ( - items []*messaging.ThingValue, itemsRemaining bool) { - - items = make([]*messaging.ThingValue, 0, limit) - itemsRemaining = true - - for i := 0; i < limit; i++ { - value, valid := svc.prev(cursor, filterKey, endTime) - if !valid { - itemsRemaining = false - break - } - items = append(items, value) - } - return items, itemsRemaining -} - -// Prev moves the cursor to the previous key from the current cursor -// Last() or Seek must have been called first. -func (svc *ReadHistory) Prev( - senderID string, args historyapi.CursorArgs) (*historyapi.CursorSingleResp, error) { - - until := time.Time{} - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - value, valid := svc.prev(cursor, ci.Filter, until) - resp := historyapi.CursorSingleResp{ - Value: value, - Valid: valid, - } - return &resp, nil -} - -// PrevN returns up to N results or until the time limit is reached. -// itemsRemaining is true if the iterator has reached the count limit, indicating -// that more items can be read before the time limit is reached. -func (svc *ReadHistory) PrevN(senderID string, args historyapi.CursorNArgs) (*historyapi.CursorNResp, error) { - - until := time.Time{} // zero time - if args.Until != "" { - until, _ = dateparse.ParseAny(args.Until) - } - limit := args.Limit - if limit <= 0 { - limit = historyapi.DefaultLimit - } - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - - values, itemsRemaining := svc.prevN(cursor, ci.Filter, until, limit) - - resp := &historyapi.CursorNResp{} - resp.Values = values - resp.ItemsRemaining = itemsRemaining - return resp, nil -} - -// Release closes the bucket and cursor -// This invalidates all values obtained from the cursor -func (svc *ReadHistory) Release(senderID string, args historyapi.CursorReleaseArgs) error { - - return svc.cursorCache.Release(senderID, args.CursorKey) -} - -// seek internal function for seeking with a cursor -func (svc *ReadHistory) seek(cursor buckets.IBucketCursor, ts time.Time, key string) ( - tm *messaging.ThingValue, valid bool) { - until := time.Now() - - // search the first occurrence at or after the given timestamp - // the bucket index uses the stringified timestamp - msec := ts.UnixMilli() - searchKey := strconv.FormatInt(msec, 10) - - k, raw, valid := cursor.Seek(searchKey) - if !valid { - // bucket is empty, no error - return nil, valid - } - thingValue, valid, err := decodeValue(cursor.BucketID(), k, raw) - if err != nil { - // the value cannot be decoded, skip this entry - thingValue, valid = svc.next(cursor, key, until) - } else if valid && key != "" && thingValue.Name != key { - thingValue, valid = svc.next(cursor, key, until) - } - return thingValue, valid -} - -// Seek positions the cursor at the given searchKey and corresponding value. -// If the key is not found, the next key is returned. -// This returns an error if the cursor is not found. -func (svc *ReadHistory) Seek(senderID string, args historyapi.CursorSeekArgs) ( - *historyapi.CursorSingleResp, error) { - - slog.Info("Seek using timestamp", - slog.String("timestamp", args.TimeStamp), - ) - - cursor, ci, err := svc.cursorCache.Get(args.CursorKey, senderID, true) - if err != nil { - return nil, err - } - - // search the first occurrence at or after the given timestamp - // the buck index uses the stringified timestamp - ts, _ := dateparse.ParseAny(args.TimeStamp) - thingValue, valid := svc.seek(cursor, ts, ci.Filter) - - resp := &historyapi.CursorSingleResp{ - Value: thingValue, - Valid: valid, - } - return resp, nil -} diff --git a/services/hiveoview/cmd/main.go b/services/hiveoview/cmd/main.go index a2a7346d..93f0d1fc 100644 --- a/services/hiveoview/cmd/main.go +++ b/services/hiveoview/cmd/main.go @@ -8,20 +8,20 @@ import ( "path" "time" - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/logging" + "github.com/hiveot/hivekit/go/modules/certs" + "github.com/hiveot/hivekit/go/modules/certs/certutils" + "github.com/hiveot/hivekit/go/modules/factory" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/runtime" "github.com/hiveot/hub/services/hiveoview/config" "github.com/hiveot/hub/services/hiveoview/src/service" ) const defaultServerPort = 8443 -const serverCertFile = runtime.DefaultServerCertFile +const serverCertFile = certs.DefaultServerCertFile // FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key -const serverKeyFile = runtime.DefaultServerKeyFile +const serverKeyFile = certs.DefaultServerKeyFile const TemplateRootPath = "services/hiveoview/src" // hiveoview will use the 'hubKey.pem' key and hubCert.pem for the server cert @@ -45,15 +45,15 @@ func main() { flag.IntVar(&serverPort, "defaultServerPort", serverPort, "Webserver listening defaultServerPort") flag.BoolVar(&extfs, "extfs", extfs, "Use external gohtml filesystem") - env := plugin.GetAppEnvironment("", true) + env := factory.NewAppEnvironment("", true) env.LogLevel = "info" - logging.SetLogging(env.LogLevel, "") + utils.SetLogging(env.LogLevel, "") // this config will be replaced with hiveoview Thing config cfg := config.NewHiveoviewConfig(serverPort) _ = env.LoadConfig(&cfg) // each app instance has its own storage directory - storageDir := path.Join(env.StoresDir, env.ClientID) + storageDir := path.Join(env.StoresDir, env.AppID) //storeDir := path.Join(env.StoresDir, "hiveoview") //err := os.MkdirAll(storeDir, 0700) @@ -62,13 +62,8 @@ func main() { // panic(err.Error()) //} // serve the hiveoview web pages - keyData, err := os.ReadFile(env.KeyFile) - if err == nil { - k := keys.NewEd25519Key() - err = k.ImportPrivate(string(keyData)) - signingKey = k.PrivateKey().(ed25519.PrivateKey) - //signingKey, err = jwt.ParseECPrivateKeyFromPEM(keyData) - } + signingKey, _, err := utils.LoadPrivateKey(env.KeyFile) + // development only, serve files and parse templates from filesystem rootPath := "" if extfs { @@ -78,7 +73,7 @@ func main() { // A server certificate is needed in the certs directory serverCertPath := path.Join(env.CertsDir, serverCertFile) serverKeyPath := path.Join(env.CertsDir, serverKeyFile) - serverCert, err := certs.LoadTLSCertFromPEM(serverCertPath, serverKeyPath) + serverCert, err := certutils.LoadTLSCertFromPEM(serverCertPath, serverKeyPath) if err != nil { slog.Error("Unable to load server certificate: " + err.Error()) return @@ -86,10 +81,10 @@ func main() { // FIXME: currently timeout is set to 20 sec to allow slow requests to complete. // However these requests should handle multiple async updates instead. // for example zwavejs refresh device info can take up to 20sec - svc := service.NewHiveovService( + svc := service.NewHiveovService(ag, cfg.ServerPort, false, signingKey, rootPath, serverCert, env.CaCert, time.Second*20, storageDir) // StartPlugin will connect to the hub and wait for a signal to end. - plugin.StartPlugin(svc, env.ClientID, env.CertsDir, env.ServerURL) + plugin.StartPlugin(svc, env.AppID, env.CertsDir, env.ServerURL) } diff --git a/services/hiveoview/src/service/CreateRoutes.go b/services/hiveoview/src/service/CreateRoutes.go index 3eee6747..f10a4ea8 100644 --- a/services/hiveoview/src/service/CreateRoutes.go +++ b/services/hiveoview/src/service/CreateRoutes.go @@ -4,9 +4,10 @@ import ( "net/http" "time" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/hiveot/hub/lib/servers/httpbasic" + "github.com/hiveot/hivekit/go/modules/transport/httpbasic" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/about" diff --git a/services/hiveoview/src/service/HiveoviewService.go b/services/hiveoview/src/service/HiveoviewService.go index 42f788df..75c0976d 100644 --- a/services/hiveoview/src/service/HiveoviewService.go +++ b/services/hiveoview/src/service/HiveoviewService.go @@ -13,12 +13,15 @@ import ( "path" "time" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/buckets/kvbtree" - "github.com/hiveot/hub/lib/servers/tlsserver" - authz "github.com/hiveot/hub/runtime/authz/api" + "github.com/hiveot/hivekit/go/modules" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/modules/bucketstore" + bucketstorepkg "github.com/hiveot/hivekit/go/modules/bucketstore/pkg" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/modules/transport/tlsserver" + tlsserverpkg "github.com/hiveot/hivekit/go/modules/transport/tlsserver/pkg" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views" @@ -30,6 +33,8 @@ const HiveoviewStoreName = "hiveoview.kvbtree" // It utilizes gin, htmx and TempL for serving html. // credits go to: https://github.com/marco-souza/gx/blob/main/cmd/server/server.go type HiveoviewService struct { + modules.HiveModuleBase + //serverAddr string // listening address port int // listening port dev bool // development configuration @@ -43,7 +48,7 @@ type HiveoviewService struct { // tls server serverCert *tls.Certificate caCert *x509.Certificate - tlsServer *tlsserver.TLSServer + tlsServer transport.IHttpServer // a web session per connections sm *session.WebSessionManager @@ -63,7 +68,7 @@ type HiveoviewService struct { // backend storage of UI configuration for clients by clientID storeDir string - configStore buckets.IBucketStore + configStore bucketstore.IBucketStore } //func (svc *HiveoviewService) GetServerURL() string { @@ -81,31 +86,32 @@ func (svc *HiveoviewService) GetSM() *session.WebSessionManager { // This is invoked by the plugin library. // // ag is the service agent connection to the hub for publishing notifications -func (svc *HiveoviewService) Start(ag *agent.Agent) error { - slog.Info("Starting HiveoviewService", "clientID", ag.GetClientID()) - svc.ag = ag +func (svc *HiveoviewService) Start() error { + slog.Info("Starting HiveoviewService", "clientID", svc.ag.GetThingID()) storePath := path.Join(svc.storeDir, HiveoviewStoreName) - svc.configStore = kvbtree.NewKVStore(storePath) + // svc.configStore = kvbtree.NewKVStore(storePath) + svc.configStore = bucketstorepkg.NewKVBTreeStore(storePath) err := svc.configStore.Open() if err != nil { return err } // publish a TD for the service and set allowable roles in this case only a management capability is published - err = authz.UserSetPermissions(ag.Consumer, authz.ThingPermissions{ - AgentID: ag.GetClientID(), - ThingID: src.HiveoviewServiceID, - Allow: []authz.ClientRole{authz.ClientRoleAdmin, authz.ClientRoleService, authz.ClientRoleManager}, - }) - if err != nil { - slog.Error("failed to set the hiveoview service permissions", "err", err.Error()) - } + // authzClient := authzpkg.NewAuthzClient(ag) + // err = authz.UserSetPermissions(ag, authz.ThingPermissions{ + // AgentID: ag.GetClientID(), + // ThingID: src.HiveoviewServiceID, + // Allow: []authz.ClientRole{authz.ClientRoleAdmin, authz.ClientRoleService, authz.ClientRoleManager}, + // }) + // if err != nil { + // slog.Error("failed to set the hiveoview service permissions", "err", err.Error()) + // } // Setup the handling of incoming web sessions // re-use the runtime connection manager svc.sm = session.NewWebSessionManager( - svc.signingKey, svc.caCert, ag, svc.configStore, svc.timeout) + svc.ag, svc.signingKey, svc.caCert, svc.configStore, svc.timeout) // parse the templates svc.tm.ParseAllTemplates() @@ -121,9 +127,10 @@ func (svc *HiveoviewService) Start(ag *agent.Agent) error { // use the hub url. if svc.serverCert != nil { - tlsServer, router := tlsserver.NewTLSServer( - "", svc.port, svc.serverCert, svc.caCert) + httpCfg := tlsserver.NewTLSServerConfig("", svc.port, svc.serverCert, svc.caCert, true) + tlsServer := tlsserverpkg.NewTLSServer(httpCfg, authenticator) + router := tlsServer.GetProtectedRoute() svc.CreateRoutes(router, svc.rootPath) svc.tlsServer = tlsServer err = tlsServer.Start() @@ -146,7 +153,7 @@ func (svc *HiveoviewService) Start(ag *agent.Agent) error { } // last, publish this service's TD and properties _ = svc.PublishServiceTD() - _ = svc.PublishServiceProps() + svc.PublishServiceProps() return nil } @@ -154,7 +161,7 @@ func (svc *HiveoviewService) Start(ag *agent.Agent) error { func (svc *HiveoviewService) Stop() { slog.Info("Stopping HiveoviewService") // TODO: send event the service has stopped - svc.ag.Disconnect() + svc.ag.Stop() svc.sm.CloseAllWebSessions() if svc.tlsServer != nil { svc.tlsServer.Stop() @@ -174,6 +181,7 @@ func (svc *HiveoviewService) Stop() { // This must contain static/, views/ and webc/ directories. // If empty, the embedded filesystem is used. // +// ag is the agent used to connect to the hub // serverPort is the port of the web server will listen on // debug to enable debugging output // signingKey used to sign cookies. Using nil means that a server restart will invalidate the cookies @@ -182,7 +190,8 @@ func (svc *HiveoviewService) Stop() { // caCert server CA certificate // timeout of client hub connections // storeDir path to directory holding client session and dashboard data -func NewHiveovService(serverPort int, debug bool, +func NewHiveovService( + ag *agent.Agent, serverPort int, debug bool, signingKey ed25519.PrivateKey, rootPath string, serverCert *tls.Certificate, caCert *x509.Certificate, timeout time.Duration, @@ -196,17 +205,20 @@ func NewHiveovService(serverPort int, debug bool, _, signingKey, _ = ed25519.GenerateKey(rand.Reader) } tm := views.InitTemplateManager(templatePath) - svc := HiveoviewService{ - port: serverPort, - shouldUpdate: true, - debug: debug, - signingKey: signingKey, - rootPath: rootPath, - tm: tm, - serverCert: serverCert, - caCert: caCert, - storeDir: storeDir, - timeout: timeout, + svc := &HiveoviewService{ + HiveModuleBase: *modules.NewHiveModuleBase(src.HiveoviewServiceID, 0), + ag: ag, + port: serverPort, + shouldUpdate: true, + debug: debug, + signingKey: signingKey, + rootPath: rootPath, + tm: tm, + serverCert: serverCert, + caCert: caCert, + storeDir: storeDir, + timeout: timeout, } - return &svc + var _ modules.IHiveModule = svc // interface check + return svc } diff --git a/services/hiveoview/src/service/PublishServiceTD.go b/services/hiveoview/src/service/PublishServiceTD.go index 6ecaf1b6..0a8278ae 100644 --- a/services/hiveoview/src/service/PublishServiceTD.go +++ b/services/hiveoview/src/service/PublishServiceTD.go @@ -7,7 +7,6 @@ import ( "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" "github.com/hiveot/hivekit/go/utils" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/hiveot/hub/services/hiveoview/src" jsoniter "github.com/json-iterator/go" ) @@ -39,7 +38,7 @@ func (svc *HiveoviewService) PublishServiceTD() error { myTD := svc.CreateServiceTD() tdJSON, _ := jsoniter.MarshalToString(myTD) - err := digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, tdJSON) + err := svc.ag.WriteTD(tdJSON) //err := svc.ag.UpdateThing(myTD) if err != nil { @@ -49,10 +48,6 @@ func (svc *HiveoviewService) PublishServiceTD() error { } // PublishServiceProps publishes the service properties -func (svc *HiveoviewService) PublishServiceProps() error { - err := svc.ag.PubProperty(src.HiveoviewServiceID, vocab.PropNetPort, svc.port) - if err != nil { - slog.Error("failed to publish the hiveoview service properties", "err", err.Error()) - } - return err +func (svc *HiveoviewService) PublishServiceProps() { + svc.ag.PubProperty(src.HiveoviewServiceID, vocab.PropNetPort, svc.port) } diff --git a/services/hiveoview/src/service/SseServe.go b/services/hiveoview/src/service/SseServe.go index 07f6cf25..cc78a0b7 100644 --- a/services/hiveoview/src/service/SseServe.go +++ b/services/hiveoview/src/service/SseServe.go @@ -5,7 +5,6 @@ import ( "log/slog" "net/http" - "github.com/hiveot/hub/lib/servers/hiveotsseserver" "github.com/hiveot/hub/services/hiveoview/src/session" ) @@ -44,7 +43,7 @@ func SseServe(w http.ResponseWriter, r *http.Request) { sseChan := cs.NewSseChan() // _send a ping event as the go-sse client doesn't have a 'connected callback' // (borrow the event name from the transports SSE server) - pingEvent := session.SSEEvent{Event: hiveotsseserver.SSEPingEvent} + pingEvent := session.SSEEvent{Event: SSEPingEvent} sseChan <- pingEvent clientID := cs.GetConsumer().GetClientID() diff --git a/services/hiveoview/src/session/ClientData.go b/services/hiveoview/src/session/ClientData.go index 0b41b959..5b4b3f30 100644 --- a/services/hiveoview/src/session/ClientData.go +++ b/services/hiveoview/src/session/ClientData.go @@ -7,7 +7,7 @@ import ( "slices" "sync" - "github.com/hiveot/hub/lib/buckets" + "github.com/hiveot/hivekit/go/modules/bucketstore" jsoniter "github.com/json-iterator/go" ) @@ -23,7 +23,7 @@ type SessionData struct { DashboardIds []string // storage bucket of client state - dataBucket buckets.IBucket + dataBucket bucketstore.IBucket // UI preferences ... } @@ -163,7 +163,7 @@ func (model *SessionData) UpdateDashboard(d *DashboardModel) error { // model.dataChanged = true //} -func NewClientDataModel(dataBucket buckets.IBucket) *SessionData { +func NewClientDataModel(dataBucket bucketstore.IBucket) *SessionData { model := SessionData{ mux: sync.RWMutex{}, dataBucket: dataBucket, diff --git a/services/hiveoview/src/session/DashboardModel.go b/services/hiveoview/src/session/DashboardModel.go index 14680e4a..bf560cbc 100644 --- a/services/hiveoview/src/session/DashboardModel.go +++ b/services/hiveoview/src/session/DashboardModel.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "github.com/hiveot/hub/lib/messaging" + "github.com/hiveot/hivekit/go/api/msg" "github.com/teris-io/shortid" ) @@ -41,7 +41,7 @@ var TileTypesLabels = map[string]string{ // The corresponding TD affordance is provided through a lookup method. type TileSource struct { // Affordance to present "property", "event" or "action" - AffordanceType messaging.AffordanceType `json:"affordanceType"` + AffordanceType msg.AffordanceType `json:"affordanceType"` // ThingID source ThingID string `json:"thingID"` // Event/property name diff --git a/services/hiveoview/src/session/SessionManager.go b/services/hiveoview/src/session/SessionManager.go index cc7a4ef0..1cbc0deb 100644 --- a/services/hiveoview/src/session/SessionManager.go +++ b/services/hiveoview/src/session/SessionManager.go @@ -9,13 +9,12 @@ import ( "sync" "time" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/clients/authclient" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" + "github.com/hiveot/hivekit/go/modules/agent" + authnpkg "github.com/hiveot/hivekit/go/modules/authn/pkg" + "github.com/hiveot/hivekit/go/modules/bucketstore" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/modules/transport/clients" "github.com/hiveot/hub/services/hiveoview/src" ) @@ -41,7 +40,7 @@ type WebSessionManager struct { ag *agent.Agent // persistence of dashboard configuration - configStore buckets.IBucketStore + configStore bucketstore.IBucketStore // timeout for hub connections timeout time.Duration @@ -49,7 +48,7 @@ type WebSessionManager struct { // add a new session with the given hub connection and send a session count event func (sm *WebSessionManager) _addSession( - r *http.Request, cid string, cc messaging.IClientConnection) ( + r *http.Request, cid string, cc transport.ITransportClient) ( cs *WebClientSession, err error) { // if the browser does not provide a CID until after the first connection, @@ -58,10 +57,10 @@ func (sm *WebSessionManager) _addSession( // // FIXME: the first connection (without cid) doesn't shutdown until it gains // a sse connection and loses it again. - cinfo := cc.GetConnectionInfo() + clientID := cc.GetClientID() sm.mux.Lock() - clcid := cinfo.ClientID + "-" + cid + clcid := clientID + "-" + cid existingSession := sm.sessions[clcid] if existingSession != nil { // Caution: Attempt to add a new connection while one exists with the cid. @@ -71,7 +70,7 @@ func (sm *WebSessionManager) _addSession( cs = existingSession //exhccid := existingSession.cid //GetConnectionID() slog.Warn("session with clcid already exists. replace its connection.", - slog.String("clientID", cinfo.ClientID), + slog.String("clientID", clientID), slog.String("clcid", cs.clcid), ) @@ -79,11 +78,12 @@ func (sm *WebSessionManager) _addSession( // session (plus its waiting for a lock). // If the connection has been replaced then it won't match so ignore the // callback if the connection differs. - co := consumer.NewConsumer(cc, sm.timeout) + co := consumer.NewConsumer(cc) + co.SetTimeout(sm.timeout) existingSession.ReplaceConsumer(co) } else { - co := consumer.NewConsumer(cc, sm.timeout) - clientID := co.GetClientID() + co := consumer.NewConsumer(cc) + co.SetTimeout(sm.timeout) clientBucket := sm.configStore.GetBucket(clientID) cs = NewWebClientSession(cid, co, r.RemoteAddr, clientBucket, sm.onClose) @@ -96,13 +96,13 @@ func (sm *WebSessionManager) _addSession( //maxAge := time.Hour * 24 * 14 //err = SetSessionCookie(w, clientID, newToken, maxAge, sm.signingKey) slog.Info("_addSession", - slog.String("clientID", cinfo.ClientID), + slog.String("clientID", clientID), slog.String("clcid", cs.clcid), slog.String("remoteAdddr", r.RemoteAddr), slog.String("cc.cid", cinfo.ConnectionID), slog.Int("nr sessions", len(sm.sessions)), ) - _ = sm.ag.PubEvent(src.HiveoviewServiceID, src.NrActiveSessionsEvent, nrSessions) + sm.ag.PubEvent(src.HiveoviewServiceID, src.NrActiveSessionsEvent, nrSessions) return cs, err } @@ -143,7 +143,7 @@ func (sm *WebSessionManager) _removeSession(cs *WebClientSession) { //slog.String("hc.cid", hccid), ) go func() { - _ = sm.ag.PubEvent(src.HiveoviewServiceID, src.NrActiveSessionsEvent, nrSessions) + sm.ag.PubEvent(src.HiveoviewServiceID, src.NrActiveSessionsEvent, nrSessions) }() } @@ -200,7 +200,8 @@ func (sm *WebSessionManager) HandleConnectWithPassword( // Authentication uses its own client that knows the auth protocol parts, _ := url.Parse(sm.hubURL) - authCl := authclient.NewAuthClient(parts.Host, sm.caCert, sm.timeout) + authCl := authnpkg.NewUserAuthnHttpClient(parts.Host, sm.caCert) + authCl.SetTimeout(sm.timeout) // attempt to login newToken, err = authCl.LoginWithPassword(loginID, password) @@ -209,13 +210,17 @@ func (sm *WebSessionManager) HandleConnectWithPassword( return "", err } // FIXME: use the session's directory cache to get the form - cc, err := clients.ConnectWithToken(loginID, newToken, sm.caCert, sm.hubURL, sm.timeout) + // cc, err := clients.ConnectWithToken(loginID, newToken, sm.caCert, sm.hubURL, sm.timeout) + cc, err := clients.NewTransportClient("", sm.hubURL, sm.caCert) + cc.SetTimeout(sm.timeout) + cc.AuthenticateWithToken(loginID, newToken) + err = cc.Connect() if err == nil { if cid != "" { _, err = sm._addSession(r, cid, cc) _ = err } else { - cc.Disconnect() + cc.Close() } // Update the session cookie with the new auth token (default 14 days) maxAge := time.Hour * 24 * 14 @@ -304,7 +309,7 @@ func (sm *WebSessionManager) GetSessionFromCookie(r *http.Request) ( // The UI is supposed to supply a cid header in sse requests. However, // sse-connect ignores the hx-header that contains the cid. As a workaround // also check query parameters. - cid = r.Header.Get(httpbasic.ConnectionIDHeader) + cid = r.Header.Get(transport.ConnectionIDHeader) if cid == "" { //slog.Error("GetSessionFromCookie: Missing CID") cid = r.URL.Query().Get("cid") @@ -322,15 +327,15 @@ func (sm *WebSessionManager) GetSessionFromCookie(r *http.Request) ( // NewWebSessionManager creates a new instance of the hiveoview service // session manager. // +// ag is the agent connecting to the Hub for reporting notifications and handling config // hubURL to connect the clients // signingKey for use with session cookies // caCert of the hub -// hc is the agent service connection for reporting notifications and handling config // configStore data store for client configuration // timeout of hub connections func NewWebSessionManager( - signingKey ed25519.PrivateKey, caCert *x509.Certificate, - ag *agent.Agent, configStore buckets.IBucketStore, + ag *agent.Agent, signingKey ed25519.PrivateKey, caCert *x509.Certificate, + configStore bucketstore.IBucketStore, timeout time.Duration) *WebSessionManager { cc := ag.GetConnection() diff --git a/services/hiveoview/src/session/WebClientSession.go b/services/hiveoview/src/session/WebClientSession.go index 8ae2887f..8a4a3eb4 100644 --- a/services/hiveoview/src/session/WebClientSession.go +++ b/services/hiveoview/src/session/WebClientSession.go @@ -9,12 +9,12 @@ import ( "sync/atomic" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/modules/bucketstore" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/modules/transport" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/buckets" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/messaging" ) type NotifyType string @@ -66,7 +66,7 @@ type WebClientSession struct { co *consumer.Consumer // Holder of consumed things for this session - ctDir *consumedthing.ConsumedThingsDirectory + ctDir *consumer.ConsumedThingsDirectory // flag, this session is active and can be used to send messages to // the hub. If sseChan is set then the return channel is active too. @@ -98,7 +98,7 @@ type WebClientSession struct { // Consume is short for consumedThingSession.Consume() // This returns nil with an error if the thing TD cannot be found func (sess *WebClientSession) Consume( - thingID string) (ct *consumedthing.ConsumedThing, err error) { + thingID string) (ct *consumer.ConsumedThing, err error) { return sess.ctDir.Consume(thingID) } @@ -133,7 +133,7 @@ func (sess *WebClientSession) GetLastError() error { } // GetConsumedThingsDirectory returns the directory of consumed things of this client -func (sess *WebClientSession) GetConsumedThingsDirectory() *consumedthing.ConsumedThingsDirectory { +func (sess *WebClientSession) GetConsumedThingsDirectory() *consumer.ConsumedThingsDirectory { return sess.ctDir } @@ -181,7 +181,7 @@ func (sess *WebClientSession) HandleWebConnectionClosed() { if sess.sseChan == nil { // disconnect from the hub. This will call back into 'HandleHubConnectionClosed' // which will end the session. - sess.co.Disconnect() + sess.co.Stop() } sess.mux.RUnlock() //}() @@ -268,7 +268,7 @@ func (sess *WebClientSession) NewSseChan() chan SSEEvent { } // onHubConnectionChange is invoked on hub client disconnect/reconnect -func (sess *WebClientSession) onHubConnectionChange(connected bool, err error, c messaging.IConnection) { +func (sess *WebClientSession) onHubConnectionChange(connected bool, err error, c transport.IConnection) { lastErrText := "" slog.Debug("onHubConnectionChange", @@ -292,7 +292,7 @@ func (sess *WebClientSession) onHubConnectionChange(connected bool, err error, c // onNotification notifies SSE clients of incoming notifications from the Hub // This is intended for notifying the client UI of the update to props or events. // The consumed thing itself is already updated. -func (sess *WebClientSession) onNotification(notif *messaging.NotificationMessage) { +func (sess *WebClientSession) onNotification(notif *msg.NotificationMessage) { //slog.Debug("received notification", // slog.String("operation", notif.Operation), @@ -310,12 +310,12 @@ func (sess *WebClientSession) onNotification(notif *messaging.NotificationMessag // hx-trigger="sse:{{.AffordanceType}}/{{.ThingID}}/{{.Name}}" // TODO: can htmx work with the ResponseMessage or InteractionOutput object? propID := fmt.Sprintf("%s/%s/%s", - messaging.AffordanceTypeProperty, notif.ThingID, notif.Name) + msg.AffordanceTypeProperty, notif.ThingID, notif.Name) propVal := utils.DecodeAsString(notif.Value, 0) sess.SendSSE(propID, propVal) // also notify of a change to updated timestamp propID = fmt.Sprintf("%s/%s/%s/updated", - messaging.AffordanceTypeProperty, notif.ThingID, notif.Name) + msg.AffordanceTypeProperty, notif.ThingID, notif.Name) sess.SendSSE(propID, utils.FormatDateTime(notif.Timestamp)) } else if notif.Operation == td.OpSubscribeEvent { // Publish sse event indicating the event affordance or value has changed. @@ -324,10 +324,10 @@ func (sess *WebClientSession) onNotification(notif *messaging.NotificationMessag // hx-trigger="sse:{{.Thing.ThingID}}/{{$k}}" // where $k is the event ID eventID := fmt.Sprintf("%s/%s/%s", - messaging.AffordanceTypeEvent, notif.ThingID, notif.Name) + msg.AffordanceTypeEvent, notif.ThingID, notif.Name) sess.SendSSE(eventID, notif.ToString(0)) eventID = fmt.Sprintf("%s/%s/%s/updated", - messaging.AffordanceTypeEvent, notif.ThingID, notif.Name) + msg.AffordanceTypeEvent, notif.ThingID, notif.Name) sess.SendSSE(eventID, utils.FormatDateTime(notif.Timestamp)) } } @@ -453,7 +453,7 @@ func (sess *WebClientSession) WritePage(w http.ResponseWriter, buff *bytes.Buffe // onClose is the callback to invoke when this session is closed. func NewWebClientSession( cid string, co *consumer.Consumer, remoteAddr string, - configBucket buckets.IBucket, + configBucket bucketstore.IBucket, onClosed func(*WebClientSession)) *WebClientSession { var err error @@ -463,7 +463,7 @@ func NewWebClientSession( // The consumed things directory holds the consumed thing instances for use // by the web client. Consumed things are automatically updated when Thing // subscription updates are received. - coDir := consumedthing.NewConsumedThingsDirectory(co) + coDir := consumer.NewConsumedThingsDirectory(co) webSess := WebClientSession{ cid: cid, diff --git a/services/hiveoview/src/views/directory/DeleteTD.go b/services/hiveoview/src/views/directory/DeleteTD.go index 040458ea..3094b1a7 100644 --- a/services/hiveoview/src/views/directory/DeleteTD.go +++ b/services/hiveoview/src/views/directory/DeleteTD.go @@ -5,10 +5,9 @@ import ( "log/slog" "net/http" - "github.com/go-chi/chi/v5" + "github.com/go-chi/chi" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/utils" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -36,7 +35,8 @@ func RenderConfirmDeleteTD(w http.ResponseWriter, r *http.Request) { return } - tdJson, err = digitwin.ThingDirectoryRetrieveThing(sess.GetConsumer(), thingID) + co := sess.GetConsumer() + tdJson, err = co.RetrieveThing(thingID) if err == nil { err = jsoniter.UnmarshalFromString(tdJson, &td) } @@ -68,7 +68,8 @@ func SubmitDeleteTD(w http.ResponseWriter, r *http.Request) { } slog.Info("Deleting TD", slog.String("thingID", thingID)) - err = digitwin.ThingDirectoryDeleteThing(sess.GetConsumer(), thingID) + co := sess.GetConsumer() + err = co.DeleteThing(thingID) // reload the cached directory cts := sess.GetConsumedThingsDirectory() diff --git a/services/hiveoview/src/views/directory/GroupByAgent.go b/services/hiveoview/src/views/directory/GroupByAgent.go index 552faf5e..974bfc3b 100644 --- a/services/hiveoview/src/views/directory/GroupByAgent.go +++ b/services/hiveoview/src/views/directory/GroupByAgent.go @@ -4,7 +4,7 @@ import ( "sort" "github.com/hiveot/hivekit/go/api/td" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" + "github.com/hiveot/hivekit/go/modules/digitwin" ) // ReadDirLimit is the maximum amount of TDs to read in one call diff --git a/services/hiveoview/src/views/history/HistoryPage.go b/services/hiveoview/src/views/history/HistoryPage.go index a296e2bf..f4db4dfd 100644 --- a/services/hiveoview/src/views/history/HistoryPage.go +++ b/services/hiveoview/src/views/history/HistoryPage.go @@ -6,10 +6,10 @@ import ( "time" "github.com/araddon/dateparse" - "github.com/go-chi/chi/v5" + "github.com/go-chi/chi" + "github.com/hiveot/hivekit/go/api/msg" + historypkg "github.com/hiveot/hivekit/go/modules/history/pkg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyclient" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" ) @@ -56,8 +56,8 @@ func RenderHistoryPage(w http.ResponseWriter, r *http.Request) { return } duration := time.Second * time.Duration(durationSec) - iout := ct.GetValue(messaging.AffordanceType(affType), name) - hist := historyclient.NewReadHistoryClient(ct.GetConsumer()) + iout := ct.GetValue(msg.AffordanceType(affType), name) + hist := historypkg.NewReadHistoryClient(ct.GetConsumer()) values, itemsRemaining, err := hist.ReadHistory( iout.ThingID, iout.Name, timestamp, duration, 500) _ = itemsRemaining diff --git a/services/hiveoview/src/views/history/HistoryTemplateData.go b/services/hiveoview/src/views/history/HistoryTemplateData.go index 98be26b0..437ffc58 100644 --- a/services/hiveoview/src/views/history/HistoryTemplateData.go +++ b/services/hiveoview/src/views/history/HistoryTemplateData.go @@ -6,19 +6,18 @@ import ( "sort" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" "github.com/hiveot/hub/services/hiveoview/src" ) // HistoryTemplateData holds the data for rendering a history table or graph type HistoryTemplateData struct { // history of this interaction output - consumedthing.InteractionOutput + consumer.InteractionOutput - //AffordanceType messaging.AffordanceType Title string // allow override to data description // property or event id as published @@ -29,7 +28,7 @@ type HistoryTemplateData struct { EndTime time.Time EndTimeStr string DurationSec int - Values []*messaging.ThingValue + Values []*msg.ThingValue ItemsRemaining bool // for paging, if supported Stepped bool // stepped graph @@ -71,9 +70,9 @@ func (ht HistoryTemplateData) AsJSON() string { // GetObjectValues returns the history as an array of interaction objects // If this is an object then return an element for each object property, otherwise // return an array with 1 element. -func (ht HistoryTemplateData) GetObjectValues() []consumedthing.InteractionOutput { +func (ht HistoryTemplateData) GetObjectValues() []consumer.InteractionOutput { if ht.Schema.Type != "object" { - return []consumedthing.InteractionOutput{ht.InteractionOutput} + return []consumer.InteractionOutput{ht.InteractionOutput} } objectValues := map[string]any{} @@ -81,7 +80,7 @@ func (ht HistoryTemplateData) GetObjectValues() []consumedthing.InteractionOutpu if err == nil { err = json.Unmarshal(objectAsJson, &objectValues) } - values := make([]consumedthing.InteractionOutput, 0, len(objectValues)) + values := make([]consumer.InteractionOutput, 0, len(objectValues)) if err != nil { slog.Error("failed decoding object", "err", err.Error()) @@ -92,7 +91,7 @@ func (ht HistoryTemplateData) GetObjectValues() []consumedthing.InteractionOutpu for name, schema := range ht.Schema.Properties { raw, found := objectValues[name] _ = found - value := consumedthing.NewDataSchemaValue(raw) + value := consumer.NewDataSchemaValue(raw) iout := ht.InteractionOutput // copy iout.Title = ht.Title + " - " + schema.Title @@ -139,8 +138,8 @@ func (ht HistoryTemplateData) CompareToday() int { // endTime of the end-time of the history range // duration to read (negative for history) func NewHistoryTemplateData( - iout *consumedthing.InteractionOutput, - values []*messaging.ThingValue, + iout *consumer.InteractionOutput, + values []*msg.ThingValue, endTime time.Time, duration time.Duration) ( data *HistoryTemplateData, err error) { @@ -165,7 +164,7 @@ func NewHistoryTemplateData( hs.Stepped = iout.Schema.Type == td.DataTypeBool // TODO: (if needed) if items remaining, get the rest in an additional call - //hist := historyclient.NewReadHistoryClient(ct.GetConsumer()) + //hist := historypkg.NewReadHistoryClient(ct.GetConsumer()) //hs.Values, hs.ItemsRemaining, err = hist.ReadHistory( // iout.ThingID, iout.Name, timestamp, duration, 500) diff --git a/services/hiveoview/src/views/history/renderLatestValueRow.go b/services/hiveoview/src/views/history/renderLatestValueRow.go index 07b8b681..31259140 100644 --- a/services/hiveoview/src/views/history/renderLatestValueRow.go +++ b/services/hiveoview/src/views/history/renderLatestValueRow.go @@ -5,9 +5,11 @@ import ( "html" "net/http" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/messaging" + "github.com/hiveot/hub/services/hiveoview/src/session" ) @@ -50,7 +52,7 @@ func RenderLatestValueRow(w http.ResponseWriter, r *http.Request) { } ct, err := sess.Consume(thingID) if err == nil { - iout := ct.GetValue(messaging.AffordanceType(affType), name) + iout := ct.GetValue(msg.AffordanceType(affType), name) fragment = fmt.Sprintf(addRowTemplate, utils.FormatDateTime(iout.Timestamp, "S"), iout.Value.Text(), unit) } else { diff --git a/services/hiveoview/src/views/login/PostLogin.go b/services/hiveoview/src/views/login/PostLogin.go index 7ab99710..af451a45 100644 --- a/services/hiveoview/src/views/login/PostLogin.go +++ b/services/hiveoview/src/views/login/PostLogin.go @@ -7,7 +7,7 @@ import ( "log/slog" "net/http" - "github.com/hiveot/hub/lib/servers/httpbasic" + "github.com/hiveot/hivekit/go/modules/transport" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" jsoniter "github.com/json-iterator/go" @@ -35,9 +35,9 @@ func PostLoginFormHandler(sm *session.WebSessionManager) http.HandlerFunc { return } // hx-headers doesnt work on posting a form so use query instead to pass a CID - cid := r.Header.Get(httpbasic.ConnectionIDHeader) + cid := r.Header.Get(transport.ConnectionIDHeader) if cid == "" { - cid = r.URL.Query().Get(httpbasic.ConnectionIDHeader) + cid = r.URL.Query().Get(transport.ConnectionIDHeader) } if cid == "" { slog.Error("PostLoginFormHandler: Missing CID for client. Disconnecting", "loginID", loginID) @@ -75,10 +75,10 @@ func PostLoginFormHandler(sm *session.WebSessionManager) http.HandlerFunc { // prevent the browser from re-posting on back button or refresh (POST-Redirect-GET) pattern // A redirect apparently cannot include the custom CID header. - header.Add(httpbasic.ConnectionIDHeader, cid) // this doesn't work + header.Add(transport.ConnectionIDHeader, cid) // this doesn't work // fall back to query params redirPath := fmt.Sprintf("%s?%s=%s", - src.RenderDashboardRootPath, httpbasic.ConnectionIDHeader, cid) + src.RenderDashboardRootPath, transport.ConnectionIDHeader, cid) http.Redirect(w, r, redirPath, http.StatusSeeOther) } } @@ -107,7 +107,7 @@ func PostLoginHandler(sm *session.WebSessionManager) http.HandlerFunc { http.Redirect(w, r, src.RenderLoginPath, http.StatusBadRequest) return } - cid := r.Header.Get(httpbasic.ConnectionIDHeader) + cid := r.Header.Get(transport.ConnectionIDHeader) if cid == "" { slog.Error("PostLoginHandler: Missing CID for client. Disconnecting", "loginID", loginID) http.Error(w, "missing CID", http.StatusBadRequest) diff --git a/services/hiveoview/src/views/login/login.go b/services/hiveoview/src/views/login/login.go index 1c862a49..78ae2a93 100644 --- a/services/hiveoview/src/views/login/login.go +++ b/services/hiveoview/src/views/login/login.go @@ -5,7 +5,7 @@ import ( "log/slog" "net/http" - "github.com/hiveot/hub/lib/servers/httpbasic" + "github.com/hiveot/hivekit/go/modules/transport" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/views" "github.com/teris-io/shortid" @@ -25,7 +25,7 @@ func RenderLogin(w http.ResponseWriter, r *http.Request) { // hx-headers doesnt work on posting a form, so use query instead to pass a CID cid := "login-" + shortid.MustGenerate() - postLoginPath := fmt.Sprintf("%s?%s=%s", src.UIPostFormLoginPath, httpbasic.ConnectionIDHeader, cid) + postLoginPath := fmt.Sprintf("%s?%s=%s", src.UIPostFormLoginPath, transport.ConnectionIDHeader, cid) data := LoginTemplateData{ LoginID: "", LoginError: "", diff --git a/services/hiveoview/src/views/thing/ActionRequest.go b/services/hiveoview/src/views/thing/ActionRequest.go index 7960ee98..dc32538a 100644 --- a/services/hiveoview/src/views/thing/ActionRequest.go +++ b/services/hiveoview/src/views/thing/ActionRequest.go @@ -8,12 +8,12 @@ import ( "time" "github.com/araddon/dateparse" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" "github.com/hiveot/hivekit/go/api/vocab" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -29,17 +29,17 @@ type ActionRequestTemplateData struct { Description string // the thing instance used to apply the action - CT *consumedthing.ConsumedThing + CT *consumer.ConsumedThing // Affordance of the action to issue containing the input dataschema Action *td.ActionAffordance // input value to edit // This defaults to the last action input - InputValue *consumedthing.InteractionInput + InputValue *consumer.InteractionInput // the previous action request record - LastActionRecord *digitwin.ActionStatus + LastActionRecord *msg.ActionStatus // input value with previous action input (if any) //LastActionInput consumedthing.DataSchemaValue // previous action timestamp (formatted) @@ -103,7 +103,7 @@ func RenderActionRequest(w http.ResponseWriter, r *http.Request) { Action: actionAff, CT: ct, // The input value will be set to the last action value, if available - InputValue: consumedthing.NewInteractionInput(tdi, name, nil), + InputValue: consumer.NewInteractionInput(tdi, name, nil), Description: actionAff.Description, } if data.Description == "" { @@ -112,7 +112,8 @@ func RenderActionRequest(w http.ResponseWriter, r *http.Request) { // get last action request that was received // reading a latest value is optional - actionVal, err := digitwin.ThingValuesQueryAction(sess.GetConsumer(), name, thingID) + co := sess.GetConsumer() + actionVal, err := co.QueryAction(name, thingID) if err == nil && actionVal.Name != "" { data.LastActionRecord = &actionVal //data.PrevValue = &lastActionRecord diff --git a/services/hiveoview/src/views/thing/EditProperty.go b/services/hiveoview/src/views/thing/EditProperty.go index eb845555..a2a358ca 100644 --- a/services/hiveoview/src/views/thing/EditProperty.go +++ b/services/hiveoview/src/views/thing/EditProperty.go @@ -6,10 +6,11 @@ import ( "log/slog" "net/http" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" "github.com/hiveot/hivekit/go/api/td" + "github.com/hiveot/hivekit/go/modules/consumer" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -24,8 +25,8 @@ type RenderEditPropertyTemplateData struct { DataSchema *td.DataSchema //Value string // The last known value of the property to edit - PropertyValue consumedthing.InteractionOutput - PropertyInput consumedthing.InteractionInput + PropertyValue consumer.InteractionOutput + PropertyInput consumer.InteractionInput SubmitPropertyPath string } @@ -65,7 +66,7 @@ func getConfigValue( // FIXME: Readproperty - handled by whom? is the digitwin service name relevant // What does the wot specification for directory say? //propValue, err := digitwin.ThingValuesReadProperty(hc, name, thingID) - //io := consumedthing.NewInteractionOutputFromValue(&propValue, td) + //io := consumer.NewInteractionOutputFromValue(&propValue, td) sv = RenderEditPropertyTemplateData{ //TD: &td, ThingID: thingID, @@ -105,7 +106,7 @@ func RenderEditProperty(w http.ResponseWriter, r *http.Request) { // The posted form value contains a 'value' field func SubmitProperty(w http.ResponseWriter, r *http.Request) { var newValue any - var ct *consumedthing.ConsumedThing + var ct *consumer.ConsumedThing var propAff *td.PropertyAffordance //stat := transports.ActionStatus{} thingID := chi.URLParam(r, "thingID") diff --git a/services/hiveoview/src/views/thing/RenderInputValue.gohtml b/services/hiveoview/src/views/thing/RenderInputValue.gohtml index d9fa0094..91c5bf0b 100644 --- a/services/hiveoview/src/views/thing/RenderInputValue.gohtml +++ b/services/hiveoview/src/views/thing/RenderInputValue.gohtml @@ -5,7 +5,7 @@ Currently only native types are supported, eg text, boolean --> -{{- /*gotype:github.com/hiveot/hub/lib/consumedthing.InteractionInput*/ -}} +{{- /*gotype:github.com/hiveot/hivekit/modules/consumer.InteractionInput*/ -}} {{$minmax := ""}} {{$iv := .Value.Text}} diff --git a/services/hiveoview/src/views/thing/RenderOutputValue.gohtml b/services/hiveoview/src/views/thing/RenderOutputValue.gohtml index 0db37451..f4bb2260 100644 --- a/services/hiveoview/src/views/thing/RenderOutputValue.gohtml +++ b/services/hiveoview/src/views/thing/RenderOutputValue.gohtml @@ -7,7 +7,7 @@ --> -{{- /*gotype:github.com/hiveot/hub/lib/consumedthing.InteractionOutput*/ -}} +{{- /*gotype:github.com/hiveot/hivekit/modules/consumer.InteractionOutput*/ -}} {{/* === Integer, Number, Enum as Text Value ===*/}} {{/* Wrap the value in a link to the history page*/}} diff --git a/services/hiveoview/src/views/thing/RenderThingRaw.go b/services/hiveoview/src/views/thing/RenderThingRaw.go index 6ae75c9b..30254c2b 100644 --- a/services/hiveoview/src/views/thing/RenderThingRaw.go +++ b/services/hiveoview/src/views/thing/RenderThingRaw.go @@ -4,8 +4,7 @@ import ( "encoding/json" "net/http" - "github.com/go-chi/chi/v5" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" + "github.com/go-chi/chi" "github.com/hiveot/hub/services/hiveoview/src/session" jsoniter "github.com/json-iterator/go" ) @@ -17,8 +16,10 @@ func RenderThingRaw(w http.ResponseWriter, r *http.Request) { var tdPretty []byte // Read the TD being displayed and its latest values _, sess, err := session.GetSessionFromContext(r) + co := sess.GetConsumer() if err == nil { - tdJSON, err = digitwin.ThingDirectoryRetrieveThing(sess.GetConsumer(), thingID) + + tdJSON, err = co.RetrieveThing(thingID) } if err == nil { // re-marshal with pretty-print JSON diff --git a/services/hiveoview/src/views/thingdetails/ThingDetailsPage.go b/services/hiveoview/src/views/thingdetails/ThingDetailsPage.go index a6b0001e..75731ad2 100644 --- a/services/hiveoview/src/views/thingdetails/ThingDetailsPage.go +++ b/services/hiveoview/src/views/thingdetails/ThingDetailsPage.go @@ -7,13 +7,14 @@ import ( "strings" "time" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/vocab" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/modules/digitwin" + historypkg "github.com/hiveot/hivekit/go/modules/history/pkg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/history/historyclient" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -35,7 +36,7 @@ type ThingDetailsTemplateData struct { EventNames []string ActionNames []string - CT *consumedthing.ConsumedThing + CT *consumer.ConsumedThing // URLs RenderConfirmDeleteTDPath string @@ -48,9 +49,9 @@ func (dt *ThingDetailsTemplateData) GetEventHistory(name string) *history.Histor timestamp := time.Now().Local() duration := time.Hour * time.Duration(-24) //iout := dt.CT.ReadEvent(name) - iout := dt.CT.GetValue(messaging.AffordanceTypeEvent, name) + iout := dt.CT.GetValue(msg.AffordanceTypeEvent, name) - hist := historyclient.NewReadHistoryClient(dt.CT.GetConsumer()) + hist := historypkg.NewReadHistoryClient(dt.CT.GetConsumer()) values, itemsRemaining, err := hist.ReadHistory( iout.ThingID, iout.Name, timestamp, duration, 500) _ = itemsRemaining @@ -80,7 +81,7 @@ func (dt *ThingDetailsTemplateData) GetRenderActionPath(name string) string { func RenderThingDetails(w http.ResponseWriter, r *http.Request) { thingID := chi.URLParam(r, "thingID") agentID, _, _ := digitwin.SplitDigitwinID(thingID) - var ct *consumedthing.ConsumedThing + var ct *consumer.ConsumedThing pathParams := map[string]string{"thingID": thingID} thingData := &ThingDetailsTemplateData{ diff --git a/services/hiveoview/src/views/tile/EditTile.go b/services/hiveoview/src/views/tile/EditTile.go index 36bc7d55..ad92090b 100644 --- a/services/hiveoview/src/views/tile/EditTile.go +++ b/services/hiveoview/src/views/tile/EditTile.go @@ -7,9 +7,8 @@ import ( "strconv" "strings" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -23,8 +22,8 @@ type EditTileTemplateData struct { Tile session.DashboardTile // Values of the tile sources by sourceID (affType/thingID/name) // FIXME: used consumed thing directory to get values? - ctDir *consumedthing.ConsumedThingsDirectory - //Values map[string]*consumedthing.InteractionOutput + ctDir *consumer.ConsumedThingsDirectory + //Values map[string]*consumer.InteractionOutput // human labels for each tile type TileTypeLabels map[string]string @@ -163,7 +162,7 @@ func SubmitEditTile(w http.ResponseWriter, r *http.Request) { parts := strings.Split(s, "/") if len(parts) >= 3 { tileSource := session.TileSource{ - AffordanceType: messaging.AffordanceType(parts[0]), + AffordanceType: msg.AffordanceType(parts[0]), ThingID: parts[1], Name: parts[2], Title: sourceTitle, diff --git a/services/hiveoview/src/views/tile/RenderTile.go b/services/hiveoview/src/views/tile/RenderTile.go index 920d3433..eaa03772 100644 --- a/services/hiveoview/src/views/tile/RenderTile.go +++ b/services/hiveoview/src/views/tile/RenderTile.go @@ -5,11 +5,10 @@ import ( "net/http" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" + historypkg "github.com/hiveot/hivekit/go/modules/history/pkg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/services/history/historyclient" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" @@ -39,14 +38,14 @@ type RenderTileTemplateData struct { // viewmodel to draw live data from //VM *session.ClientViewModel - cts *consumedthing.ConsumedThingsDirectory + cts *consumer.ConsumedThingsDirectory } // GetHistory returns the 24 hour history for the given thing affordance. // This truncates the result if there are too many values in the range. // The max amount of values is the limit set in historyapi.DefaultLimit (1000) func (dt RenderTileTemplateData) GetHistory( - affType messaging.AffordanceType, thingID string, name string) *history.HistoryTemplateData { + affType msg.AffordanceType, thingID string, name string) *history.HistoryTemplateData { timestamp := time.Now().Local() ct, err := dt.cts.Consume(thingID) @@ -55,7 +54,7 @@ func (dt RenderTileTemplateData) GetHistory( } duration, _ := time.ParseDuration("-24h") iout := ct.GetValue(affType, name) - hist := historyclient.NewReadHistoryClient(ct.GetConsumer()) + hist := historypkg.NewReadHistoryClient(ct.GetConsumer()) values, itemsRemaining, err := hist.ReadHistory( iout.ThingID, iout.Name, timestamp, duration, 500) _ = itemsRemaining @@ -71,15 +70,15 @@ func (dt RenderTileTemplateData) GetHistory( // tileSource whose value to display // // This returns the interaction output to display -func (d RenderTileTemplateData) GetOutputValue(tileSource session.TileSource) (iout *consumedthing.InteractionOutput) { +func (d RenderTileTemplateData) GetOutputValue(tileSource session.TileSource) (iout *consumer.InteractionOutput) { ct, _ := d.cts.Consume(tileSource.ThingID) if ct == nil { // Thing not found. return a dummy interaction output with a non-schema - dummy := consumedthing.InteractionOutput{} + dummy := consumer.InteractionOutput{} dummy.AffordanceType = "unknown" dummy.ThingID = tileSource.ThingID dummy.Name = tileSource.Name - dummy.Value = consumedthing.NewDataSchemaValue("n/a") + dummy.Value = consumer.NewDataSchemaValue("n/a") dummy.Schema = &td.DataSchema{ Title: "NewInteractionOutput: unknown thing", } @@ -87,13 +86,13 @@ func (d RenderTileTemplateData) GetOutputValue(tileSource session.TileSource) (i return &dummy } - if tileSource.AffordanceType == messaging.AffordanceTypeAction { + if tileSource.AffordanceType == msg.AffordanceTypeAction { aff := ct.GetActionAff(tileSource.Name) if aff != nil { ct.QueryAction(tileSource.Name) iout = ct.GetActionOutput(tileSource.Name) } - } else if tileSource.AffordanceType == messaging.AffordanceTypeEvent { + } else if tileSource.AffordanceType == msg.AffordanceTypeEvent { iout = ct.GetEventOutput(tileSource.Name) } else { // must be a property @@ -101,7 +100,7 @@ func (d RenderTileTemplateData) GetOutputValue(tileSource session.TileSource) (i } if iout == nil { - iout = consumedthing.NewInteractionOutput(ct, + iout = consumer.NewInteractionOutput(ct, tileSource.AffordanceType, tileSource.Name, "no output value", "") } diff --git a/services/hiveoview/src/views/tile/SelectSources.go b/services/hiveoview/src/views/tile/SelectSources.go index 4ab8d15e..a9788869 100644 --- a/services/hiveoview/src/views/tile/SelectSources.go +++ b/services/hiveoview/src/views/tile/SelectSources.go @@ -3,9 +3,8 @@ package tile import ( "net/http" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" "github.com/hiveot/hub/services/hiveoview/src/session" "github.com/hiveot/hub/services/hiveoview/src/views/app" "github.com/hiveot/hub/services/hiveoview/src/views/directory" @@ -14,17 +13,17 @@ import ( const RenderSelectSourceTemplate = "SelectSources.gohtml" type RenderSelectSourcesTemplateData struct { - ctDir *consumedthing.ConsumedThingsDirectory + ctDir *consumer.ConsumedThingsDirectory AgentThings []*directory.AgentThings // map of thing latest event values //Values map[string]transports.IConsumerMap // Map of thingID to thing interaction affordances - //IOValues map[string]consumedthing.InteractionOutputMap + //IOValues map[string]consumer.InteractionOutputMap } // GetActionValue returns the action value of a source -func (data RenderSelectSourcesTemplateData) GetActionValue(thingID, name string) *consumedthing.InteractionOutput { - return data.GetValue(messaging.AffordanceTypeAction, thingID, name) +func (data RenderSelectSourcesTemplateData) GetActionValue(thingID, name string) *consumer.InteractionOutput { + return data.GetValue(msg.AffordanceTypeAction, thingID, name) } // TODO: use interactionoutput from consumed thing? @@ -35,27 +34,27 @@ func (data RenderSelectSourcesTemplateData) GetActionValue(thingID, name string) // alternatively, show only the schema and get the value on demand? // GetEventValue returns the property value of a tile source -func (data RenderSelectSourcesTemplateData) GetEventValue(thingID, name string) *consumedthing.InteractionOutput { - return data.GetValue(messaging.AffordanceTypeEvent, thingID, name) +func (data RenderSelectSourcesTemplateData) GetEventValue(thingID, name string) *consumer.InteractionOutput { + return data.GetValue(msg.AffordanceTypeEvent, thingID, name) } // GetPropertyValue returns the property value of a tile source -func (data RenderSelectSourcesTemplateData) GetPropertyValue(thingID, name string) *consumedthing.InteractionOutput { - return data.GetValue(messaging.AffordanceTypeProperty, thingID, name) +func (data RenderSelectSourcesTemplateData) GetPropertyValue(thingID, name string) *consumer.InteractionOutput { + return data.GetValue(msg.AffordanceTypeProperty, thingID, name) } // GetValue returns the value of a tile source func (data RenderSelectSourcesTemplateData) GetValue( - affType messaging.AffordanceType, thingID, name string) *consumedthing.InteractionOutput { + affType msg.AffordanceType, thingID, name string) *consumer.InteractionOutput { ct, err := data.ctDir.Consume(thingID) if err != nil { // should never happen, but just in case - return consumedthing.NoValue() + return consumer.NoValue() } iout := ct.GetValue(affType, name) return iout } -func (data RenderSelectSourcesTemplateData) GetUpdated(affType messaging.AffordanceType, thingID, name string) string { +func (data RenderSelectSourcesTemplateData) GetUpdated(affType msg.AffordanceType, thingID, name string) string { ct, err := data.ctDir.Consume(thingID) if err != nil { return "" diff --git a/services/hiveoview/src/views/tile/card/RenderCardInput.gohtml b/services/hiveoview/src/views/tile/card/RenderCardInput.gohtml index e3548f48..0d58685e 100644 --- a/services/hiveoview/src/views/tile/card/RenderCardInput.gohtml +++ b/services/hiveoview/src/views/tile/card/RenderCardInput.gohtml @@ -3,7 +3,7 @@ Intended for interaction such as a checkbox or switch --> -{{- /*gotype:github.com/hiveot/hub/lib/consumedthing.InteractionOutput*/ -}} +{{- /*gotype:github.com/hiveot/hivekit/modules/consumer.InteractionOutput*/ -}} {{if eq .Schema.Type "boolean"}} {{/* === Boolean Switch Input ===*/}} diff --git a/services/hiveoview/src/views/tile/renderTileSourceRow.go b/services/hiveoview/src/views/tile/renderTileSourceRow.go index 26cc4020..bb632234 100644 --- a/services/hiveoview/src/views/tile/renderTileSourceRow.go +++ b/services/hiveoview/src/views/tile/renderTileSourceRow.go @@ -5,10 +5,10 @@ import ( "html" "net/http" + "github.com/go-chi/chi" "github.com/go-chi/chi/v5" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/consumedthing" - "github.com/hiveot/hub/lib/messaging" "github.com/hiveot/hub/services/hiveoview/src/session" ) @@ -60,10 +60,10 @@ func RenderTileSourceRow(w http.ResponseWriter, r *http.Request) { cts := sess.GetConsumedThingsDirectory() // get the latest event values of this source - var iout *consumedthing.InteractionOutput + var iout *consumer.InteractionOutput ct, err := cts.Consume(thingID) if ct != nil { - iout = ct.GetValue(messaging.AffordanceType(affType), name) + iout = ct.GetValue(msg.AffordanceType(affType), name) } if err == nil && iout == nil { err = fmt.Errorf("RenderTileSourceRow: No such affordance!?: %s", sourceID) diff --git a/services/hiveoview/src/views/tile/selectSourcesRow.gohtml b/services/hiveoview/src/views/tile/selectSourcesRow.gohtml index 1ec84712..ccd6e6e8 100644 --- a/services/hiveoview/src/views/tile/selectSourcesRow.gohtml +++ b/services/hiveoview/src/views/tile/selectSourcesRow.gohtml @@ -1,4 +1,4 @@ -{{- /*gotype: github.com/hiveot/hub/lib/consumedthing.InteractionOutput*/ -}} +{{- /*gotype:github.com/hiveot/hivekit/modules/consumer.InteractionOutput*/ -}}
  • {{/* hx-get the html of the source and inject (hx-target) it in the edit tile dialog*/}} diff --git a/services/hiveoview/test/Hiveov_test.go b/services/hiveoview/test/Hiveov_test.go index 88f65768..292429d2 100644 --- a/services/hiveoview/test/Hiveov_test.go +++ b/services/hiveoview/test/Hiveov_test.go @@ -10,14 +10,14 @@ import ( "testing" "time" + "github.com/hiveot/hivekit/go/api/msg" "github.com/hiveot/hivekit/go/api/td" - "github.com/hiveot/hub/lib/clients/hiveotsseclient" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/httpbasic" - "github.com/hiveot/hub/lib/testenv" - authz "github.com/hiveot/hub/runtime/authz/api" + "github.com/hiveot/hivekit/go/modules/authn" + "github.com/hiveot/hivekit/go/modules/transport" + ssescpkg "github.com/hiveot/hivekit/go/modules/transport/ssesc/pkg" + tlsclientpkg "github.com/hiveot/hivekit/go/modules/transport/tlsclient/pkg" + "github.com/hiveot/hivekit/go/testenv" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/services/hiveoview/src" "github.com/hiveot/hub/services/hiveoview/src/service" jsoniter "github.com/json-iterator/go" @@ -37,7 +37,7 @@ const timeout = time.Second * 100 var testFolder = path.Join(os.TempDir(), "test-hiveoview") // the following are set by the testmain -var ts *testenv.TestServer +var testEnv *testenv.TestEnv // return the form with href for login operations to the hiveoview server // these must match the paths in hiveoview createRoutes. @@ -70,12 +70,12 @@ func getHiveoviewForm(op, thingID, name string) *td.Form { // The TestLogin test must succeed before using this. // This returns a client. Call Close() when done. func WebLogin(sseURL string, clientID string, - onConnection func(bool, error, messaging.IConnection), - onNotification messaging.NotificationHandler, - onRequest messaging.RequestHandler, - onResponse messaging.ResponseHandler, + onConnection func(bool, error, transport.IConnection), + onNotification msg.NotificationHandler, + onRequest msg.RequestHandler, + onResponse msg.ResponseHandler, ) ( - cl messaging.IConnection, err error) { + cl transport.IConnection, err error) { //sseCl := clients.NewHubClient(sseURL, clientID, ts.Certs.CaCert) // websocket client @@ -89,8 +89,8 @@ func WebLogin(sseURL string, clientID string, // htmx sse triggers rely on this format. (for now) // FIXME: can htmx sse trigger using additional fields (type=notification, thingID/name=blah?) // or is this too painful in htmx. - sseCl := hiveotsseclient.NewHiveotSseClient(sseURL, - clientID, nil, ts.Certs.CaCert, + sseCl := ssescpkg.NewHiveotSseClient(sseURL, + clientID, nil, testEnv.CertBundle.CaCert, getHiveoviewForm, time.Minute) // hiveoview uses a different login path as the hub @@ -131,19 +131,22 @@ func WebLogin(sseURL string, clientID string, func TestMain(m *testing.M) { var err error + var cancelFn func() + // raise loglevel where you want it in testing - logging.SetLogging("warn", "") + utils.SetLogging("warn", "") // clean start _ = os.RemoveAll(testFolder) err = os.MkdirAll(testFolder, 0700) - ts = testenv.StartTestServer(true) + testEnv, cancelFn = testenv.StartTestEnv(true) if err != nil { + cancelFn() panic(err) } res := m.Run() - ts.Stop() + cancelFn() os.Exit(res) } @@ -151,11 +154,11 @@ func TestStartStop(t *testing.T) { t.Log("--- TestStartStop ---") storeDir := testFolder - svc := service.NewHiveovService(servicePort, true, nil, "", - ts.Certs.ServerCert, ts.Certs.CaCert, timeout, storeDir) - hc1, _ := ts.AddConnectService(serviceID) + ag1, _ := testEnv.NewConnectedClient(serviceID, authn.ClientRoleService) + svc := service.NewHiveovService(ag1, servicePort, true, nil, "", + testEnv.CertBundle.ServerCert, testEnv.CertBundle.CaCert, timeout, storeDir) - err := svc.Start(hc1) + err := svc.Start() require.NoError(t, err) time.Sleep(time.Second * 3) svc.Stop() @@ -168,29 +171,28 @@ func TestLogin(t *testing.T) { // 1: setup: start a runtime and service; this generates an error that // the state service isnt found. ignore it. - svc := service.NewHiveovService(servicePort, true, nil, - "", ts.Certs.ServerCert, ts.Certs.CaCert, timeout, storeDir) - avcAg, _ := ts.AddConnectService(serviceID) - require.NotNil(t, avcAg) - defer avcAg.Disconnect() - err := svc.Start(avcAg) + ag1, cc1, _ := testEnv.NewRCAgent(serviceID, nil) + require.NotNil(t, ag1) + defer cc1.Close() + svc := service.NewHiveovService(ag1, servicePort, true, nil, + "", testEnv.CertBundle.ServerCert, testEnv.CertBundle.CaCert, timeout, storeDir) + err := svc.Start() require.NoError(t, err) defer svc.Stop() - // make sure the client to login as exists - cl1, _, token1 := ts.AddConnectConsumer(clientID1, authz.ClientRoleOperator) + // make sure the client to login exists + cl1, _, token1 := testEnv.NewConnectedConsumer(clientID1, authn.ClientRoleOperator, false) //defer cl1.Disconnect() - cl1.Disconnect() + cl1.Stop() _ = token1 time.Sleep(time.Millisecond * 10) // 2: login using plain TLS connection and a form hostPort := fmt.Sprintf("localhost:%d", servicePort) - cl2 := tlsclient.NewTLSClient( - hostPort, nil, ts.Certs.CaCert, time.Second*60) + cl2 := tlsclientpkg.NewTLSClient(hostPort, testEnv.CertBundle.CaCert, time.Second*60) // hiveot http requires a connection-id to link the return channel. - cl2.SetHeader(httpbasic.ConnectionIDHeader, shortid.MustGenerate()) + cl2.SetHeader(transport.ConnectionIDHeader, shortid.MustGenerate()) // try login. The test user password is the clientID // authenticate the connection with the hiveot http/sse service (not the hub server) @@ -220,7 +222,7 @@ func TestMultiConnectDisconnect(t *testing.T) { const agentID = "agent1" const testConnections = 1 const eventName = "event1" - var webClients = make([]messaging.IConnection, 0) + var webClients = make([]transport.IConnection, 0) var connectCount atomic.Int32 var disConnectCount atomic.Int32 var messageCount atomic.Int32 @@ -228,34 +230,34 @@ func TestMultiConnectDisconnect(t *testing.T) { storeDir := testFolder - logging.SetLogging("info", "") + utils.SetLogging("info", "") // 1: setup: start a runtime and service; this generates an error that // the state service isnt found. ignore it. - svc := service.NewHiveovService(servicePort, true, nil, - "", ts.Certs.ServerCert, ts.Certs.CaCert, timeout, storeDir) - avcAg, _ := ts.AddConnectService(serviceID) - err := svc.Start(avcAg) + avcAg, _ := testEnv.AddConnectService(serviceID) + svc := service.NewHiveovService(avcAg, servicePort, true, nil, + "", testEnv.CertBundle.ServerCert, testEnv.CertBundle.CaCert, timeout, storeDir) + err := svc.Start() require.NoError(t, err) defer svc.Stop() // the agent for publishing events. A TD is needed for them to be accepted. - ag1, _, _ := ts.AddConnectAgent(agentID) + ag1, _, _ := testEnv.AddConnectAgent(agentID) _ = ag1 - td1 := ts.AddTD(agentID, nil) + td1 := testEnv.AddTD(agentID, nil) _ = td1 // create the user account this test is going to connect as. // no notifications are expected as it doesnt subscribe // hiveoview server only supports HTTP/SSE - co1, _, token1 := ts.AddConnectConsumer(clientID1, authz.ClientRoleOperator) + co1, _, token1 := testEnv.AddConnectConsumer(clientID1, authn.ClientRoleOperator) defer co1.Disconnect() require.NoError(t, err) time.Sleep(waitamoment) _ = token1 //handler for web connection notifications - onConnection := func(connected bool, err error, _ messaging.IConnection) { + onConnection := func(connected bool, err error, _ transport.IConnection) { if connected { connectCount.Add(1) } else { @@ -263,10 +265,10 @@ func TestMultiConnectDisconnect(t *testing.T) { } } // handler hiveoview SSE notifications - onNotification := func(msg *messaging.NotificationMessage) { + onNotification := func(notif *msg.NotificationMessage) { // the UI expects this format for triggering htmx expectedType := fmt.Sprintf("event/dtw:%s:%s/%s", agentID, td1.ID, eventName) - if msg.Operation == expectedType { + if notif.Operation == expectedType { messageCount.Add(1) } return @@ -306,7 +308,7 @@ func TestMultiConnectDisconnect(t *testing.T) { for _, c := range webClients { // disconnect the client connection - c.Disconnect() + c.Close() time.Sleep(waitamoment) } //time.Sleep(waitamoment) diff --git a/services/idprov/IdProv_test.go b/services/idprov/IdProv_test.go deleted file mode 100644 index ce75f025..00000000 --- a/services/idprov/IdProv_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package idprov_test - -import ( - "fmt" - "os" - "testing" - "time" - - "github.com/hiveot/hub/lib/agent" - clients "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/keys" - "github.com/hiveot/hub/lib/testenv" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/services/idprov/idprovapi" - "github.com/hiveot/hub/services/idprov/idprovclient" - "github.com/hiveot/hub/services/idprov/service" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/logging" -) - -// when testing using the capnp RPC -var testPort = 23001 - -// the following are set by newIdProvService -var ts *testenv.TestServer - -// Create a new store, delete if it already exists -func newIdProvService() (svc *service.IdProvService, co1 *consumer.Consumer, stopFn func()) { - - ts = testenv.StartTestServer(true) - agentConn, token1 := ts.AddConnectService(idprovapi.AgentID) - _ = token1 - svc = service.NewIdProvService(testPort, ts.Certs.ServerCert, ts.Certs.CaCert) - err := svc.Start(agentConn) - if err != nil { - panic("failed starting service: " + err.Error()) - } - - //ag := service.StartIdProvAgent(svc.ManageIdProv, ag) - //_ = ag - - // create an end user client for testing - co1, _, token2 := ts.AddConnectConsumer("test-client", authz.ClientRoleManager) - _ = token2 - return svc, co1, func() { - co1.Disconnect() - svc.Stop() - agentConn.Disconnect() - ts.Stop() - } -} - -func TestMain(m *testing.M) { - logging.SetLogging("info", "") - - res := m.Run() - - os.Exit(res) -} - -// Test starting the provisioning service -func TestStartStop(t *testing.T) { - t.Log("TestStartStop") - svc, hc, stopFn := newIdProvService() - _ = svc - _ = hc - time.Sleep(time.Second) - stopFn() -} - -func TestAutomaticProvisioning(t *testing.T) { - t.Log("TestAutomaticProvisioning") - const device1ID = "device1" - const device2ID = "device2" - - svc, co, stopFn := newIdProvService() - _ = svc - defer stopFn() - - mngCl := idprovclient.NewIdProvManageClient(co) - device1KP := keys.NewEcdsaKey() - device2KP := keys.NewEcdsaKey() - - approvedDevices := make([]idprovapi.PreApprovedClient, 2) - approvedDevices[0] = idprovapi.PreApprovedClient{ - ClientID: device1ID, ClientType: authn.ClientTypeAgent, PubKey: device1KP.ExportPublic()} - approvedDevices[1] = idprovapi.PreApprovedClient{ - ClientID: device2ID, ClientType: authn.ClientTypeAgent, PubKey: device2KP.ExportPublic()} - - err := mngCl.PreApproveDevices(approvedDevices) - assert.NoError(t, err) - - // next, provisioning should succeed - hostPort := fmt.Sprintf("localhost:%d", testPort) - tlsClient := tlsclient.NewTLSClient(hostPort, nil, ts.Certs.CaCert, 0) - //tlsClient.ConnectNoAuth() - status, token1, err := idprovclient.SubmitIdProvRequest( - device1ID, device1KP.ExportPublic(), "", tlsClient) - require.NoError(t, err) - - assert.Equal(t, device1ID, status.ClientID) - assert.NotEmpty(t, token1) - assert.False(t, status.Pending) - assert.NotEmpty(t, status.ApprovedMSE) - - // provisioned device should show up in the list of approved devices - approved, err := mngCl.GetRequests(true, true, true) - assert.NoError(t, err) - require.True(t, len(approved) > 0) - hasDevice1 := false - for _, a := range approved { - if a.ClientID == device1ID { - hasDevice1 = true - } - } - assert.True(t, hasDevice1) - - // token should be used to connect - srvURL := ts.GetServerURL(authn.ClientTypeAgent) - - cc, err := clients.ConnectWithToken(device1ID, token1, ts.Certs.CaCert, srvURL, 0) - //agConn, err := clients.NewClient(device1ID, ts.Certs.CaCert, ts.GetForm, srvURL, 0) - require.NoError(t, err) - //err = agConn.ConnectWithToken(token1) - //require.NoError(t, err) - ag1 := agent.NewAgent(cc, nil, nil, nil, nil, 0) - //ag1.SetRetryConnect(false) - ag1.Disconnect() -} - -func TestAutomaticProvisioningBadParameters(t *testing.T) { - t.Log("TestAutomaticProvisioningBadParameters") - const device1ID = "device1" - - device1Keys := keys.NewKey(keys.KeyTypeEd25519) - device1PubPEM := device1Keys.ExportPublic() - - svc, co, stopFn := newIdProvService() - _ = svc - defer stopFn() - mngCl := idprovclient.NewIdProvManageClient(co) - - approvedDevices := make([]idprovapi.PreApprovedClient, 2) - approvedDevices[0] = idprovapi.PreApprovedClient{ - ClientID: device1ID, PubKey: device1PubPEM} - - err := mngCl.PreApproveDevices(approvedDevices) - assert.NoError(t, err) - - // test missing deviceID - hostPort := "localhost:9002" - tlsClient := tlsclient.NewTLSClient(hostPort, nil, ts.Certs.CaCert, 0) - status, tokenEnc, err := idprovclient.SubmitIdProvRequest( - "", device1PubPEM, "", tlsClient) - assert.Error(t, err) - assert.Empty(t, status) - assert.Empty(t, tokenEnc) - - // test missing public key - status, tokenEnc, err = idprovclient.SubmitIdProvRequest( - device1ID, "", "", tlsClient) - assert.Error(t, err) - assert.Empty(t, status) - assert.Empty(t, tokenEnc) - - // test bad public key - status, tokenEnc, err = idprovclient.SubmitIdProvRequest( - device1ID, "badpubkey", "", tlsClient) - require.Error(t, err) - assert.Empty(t, status) - assert.Empty(t, tokenEnc) -} - -func TestManualProvisioning(t *testing.T) { - t.Log("TestManualProvisioning") - const device1ID = "device1" - device1Keys := keys.NewKey(keys.KeyTypeEd25519) - device1PubPEM := device1Keys.ExportPublic() - - svc, co, stopFn := newIdProvService() - mngCl := idprovclient.NewIdProvManageClient(co) - _ = svc - defer stopFn() - - // request provisioning - hostPort := fmt.Sprintf("localhost:%d", testPort) - tlsClient := tlsclient.NewTLSClient(hostPort, nil, ts.Certs.CaCert, 0) - //tlsClient.ConnectNoAuth() - status, token, err := idprovclient.SubmitIdProvRequest(device1ID, device1PubPEM, "", tlsClient) - require.NoError(t, err) - - assert.Equal(t, device1ID, status.ClientID) - assert.Empty(t, token) - assert.True(t, status.Pending) - assert.NotEmpty(t, status.ReceivedMSE) - - // provisioned device should be added to the list of pending devices - pendingList, err := mngCl.GetRequests(true, false, false) - require.NoError(t, err) - require.True(t, len(pendingList) > 0) - assert.Equal(t, device1ID, pendingList[0].ClientID) - - approvedList, err := mngCl.GetRequests(false, true, false) - assert.NoError(t, err) - assert.True(t, len(approvedList) == 0) - - // Stage 2: approve the request - err = mngCl.ApproveRequest(device1ID, authn.ClientTypeAgent) - assert.NoError(t, err) - - // provisioning request should now succeed - status, _, err = idprovclient.SubmitIdProvRequest( - device1ID, device1PubPEM, "", tlsClient) - require.NoError(t, err) - require.False(t, status.Pending) - require.NotEmpty(t, status.ApprovedMSE) - require.Empty(t, status.RejectedMSE) - - // provisioned device should now show up in the list of approved devices - approvedList, err = mngCl.GetRequests(false, true, false) - assert.NoError(t, err) - require.True(t, len(approvedList) > 0) - assert.Equal(t, device1ID, approvedList[0].ClientID) - - pendingList, err = mngCl.GetRequests(true, false, false) - assert.NoError(t, err) - require.True(t, len(pendingList) == 0) -} diff --git a/services/idprov/README.md b/services/idprov/README.md deleted file mode 100644 index 0489a1fa..00000000 --- a/services/idprov/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# IdProv - IoT Device Provisioning - -## Objective - -Provides a simple means to securely provision IoT devices using out-of-band device verification and bulk provisioning. - -Provisioned devices receive a device token that can be used to authenticate themselves with the Hub. - -## Status - -This service is being converted to work with the HiveOT digital twin runtime. Breaking changes can still be expected. - -TODO: -1. ~~run as external service for the new runtime~~ -2. Add monitoring for rogue DNS-SD publications -2. TBD: Checks if multiple connections are made by the same device using different device IDs -3. TBD: flag suspect devices -4. TBD: MAC address check during provisioning - -## Summary - -The purpose of this service is to enable fast and easy onboarding of one or multiple IoT devices without the need to setup or configure the device itself. - -The typical use-case is that upon installation of one or more IoT devices, the administrator collects the device ID and corresponding out-of-band secret and provides these to the provisioning server using the commandline utility or Hub management web interface. When the devices are powered on they auto-provision following these steps: - -1. The administrator uploads the pre-approved list of device IDs with their public key as provided by the factory. A MAC address can be used instead of a public key, in which case the device's MAC will be matched and the device's public key recorded. -1. The IoT device discovers the provisioning service on the local network using DNS-SD. -1. The IoT device connects to the server on the provisioning port and initiates the start of the provisioning process, providing its actual ID, public key and MAC address. -1. The server verifies the ID, public key and MAC address. - A: if client ID is unknown then the administrator must approve the request. The server returns a 'pending approval' answer. - B: if the client ID and public key are known, the client is added and a short lived auth token is returned. - C: if the client ID and MAC are known, the client is added along with the provided public key and a short-lived auth token is returned. -5. After receiving the token, the client uses it to connect to the Hub with its own ID. As the token is short lived, the device must immediately refresh the token from the auth service. The lifespan of tokens issued by the auth service is configurable and defaults to 1 month. -6. Periodically the device must check the remaining token lifespan and requests a new token from the auth service when less than half its lifespan remains. The lifespan can be determined by decoding the JWT token and verifying the expiry date. - -Devices that are pre-loaded with the server CA certificate, are secured against a rogue server as establishing a connection with this server will only succeed with the valid server. - -Devices that do not possess the server CA certificate are susceptible to a man-in-the-middle attack that relays the provisioning request to the actual service to obtain a device token. -To mitigate the risk of issuing tokens to rogue agents, the Hub: -1. monitors the network for DNS-SD records of provisioning servers. If a rogue provisioning server is detected it notifies the administrator and disables the provisioning process until the administrator re-enables it. (todo) -2. Checks if multiple provisioning requests come from the same device. Flag the device for review. (todo) -3. Checks if multiple connections are made by the same device using different device IDs. Flag the devices for review. (todo) -4. Checks if the auth token matches the client MAC address provided during provisioning. Flag the device for review. (todo) - -Administrators can further reduce the risk by: -1. Preload the devices public keys into the service. Only known devices are issued a token. A rogue agent can't use the issued token as it doesn't know the private key. This is the preferred method for larger installations. -2. Verify the list of provisioned devices afterwards. Devices that are not showing up might have been taken 'hostage' by a rogue agent. -3. Test each device functions as expected. If a rogue agent obtained a token the device might not show up, unless the agent remains active as a relay. - -The highest risk exists when a server CA is not installed on the devices, the device ID and public key are not pre-loaded in the server, and a rogue provisioning server remains undetected by the real provisioning service. In this case a rogue server can receive a provisioning request and forward it to the real service with its own public key. The administrator might approve it as the timing is right. - - -## Usage - -On startup, the IDProv server publishes a DNS-SD record on the local network. IoT devices can discovery it using the idprov client 'discover' function. Alternatively, IoT devices are provided directly with the server address and port. The default port is 8445. - -Once the idprov server is discovered, devices submit a provisioning request including their ID, out-of-band secret and public key. Once approved the server returns an auth token and server URL that are stored by the device. Periodically the Device renews the token using the auth services midway the token validity period. - -In order to receive a token, the device ID and public key or MAC address must be submitted out of band to the server before the devices requests provisioning. This can be done using the oob utility or via the Hub's admin UI if available. The admin UI shows a list of requests which the administrator can approve. Devices must retry repeatedly if their request returns the status 'pending'. - -## Installation - -This service is included with the Hub as a core service and is bundled with the Hub installation. - -It will run out of the box without any configuration when activated using the launcher. - diff --git a/services/idprov/cmd/main.go b/services/idprov/cmd/main.go deleted file mode 100644 index 242cabfb..00000000 --- a/services/idprov/cmd/main.go +++ /dev/null @@ -1,49 +0,0 @@ -// Package main with the provisioning service -package main - -import ( - "log/slog" - "os" - "path" - - "github.com/hiveot/hub/lib/certs" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" - "github.com/hiveot/hub/runtime" - "github.com/hiveot/hub/services/idprov/service" -) - -// TODO: merge the server with a web server that hosts the admin ui server -// TODO: option to enable/disable the request server - -// DefaultIDProvPort is the default listening port for https requests -const DefaultIDProvPort = 8445 - -// Start the service. -// Preconditions: -// 1. A loginID and keys for this service must already have been added. -// This can be done manually using the hubcli or simply be starting it using the launcher. -// 2. The hub core config hub.yaml must be available to load the server cert. -func main() { - var err error - - // Determine the folder layout and handle commandline options - env := plugin.GetAppEnvironment("", true) - logging.SetLogging(env.LogLevel, "") - slog.Warn("Starting idprov service", "clientID", env.ClientID, "loglevel", env.LogLevel) - - // load the server cert - // TODO: get server cert info from idprov config - serverCertPath := path.Join(env.CertsDir, runtime.DefaultServerCertFile) - serverKeyPath := path.Join(env.CertsDir, runtime.DefaultServerKeyFile) - // FYI, not all browsers support certificates with ed25519 keys, so this file contains a ecdsa key - serverCert, err := certs.LoadTLSCertFromPEM(serverCertPath, serverKeyPath) - if err != nil { - slog.Error("idprov: Failed loading server certificate", "err", err) - os.Exit(1) - } - - // start the service using the connection and hub server certificate - svc := service.NewIdProvService(DefaultIDProvPort, serverCert, env.CaCert) - plugin.StartPlugin(svc, env.ClientID, env.CertsDir, env.ServerURL) -} diff --git a/services/idprov/idprovapi/IdProvUser.go b/services/idprov/idprovapi/IdProvUser.go deleted file mode 100644 index 78daee95..00000000 --- a/services/idprov/idprovapi/IdProvUser.go +++ /dev/null @@ -1,62 +0,0 @@ -package idprovapi - -import ( - authn "github.com/hiveot/hub/runtime/authn/api" -) - -// ProvisioningServerType defines the discovery type for the provisioning -// this will be published as _provisioning._hiveot._tcp -// TODO: look also at the wot discovery specification -const ProvisioningServerType = "idprov" - -// ProvisionRequestPath to post a request provisioning through the HTTP endpoint -const ProvisionRequestPath = "/idprov/request" - -// ProvisionStatus holds the status of a provisioning request -type ProvisionStatus struct { - // ClientID is the unique ID of the device or service on the local network. - ClientID string `json:"clientID,omitempty"` - - // ClientType is either ClientTypeDevice or ClientTypeService - ClientType authn.ClientType `json:"clientType"` - - // PubKey holds the device's public key - PubKey string `json:"pubKey"` - - // MAC contains the device's MAC address either for pre-approval or when issuing a token - MAC string `json:"mac"` - - // the request is pending. Wait retrySec seconds before retrying - Pending bool `json:"pending,omitempty"` - - // timestamp in msec since epoc when the request was approved. - ApprovedMSE int64 `json:"approvedMSE,omitempty"` - - // timestamp in msec since epoc when the request was received. Used to expire requests. - ReceivedMSE int64 `json:"receivedMSE,omitempty"` - - // timestamp in msec since epoc when the request was rejected. - RejectedMSE int64 `json:"rejectedMSE,omitempty"` - - // Optional delay for retrying the request in seconds in case status is pending - RetrySec int `json:"retrySec,omitempty"` -} - -// ProvisionRequestArgs arguments to request provisioning -type ProvisionRequestArgs struct { - // ClientID of the device or service under which it will connect and publish events - ClientID string `json:"clientID,omitempty"` - // MAC address of the device - MAC string `json:"mac"` - // The device or service public key - PubKey string `json:"pubKey,omitempty"` -} - -// ProvisionRequestResp holds the response to the request -type ProvisionRequestResp struct { - // The current status - Status ProvisionStatus `json:"status"` - // Token when approved. - // This has a short lifespan and must be refresh immediately after connecting to the hub. - Token string `json:"token,omitempty"` -} diff --git a/services/idprov/idprovapi/ManageIdProv.go b/services/idprov/idprovapi/ManageIdProv.go deleted file mode 100644 index 40de2e80..00000000 --- a/services/idprov/idprovapi/ManageIdProv.go +++ /dev/null @@ -1,81 +0,0 @@ -package idprovapi - -import ( - authn "github.com/hiveot/hub/runtime/authn/api" -) - -// AgentID is the connect ID of the agent connecting to the Hub -const AgentID = "idprov" - -// ManageServiceID is the ID of the service as used by the agent -const ManageServiceID = "manage" - -// Manage methods -const ( - // ApproveRequestMethod approves a provisioning request. - // This does not add the device until a provisioning request is made - ApproveRequestMethod = "approveRequest" - - // GetRequestsMethod returns a list of provisioning requests - // This is an in-memory list that is cleared when the service restarts - GetRequestsMethod = "getRequests" - - // PreApproveClientsMethod uploads a list of pre-approved devices or services - // This list remains active until the service restarts. - // Devices are not added until the request is received and accepted. - PreApproveClientsMethod = "preApproveClients" - - // RejectRequestMethod rejects a provisioning request - RejectRequestMethod = "rejectRequest" - - // SubmitRequestMethod submits a provisioning request - SubmitRequestMethod = "submitRequest" -) - -type ApproveRequestArgs struct { - // ClientID of an approved device or service - ClientID string `json:"clientID"` - // ClientType to assign to the approval - ClientType authn.ClientType `json:"clientType"` -} - -type GetRequestsArgs struct { - // include accepted,pending and/or rejected requests - Approved bool `json:"approved,omitempty"` - Pending bool `json:"pending,omitempty"` - Rejected bool `json:"rejected,omitempty"` -} - -type GetRequestsResp struct { - Requests []ProvisionStatus `json:"requests"` -} - -type PreApprovedClient struct { - // AgentID of a pre-approved device or service - ClientID string `json:"clientID,omitempty"` - // client is a device or service - ClientType authn.ClientType `json:"clientType"` - // Optional MAC for extra checking - MAC string `json:"mac"` - // Device or service public key used to issue tokens - PubKey string `json:"pubKey"` -} -type PreApproveClientsArgs struct { - Approvals []PreApprovedClient `json:"approvals,omitempty"` -} - -type RejectRequestArgs struct { - // ClientID of an rejected device or service - ClientID string `json:"clientID,omitempty"` -} - -type SubmitRequestArgs struct { - // ClientID of the device or service - ClientID string `json:"clientID,omitempty"` - // client is a device or service (default is ClientTypeDevice) - ClientType authn.ClientType `json:"clientType"` - // Optional MAC if available - MAC string `json:"mac"` - // Device or service public key used to issue tokens (required) - PubKey string `json:"pubKey"` -} diff --git a/services/idprov/idprovclient/DiscoveryIdProvClient.go.old b/services/idprov/idprovclient/DiscoveryIdProvClient.go.old deleted file mode 100644 index 5d7b03f6..00000000 --- a/services/idprov/idprovclient/DiscoveryIdProvClient.go.old +++ /dev/null @@ -1,33 +0,0 @@ -package idprovclient - -import ( - "fmt" - "github.com/hiveot/hub/services/idprov/idprovapi" - "github.com/hiveot/hivekit/go/utils/discovery" - "log/slog" - "time" -) - -// DiscoverIdProvServer attempts to discover the provisioning server -// This returns its URL, the server core used, or an error if not found -func DiscoverIdProvServer() (url string, core string, error error) { - address, port, params, records, err := discovery.DiscoverService( - idprovapi.ProvisioningServerType, time.Second*3, true) - - if err != nil { - // failed, nothing to be found - slog.Warn("DiscoverIdProvServer: not found") - return "", "", err - } - core = params["core"] - url, found := params["url"] - if !found { - url = fmt.Sprintf("https://%s:%d%s", address, port, params["path"]) - } - slog.Info("DiscoverIdProvServer", - slog.Int("Nr records", len(records)), - slog.String("url", url), - slog.String("core", core)) - return url, core, err - -} diff --git a/services/idprov/idprovclient/ManageIdProvClient.go b/services/idprov/idprovclient/ManageIdProvClient.go deleted file mode 100644 index 13f79e79..00000000 --- a/services/idprov/idprovclient/ManageIdProvClient.go +++ /dev/null @@ -1,90 +0,0 @@ -package idprovclient - -import ( - "github.com/hiveot/hub/lib/consumer" - authn "github.com/hiveot/hub/runtime/authn/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" - "github.com/hiveot/hub/services/idprov/idprovapi" -) - -// ManageIdProvClient is a hiveot client for communicating with the provisioning -// service using the message bus. -// This requires admin permissions. -type ManageIdProvClient struct { - co *consumer.Consumer - // thingID digital twin service ID of this capability (digitwin version with agent prefix) - dThingID string -} - -// _send the IDProv method -// This returns an error if anything goes wrong: not delivered, delivery incomplete or processing error -func (cl *ManageIdProvClient) call(method string, args interface{}, resp interface{}) error { - err := cl.co.InvokeAction(cl.dThingID, method, args, resp) - return err -} - -// ApproveRequest approves a pending provisioning request -func (cl *ManageIdProvClient) ApproveRequest(ClientID string, clientType authn.ClientType) error { - args := idprovapi.ApproveRequestArgs{ - ClientID: ClientID, - ClientType: clientType, - } - err := cl.call(idprovapi.ApproveRequestMethod, &args, nil) - return err -} - -// GetRequests returns requests -// Expired requests are not included. -func (cl *ManageIdProvClient) GetRequests( - pending, approved, rejected bool) ([]idprovapi.ProvisionStatus, error) { - args := idprovapi.GetRequestsArgs{ - Pending: pending, - Approved: approved, - Rejected: rejected, - } - resp := idprovapi.GetRequestsResp{} - err := cl.call(idprovapi.GetRequestsMethod, &args, &resp) - return resp.Requests, err -} - -// PreApproveDevices uploads a list of pre-approved devices ID, MAC and PubKey -func (cl *ManageIdProvClient) PreApproveDevices( - approvals []idprovapi.PreApprovedClient) error { - - args := idprovapi.PreApproveClientsArgs{ - Approvals: approvals, - } - err := cl.call(idprovapi.PreApproveClientsMethod, &args, nil) - return err -} - -// RejectRequest rejects a pending provisioning request -func (cl *ManageIdProvClient) RejectRequest(clientID string) error { - args := idprovapi.RejectRequestArgs{ClientID: clientID} - err := cl.call(idprovapi.RejectRequestMethod, &args, nil) - return err -} - -func (cl *ManageIdProvClient) SubmitRequest( - clientID string, pubKey string, mac string) ( - status *idprovapi.ProvisionStatus, token string, err error) { - - args := idprovapi.SubmitRequestArgs{ - ClientID: clientID, - ClientType: authn.ClientTypeAgent, - PubKey: pubKey, - MAC: mac, - } - resp := idprovapi.ProvisionRequestResp{} - err = cl.call(idprovapi.RejectRequestMethod, &args, &resp) - return &resp.Status, resp.Token, err -} - -func NewIdProvManageClient(co *consumer.Consumer) *ManageIdProvClient { - agentID := idprovapi.AgentID - cl := &ManageIdProvClient{ - co: co, - dThingID: digitwin.MakeDigitwinID(agentID, idprovapi.ManageServiceID), - } - return cl -} diff --git a/services/idprov/idprovclient/SubmitIdProvRequest.go b/services/idprov/idprovclient/SubmitIdProvRequest.go deleted file mode 100644 index 00ea5ca8..00000000 --- a/services/idprov/idprovclient/SubmitIdProvRequest.go +++ /dev/null @@ -1,35 +0,0 @@ -package idprovclient - -import ( - "github.com/hiveot/hub/lib/clients/tlsclient" - "github.com/hiveot/hub/services/idprov/idprovapi" - jsoniter "github.com/json-iterator/go" -) - -// IdProvUserClient is a marshaller for provisioning client messages over https -// This uses the default serializer to marshal and unmarshal messages, eg JSON -type IdProvUserClient struct { - // The full URL of the provisioning service - provServiceURL string -} - -// SubmitIdProvRequest send a request to provision this client and obtain an auth token -// This returns the request status, an encrypted token and an error -// If the status is approved the token will contain the auth token. -// The token is only usable by the owner of the private key and has a limited lifespan -// It should immediately be used to connect to the Hub and refresh for a new token with -// a longer lifespan. JWT decode allows to determine the expiry. -func SubmitIdProvRequest(clientID string, pubKey string, mac string, tlsClient *tlsclient.TLSClient) ( - status idprovapi.ProvisionStatus, token string, err error) { - - req := idprovapi.ProvisionRequestArgs{ - ClientID: clientID, - PubKey: pubKey, - MAC: mac, - } - reqData, _ := jsoniter.Marshal(req) - respData, _, err := tlsClient.Post(idprovapi.ProvisionRequestPath, reqData) - resp := idprovapi.ProvisionRequestResp{} - err = jsoniter.Unmarshal(respData, &resp) - return resp.Status, resp.Token, err -} diff --git a/services/idprov/service/IdProvAgent.go b/services/idprov/service/IdProvAgent.go deleted file mode 100644 index 1165ebec..00000000 --- a/services/idprov/service/IdProvAgent.go +++ /dev/null @@ -1,30 +0,0 @@ -package service - -import ( - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/hubagent" - "github.com/hiveot/hub/services/idprov/idprovapi" -) - -// StartIdProvAgent registers the idprov messaging agent for the idprov service. -// This uses the given hub client for subscribing to actions. -// -// If the transport is nil then use the HandleMessage method directly to pass methods to the agent, -// for example when testing. -// -// svc is the service whose capabilities to expose -// ag is the optional message client connected to the server protocol -func StartIdProvAgent(svc *ManageIdProvService, ag *agent.Agent) *hubagent.AgentHandler { - - methods := map[string]interface{}{ - idprovapi.ApproveRequestMethod: svc.ApproveRequest, - idprovapi.GetRequestsMethod: svc.GetRequests, - idprovapi.PreApproveClientsMethod: svc.PreApproveClients, - idprovapi.RejectRequestMethod: svc.RejectRequest, - idprovapi.SubmitRequestMethod: svc.SubmitRequest, - } - agentHandler := hubagent.NewAgentHandler(idprovapi.ManageServiceID, methods) - ag.SetRequestHandler(agentHandler.HandleRequest) - // TODO: publish service TD - return agentHandler -} diff --git a/services/idprov/service/IdProvHttpServer.go b/services/idprov/service/IdProvHttpServer.go deleted file mode 100644 index 80dd69fd..00000000 --- a/services/idprov/service/IdProvHttpServer.go +++ /dev/null @@ -1,68 +0,0 @@ -package service - -import ( - "crypto/tls" - "crypto/x509" - "encoding/json" - "io" - "log/slog" - "net/http" - - "github.com/hiveot/hub/lib/servers/tlsserver" - "github.com/hiveot/hub/services/idprov/idprovapi" - jsoniter "github.com/json-iterator/go" -) - -// IdProvHttpServer serves the provisioning requests -// This runs its own server, separate from the Hub. -type IdProvHttpServer struct { - tlsServer *tlsserver.TLSServer - mng *ManageIdProvService -} - -// Stop the http server -func (srv *IdProvHttpServer) Stop() { - if srv.tlsServer != nil { - srv.tlsServer.Stop() - srv.tlsServer = nil - } -} - -func (srv *IdProvHttpServer) handleRequest(w http.ResponseWriter, req *http.Request) { - slog.Info("handleRequest", slog.String("remoteAddr", req.RemoteAddr)) - - args := idprovapi.ProvisionRequestArgs{} - data, err := io.ReadAll(req.Body) - err2 := jsoniter.Unmarshal(data, &args) - if err != nil || err2 != nil { - slog.Warn("idprov handleRequest. bad request", "remoteAddr", req.RemoteAddr) - w.WriteHeader(http.StatusBadRequest) - return - } - resp, err := srv.mng.SubmitRequest("unknown", &args) - if err != nil { - slog.Warn("idprov handleRequest. refused", "err", err.Error()) - w.WriteHeader(http.StatusNotAcceptable) - return - } - respData, _ := json.Marshal(resp) - _, err = w.Write(respData) - if err != nil { - slog.Error("error sending response", "err", err.Error(), "remoteAddr", req.RemoteAddr) - } -} - -// StartIdProvHttpServer starts the http server to handle provisioning requests -func StartIdProvHttpServer( - port int, serverCert *tls.Certificate, caCert *x509.Certificate, mng *ManageIdProvService) (*IdProvHttpServer, error) { - - tlsServer, mux := tlsserver.NewTLSServer("", port, serverCert, caCert) - _ = mux - srv := IdProvHttpServer{ - tlsServer: tlsServer, - mng: mng, - } - mux.Post(idprovapi.ProvisionRequestPath, srv.handleRequest) - err := srv.tlsServer.Start() - return &srv, err -} diff --git a/services/idprov/service/IdProvService.go b/services/idprov/service/IdProvService.go deleted file mode 100644 index 3e8cfb38..00000000 --- a/services/idprov/service/IdProvService.go +++ /dev/null @@ -1,108 +0,0 @@ -package service - -import ( - "crypto/tls" - "crypto/x509" - "log/slog" - - "github.com/hiveot/hub/lib/agent" - authz "github.com/hiveot/hub/runtime/authz/api" - "github.com/hiveot/hub/services/idprov/idprovapi" -) - -// -//const DefaultIoTCertValidityDays = 14 -//const ApprovedSecret = "approved" -//const DefaultRetrySec = 12 * 3600 - -// IdProvService handles provisioning requests from devices and services. -// This starts listening on the provisioning port using a server certificate signed by the Hub CA. -// If enabled, publish the DNS-SD discovery record with the server address and port. -// -// Connecting clients can request an auth token, providing their ID and public key. -// The server responds with a token or wait-for-approval. -// In case of wait for approval the client must repeat the request, in 1 minute intervals until approval is received -// or rejected. -// -// The server connects to the Hub to obtain auth tokens from the auth service. Tokens issued will be very short lived -// and require the device to refresh with the auth service directly, after connecting to the hub. -// -// The provisioning server can be started and stopped on the fly so it only runs when needed. -type IdProvService struct { - - // Hub connection - ag *agent.Agent - // the manage service - ManageIdProv *ManageIdProvService - - // server listening port - port int - // server TLS certificate - serverCert *tls.Certificate - // hiveot CA that signed the server cert - caCert *x509.Certificate - // the http server that received provisioning requests - httpServer *IdProvHttpServer -} - -// Start the provisioning service -// 1. start the management service -// 2. set allowed roles for RPC calls to this service -// 3. Start the http request server -// 4. start the security check for rogue DNS-SD records -// 5. start DNS-SD discovery server -// -// cc is the connection with the hub to receive requests -func (svc *IdProvService) Start(ag *agent.Agent) (err error) { - - slog.Info("Starting the provisioning service", "clientID", ag.GetClientID()) - svc.ag = ag - svc.ManageIdProv, err = StartManageIdProvService(svc.ag) - if err != nil { - return err - } - - // Set the required permissions for using this service - err = authz.UserSetPermissions(ag.Consumer, authz.ThingPermissions{ - AgentID: ag.GetClientID(), - ThingID: idprovapi.ManageServiceID, - Allow: []authz.ClientRole{ - authz.ClientRoleManager, - authz.ClientRoleAdmin, - authz.ClientRoleService}, - }) - if err != nil { - return err - } - // the agent maps incoming action requests to the management service methods - StartIdProvAgent(svc.ManageIdProv, ag) - - // Start the HTTP server - svc.httpServer, err = StartIdProvHttpServer( - svc.port, svc.serverCert, svc.caCert, svc.ManageIdProv) - return err -} - -// Stop the provisioning service -func (svc *IdProvService) Stop() { - slog.Info("Stopping the provisioning service") - if svc.httpServer != nil { - svc.httpServer.Stop() - svc.httpServer = nil - } - if svc.ManageIdProv != nil { - svc.ManageIdProv.Stop() - svc.ManageIdProv = nil - } -} - -// NewIdProvService creates a new provisioning service instance -func NewIdProvService(port int, serverCert *tls.Certificate, caCert *x509.Certificate) *IdProvService { - svc := &IdProvService{ - port: port, - serverCert: serverCert, - caCert: caCert, - } - - return svc -} diff --git a/services/idprov/service/ManageIdProvService.go b/services/idprov/service/ManageIdProvService.go deleted file mode 100644 index 13891571..00000000 --- a/services/idprov/service/ManageIdProvService.go +++ /dev/null @@ -1,203 +0,0 @@ -package service - -import ( - "fmt" - "log/slog" - "sync" - "time" - - "github.com/hiveot/hub/lib/agent" - authn "github.com/hiveot/hub/runtime/authn/api" - "github.com/hiveot/hub/services/idprov/idprovapi" -) - -type ManageIdProvService struct { - - // request status by deviceID - // [deviceID] pub-key simple in-memory store - requests map[string]idprovapi.ProvisionStatus - - // - ag *agent.Agent - // mutex to guard access to maps - mux sync.RWMutex -} - -// ApproveRequest approves an existing provisioning request. -// The client will be added on the next request. -// The next repeat request will return a short-lived token. -func (svc *ManageIdProvService) ApproveRequest( - senderID string, args *idprovapi.ApproveRequestArgs) error { - svc.mux.Lock() - defer svc.mux.Unlock() - - slog.Info("ApproveRequest", - slog.String("senderID", senderID), - slog.String("deviceID", args.ClientID)) - status, found := svc.requests[args.ClientID] - if !found { - return fmt.Errorf("provisioning request for device '%s' not found", args.ClientID) - } - status.Pending = false - status.ClientType = args.ClientType - status.ApprovedMSE = time.Now().UnixMilli() - status.RejectedMSE = 0 - svc.requests[args.ClientID] = status - return nil -} - -// GetRequests returns list of requests since last start -// If args.OnlyPending is set then only return pending requests -// Note that rejected requests are never returned -func (svc *ManageIdProvService) GetRequests( - senderID string, args *idprovapi.GetRequestsArgs) (*idprovapi.GetRequestsResp, error) { - svc.mux.RLock() - defer svc.mux.RUnlock() - - resp := &idprovapi.GetRequestsResp{ - Requests: make([]idprovapi.ProvisionStatus, 0, len(svc.requests)), - } - for _, status := range svc.requests { - if (status.Pending && args.Pending) || - (status.ApprovedMSE != 0 && args.Approved) || - (status.RejectedMSE != 0 && args.Rejected) { - resp.Requests = append(resp.Requests, status) - } - } - return resp, nil -} - -// PreApproveClients uploads list of pre-approved devices and services -func (svc *ManageIdProvService) PreApproveClients( - senderID string, args *idprovapi.PreApproveClientsArgs) error { - svc.mux.Lock() - defer svc.mux.Unlock() - slog.Info("PreApproveClients", - slog.String("senderID", senderID), - slog.Int("count", len(args.Approvals))) - - for _, approval := range args.Approvals { - if approval.ClientID == "" { - slog.Warn("PreApproval of client without clientID", "clientID", senderID) - } else { - svc.requests[approval.ClientID] = idprovapi.ProvisionStatus{ - ClientID: approval.ClientID, - ClientType: approval.ClientType, - PubKey: approval.PubKey, - MAC: approval.MAC, - Pending: false, - ApprovedMSE: time.Now().UnixMilli(), - } - } - } - return nil -} - -// RejectRequest rejects a provisioning request -func (svc *ManageIdProvService) RejectRequest( - senderID string, args *idprovapi.RejectRequestArgs) error { - svc.mux.Lock() - defer svc.mux.Unlock() - - slog.Info("RejectRequest", - slog.String("senderID", senderID), - slog.String("deviceID", args.ClientID)) - status, found := svc.requests[args.ClientID] - if !found { - return fmt.Errorf("provisioning request for client '%s' not found", args.ClientID) - } - status.Pending = false - status.RejectedMSE = time.Now().UnixMilli() - svc.requests[args.ClientID] = status - return nil -} - -// SubmitRequest creates a provisioning request for a device -// -// If the request is pre-approved a token will be returned if the pubKey and/or -// MAC matches. -// If the pre-approval does not include a public key then only match required is the MAC. -func (svc *ManageIdProvService) SubmitRequest(senderID string, args *idprovapi.ProvisionRequestArgs) ( - resp *idprovapi.ProvisionRequestResp, err error) { - svc.mux.Lock() - defer svc.mux.Unlock() - var token string - - slog.Info("SubmitRequest", - slog.String("senderID", senderID), - slog.String("deviceID", args.ClientID)) - status, found := svc.requests[args.ClientID] - if !found { - // new request - status = idprovapi.ProvisionStatus{ - ClientID: args.ClientID, - PubKey: args.PubKey, - MAC: args.MAC, - Pending: true, - ReceivedMSE: time.Now().UnixMilli(), - RetrySec: 60, - } - } else if status.ApprovedMSE != 0 { - // (pre)approved request, add the user and issue a token - status.ReceivedMSE = time.Now().UnixMilli() - // public key or mac must match if provided - if status.PubKey != "" && status.PubKey != args.PubKey { - err = fmt.Errorf( - "approval for '%s' denied as public key doesn't match", args.ClientID) - } else if status.MAC != "" && status.MAC != args.MAC { - err = fmt.Errorf( - "approval for '%s' denied as mac address doesn't match", args.ClientID) - } else if args.PubKey == "" { - err = fmt.Errorf( - "approval for '%s' denied as no public key was provided", args.ClientID) - } else { - err = nil - } - if err != nil { - slog.Warn(err.Error(), "clientID", args.ClientID) - return nil, err - } - - status.Pending = false - token, err = authn.AdminAddAgent(svc.ag.Consumer, status.ClientID, status.ClientID, status.PubKey) - - if err != nil { - return nil, err - } - slog.Warn("provisioning token created for client", "clientID", args.ClientID) - - } else if status.RejectedMSE != 0 { - // rejected request, ignore request - // delay next request to 1 hour - status.RetrySec = 3600 - status.ReceivedMSE = time.Now().UnixMilli() - status.Pending = false - } else { - // repeat of request, update the request received timestamp and increase retry - status.Pending = true - status.ReceivedMSE = time.Now().UnixMilli() - // delay next request to a maximum of 10 minutes - if status.RetrySec < 600 { - status.RetrySec += 30 - } - } - svc.requests[args.ClientID] = status - resp = &idprovapi.ProvisionRequestResp{ - Status: status, - Token: token, - } - return resp, nil -} -func (svc *ManageIdProvService) Stop() { -} - -func StartManageIdProvService(ag *agent.Agent) (*ManageIdProvService, error) { - - svc := &ManageIdProvService{ - // map of requests by SenderID - requests: make(map[string]idprovapi.ProvisionStatus), - ag: ag, - } - - return svc, nil -} diff --git a/services/launcher/Launcher_test.go b/services/launcher/Launcher_test.go index b44bf081..aef47813 100644 --- a/services/launcher/Launcher_test.go +++ b/services/launcher/Launcher_test.go @@ -7,25 +7,23 @@ import ( "testing" "time" - "github.com/hiveot/hub/lib/testenv" - authn "github.com/hiveot/hub/runtime/authn/api" - authz "github.com/hiveot/hub/runtime/authz/api" + "github.com/hiveot/hivekit/go/modules/authn" + "github.com/hiveot/hivekit/go/modules/consumer" + "github.com/hiveot/hivekit/go/testenv" + "github.com/hiveot/hivekit/go/utils" launcher "github.com/hiveot/hub/services/launcher/api" "github.com/hiveot/hub/services/launcher/config" "github.com/hiveot/hub/services/launcher/service" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/hiveot/hub/lib/consumer" - "github.com/hiveot/hub/lib/logging" ) // var homeDir = "/tmp/test-launcher" var logDir = "/tmp/test-launcher" // the following are set by the testmain -var ts *testenv.TestServer +var testEnv *testenv.TestEnv const agentUsesWSS = false @@ -43,7 +41,7 @@ func startService() (l *consumer.Consumer, stopFn func()) { const launcherID = launcher.AdminAgentID const adminID = "admin" - ts = testenv.StartTestServer(true) + testEnv = testenv.NewTestEnv() //hc1, _ := ts.AddConnectService(launcherID) var launcherConfig = config.NewLauncherConfig() @@ -57,16 +55,16 @@ func startService() (l *consumer.Consumer, stopFn func()) { launcherConfig.ProvideServerURL = false //var env = plugin.GetAppEnvironment(ts.AppEnv.HomeDir, false) - binDir := ts.AppEnv.BinDir + binDir := testEnv.AppEnv.BinDir pluginsDir := "/bin" // for /bin/yes - certsDir := ts.AppEnv.CertsDir + certsDir := testEnv.AppEnv.CertsDir clientID := launcherID //env.LogsDir = logDir //env.CertsDir = homeDir //env.CaCert = ts.Certs.CaCert - serverURL := ts.GetServerURL(authn.ClientTypeService) + serverURL := testEnv.GetServerURL(authn.ClientTypeService) svc := service.NewLauncherService( serverURL, clientID, binDir, pluginsDir, certsDir, launcherConfig) err := svc.Start() @@ -78,18 +76,18 @@ func startService() (l *consumer.Consumer, stopFn func()) { //agent := service.StartLauncherAgent(svc, hc1) //_ = agent //--- connect the launcher user - co1, _, _ := ts.AddConnectConsumer(adminID, authz.ClientRoleAdmin) + co1, _, _ := testEnv.AddConnectConsumer(adminID, authn.ClientRoleAdmin) return co1, func() { co1.Disconnect() //hc1.Disconnect() _ = svc.Stop() time.Sleep(time.Millisecond) - ts.Stop() + testEnv.Stop() } } func TestMain(m *testing.M) { - logging.SetLogging("info", "") + utils.SetLogging("info", "") res := m.Run() os.Exit(res) } @@ -114,7 +112,7 @@ func TestList(t *testing.T) { require.NoError(t, err) assert.Greater(t, len(infoList), 10) - co2, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleAdmin) + co2, _, _ := testEnv.AddConnectConsumer(userID, authn.ClientRoleAdmin) defer co2.Disconnect() infoList2, err := launcher.AdminListPlugins(co2, false) require.NoError(t, err) @@ -129,7 +127,7 @@ func TestListNoPermission(t *testing.T) { defer cancelFunc() require.NotNil(t, co1) - co2, _, _ := ts.AddConnectConsumer(userID, authz.ClientRoleNone) + co2, _, _ := testEnv.AddConnectConsumer(userID, authn.ClientRoleNone) defer co1.Disconnect() info2, err := launcher.AdminListPlugins(co2, false) require.Error(t, err, "user without role should not be able to use launcher") diff --git a/services/launcher/api/AdminAgentAPI.go b/services/launcher/api/AdminAgentAPI.go index bbcea388..a3317688 100644 --- a/services/launcher/api/AdminAgentAPI.go +++ b/services/launcher/api/AdminAgentAPI.go @@ -1,93 +1,95 @@ // Package launcher with the agent request handler for using service 'Admin' // This builds a service agent that calls the service. // DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. +// Generated 09 Apr 2026 13:24 PDT. package launcher -import "errors" -import "github.com/hiveot/hivekit/go/utils" -import "github.com/hiveot/hub/lib/messaging" +import ( + "errors" + "github.com/hiveot/hivekit/go/api/msg" + "github.com/hiveot/hivekit/go/modules/transport" + "github.com/hiveot/hivekit/go/utils" +) // IAdminService defines the interface of the 'Admin' service // -// This defines a method for each of the actions in the TD. -// +// This defines a method for each of the actions in the TD. type IAdminService interface { - // ListPlugins List Plugins - // List available plugins and their status - ListPlugins(senderID string, onlyrunning bool) (plugins []PluginInfo, err error) + // ListPlugins List Plugins + // List available plugins and their status + ListPlugins(senderID string, onlyrunning bool) (plugins []PluginInfo, err error) - // StartAllPlugins Start All - // Start all available plugins - StartAllPlugins(senderID string) error + // StartAllPlugins Start All + // Start all available plugins + StartAllPlugins(senderID string) error - // StartPlugin Start A Plugin - // Start a plugin - StartPlugin(senderID string, pluginID string) (pluginStatus PluginInfo, err error) + // StartPlugin Start A Plugin + // Start a plugin + StartPlugin(senderID string, pluginID string) (pluginStatus PluginInfo, err error) - // StopAllPlugins Stop All - // Stop all running plugins - StopAllPlugins(senderID string, fullStop bool) error + // StopAllPlugins Stop All + // Stop all running plugins + StopAllPlugins(senderID string, fullStop bool) error - // StopPlugin Stop A Plugin - // Stop a plugin - StopPlugin(senderID string, pluginID string) (pluginStatus PluginInfo, err error) + // StopPlugin Stop A Plugin + // Stop a plugin + StopPlugin(senderID string, pluginID string) (pluginStatus PluginInfo, err error) } // NewHandleAdminRequest returns an agent handler for Thing 'admin' requests. // // This unmarshalls the request payload into an args struct and passes it to the service // that implements the corresponding interface method. -// +// // This returns the marshalled response data or an error. -func NewHandleAdminRequest(svc IAdminService)(func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage) { - return func(msg *messaging.RequestMessage, c messaging.IConnection) *messaging.ResponseMessage { - var output any - var err error - switch msg.Name { - case "startPlugin": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.StartPlugin(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "stopAllPlugins": - var args bool - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - err = svc.StopAllPlugins(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "stopPlugin": - var args string - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.StopPlugin(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "listPlugins": - var args bool - err = utils.DecodeAsObject(msg.Input, &args) - if err == nil { - output, err = svc.ListPlugins(msg.SenderID, args) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - case "startAllPlugins": - if err == nil { - err = svc.StartAllPlugins(msg.SenderID) - } else { - err = errors.New("bad function argument: " + err.Error()) - } - default: - err = errors.New("Unknown Method '"+msg.Name+"' of service '"+msg.ThingID+"'") - } - return msg.CreateResponse(output,err) - } -} \ No newline at end of file +func NewHandleAdminRequest(svc IAdminService) func(msg *msg.RequestMessage, c transport.IConnection) *msg.ResponseMessage { + return func(msg *msg.RequestMessage, c transport.IConnection) *msg.ResponseMessage { + var output any + var err error + switch msg.Name { + case "startPlugin": + var args string + err = utils.DecodeAsObject(msg.Input, &args) + if err == nil { + output, err = svc.StartPlugin(msg.SenderID, args) + } else { + err = errors.New("bad function argument: " + err.Error()) + } + case "stopAllPlugins": + var args bool + err = utils.DecodeAsObject(msg.Input, &args) + if err == nil { + err = svc.StopAllPlugins(msg.SenderID, args) + } else { + err = errors.New("bad function argument: " + err.Error()) + } + case "stopPlugin": + var args string + err = utils.DecodeAsObject(msg.Input, &args) + if err == nil { + output, err = svc.StopPlugin(msg.SenderID, args) + } else { + err = errors.New("bad function argument: " + err.Error()) + } + case "listPlugins": + var args bool + err = utils.DecodeAsObject(msg.Input, &args) + if err == nil { + output, err = svc.ListPlugins(msg.SenderID, args) + } else { + err = errors.New("bad function argument: " + err.Error()) + } + case "startAllPlugins": + if err == nil { + err = svc.StartAllPlugins(msg.SenderID) + } else { + err = errors.New("bad function argument: " + err.Error()) + } + default: + err = errors.New("Unknown Method '" + msg.Name + "' of service '" + msg.ThingID + "'") + } + return msg.CreateResponse(output, err) + } +} diff --git a/services/launcher/api/AdminConsumerAPI.go b/services/launcher/api/AdminConsumerAPI.go index e1c78131..8b4e3540 100644 --- a/services/launcher/api/AdminConsumerAPI.go +++ b/services/launcher/api/AdminConsumerAPI.go @@ -1,48 +1,45 @@ // Package launcher with the agent request handler for using service 'Admin' // This builds a service consumer that send a service request. -// DO NOT EDIT. This file is auto generated by tm2go. Any changes will be overwritten. -// Generated 09 Apr 2026 13:24 PDT. package launcher -import "github.com/hiveot/hub/lib/consumer" - +import "github.com/hiveot/hivekit/go/modules/consumer" // AdminListPlugins client method - List Plugins. // List available plugins and their status -func AdminListPlugins(hc *consumer.Consumer, onlyrunning bool)(plugins []PluginInfo, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminListPluginsMethod, &onlyrunning, &plugins) - return +func AdminListPlugins(hc *consumer.Consumer, onlyrunning bool) (plugins []PluginInfo, err error) { + + err = hc.Rpc("invokeaction", AdminDThingID, AdminListPluginsMethod, &onlyrunning, &plugins) + return } // AdminStartAllPlugins client method - Start All. // Start all available plugins -func AdminStartAllPlugins(hc *consumer.Consumer)(err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminStartAllPluginsMethod, nil, nil) - return +func AdminStartAllPlugins(hc *consumer.Consumer) (err error) { + + err = hc.Rpc("invokeaction", AdminDThingID, AdminStartAllPluginsMethod, nil, nil) + return } // AdminStartPlugin client method - Start A Plugin. // Start a plugin -func AdminStartPlugin(hc *consumer.Consumer, pluginID string)(pluginStatus PluginInfo, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminStartPluginMethod, &pluginID, &pluginStatus) - return +func AdminStartPlugin(hc *consumer.Consumer, pluginID string) (pluginStatus PluginInfo, err error) { + + err = hc.Rpc("invokeaction", AdminDThingID, AdminStartPluginMethod, &pluginID, &pluginStatus) + return } // AdminStopAllPlugins client method - Stop All. // Stop all running plugins -func AdminStopAllPlugins(hc *consumer.Consumer, fullStop bool)(err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminStopAllPluginsMethod, &fullStop, nil) - return +func AdminStopAllPlugins(hc *consumer.Consumer, fullStop bool) (err error) { + + err = hc.Rpc("invokeaction", AdminDThingID, AdminStopAllPluginsMethod, &fullStop, nil) + return } // AdminStopPlugin client method - Stop A Plugin. // Stop a plugin -func AdminStopPlugin(hc *consumer.Consumer, pluginID string)(pluginStatus PluginInfo, err error){ - - err = hc.Rpc("invokeaction", AdminDThingID, AdminStopPluginMethod, &pluginID, &pluginStatus) - return +func AdminStopPlugin(hc *consumer.Consumer, pluginID string) (pluginStatus PluginInfo, err error) { + + err = hc.Rpc("invokeaction", AdminDThingID, AdminStopPluginMethod, &pluginID, &pluginStatus) + return } diff --git a/services/launcher/cmd/main.go b/services/launcher/cmd/main.go index 27048f50..ffa2cf85 100644 --- a/services/launcher/cmd/main.go +++ b/services/launcher/cmd/main.go @@ -4,8 +4,8 @@ import ( "log/slog" "os" - "github.com/hiveot/hub/lib/logging" - "github.com/hiveot/hub/lib/plugin" + "github.com/hiveot/hivekit/go/modules/factory" + "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/services/launcher/config" "github.com/hiveot/hub/services/launcher/service" ) @@ -13,8 +13,8 @@ import ( // Connect the launcher service func main() { // setup environment and config - env := plugin.GetAppEnvironment("", true) - logging.SetLogging(env.LogLevel, "") + env := factory.NewAppEnvironment("", true) + utils.SetLogging(env.LogLevel, "") cfg := config.NewLauncherConfig() cfg.LogLevel = env.LogLevel @@ -25,11 +25,11 @@ func main() { slog.Error("Failed loading launcher config: ", "err", err) os.Exit(1) } - logging.SetLogging(cfg.LogLevel, "") + utils.SetLogging(cfg.LogLevel, "") // start the launcher but do not connect yet as the runtime can be started by the launcher itself. // the runtime will generate the launcher key and token. - svc := service.NewLauncherService(env.ServerURL, env.ClientID, + svc := service.NewLauncherService(env.ServerURL, env.AppID, env.BinDir, env.PluginsDir, env.CertsDir, cfg) err = svc.Start() if err != nil { diff --git a/services/launcher/service/LauncherService.go b/services/launcher/service/LauncherService.go index f12f2f09..ba891708 100644 --- a/services/launcher/service/LauncherService.go +++ b/services/launcher/service/LauncherService.go @@ -12,19 +12,17 @@ import ( "time" "github.com/fsnotify/fsnotify" + "github.com/hiveot/hivekit/go/api/msg" + "github.com/hiveot/hivekit/go/modules/agent" + "github.com/hiveot/hivekit/go/modules/transport/clients" + "github.com/hiveot/hivekit/go/modules/transport/wss" "github.com/hiveot/hivekit/go/utils" - "github.com/hiveot/hub/lib/agent" - "github.com/hiveot/hub/lib/clients" - "github.com/hiveot/hub/lib/messaging" - "github.com/hiveot/hub/lib/servers/wssserver" - authz "github.com/hiveot/hub/runtime/authz/api" - digitwin "github.com/hiveot/hub/runtime/digitwin/api" launcher "github.com/hiveot/hub/services/launcher/api" "github.com/hiveot/hub/services/launcher/config" ) // Use this default path instead of discovery when running locally and no server is configured -const DefaultLocalServerURL = "wss://localhost" + wssserver.DefaultWssPath +const DefaultLocalServerURL = "wss://localhost" + wss.WotWebsocketPath // LauncherService manages starting and stopping of plugins // This implements the ILauncher interface @@ -62,7 +60,7 @@ type LauncherService struct { done chan bool // request handler - adminHandler messaging.RequestHandler + adminHandler msg.RequestHandler } // Add discovered runtime to svc.plugins @@ -238,22 +236,22 @@ func (svc *LauncherService) Start() error { } // publish this service TD - err = digitwin.ThingDirectoryUpdateThing(svc.ag.Consumer, launcher.AdminTD) + err = svc.ag.WriteTD(launcher.AdminTD) if err != nil { slog.Error("failed to publish the launcher service TD", "err", err.Error()) } // permissions for using this service for administrators and managers - err = authz.UserSetPermissions(svc.ag.Consumer, authz.ThingPermissions{ - AgentID: svc.ag.GetClientID(), - ThingID: launcher.AdminServiceID, - Allow: []authz.ClientRole{authz.ClientRoleManager, authz.ClientRoleAdmin, authz.ClientRoleService}, - Deny: nil, - }) + // err = authz.UserSetPermissions(svc.ag.Consumer, authz.ThingPermissions{ + // AgentID: svc.ag.GetClientID(), + // ThingID: launcher.AdminServiceID, + // Allow: []authz.ClientRole{authz.ClientRoleManager, authz.ClientRoleAdmin, authz.ClientRoleService}, + // Deny: nil, + // }) // 4: start listening to action requests adminHandler := launcher.NewHandleAdminRequest(svc) - svc.ag.SetRequestHandler(adminHandler) + svc.ag.SetAppRequestHook(adminHandler) //StartLauncherAgent(svc, svc.ag) @@ -276,7 +274,7 @@ func (svc *LauncherService) Stop() error { err := svc.StopAllPlugins(svc.clientID, true) if svc.ag != nil { - svc.ag.Disconnect() + svc.ag.Stop() } return err } diff --git a/services/launcher/service/StartStopPlugins.go b/services/launcher/service/StartStopPlugins.go index e83a3c67..684db973 100644 --- a/services/launcher/service/StartStopPlugins.go +++ b/services/launcher/service/StartStopPlugins.go @@ -14,7 +14,6 @@ import ( "github.com/hiveot/hivekit/go/utils" "github.com/hiveot/hub/lib/plugin" - authn "github.com/hiveot/hub/runtime/authn/api" launcher "github.com/hiveot/hub/services/launcher/api" "github.com/struCoder/pidusage" @@ -125,7 +124,7 @@ func (svc *LauncherService) _startPlugin(pluginID string) (pi launcher.PluginInf // add a service account. Authn admin generates a new token file in the keys directory // the service must have read access to this directory, or the keys must be // copied elsewhere by the administrator. - _, err = authn.AdminAddService(svc.ag.Consumer, pluginID, pluginID, "") + _, err = authn.AdminAddService(svc.ag, pluginID, pluginID, "") if err != nil { slog.Error("Unable to add plugin to hub and create credentials. Continuing anyways", "err", err) }