AWS IoT Core
Floci's IoT service emulates the AWS IoT Core control plane, IoT Data shadow APIs, and MQTT data-plane behavior used by local device and SDK tests.
MVP 1 Coverage
Status: complete for the local emulator slice.
Supported MVP 1 behavior:
- Thing CRUD with idempotent identical
CreateThing, duplicate-conflict semantics,UpdateThing.expectedVersion, and list pagination. - Certificate basics:
CreateKeysAndCertificate,CreateCertificateFromCsr,DescribeCertificate,ListCertificates,UpdateCertificate, andDeleteCertificatewith active/attached delete constraints.CreateKeysAndCertificatereturns a real X.509 client certificate issued by Floci's local CA (GET /_floci/ca.pem) with a fresh RSA 2048 key pair, valid until 2049-12-31T23:59:59Z as on AWS, andcertificateIdis the SHA-256 of the certificate's DER encoding as on AWS.DescribeCertificatereportsvalidityandcertificateMode.CreateCertificateFromCsrsigns the request's public key (RSA of at least 2048 bits, or EC on P-256, P-384 or P-521) with the same CA and, as on AWS, returns no private key. - Policy basics:
CreatePolicy,GetPolicy,ListPolicies,DeletePolicy, policy version lifecycle,AttachPolicy,DetachPolicy,ListAttachedPolicies, andListTargetsForPolicy. - Thing principal basics:
AttachThingPrincipal,DetachThingPrincipal,ListThingPrincipals, andListPrincipalThings. - Tags for things, certificates, policies, and topic rules.
- IoT Data retained messages: retained
Publish,GetRetainedMessage, and paginatedListRetainedMessages. - Shadow null-delete and version-conflict behavior for HTTP and shared service paths.
- Topic rule duplicate/delete/replace semantics, plus
republish,sqs,sns,s3,dynamoDBv2,kinesis,lambda,firehose, andcloudwatchLogsaction dispatch.
Current MVP 1 limitations:
- The plaintext MQTT listener on 1883 stays permissive. The TLS listener on 8883 admits a device only when its certificate is registered and
ACTIVEand an attached policy allowsiot:Connectfor the client id, see Device verification on 8883.Publish,SubscribeandReceiveare not evaluated yet on either port. - Rules evaluate the SQL subset described under Rule SQL; substitution templates remain follow-up scope.
MVP 2 Coverage
Status: implemented for the current SDK compatibility slice.
Supported MVP 2 behavior:
- Thing types:
CreateThingType,DescribeThingType,ListThingTypes,UpdateThingType,DeprecateThingType, andDeleteThingTypewith typedCreateThingassociation and in-use delete protection. - Static thing groups:
CreateThingGroup,DescribeThingGroup,ListThingGroups,UpdateThingGroup,DeleteThingGroup,AddThingToThingGroup,RemoveThingFromThingGroup,ListThingsInThingGroup, andListThingGroupsForThing. - Jobs control plane:
CreateJob,DescribeJob, andListJobs, including thing ARN targets and static thing group targets. - Jobs data plane: pending-job listing,
StartNextPendingJobExecution,DescribeJobExecution, andUpdateJobExecutionwith version conflicts and terminal-state checks. - Endpoint discovery accepts
iot:Jobsandiot:CredentialProviderin addition to IoT Data endpoint types. - MQTT clients can use QoS 1 subscribe/publish paths with broker PUBACK and delivery behavior.
- IoT Data connection APIs for live MQTT sessions:
GetConnection,DeleteConnection,ListSubscriptions, andSendDirectMessage. DeleteConnectioncloses active MQTT client sessions through the embedded broker and optionally purges broker session state forcleanSession=true.- IoT rules can dispatch matching payloads to SQS, SNS, S3, DynamoDB v2, Kinesis, Lambda, Kinesis Data Firehose, CloudWatch Logs, and MQTT republish targets.
Current MVP 2 limitations:
DeleteConnection.preventWillMessageis accepted for SDK request compatibility, but the embedded broker does not expose selective Last Will suppression.- HTTP IoT Data
Publishstill treats QoS and MQTT5 metadata as compatibility inputs only; those properties are not fully forwarded or persisted yet. SendDirectMessagepublishes to the requested MQTT topic through the embedded broker. Unlike AWS IoT Core, it does not yet bypass subscription matching to deliver to a client that is not subscribed to that topic.GetConnectionandListSubscriptionsreport live in-memory broker state only; offline persistent session subscription reporting is not modeled yet.- Jobs reserved MQTT topics remain follow-up scope; Jobs Data HTTP APIs are implemented first.
- Dynamic thing groups, job rollouts, cancellations, documents from S3, and advanced job scheduling are not yet modeled. Fleet indexing has its configuration and a bounded
SearchIndexsubset, see Fleet Indexing.
Domain Configurations
Status: control plane only.
CreateDomainConfiguration, DescribeDomainConfiguration, UpdateDomainConfiguration, DeleteDomainConfiguration and ListDomainConfigurations are served on the REST-JSON paths the AWS SDKs use, with the AWS shapes and error codes:
- A new configuration is
ENABLEDandCUSTOMER_MANAGED, its server certificate is reportedVALID, and its ARN carries the short id AWS appends (domainconfiguration/<name>/<id>). A configuration created without a domain name has theENDPOINTdomain type. UpdateDomainConfigurationchanges the status, the authorizer (or removes it withremoveAuthorizerConfig), the TLS, server certificate and client certificate settings, the authentication type and the application protocol.DeleteDomainConfigurationrefuses anENABLEDconfiguration withInvalidRequestException; disable it first, as on AWS.ListDomainConfigurationsfilters byserviceTypeand pages withmarkerandpageSize.- Tags work through
TagResource,UntagResourceandListTagsForResourceon the configuration ARN. - CloudFormation provisions
AWS::IoT::DomainConfigurationthrough the same operations; see the CloudFormation service page for the attribute list. - With TLS enabled, a customer-managed configuration (
domainNameset) adds its domain to Floci's server certificate as soon as it is created, sohttps://<domain>verifies without a restart; see TLS for the accepted suffixes. A configuration without a domain name registers nothing. - The four AWS-managed configurations every account has (
iot:Data-ATS,iot:Data,iot:CredentialProvider,iot:Jobs) exist in every region without being created:AWS_MANAGED,ENABLED, no server certificate, and the addressDescribeEndpointreturns as their domain name. They can be updated and tagged but not deleted, as on AWS.
Current limitations:
- A custom domain does not change where the broker listens or what
DescribeEndpointreturns (see Endpoint address), and pointing DNS at the emulator is outside its scope. - Server certificate ARNs are stored as given and reported
VALID; they are not checked against ACM.
Authorizers
Status: control plane, TestInvokeAuthorizer and MQTT CONNECT enforcement.
CreateAuthorizer, DescribeAuthorizer, UpdateAuthorizer, DeleteAuthorizer, ListAuthorizers, SetDefaultAuthorizer, DescribeDefaultAuthorizer, ClearDefaultAuthorizer and TestInvokeAuthorizer are served on the REST-JSON paths the AWS SDKs use, with the shapes, error codes and messages measured against AWS:
- A new authorizer is
INACTIVEwith HTTP caching off unless the request says otherwise. UnlesssigningDisabledistrue, it needs atokenKeyNameand one or two RSA-2048 public keys in PEM form; a signing-disabled authorizer takes no keys. The function ARN is checked for ARN syntax only; the function is resolved when it is invoked. DescribeAuthorizerreturns every member; an unset token key name or key map is JSONnull, and tags are not shown.UpdateAuthorizerchanges only the members sent and merges signing keys by key name, refusing a third key. Any member sent moveslastModifiedDate; an empty body does not.DeleteAuthorizerrefuses anACTIVEauthorizer (InvalidRequestException) and the default authorizer (DeleteConflictException).ListAuthorizersreturns the newest authorizer first (isAscendingOrder=truereverses it), filters bystatusand pages withmarkerandpageSize(1 to 250).- Setting the authorizer that already is the default fails with
ResourceAlreadyExistsException; setting another one replaces it. - Tags work through
TagResource,UntagResourceandListTagsForResourceon the authorizer ARN. - CloudFormation provisions
AWS::IoT::Authorizer:Refis the name andFn::GetAtt Arnthe ARN.
The function is invoked synchronously with the event AWS IoT sends (protocolData, protocols, token for a signed authorizer, signatureVerified, connectionMetadata), and its answer is checked as AWS checks it: isAuthenticated, a principalId of 1 to 128 letters and digits, at most 10 policyDocuments of at most 2048 characters each that parse as JSON objects, and TTLs from 300 to 86400 seconds. Omitted TTLs become 300 (refreshAfterInSeconds) and 86400 (disconnectAfterInSeconds), and a policy returned as a JSON object becomes one compact string. A signed authorizer verifies the token's SHA256withRSA signature against each of its keys before the function is invoked.
TestInvokeAuthorizerworks onACTIVEandINACTIVEauthorizers. It returns the request errors (InvalidRequestException) and answer errors (InvalidResponseException) with AWS's messages, and passes the contexts on as given: header case is kept andqueryStringkeeps its leading?.- An MQTT
CONNECTon the plaintext listener or on MQTT over WebSocket that names an authorizer, in the username (<clientId>?x-amz-customauthorizer-name=<name>), the WebSocket URL query or thex-amz-customauthorizer-nameupgrade header, is admitted only when the authorizer exists and isACTIVE, the signature verifies for a signed authorizer, the answer is valid,isAuthenticatedistrue, and the returned policies allowiot:Connectonarn:aws:iot:<region>:<account>:client/<clientId>. The event carries the username with its query, the password in base64, and for WebSocket the lower-cased upgrade headers, the query string without?and, over TLS, the server name. - A refused MQTT 3.1.1 client is disconnected without a
CONNACK; an MQTT 5 client getsCONNACKreason code0x87with the reason string AWS sends, then the connection closes. ACONNECTthat names no authorizer is handled as before.
Current limitations:
- The default authorizer is not used: a
CONNECTthat names no authorizer is not authenticated. - HTTPS publish does not invoke authorizers, and
enableCachingForHttphas no effect. refreshAfterInSecondsanddisconnectAfterInSecondsare returned but do not schedule a re-invocation or a disconnect.- The returned policies decide
iot:Connectonly; publish, subscribe and receive are not checked against them. - The function's resource policy is not checked, so
AddPermissionforiot.amazonaws.comis not required. - A WebSocket upgrade without the
mqttsubprotocol is not answered with AWS's 426. - A refused WebSocket client sees a close frame with status
1000. AWS sends an empty close frame to an MQTT 3.1.1 client and closes the connection without a close frame after the MQTT 5CONNACK. - An accepting MQTT 5
CONNACKcarries no properties. - The MQTT over TLS listener (8883) authenticates by device certificate only.
- On a connection, the authorizer is looked up in Floci's default account and region.
- The MQTT password reaches the function as the broker decodes it, as UTF-8 text, so password bytes that are not valid UTF-8 arrive altered. The broker library, Vert.x MQTT, exposes the password only as a string.
Fleet Indexing
Status: configuration and a bounded SearchIndex subset.
UpdateIndexingConfiguration and GetIndexingConfiguration (/indexing/config) and DescribeIndex (/indices/{indexName}) are served on the REST-JSON paths the AWS SDKs use, with the AWS shapes, error codes and validation messages:
- There is one configuration per account and region. Before the first update both indexes are
OFF. - An update may carry the thing configuration, the thing group configuration, or both; the one it leaves out keeps its value. Turning
thingIndexingModetoOFFclears the whole thing configuration, as on AWS. managedFieldsinGetIndexingConfigurationare derived from the modes, as AWS derives them. Managed fields sent in an update are only checked: every entry must be a field AWS manages, with its type, or the update fails withInvalidRequestException. A custom shadow path such asshadow.name.<shadow>.reported.<field>is rejected this way on AWS too. The thing group's managed fields are checked the same way, also whilethingGroupIndexingModeisOFF.- The thing configuration's
customFieldsandfilter(named shadow names, geolocations, socket information) are stored and returned as sent, except that a geolocation without anordergetsLatLon. A thing custom field without a name or a type, or named after a managed field, fails withInvalidRequestException. - A member of the wrong JSON type, or content after the JSON body, fails with
SerializationException. DescribeIndexreportsAWS_ThingsandAWS_ThingGroupswith the schema the modes select. The index isACTIVEas soon as it is enabled: Floci has noBUILDINGorREBUILDINGwindow. A disabled index isResourceNotFoundException, any other index nameInvalidRequestException.
SearchIndex
SearchIndex (POST /indices/search) searches the things of the caller's account and region in the AWS_Things index, the default index name. Floci evaluates this part of the query language:
field:value. Values match case-insensitively, field names exactly:attributes.Site:northmatches nothing when the attribute is namedsite.*(any run of characters) and?(one character) inside a value, and\to escape one character. A quoted value such asthingName:"x"matches literally.field:*matches the things that have the field. A bare*matches every thing.AND,ORandNOTin upper case, their forms&&,||and!, a leading-for negation, and parentheses.- Precedence as measured on AWS:
NOTbinds tightest, thenAND, thenOR. Whitespace is anANDthat binds looser thanOR, soa OR b cmeans(a OR b) AND c.
The fields are thingName, thingId, thingTypeName, thingGroupNames (direct memberships) and attributes.<name>.
Errors follow AWS, with AWS's messages. The body and member types are checked first, then the member constraints, then the index, then the query, then nextToken:
InvalidRequestExceptionfor a missing or emptyqueryString, aqueryStringlonger than 1000 characters, amaxResultsbelow 1 (AWS truncates it toward zero and caps it at the 32-bit integer range), an emptyindexName, aqueryVersionother than2017-09-30, an unknown index name, anextTokenthat is empty, was not issued bySearchIndex, or was edited or truncated (Invalid nextToken), and anextTokenissued for a differentqueryString,indexNameorqueryVersion(nextToken was from a request with different parameters).ResourceNotFoundExceptionfor an index that is not enabled.InvalidQueryExceptionfor invalid syntax, an invalid field name (including ashadow.name.<shadow>.<field>whose shadow is not in the named shadow filter), more than 12 terms, more than two*wildcards in a value, a value starting with?, the+operator, and fuzzy, regular expression and boost queries.InvalidRequestExceptionfor aconnectivity.*,shadow.*ordeviceDefender.*field while that indexing is off. A classicshadow.*field needsREGISTRY_AND_SHADOW; ashadow.name.*field is also served by named shadow indexing.SerializationExceptionfor a body that is not a JSON object, malformed JSON or content after it, aqueryString,indexName,queryVersionornextTokenthat is not a string, and amaxResultsthat is not a number.
Within the query, invalid syntax is reported before more than 12 terms, and more than 12 terms before any other query error, including a connectivity.*, shadow.* or deviceDefender.* field while that indexing is off and the constructs Floci refuses below. Each value of a field group counts as a term, the field before it does not. The field of a group is checked like any other field, before the group is refused. A range is one term: two bounds, separated by spaces and optionally by TO, in [ or { and ] or }. An unclosed or otherwise malformed range, and an unescaped [, ], { or } outside a range, is invalid syntax, as on AWS.
Syntax AWS accepts but Floci does not evaluate is refused with InvalidQueryException and the message Floci does not support <construct> in fleet index queries, query string: <query>. It never answers with an empty result. This covers:
- range queries (
field:[a TO b],field:{a TO b}) - comparisons (
>,<,>=,<=) - free text terms without a field, including lower case
and,orandnot - field grouping (
field:(a OR b)) - every
connectivity.*,shadow.*anddeviceDefender.*field once its indexing is on. While that indexing is off the answer is AWS'sInvalidRequestExceptionabove.
Searching AWS_ThingGroups is refused with InvalidRequestException and Floci does not support searching AWS_ThingGroups while that index is enabled. While it is disabled the answer is AWS's ResourceNotFoundException.
Results:
- A thing has
thingNameandthingId, andthingTypeName,thingGroupNamesandattributesonly when it has them.shadowanddeviceDefenderare never returned: Floci keeps no indexed shadow document. connectivityis never returned either. AWS adds it whilethingConnectivityIndexingModeisSTATUS, where a thing that never connected showsconnectedfalseandtimestamp0.- Things come in thing name order. AWS does not specify an order.
maxResultsandnextTokenpage the results. WithoutmaxResultsevery match comes in one page. The token marks the last thing returned, so the next page continues after it even when things before it were deleted, as on AWS. A token continues only the request that issued it: the samequeryString, andindexNameandqueryVersionsent the same way, since an omitted member differs from its explicit default.maxResultsmay change between pages.- An index answers at once: a thing is searchable as soon as it is created, where AWS takes a few seconds.
Current limitations:
ListIndicesand the statistics and aggregation APIs (GetStatistics,GetCardinality,GetPercentiles,GetBucketsAggregation) are not modeled yet.customFieldstypes are not applied to search: attribute values are matched as strings.managedFieldsandcustomFieldssent in the thing group configuration are checked (the custom fields for their types only) but not stored:GetIndexingConfigurationreports the managed fields the mode selects and no custom fields.
MQTT Broker
Status: complete.
Floci uses Vert.x MQTT as the embedded MQTT protocol server. IotMqttBrokerService owns the broker lifecycle, live session registry, subscription registry, MQTT fan-out, and the bridge into IoT service behavior.
Broker scope:
- Target real AWS IoT/device SDK style MQTT clients, not only handcrafted packet tests.
- Support MQTT v3 and MQTT 5 CONNECT handling used by local compatibility tests.
- Support QoS 0 and QoS 1 publish/subscribe behavior for the local AWS IoT slice.
- Accept PUBLISH payloads up to 128 KB on 1883, 8883 and
/mqtt, the AWS IoT Core quota (packets up to 146 KB including the variable header). A larger publish disconnects the client without acknowledgement, delivery or rule evaluation, as on AWS. - Serve MQTT over TLS on 8883 next to plaintext 1883 when TLS is enabled, and verify the device certificate and its
iot:Connectpermission there. - Keep the plaintext listener permissive except for a
CONNECTthat names a custom authorizer; topic-level authorization (Publish,Subscribe,Receive) is follow-up scope. - Keep MQTT broker logging minimal.
- Validate the relevant IoT compatibility tests against the native binary before considering the phase complete.
MQTT over TLS
With FLOCI_TLS_ENABLED=true the broker also listens on FLOCI_SERVICES_IOT_MQTT_TLS_PORT (default 8883, the port AWS IoT uses for X.509 device connections; 0 disables it). It presents the same certificate as the HTTPS endpoint, issued by the Floci CA, over TLS 1.2 or 1.3. A device connects with ssl://localhost:8883 trusting GET /_floci/ca.pem, as it would trust Amazon Root CA 1 against AWS IoT. A custom domain added to the server certificate at runtime (see TLS) is served on 8883 from the next connection on, without a restart.
The listener asks for a client certificate and decides the connection when the CONNECT arrives, see Device verification on 8883. Sessions, subscriptions and reserved topics are shared with the plaintext listener, so a client id connecting on one port replaces its session on the other, as on AWS. Both listeners start together: with the first IoT API call, or at boot with FLOCI_SERVICES_IOT_MQTT_AUTO_START=true.
docker run -e FLOCI_TLS_ENABLED=true -e FLOCI_SERVICES_IOT_MQTT_AUTO_START=true -p 4566:4566 -p 8883:8883 floci/floci:latest
curl http://localhost:4566/_floci/ca.pem -o ca.pem
aws --endpoint-url http://localhost:4566 iot create-keys-and-certificate --set-as-active \
--certificate-pem-outfile device.crt --private-key-outfile device.key --query certificateArn --output text
aws --endpoint-url http://localhost:4566 iot create-policy --policy-name connect \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iot:Connect","Resource":"arn:aws:iot:*:*:client/*"}]}'
aws --endpoint-url http://localhost:4566 iot attach-policy --policy-name connect --target <certificateArn>
mosquitto_sub -h localhost -p 8883 --cafile ca.pem --cert device.crt --key device.key -t 'devices/#'
Device verification on 8883
The connection is admitted when all of these hold, otherwise the broker answers CONNACK not authorized (return code 5, reason code 0x87 on MQTT 5) and closes the connection:
- A certificate was presented and the SHA-256 of its DER encoding is a registered
certificateId(CreateKeysAndCertificateorCreateCertificateFromCsr), in any account and region. Who signed it does not matter, registration does, as on AWS. - The certificate is
ACTIVEand inside its validity dates. - The default versions of the policies attached to the certificate (
AttachPolicywith the certificate ARN as target, in the certificate's own account and region) allowiot:Connectonarn:aws:iot:<region>:<account>:client/<clientId>. No attached policy means deny, and an explicitDenywins.
Policy variables are substituted and are also available as condition keys: iot:ClientId, aws:SourceIp, iot:DomainName (the server name the client sent in the TLS handshake, absent when it dialled an IP address), iot:Connection.Thing.IsAttached and, only when the client id names a thing attached to the certificate with AttachThingPrincipal, iot:Connection.Thing.ThingName, iot:Connection.Thing.ThingTypeName and iot:Connection.Thing.Attributes[name]. A statement that uses a variable Floci cannot resolve, such as the certificate variables iot:Certificate.*, matches nothing.
Differences from AWS: AWS rejects an unregistered, inactive or expired certificate during the TLS handshake and closes an MQTT 3.1.1 connection it does not authorize without a CONNACK; Floci completes the handshake and always answers with the return code, so the reason is visible to the client. Deactivating the certificate or detaching the policy takes effect on the next connect; established sessions are not dropped. Policies attached to thing groups are not consulted, and exclusive thing attachment (thingPrincipalType) is not modelled: the thing is always the one named by the client id.
Endpoint address
DescribeEndpoint returns host:4566 by default, the host and port of Floci's base URL, because that is where the HTTP data plane lives and IoT Data clients build https://<endpointAddress> from it. The four AWS-managed domain configurations report the same value as their domainName.
AWS returns a bare hostname and lets each client add its own port: 8883 for MQTT with a client certificate, 443 for HTTPS and MQTT over WebSocket, 8443 for HTTPS with a client certificate. With TLS enabled, Floci serves MQTT on 8883 and HTTPS on 443 next to 4566. When those ports reach Floci, set FLOCI_SERVICES_IOT_ENDPOINT_ADDRESS and DescribeEndpoint answers like AWS:
services:
floci:
image: floci/floci:latest
environment:
FLOCI_TLS_ENABLED: "true"
FLOCI_SERVICES_IOT_ENDPOINT_ADDRESS: iot.example.localhost.floci.io
ports:
- "4566:4566"
- "8883:8883" # MQTT over TLS
- "443:443" # HTTPS data plane, https://<endpointAddress>/topics/<topic>
- "8443:443" # optional: the HTTPS port AWS uses with client certificates
The value is a hostname or host:port, never a URL, and is returned as is for every endpoint type: AWS hands out one hostname per type (iot:Data-ATS, iot:Data, iot:Jobs, iot:CredentialProvider), Floci answers all four with this one. The name is added to the generated server certificate, like FLOCI_HOSTNAME, so devices verify it on 8883 and 443; a name under localhost.floci.io resolves to 127.0.0.1 on its own, any other needs a DNS or /etc/hosts entry. Floci does not detect published ports itself: unset, or set to an empty value, DescribeEndpoint keeps returning host:4566, so the plain -p 4566:4566 setup keeps working for IoT Data clients.
MQTT over WebSocket
The broker is also reachable as MQTT over WebSocket at /mqtt on Floci's HTTP and HTTPS ports
(ws://<host>:4566/mqtt, wss://<host>:4566/mqtt), the path AWS IoT serves on 443 for browser
clients, the AWS IoT Device SDK's WebSocket transport and secure tunnelling. With the 443:443
mapping above (or 443:4566) the AWS URL wss://<endpoint>:443/mqtt works unchanged. The handshake echoes
the mqtt subprotocol and uses the same certificate as HTTPS, so hostnames learned at runtime
apply to it too. Frames are piped to the plaintext MQTT listener, so every broker feature behaves
the same on both transports. There is no separate port or setting for it; disabling the broker
(FLOCI_SERVICES_IOT_MQTT_ENABLED=false) makes /mqtt answer 503 to an upgrade, and a plain
request to /mqtt gets the usual 404.
No client certificate is requested on this path. On AWS it authenticates by SigV4 (signed query
string on the upgrade URL) or by a custom authorizer (<clientId>?x-amz-customauthorizer-name=<name>
as the MQTT username with a token as the password). Floci enforces a custom authorizer named in the
username, the URL query or the x-amz-customauthorizer-name upgrade header, see
Authorizers; it does not verify a SigV4 signature, and a CONNECT that names no
authorizer is accepted as is.
Reserved Topics
AWS IoT reserved topics such as $aws/things/{thingName}/shadow/update are service control topics, not ordinary application topics. Floci should handle these publishes by invoking IoT shadow behavior and then publishing the AWS-compatible response topics through the broker.
Required phase 7 reserved-topic behavior:
- Classic unnamed shadows:
$aws/things/{thingName}/shadow/update,get, anddelete. - Named shadows:
$aws/things/{thingName}/shadow/name/{shadowName}/update,get, anddelete. - Shadow response topics:
accepted,rejected,documents, anddeltawhere applicable. - Basic Ingest and Jobs topic families are desired follow-up scope, but should not block restoring the broker unless explicitly pulled into the implementation phase.
Reserved request topics are handled by Floci before normal MQTT fan-out. The original $aws/... request publish is not routed as an application message; generated accepted, rejected, documents, and delta responses are published back through IotMqttBrokerService.publish(...) so matching MQTT subscribers receive broker-native messages.
Implementation notes:
- Vert.x MQTT handles the wire protocol and connection lifecycle.
- Floci-owned session, subscription, and retained-message state drives local AWS IoT compatibility behavior.
- Normal client publishes call
IotService.publish(...)so retained-message storage, event recording, and rule evaluation remain service-owned. - An MQTT publish stores the retained message and records the publish on the event loop, then hands topic rule evaluation to a single-thread worker the broker owns, which runs the rules of MQTT publishes one at a time in the order they arrived. A slow rule action therefore delays the rules of later publishes from every connection, but not their PUBACK or fan-out, and not other services' work.
- A state reset or a broker shutdown skips the rules of MQTT publishes still waiting, and an MQTT publish received during the reset or while the broker is stopped never runs its rules. An evaluation already running finishes.
- Internal broker publishes fan out only to MQTT subscribers and do not recursively evaluate IoT topic rules.
Current accepted limitation:
- Certificate and
iot:Connectchecks are enforced on 8883 only; topic-level authorization is not enforced on either port yet. - Persistent offline sessions are not modeled yet.
- QoS 2 and advanced MQTT 5 property semantics remain follow-up scope.
- At most 1,000 MQTT publishes are pending rule evaluation, running or waiting. While that many are pending, further publishes are still delivered to subscribers but their topic rules are skipped, with one warning logged until the backlog drains.
Implementation Shape
The MQTT integration should keep service behavior separated from broker mechanics:
IotMqttBrokerServiceowns Vert.x MQTT lifecycle and broker-native publish helpers.- The broker publish handler detects AWS IoT reserved topics.
- IoT reserved-topic handling lives in IoT service code or a focused reserved-topic handler, not in packet parsing code.
- AWS-generated shadow responses are published back through
IotMqttBrokerService.publish(...)so regular MQTT subscribers receive broker-native messages.
Phase 7 Completion Criteria
Phase 7 completion criteria:
- Vert.x MQTT is the active MQTT broker implementation.
- Reserved shadow topics are handled from the broker publish handler.
- AWS-generated shadow responses are published through the broker service, not by manually writing MQTT packets.
- MQTT 5 CONNECT and publish/subscribe behavior are covered by automated tests.
- Classic unnamed shadow MQTT topics are covered by automated tests.
- Named shadow MQTT topics are covered by automated tests.
- Relevant IoT compatibility tests pass against the native binary.
Rules Engine
Status: complete for the MVP 2 action slice.
Phase 8 adds stored IoT topic rules and dispatches matching IoT publishes to rule actions.
Supported rule behavior:
CreateTopicRule,GetTopicRule,ListTopicRules,EnableTopicRule,DisableTopicRule, andDeleteTopicRulethrough AWS SDK-compatible IoT control-plane paths.- Rule SQL parsing and evaluation for the subset described under Rule SQL: the
SELECTprojection, theFROMtopic filter, and theWHEREpredicate. - MQTT-style topic filter matching for exact topics,
+, and terminal#. - IoT Data
Publishand MQTT publishes use the same rule dispatch path. An IoT DataPublishevaluates its rules before it returns. An MQTT publish evaluates them on the broker's rule worker, so neither its PUBACK nor its fan-out waits for rule actions, and a republished message can reach subscribers before or after the source message's other deliveries; AWS gives no ordering guarantee there either. - Rule matching is region-scoped: an IoT Data
Publishevaluates the rules of the region named by its SigV4 credential, and a rule's actions target the rule's own region. - Publishes that carry no region (MQTT, or an IoT Data
PublishwhoseAuthorizationheader is absent or not SigV4) are evaluated against every region's rules. - Actions receive the projected document, which is the payload itself for a statement that selects only
*. republishaction republishes to another MQTT topic throughIotMqttBrokerService.sqsaction sends to an SQS queue through Floci's SQS service boundary.snsaction publishes to an SNS topic through Floci's SNS service boundary.s3action writes to the configured bucket/key through Floci's S3 service boundary.dynamoDBv2action writes JSON object fields as DynamoDB attribute values through Floci's DynamoDB service boundary, as the account that owns the rule.kinesisaction puts the document into a Kinesis stream through Floci's Kinesis service boundary.lambdaaction invokes the configured function ARN through Floci's Lambda service boundary.firehoseaction puts the document into a Kinesis Data Firehose delivery stream through Floci's Firehose service boundary, withseparatorappended to each record; the separator must be\n,\t,\r\nor,, as the API model requires, or the rule is rejected withInvalidRequestException. WithbatchMode, a JSON array document becomes one record per element.cloudwatchLogsaction writes the document as a log event through Floci's CloudWatch Logs service boundary, into a log stream named after the rule that is created inlogGroupNameon first use. The log group must exist. WithbatchMode, a JSON array document becomes one event per element, and each element supplies its owntimestamp(epoch milliseconds) andmessage, as the AWS message format for batched device logs requires; an element without them is logged as its JSON text at publish time.- One failing action never fails the publish or the other actions of the rule. The failure is logged, and once every action ran the rule's
errorActionreceives the AWS failure document:ruleName,topic,base64OriginalPayloadandfailureswithfailedAction,failedResourceanderrorMessageper failed action. GetTopicRulereturnsawsIotSqlVersionanderrorActionas they were given toCreateTopicRuleorReplaceTopicRule.
Rule SQL
A rule's statement is parsed once when it is created or replaced, and once on the first publish for rules restored from storage. The grammar Floci understands is:
statement := SELECT item (',' item)* FROM '<topic filter>' [WHERE expr]
item := '*' | operand [AS identifier]
expr := term (OR term)*
term := factor (AND factor)*
factor := NOT factor | '(' expr ')' | operand [comparison operand | IN operand]
comparison := '=' | '<>' | '!=' | '<' | '<=' | '>' | '>='
operand := path | literal | array | call
array := '[' [operand (',' operand)*] ']'
call := topic() | topic(<segment>) | startswith(operand, operand) | endswith(operand, operand)
| clientid() | timestamp() | accountid() | newuuid() | isNull(operand) | isUndefined(operand)
path := identifier ('.' identifier)*
literal := 'string' | "string" | number | TRUE | FALSE | NULL
Semantics:
- Keywords and function names are case insensitive, field names are case sensitive.
topic()is the full MQTT topic,topic(n)is its nth segment counting from 1.clientid()is the MQTT client that published the message, orn/afor an IoT DataPublishover HTTP, as on AWS.accountid()is the account that owns the rule.timestamp()is the current time in milliseconds since the epoch.newuuid()is a fresh random UUID.isNull(x)is true only for a JSONnull,isUndefined(x)only for a missing field or an undefined expression. Neither is ever undefined itself.x IN yis true when the arrayyholds a value equal toxunder the equality rules below, false otherwise. It is undefined whenxis undefined oryis not an array.ymay be a payload field or an array literal such as[1, 'a'].- An array literal is undefined as a whole when any of its elements is, so a position is never silently dropped.
- A select item without
ASis written under the last segment of its path, or under the function name, sotopic()becomestopic. - When
*is present, every payload field is copied first and the other select items are written over it.SELECT *, topic() as topicon a payload that already has atopicfield therefore yields the MQTT topic, which is what AWS does. - A select item whose value is undefined is left out of the document.
- A missing field, an out of range topic segment, and a function argument that cannot be converted
are
Undefined, as in AWS. It spreads: a comparison,AND,ORorNOTwith an undefined operand is undefined, and a rule fires only when itsWHEREis true.endswith(clientToken, 'x')therefore does not fire when the payload has noclientToken, and neither doesclientToken <> 'x'. - JSON
nullis a value, notUndefined: it equals onlyNULL, soclientToken <> 'x'is true when the field is null and undefined when it is missing. =and<>compare two numbers by value, arrays element by element in order, objects by key in any order, and anything else by type and value, so operands of different types are simply not equal:level = '3'is false andlevel <> '3'is true whenlevelis the number 3. These follow the operator tables in the AWS IoT SQL reference.<,<=,>and>=convert both operands to a number. A string converts when it looks like one ('10' > 9is true); any other operand makes the comparison undefined.- Payload numbers are read exactly, never through a double, so
9007199254740993.0,1e-400and0.30000000000000004compare as written, at any size or precision, as AWS's Decimal does. - Number literals may carry an exponent (
1e5,1E-3,-2.411E247), and an integer beyond 64 bits (99999999999999999999) is kept exact as a decimal. AND,ORandNOTtake booleans or the strings'true'and'false'in any case. Any other operand makes the result undefined.startswithandendswithconvert numbers, booleans, arrays and objects to their string form first. Anullor undefined argument makes the result undefined.- String literals use single or double quotes, doubled to escape the quote itself.
- A statement that selects only
*forwards the published bytes unchanged, so the payload does not have to be JSON when there is noWHERE. Any other statement needs a JSON object: a payload that is not one is logged at DEBUG and the rule does not fire.
Statements outside this grammar are not rejected. They are stored as sent and keep the behavior they
had before Floci evaluated rule SQL: the topic filter is read out of the statement and the rule fires
on every matching topic with the whole payload. Floci logs one WARN naming the rule and the token it
could not parse. Setting floci.services.iot.rule-sql-strict to true
(FLOCI_SERVICES_IOT_RULE_SQL_STRICT) makes CreateTopicRule and ReplaceTopicRule reject such a
statement with SqlParseException instead, the way AWS does. It is false by default.
Current limitations:
- Not evaluated:
principal(), which needs the certificate a device authenticated with and the broker does not verify one yet;traceid();encode(),get(),get_thing_shadow()and the rest of the AWS function list; arithmetic; array indexing;CASE;SELECT VALUE; object literals; theSETclause;EXISTSsubqueries; and${}substitution templates in action fields. A rule using any of them takes the unparsed path described above. awsIotSqlVersionis stored and echoed back but not acted on. The versions differ in howSELECT *treats arrays, which Floci does not model.- Less common AWS IoT rule action types are follow-up scope.
Open follow-up scope for phase 7 unless explicitly deferred:
- Basic Ingest topics under
$aws/rules/.... - AWS IoT Jobs reserved topics and required job lifecycle behavior.