From 00e5771bebf05659bb914fe1f85e95df366ace32 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 6 Sep 2026 06:35:31 +0800 Subject: [PATCH] feat(bluetooth): serve the API and readers over Bluetooth LE - Add a BlueZ D-Bus layer (pkg/bluetooth/bluez) covering the peripheral role (GATT server, advertising) and the central role (scan, connect, subscribe), Linux only, with a manager that opens the adapter while [service.ble] enabled is set and retries after hot-plug. - Serve the JSON-RPC API over a Zaparoo GATT service: chunked framing in pkg/bluetooth/apigatt with a client-chosen session tag, per-connection sessions that reuse the encrypted client session with AAD label ":ble", pre-auth pair.start and pair.finish methods that run the existing PAKE exchange, and per-transport response size limits. - Decouple the WebSocket dispatcher from melody through a sessionWriter interface and share the request environment through requestDeps so the BLE transport reuses the dispatcher, priorities and input sessions. - Add the simpleserial_ble reader driver, which connects to a configured Nordic UART Service device and reconnects in the background, and move the simple serial line parser into pkg/readers/shared/simpleproto. - Expose bleEnabled on settings and settings.update, document the GATT contract and error codes -32004 and -32005, install dbus on CI, and add fuzz targets for the chunk parser and reassembler. --- .github/workflows/lint-and-test.yml | 2 + Taskfile.dist.yml | 2 + docs/ARCHITECTURE.md | 3 +- docs/api/encryption.md | 10 +- docs/api/index.md | 33 + docs/api/methods.md | 3 + pkg/api/ble_session.go | 604 +++++++++++++ pkg/api/ble_session_test.go | 854 ++++++++++++++++++ pkg/api/ble_transport.go | 301 ++++++ pkg/api/decrypt_frame_test.go | 131 +++ pkg/api/methods/settings.go | 6 + pkg/api/methods/settings_test.go | 50 + pkg/api/middleware/encryption.go | 38 +- .../middleware/encryption_transport_test.go | 130 +++ pkg/api/models/params.go | 1 + pkg/api/models/responses.go | 1 + pkg/api/pairing.go | 84 +- pkg/api/request_deps.go | 122 +++ pkg/api/server.go | 279 +++--- pkg/api/server_encryption.go | 4 +- pkg/api/server_encryption_test.go | 63 +- pkg/api/session_writer.go | 39 + pkg/api/ws_dispatcher.go | 70 +- pkg/api/ws_dispatcher_writer_test.go | 184 ++++ pkg/bluetooth/apigatt/frame.go | 359 ++++++++ pkg/bluetooth/apigatt/frame_fuzz_test.go | 90 ++ pkg/bluetooth/apigatt/frame_test.go | 427 +++++++++ pkg/bluetooth/apigatt/uuids.go | 95 ++ pkg/bluetooth/bluez/bluez.go | 245 +++++ pkg/bluetooth/bluez/bluez_linux.go | 451 +++++++++ pkg/bluetooth/bluez/bluez_other.go | 31 + pkg/bluetooth/bluez/central_linux.go | 369 ++++++++ pkg/bluetooth/bluez/integration_linux_test.go | 556 ++++++++++++ pkg/bluetooth/bluez/peripheral_linux.go | 409 +++++++++ pkg/bluetooth/bluez/signals_linux.go | 95 ++ pkg/bluetooth/bluez/signals_linux_test.go | 182 ++++ pkg/bluetooth/manager.go | 252 ++++++ pkg/bluetooth/manager_test.go | 244 +++++ pkg/config/configservice.go | 37 + pkg/config/configservice_test.go | 54 ++ pkg/platforms/batocera/platform.go | 2 + pkg/platforms/libreelec/platform.go | 2 + pkg/platforms/mister/platform.go | 2 + pkg/platforms/mistex/platform.go | 2 + pkg/platforms/recalbox/platform.go | 2 + pkg/platforms/retropie/platform.go | 2 + pkg/platforms/shared/linuxbase/readers.go | 2 + pkg/readers/shared/simpleproto/simpleproto.go | 140 +++ .../shared/simpleproto/simpleproto_test.go | 127 +++ pkg/readers/simpleserial/simpleserial.go | 59 +- .../simpleserialble/simpleserialble.go | 418 +++++++++ .../simpleserialble/simpleserialble_test.go | 316 +++++++ pkg/service/discovery/discovery.go | 16 +- pkg/service/service.go | 10 +- pkg/testing/mocks/bluez.go | 334 +++++++ 55 files changed, 8135 insertions(+), 209 deletions(-) create mode 100644 pkg/api/ble_session.go create mode 100644 pkg/api/ble_session_test.go create mode 100644 pkg/api/ble_transport.go create mode 100644 pkg/api/decrypt_frame_test.go create mode 100644 pkg/api/middleware/encryption_transport_test.go create mode 100644 pkg/api/request_deps.go create mode 100644 pkg/api/session_writer.go create mode 100644 pkg/api/ws_dispatcher_writer_test.go create mode 100644 pkg/bluetooth/apigatt/frame.go create mode 100644 pkg/bluetooth/apigatt/frame_fuzz_test.go create mode 100644 pkg/bluetooth/apigatt/frame_test.go create mode 100644 pkg/bluetooth/apigatt/uuids.go create mode 100644 pkg/bluetooth/bluez/bluez.go create mode 100644 pkg/bluetooth/bluez/bluez_linux.go create mode 100644 pkg/bluetooth/bluez/bluez_other.go create mode 100644 pkg/bluetooth/bluez/central_linux.go create mode 100644 pkg/bluetooth/bluez/integration_linux_test.go create mode 100644 pkg/bluetooth/bluez/peripheral_linux.go create mode 100644 pkg/bluetooth/bluez/signals_linux.go create mode 100644 pkg/bluetooth/bluez/signals_linux_test.go create mode 100644 pkg/bluetooth/manager.go create mode 100644 pkg/bluetooth/manager_test.go create mode 100644 pkg/readers/shared/simpleproto/simpleproto.go create mode 100644 pkg/readers/shared/simpleproto/simpleproto_test.go create mode 100644 pkg/readers/simpleserialble/simpleserialble.go create mode 100644 pkg/readers/simpleserialble/simpleserialble_test.go create mode 100644 pkg/testing/mocks/bluez.go diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index b20d8763c..5ed51953f 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -167,6 +167,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ + dbus \ libnfc-dev \ libpcsclite-dev @@ -290,6 +291,7 @@ jobs: run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ + dbus \ libnfc-dev \ libpcsclite-dev diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index db0465b9c..440577708 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -141,6 +141,8 @@ tasks: "FuzzParseTitleFromFilename ./pkg/database/tags" "FuzzExtractSpecialPatterns ./pkg/database/tags" "FuzzParseLine ./pkg/readers/rs232barcode" + "FuzzParseChunk ./pkg/bluetooth/apigatt" + "FuzzReassembler ./pkg/bluetooth/apigatt" "FuzzDecodeURIIfNeeded ./pkg/helpers" "FuzzIsValidExtension ./pkg/helpers" "FuzzFilenameFromPath ./pkg/helpers" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4b7301ff..d6fef8f6d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,7 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de - **Launch endpoint**: `/l/{zapscript}` - GET-based execution for QR codes - **Auth**: API keys via `auth.toml`, anonymous access from localhost - **Discovery**: mDNS (`_zaparoo._tcp`) +- **Bluetooth LE** (Linux, off by default): the same JSON-RPC API and pairing over a GATT service, `pkg/bluetooth` + `pkg/api/ble_*.go`. See the "Bluetooth LE" section of `docs/api/index.md`. - **Notifications**: Real-time WebSocket events (readers, tokens, media, indexing, playtime, global UI). See `docs/api/notifications.md`. - **Full docs**: `docs/api/` @@ -69,4 +70,4 @@ Device profiles are named buckets of preferences and limits, with no passwords o ## Reader Auto-Detection -11 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, operator (MiSTer only), opticaldrive, pn532, rs232barcode, simpleserial, tty2oled +12 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, operator (MiSTer only), opticaldrive, pn532, rs232barcode, simpleserial, simpleserialble (Linux only, Nordic UART Service over Bluetooth LE, manual configuration only), tty2oled diff --git a/docs/api/encryption.md b/docs/api/encryption.md index 4ca83ce44..f54f8d626 100644 --- a/docs/api/encryption.md +++ b/docs/api/encryption.md @@ -186,12 +186,14 @@ Counters don't wrap. Disconnect and reconnect with a fresh salt to start over. ### AAD -All encrypt/decrypt operations bind ciphertext to the session: +All encrypt/decrypt operations bind ciphertext to the session and to the transport it travels over: ```text -aad = authToken + ":ws" +aad = authToken + ":" + transport ``` +`transport` is `ws` for WebSocket and `ble` for Bluetooth LE. A frame encrypted for one transport fails authentication on the other, so credentials captured on one link cannot be replayed on another. + ## Security limits - **Salt reuse**: The server rejects duplicate session salts per client (200-entry / 10-minute sliding window). Always use a CSPRNG for session salts. @@ -242,6 +244,8 @@ WebSocket errors (plaintext JSON-RPC error, then connection closed): |---|---| | -32001 | Unsupported encryption version | | -32002 | Encryption required. Remote clients must send an encrypted first frame. | +| -32004 | Response too large for the transport (Bluetooth LE only). `data.limit` and `data.size` say by how much. | +| -32005 | Pairing failed (Bluetooth LE `pair.start` / `pair.finish` only). `data.status` and `data.message` mirror the HTTP endpoints. | ## Connection lifecycle @@ -280,7 +284,7 @@ function connect(url, authToken, pairingKey): c2sBase = hkdf_expand(prk, info="zaparoo-c2s-nonce-v1", len=12) s2cBase = hkdf_expand(prk, info="zaparoo-s2c-nonce-v1", len=12) - aad = encode(authToken + ":ws") + aad = encode(authToken + ":ws") // ":ble" over Bluetooth LE sendCounter = 0 recvCounter = 0 diff --git a/docs/api/index.md b/docs/api/index.md index 038808c3d..8e8e645cd 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -95,6 +95,39 @@ data: {"jsonrpc":"2.0","method":"media.started","params":{"systemId":"NES","syst SSE connections are long-lived and will continue receiving events until the client disconnects. To call methods, use HTTP POST to the standard API endpoint alongside the SSE connection. +### Bluetooth LE + +On Linux platforms with a Bluetooth adapter, Core can also serve the API over Bluetooth Low Energy, so the app works with no shared Wi-Fi at all: discovery, pairing and every method run over the one Bluetooth connection. The transport is off by default; enable it with the `bleEnabled` setting (`[service.ble] enabled = true` in the config file). Core is the peripheral and the client is the central. + +Core advertises a primary service and the local name from `[service.ble] name`, falling back to the mDNS discovery instance name. The name is read when advertising starts, so a rename shows up after the transport restarts: + +| Characteristic | UUID | Properties | Purpose | +| -------------- | -------------------------------------- | ------------------------------ | ----------------------------------------------------------------------- | +| Service | `0da70001-b359-443b-836f-477d34b6a638` | | Primary service, also in the advertisement so clients can filter on it. | +| RX | `0da70002-b359-443b-836f-477d34b6a638` | write, write without response | Chunks from the client to Core. | +| TX | `0da70003-b359-443b-836f-477d34b6a638` | notify | Chunks from Core to the client. | +| Info | `0da70004-b359-443b-836f-477d34b6a638` | read | JSON description of the endpoint, readable before authenticating. | + +Info returns `{"v": 1, "deviceId": "", "maxMessage": 262144, "preferredMtu": 512}`. `deviceId` lets a client pick the stored credentials for this device before it speaks; peer addresses are not stable enough for that. Clients should request the largest ATT MTU the platform allows. + +**Framing.** A message is one complete WebSocket-equivalent frame: an encrypted frame, or one of the plaintext pairing requests below. It is split into chunks that fit `MTU - 3` bytes and written to RX one after another; Core sends replies the same way on TX. Every chunk starts with a header: + +```text +byte 0 flags bits 7..4 = protocol version (1), bit 1 = LAST, bit 0 = FIRST, bits 3..2 reserved (0) +byte 1 seq 0 on the FIRST chunk of a message, +1 per chunk, wrapping at 256 +byte 2-3 tag session tag, big-endian +byte 4-7 length total message length, big-endian, FIRST chunk only +payload at least one byte +``` + +The client picks a random non-zero 16-bit tag for the connection and sends it on every chunk. Core stamps the same tag on every chunk it sends that client, and clients must drop TX chunks carrying any other tag, because the radio delivers notifications to every subscribed central: every connected central sees every TX chunk, including pairing replies, and only the per-session encryption keeps another client's traffic unreadable. A message may not exceed `maxMessage` bytes in either direction; a response that would is replaced by error `-32004` (`response too large for transport`) whose `data` carries the `limit` and `size`. Chunks may reach Core slightly out of order; a chunk more than 128 places ahead of the one expected, a repeated sequence number, or a length that does not add up ends the connection. + +**Pairing.** Pairing runs the same exchange as the [HTTP pairing endpoints](./encryption#pairing-flow), carried as two plaintext JSON-RPC requests that exist only on this transport: `pair.start` with params `{"pake": "", "name": ""}` returning `{"session": "...", "pake": ""}`, then `pair.finish` with params `{"session": "...", "confirm": ""}` returning `{"authToken": "...", "clientId": "...", "confirm": ""}`. The pairing PIN is still generated and shown on the device by `clients.pair.start`. Failures return error `-32005` (`pairing failed`) whose `data` carries the HTTP `status` and `message` the endpoints would have used, including `429` when a connection sends more than a couple of pairing requests per second. + +**Sessions.** A connection accepts only pairing requests and an [encrypted first frame](./encryption#first-frame-client--server); plaintext method calls end the connection, and there is no localhost or legacy access over Bluetooth. The encrypted frames are exactly the WebSocket ones with the AAD transport label `ble` (see [AAD](./encryption#aad)). After pairing, the client sends its encrypted first frame on the same connection. An unauthenticated connection that stays silent for two minutes is dropped, and an authenticated one that sends nothing for five minutes is dropped too, so send the `ping` heartbeat at least once a minute; it works unchanged over Bluetooth. Once authenticated, notifications arrive on TX like WebSocket notifications, except that `media.indexing` and `media.scraping` are skipped while the link is backed up. + +**Readers.** The same adapter also serves the `simpleserial_ble` reader driver, which connects to a configured Nordic UART Service device as a central. A configured reader that cannot be reached scans for it with pauses growing from one to thirty seconds; scanning shares the radio with advertising, so the device can be a little harder for the app to discover until the reader turns up. + ### JSON Payloads Server and clients communicate back and forth using JSON payloads, following the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) protocol. diff --git a/docs/api/methods.md b/docs/api/methods.md index b6ccd0041..b16a8ad72 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -2676,6 +2676,7 @@ None. | readersScanIgnoreSystems | string[] | Yes | List of system IDs to ignore during scanning. | | errorReporting | boolean | Yes | Whether error reporting is enabled. | | encryption | boolean | Yes | Whether paired encryption is required for remote WebSocket connections. Localhost remains exempt. | +| bleEnabled | boolean | Yes | Whether the API is also served over [Bluetooth LE](./#bluetooth-le). Defaults to false. | | readersConnect | [ReaderConnection](#reader-connection-object)[] | Yes | List of manually configured reader connections. | | systemDefaults | [SystemDefault](#system-default-object)[] | Yes | Per-system overrides for default launcher and exit ZapScript. | | profilesRequireForLaunch | boolean | Yes | Whether media launches are blocked while no personal profile is active. | @@ -2737,6 +2738,7 @@ None. "readersScanIgnoreSystems": ["DOS"], "errorReporting": true, "encryption": false, + "bleEnabled": false, "readersConnect": [], "systemDefaults": [ { @@ -2771,6 +2773,7 @@ An object containing any of the following optional keys: | readersScanIgnoreSystems | string[] | No | List of system IDs to ignore during scanning. | | errorReporting | boolean | No | Whether error reporting is enabled. | | encryption | boolean | No | Require paired encryption for remote WebSocket connections. This setting can only be changed from localhost. | +| bleEnabled | boolean | No | Serve the API over [Bluetooth LE](./#bluetooth-le) as well. Takes effect within about fifteen seconds without a restart. | | readersConnect | [ReaderConnection](#reader-connection-object)[] | No | List of manually configured reader connections. | | systemDefaults | [SystemDefault](#system-default-object)[] | No | Replace the full list of per-system launcher/exit-script overrides. Each `launcher` value, if non-empty, must match a known launcher ID or group (case-insensitive). | | profilesRequireForLaunch | boolean | No | Whether media launches are blocked while no personal profile is active. | diff --git a/pkg/api/ble_session.go b/pkg/api/ble_session.go new file mode 100644 index 000000000..bd51c76a8 --- /dev/null +++ b/pkg/api/ble_session.go @@ -0,0 +1,604 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "sync" + "time" + + apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/apigatt" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/jonboulle/clockwork" + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" +) + +const ( + // bleOutboundLimit caps the bytes queued for one session. A link that + // cannot drain this much is dead or hopelessly slow, and an encrypted + // frame can never be dropped without desyncing the counters, so the + // session is closed instead. + bleOutboundLimit = 1 << 20 + // bleOutboundMessages caps the number of queued messages. + bleOutboundMessages = 64 + // bleInboundMessages caps reassembled messages waiting to be handled. + // Chunks arrive on BlueZ's goroutines; handling runs on one goroutine + // per session so encrypted frames are decrypted in the order they + // completed, which the AEAD counters require. + bleInboundMessages = 32 + + // Pre-auth pairing methods, handled by the transport and never exposed + // on the method map. + blePairStartMethod = "pair.start" + blePairFinishMethod = "pair.finish" + + // Pairing over BLE is limited per connection to one request per second + // with room for the start and finish pair to arrive back to back, and + // across all connections by the transport's own limiter, because a peer + // can reset the per-connection one by reconnecting under a new private + // address. The PIN attempt counter is what actually bounds guessing. + blePairingRate = rate.Limit(1) + blePairingBurst = 2 + bleTransportPairingRate = rate.Limit(2) + bleTransportPairingBurst = 4 + + // Pairing requests are capped at the sizes the HTTP endpoints accept. + blePairStartMaxParams = 16 * 1024 + blePairFinishMaxParams = 4 * 1024 + + // blePendingIdleTimeout is how long an unauthenticated session may sit + // silent. It covers a user reading the PIN off the screen and typing it + // before the app sends anything, and matches the pairing session TTL. + blePendingIdleTimeout = 2 * time.Minute + // bleAuthenticatedIdleTimeout ends an authenticated session that sends + // nothing at all. The link layer normally reports a lost peer, but the + // report travels over a signal path that can drop under load, and the + // heartbeat is cheap on a radio link. + bleAuthenticatedIdleTimeout = 5 * time.Minute + + // bleEnvelopeOverhead is the fixed cost of the encrypted frame around a + // plaintext: the AEAD tag, the JSON envelope, and base64 rounding. + bleEnvelopeOverhead = 64 +) + +var ( + errBLESessionClosed = errors.New("bluetooth session closed") + errBLEOutboundFull = errors.New("bluetooth session outbound queue full") +) + +// bleDroppableNotifications are the chatty progress notifications a +// backed-up link can skip without the app losing state. +var bleDroppableNotifications = map[string]bool{ + models.NotificationMediaIndexing: true, + models.NotificationMediaScraping: true, +} + +type bleAuthState uint8 + +const ( + // bleAuthPending is the initial state: only pairing requests and an + // encrypted first frame are accepted. + bleAuthPending bleAuthState = iota + bleAuthEncrypted +) + +// blePlaintextLimit is the largest plaintext whose encrypted frame still +// fits a wire message of the given size. Base64 grows the ciphertext by a +// third. +func blePlaintextLimit(wire int) int { + return wire*3/4 - bleEnvelopeOverhead +} + +// bleSession is one central's connection: it reassembles chunks, runs the +// pre-auth pairing methods, establishes the encrypted session, feeds the +// shared dispatcher, and chunks everything going back out. +type bleSession struct { + t *bleTransport + p bluez.Peripheral + reasm *apigatt.Reassembler + dispatcher *wsSessionDispatcher + cs *apimiddleware.ClientSession + idleTimer clockwork.Timer + pairLimiter *rate.Limiter + inbound chan []byte + outbound chan []byte + ctx context.Context + cancel context.CancelFunc + peer bluez.Peer + maxPlaintext int + outboundBytes int + mtu int + mu syncutil.Mutex + closeOnce sync.Once + tag uint16 + tagSet bool + state bleAuthState + closed bool +} + +func newBLESession(t *bleTransport, peer bluez.Peer, p bluez.Peripheral) *bleSession { + ctx, cancel := context.WithCancel(t.ctx) + s := &bleSession{ + t: t, + p: p, + reasm: apigatt.NewReassembler(t.clock, apigatt.MaxMessageSize), + pairLimiter: rate.NewLimiter(blePairingRate, blePairingBurst), + inbound: make(chan []byte, bleInboundMessages), + outbound: make(chan []byte, bleOutboundMessages), + ctx: ctx, + cancel: cancel, + peer: peer, + maxPlaintext: blePlaintextLimit(apigatt.MaxMessageSize), + mtu: apigatt.DefaultMTU, + } + s.dispatcher = newSessionDispatcher(ctx, s, t.core.platform) + s.dispatcher.maxResponseSize = s.maxPlaintext + s.idleTimer = t.clock.AfterFunc(blePendingIdleTimeout, func() { + s.shutdown("idle timeout") + }) + go s.reader() + go s.writer() + return s +} + +// clientID names the session the way RemoteAddr names a WebSocket client. +func (s *bleSession) clientID() string { + return "ble:" + s.peer.Address +} + +// handleChunk consumes one write to the RX characteristic. +func (s *bleSession) handleChunk(chunk []byte, mtu int) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + if mtu > 0 { + s.mtu = mtu + } + if !s.tagSet { + if h, _, err := apigatt.ParseChunk(chunk); err == nil { + s.tag = h.Tag + s.tagSet = true + } + } + msg, err := s.reasm.Push(chunk) + queued := true + if err == nil && msg != nil { + // Queued under the lock so messages are handled in the order they + // completed, whatever order BlueZ's goroutines run in. + select { + case s.inbound <- msg: + default: + queued = false + } + } + s.mu.Unlock() + + if err != nil { + log.Warn().Err(err).Str("peer", s.peer.Address).Msg("bluetooth framing error") + s.shutdown("framing error") + return + } + if !queued { + s.shutdown("inbound queue overflow") + return + } + s.touchIdle() +} + +// reader handles reassembled messages one at a time. +func (s *bleSession) reader() { + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.inbound: + s.handleMessage(msg) + } + } +} + +// touchIdle restarts the idle timer for the session's current state. +func (s *bleSession) touchIdle() { + s.mu.Lock() + timeout := blePendingIdleTimeout + if s.state == bleAuthEncrypted { + timeout = bleAuthenticatedIdleTimeout + } + timer := s.idleTimer + closed := s.closed + s.mu.Unlock() + if !closed && timer != nil { + timer.Reset(timeout) + } +} + +// handleMessage dispatches one reassembled message the same way +// handleWSMessage dispatches one WebSocket frame. +func (s *bleSession) handleMessage(msg []byte) { + tracker := s.t.tracker + trackerActive := false + defer func() { + if r := recover(); r != nil { + if trackerActive && tracker != nil { + tracker.RequestEnded() + } + log.Error().Interface("panic", r).Str("peer", s.peer.Address).Msg("panic in bluetooth message handler") + s.shutdown("internal error") + } + }() + if tracker != nil { + tracker.RequestStarted() + trackerActive = true + } + endTrackedRequest := func() { + if trackerActive && tracker != nil { + tracker.RequestEnded() + } + trackerActive = false + } + handoffTrackedRequest := func() { + trackerActive = false + } + + s.mu.Lock() + cs, state := s.cs, s.state + s.mu.Unlock() + + if state == bleAuthPending && s.handlePairing(msg) { + endTrackedRequest() + return + } + + frame, err := decryptFrame(cs, msg, s.t.encGateway, s.clientID(), apimiddleware.TransportBLE) + switch frame.outcome { + case frameDecrypted: + if err != nil { + log.Warn().Err(err).Str("peer", s.peer.Address).Msg("ble: decryption failed on established session") + endTrackedRequest() + s.shutdown("decryption failed") + return + } + case frameEstablished: + if err != nil { + log.Warn().Err(err).Str("peer", s.peer.Address).Msg("ble: failed to establish encrypted session") + endTrackedRequest() + s.shutdown("failed to establish encrypted session") + return + } + s.mu.Lock() + s.cs = frame.session + s.state = bleAuthEncrypted + timer := s.idleTimer + s.mu.Unlock() + if timer != nil { + timer.Reset(bleAuthenticatedIdleTimeout) + } + cs = frame.session + log.Info().Str("peer", s.peer.Address).Msg("bluetooth client authenticated") + case frameUnsupportedVersion: + // Sent on the spot: the queue would be cancelled by the shutdown + // before the writer got to it. + if data, marshalErr := unsupportedEncryptionVersionResponse(); marshalErr == nil { + s.writeNow(data) + } + endTrackedRequest() + s.shutdown("unsupported encryption version") + return + case framePlaintext: + // Plaintext is never acceptable over BLE: there is no loopback and + // no legacy role, only paired clients. + endTrackedRequest() + s.shutdown("plaintext is not accepted over bluetooth") + return + default: + log.Error().Uint8("outcome", uint8(frame.outcome)).Msg("ble: unhandled frame outcome") + endTrackedRequest() + s.shutdown("internal error") + return + } + plaintext := frame.plaintext + + if s.t.lastSeen != nil { + s.t.lastSeen.Touch(cs.AuthToken(), time.Now().Unix()) + } + + if bytes.Equal(plaintext, []byte("ping")) { + if err := s.dispatcher.enqueuePong(cs, tracker); err != nil { + log.Warn().Err(err).Msg("ble: queueing pong") + endTrackedRequest() + s.shutdown("pong queue failed") + return + } + handoffTrackedRequest() + return + } + + platformID := "" + if s.t.core.platform != nil { + platformID = s.t.core.platform.ID() + } + env := s.t.core.newRequestEnv( + s.t.core.st.GetContext(), s.dispatcher.inputSession, s.clientID(), platformID, false, + ) + env.ClientRole = cs.ClientRole() + + if err := enqueueWSRequest(s.dispatcher, s.t.methodMap, &env, plaintext, cs, tracker); err != nil { + var queueFullErr *wsRequestQueueFullError + if errors.As(err, &queueFullErr) { + log.Warn(). + Str("method", queueFullErr.method). + Str("requestId", requestIDForLog(queueFullErr.requestID)). + Str("priority", queueFullErr.priority.String()). + Msg("bluetooth request rejected because queue is full") + if queueFullErr.requestID.IsAbsent() { + endTrackedRequest() + return + } + s.dispatcher.enqueueResponse(&wsResponseJob{ + result: requestResult{ + ID: queueFullErr.requestID, + Error: &JSONRPCErrorServerBusy, + ShouldReply: true, + }, + cs: cs, + tracker: tracker, + method: queueFullErr.method, + }) + handoffTrackedRequest() + return + } + + log.Warn().Err(err).Msg("failed to queue bluetooth request") + endTrackedRequest() + if sendErr := sendWSEncryptedError(s, cs, models.NullRPCID, JSONRPCErrorInternalError); sendErr != nil { + s.shutdown("error response failed") + } + return + } + handoffTrackedRequest() +} + +// handlePairing answers the pre-auth pairing methods. It reports false when +// the message is not a pairing request, leaving it to the encryption path. +func (s *bleSession) handlePairing(msg []byte) bool { + var req models.RequestObject + if err := json.Unmarshal(msg, &req); err != nil || req.Method == "" { + return false + } + method := strings.ToLower(req.Method) + if method != blePairStartMethod && method != blePairFinishMethod { + return false + } + id := req.ID + if id.IsAbsent() { + id = models.NullRPCID + } + + if s.t.pairing == nil { + s.writePairingError(id, http.StatusServiceUnavailable, "pairing unavailable") + return true + } + if !s.pairLimiter.Allow() || !s.t.pairLimiter.Allow() { + s.writePairingError(id, http.StatusTooManyRequests, "too many pairing requests") + return true + } + + var ( + result any + err error + ) + switch method { + case blePairStartMethod: + var params pairStartRequest + if len(req.Params) > blePairStartMaxParams || json.Unmarshal(req.Params, ¶ms) != nil { + s.writePairingError(id, http.StatusBadRequest, "invalid request body") + return true + } + result, err = s.t.pairing.pairStart(params) + default: + var params pairFinishRequest + if len(req.Params) > blePairFinishMaxParams || json.Unmarshal(req.Params, ¶ms) != nil { + s.writePairingError(id, http.StatusBadRequest, "invalid request body") + return true + } + result, err = s.t.pairing.pairFinish(params, s.clientID()) + } + if err != nil { + status, public := pairingErrorStatus(err) + s.writePairingError(id, status, public) + return true + } + + data, marshalErr := json.Marshal(models.ResponseObject{JSONRPC: "2.0", ID: id, Result: result}) + if marshalErr != nil { + log.Error().Err(marshalErr).Msg("ble: marshalling pairing response") + s.shutdown("pairing response failed") + return true + } + if writeErr := s.Write(data); writeErr != nil { + log.Warn().Err(writeErr).Msg("ble: writing pairing response") + } + return true +} + +// writePairingError sends the pairing error the HTTP endpoints would have +// answered with, carried as a JSON-RPC error. +func (s *bleSession) writePairingError(id models.RPCID, status int, message string) { + errObj := JSONRPCErrorPairingFailed + errObj.Data = map[string]any{"status": status, "message": message} + data, err := json.Marshal(models.ResponseErrorObject{JSONRPC: "2.0", ID: id, Error: &errObj}) + if err != nil { + log.Error().Err(err).Msg("ble: marshalling pairing error") + return + } + if writeErr := s.Write(data); writeErr != nil { + log.Warn().Err(writeErr).Msg("ble: writing pairing error") + } +} + +// sendNotification encrypts and queues one notification for an +// authenticated session, dropping what the link cannot afford. +func (s *bleSession) sendNotification(method string, data []byte) { + s.mu.Lock() + closed, state, cs, queued := s.closed, s.state, s.cs, s.outboundBytes + s.mu.Unlock() + if closed || state != bleAuthEncrypted || cs == nil { + return + } + if len(data) > s.maxPlaintext { + log.Debug().Str("method", method).Int("bytes", len(data)).Msg("ble: notification too large, dropped") + return + } + if queued > bleOutboundLimit/2 && bleDroppableNotifications[method] { + log.Debug().Str("method", method).Int("queued", queued).Msg("ble: link backed up, dropping notification") + return + } + if err := cs.SendEncryptedFrame(data, s.Write); err != nil { + log.Warn().Err(err).Str("peer", s.peer.Address).Msg("ble: sending notification") + s.shutdown("notification write failed") + } +} + +// Write implements sessionWriter: it queues one complete wire message. +// Encryption has already happened, so a message that cannot be queued +// ends the session rather than desyncing the counters. +func (s *bleSession) Write(msg []byte) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return errBLESessionClosed + } + if s.outboundBytes+len(msg) > bleOutboundLimit { + s.mu.Unlock() + s.shutdown("outbound queue overflow") + return errBLEOutboundFull + } + s.outboundBytes += len(msg) + s.mu.Unlock() + + select { + case s.outbound <- append([]byte(nil), msg...): + return nil + default: + s.mu.Lock() + s.outboundBytes -= len(msg) + s.mu.Unlock() + s.shutdown("outbound queue overflow") + return errBLEOutboundFull + } +} + +// Close implements sessionWriter. +func (s *bleSession) Close() error { + s.shutdown("closed by dispatcher") + return nil +} + +// writeNow chunks a message straight onto the characteristic, bypassing +// the queue, for the last words of a session that is about to end. +func (s *bleSession) writeNow(msg []byte) { + s.mu.Lock() + tag, mtu := s.tag, s.mtu + s.mu.Unlock() + chunks, err := apigatt.Chunker{MTU: mtu, Tag: tag}.Split(msg) + if err != nil { + return + } + for _, chunk := range chunks { + if err := s.p.Notify(apigatt.TXCharUUID, chunk); err != nil { + return + } + } +} + +// writer chunks queued messages onto the TX characteristic in order. +func (s *bleSession) writer() { + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.outbound: + s.mu.Lock() + s.outboundBytes -= len(msg) + tag, mtu := s.tag, s.mtu + s.mu.Unlock() + + chunks, err := apigatt.Chunker{MTU: mtu, Tag: tag}.Split(msg) + if err != nil { + log.Warn().Err(err).Int("bytes", len(msg)).Msg("ble: message cannot be framed") + s.shutdown("message cannot be framed") + return + } + for _, chunk := range chunks { + if s.ctx.Err() != nil { + return + } + if err := s.p.Notify(apigatt.TXCharUUID, chunk); err != nil { + log.Warn().Err(err).Str("peer", s.peer.Address).Msg("ble: notify failed") + s.shutdown("notify failed") + return + } + } + } + } +} + +// shutdown ends the session and drops the peer's link. +func (s *bleSession) shutdown(reason string) { + s.shutdownWith(reason, true) +} + +// shutdownWith ends the session once. The dispatcher teardown waits on +// worker goroutines, so it runs off the caller's goroutine, which may be +// one of those workers. +func (s *bleSession) shutdownWith(reason string, disconnect bool) { + s.closeOnce.Do(func() { + s.mu.Lock() + s.closed = true + timer := s.idleTimer + s.mu.Unlock() + + log.Info().Str("peer", s.peer.Address).Str("reason", reason).Msg("bluetooth client session closed") + s.cancel() + if timer != nil { + timer.Stop() + } + s.t.forget(s) + s.t.wg.Add(1) + go func() { + defer s.t.wg.Done() + s.dispatcher.close() + if disconnect { + disconnectPeer(s.p, s.peer) + } + }() + }) +} diff --git a/pkg/api/ble_session_test.go b/pkg/api/ble_session_test.go new file mode 100644 index 000000000..f24196398 --- /dev/null +++ b/pkg/api/ble_session_test.go @@ -0,0 +1,854 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "context" + "crypto/hkdf" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/crypto" + apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/apigatt" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/broker" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/jonboulle/clockwork" + "github.com/schollz/pake/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const bleTestTimeout = 5 * time.Second + +// bleTestRig is a BLE transport served on a fake peripheral. Notifications +// are routed to clients by tag, the way each phone filters the shared TX +// characteristic, so clients can be exercised in any order. +type bleTestRig struct { + transport *bleTransport + peripheral *mocks.FakePeripheral + clock *clockwork.FakeClock + cfg *config.Instance + inboxes map[uint16]chan []byte + mu syncutil.Mutex +} + +type bleTestRigOptions struct { + methodMap *MethodMap + encGateway *apimiddleware.EncryptionGateway + pairing *PairingManager + notifBroker *broker.Broker +} + +func newBLETestRig(t *testing.T, opts bleTestRigOptions) *bleTestRig { + t.Helper() + + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + st, _ := state.NewState(nil, "test-boot") + t.Cleanup(st.StopService) + + methodMap := opts.methodMap + if methodMap == nil { + methodMap = &MethodMap{} + } + encGateway := opts.encGateway + if encGateway == nil { + encGateway = apimiddleware.NewEncryptionGateway(helpers.NewMockUserDBI()) + } + + clock := clockwork.NewFakeClock() + transport := newBLETransport(&bleTransportDeps{ + core: &requestDeps{cfg: cfg, st: st}, + methodMap: methodMap, + encGateway: encGateway, + pairing: opts.pairing, + notifBroker: opts.notifBroker, + clock: clock, + }) + peripheral := mocks.NewFakePeripheral() + transport.serve(peripheral) + require.Eventually(t, func() bool { return peripheral.Handler() != nil }, bleTestTimeout, 5*time.Millisecond) + t.Cleanup(transport.stop) + + rig := &bleTestRig{ + transport: transport, + peripheral: peripheral, + clock: clock, + cfg: cfg, + inboxes: make(map[uint16]chan []byte), + } + go rig.route(t.Context()) + return rig +} + +// route delivers every TX chunk to the inbox of the client whose tag it +// carries. Chunks for unknown tags are dropped, as a phone would. +func (r *bleTestRig) route(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case n := <-r.peripheral.Notifications: + if n.CharUUID != apigatt.TXCharUUID { + continue + } + h, _, err := apigatt.ParseChunk(n.Value) + if err != nil { + continue + } + r.mu.Lock() + inbox := r.inboxes[h.Tag] + r.mu.Unlock() + if inbox != nil { + select { + case inbox <- n.Value: + case <-ctx.Done(): + return + } + } + } + } +} + +// bleTestClient drives the rig the way a phone would: chunked writes to RX, +// reassembled notifications from TX filtered by its tag. +type bleTestClient struct { + rig *bleTestRig + reasm *apigatt.Reassembler + inbox chan []byte + peer bluez.Peer + tag uint16 + mtu int +} + +func (r *bleTestRig) client(address string, tag uint16, mtu int) *bleTestClient { + inbox := make(chan []byte, 1024) + r.mu.Lock() + r.inboxes[tag] = inbox + r.mu.Unlock() + return &bleTestClient{ + rig: r, + reasm: apigatt.NewReassembler(clockwork.NewRealClock(), 0), + inbox: inbox, + peer: bluez.Peer{ + Path: "/org/bluez/hci0/dev_" + strings.ReplaceAll(address, ":", "_"), + Address: address, + }, + tag: tag, + mtu: mtu, + } +} + +func (c *bleTestClient) send(t *testing.T, msg []byte) { + t.Helper() + chunks, err := apigatt.Chunker{MTU: c.mtu, Tag: c.tag}.Split(msg) + require.NoError(t, err) + for _, chunk := range chunks { + c.rig.peripheral.Handler().OnWrite(c.peer, apigatt.RXCharUUID, chunk, c.mtu) + } +} + +// recv returns the next complete message addressed to this client. +func (c *bleTestClient) recv(t *testing.T) []byte { + t.Helper() + deadline := time.After(bleTestTimeout) + for { + select { + case chunk := <-c.inbox: + msg, err := c.reasm.Push(chunk) + require.NoError(t, err) + if msg != nil { + return msg + } + case <-deadline: + t.Fatal("no message from the transport") + return nil + } + } +} + +// expectNothing asserts that nothing arrives for this client for a while. +func (c *bleTestClient) expectNothing(t *testing.T) { + t.Helper() + select { + case chunk := <-c.inbox: + t.Fatalf("unexpected chunk of %d bytes", len(chunk)) + case <-time.After(100 * time.Millisecond): + } +} + +func (c *bleTestClient) session() *bleSession { + c.rig.transport.mu.Lock() + defer c.rig.transport.mu.Unlock() + return c.rig.transport.sessions[c.peer.Path] +} + +func (c *bleTestClient) waitClosed(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { return c.session() == nil }, bleTestTimeout, 5*time.Millisecond) +} + +func decryptS2C(t *testing.T, secrets *testEncryptionPeerSecrets, wire []byte, counter uint64) []byte { + t.Helper() + var frame apimiddleware.EncryptedFrame + require.NoError(t, json.Unmarshal(wire, &frame)) + ct, err := base64.StdEncoding.DecodeString(frame.Ciphertext) + require.NoError(t, err) + pt, err := crypto.Decrypt(secrets.s2cGCM, secrets.s2cNonce, counter, ct, secrets.aad) + require.NoError(t, err) + return pt +} + +func versionMethodMap(t *testing.T) *MethodMap { + t.Helper() + var methodMap MethodMap + require.NoError(t, methodMap.AddMethod("version", func(requests.RequestEnv) (any, error) { + return map[string]string{"version": "test"}, nil + }, false)) + return &methodMap +} + +func TestBLESession_EncryptedRequestResponse(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 0x1234, 23) + + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + + var resp models.ResponseObject + require.NoError(t, json.Unmarshal(decryptS2C(t, first.secrets, client.recv(t), 0), &resp)) + assert.Equal(t, models.NewNumberID(1), resp.ID) + assert.Equal(t, map[string]any{"version": "test"}, resp.Result) + + // The session is authenticated: a second request on the same session + // decrypts with the next counter. + client.send(t, first.secrets.encryptSubsequent(t, []byte(`{"jsonrpc":"2.0","method":"version","id":2}`), 1)) + require.NoError(t, json.Unmarshal(decryptS2C(t, first.secrets, client.recv(t), 1), &resp)) + assert.Equal(t, models.NewNumberID(2), resp.ID) + + s := client.session() + require.NotNil(t, s) + s.mu.Lock() + authState := s.state + s.mu.Unlock() + assert.Equal(t, bleAuthEncrypted, authState) +} + +func TestBLESession_PingPongAfterAuth(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + client.recv(t) + + client.send(t, first.secrets.encryptSubsequent(t, []byte("ping"), 1)) + assert.Equal(t, "pong", string(decryptS2C(t, first.secrets, client.recv(t), 1))) +} + +func TestBLESession_WebSocketFrameIsRejected(t *testing.T) { + t.Parallel() + + // A first frame bound to the WebSocket transport must not open a BLE + // session, even with valid credentials. + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportWebSocket) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + client.waitClosed(t) + client.expectNothing(t) + require.Eventually(t, func() bool { + return slices.ContainsFunc(rig.peripheral.Disconnects(), func(p bluez.Peer) bool { + return p.Path == client.peer.Path + }) + }, bleTestTimeout, 5*time.Millisecond) +} + +func TestBLESession_PlaintextRequestIsRejected(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t)}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + client.send(t, []byte(`{"jsonrpc":"2.0","method":"version","id":1}`)) + client.waitClosed(t) + client.expectNothing(t) +} + +func TestBLESession_FramingErrorClosesSession(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + rig.peripheral.Handler().OnWrite(client.peer, apigatt.RXCharUUID, []byte{0xff, 0, 0, 0, 1}, 185) + client.waitClosed(t) +} + +func TestBLESession_IdleBeforeAuthClosesSession(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + // A chunk that starts a message but never finishes it opens the session. + chunks, err := apigatt.Chunker{MTU: 23, Tag: 7}.Split([]byte(`{"jsonrpc":"2.0","method":"pair.start","id":1}`)) + require.NoError(t, err) + rig.peripheral.Handler().OnWrite(client.peer, apigatt.RXCharUUID, chunks[0], 23) + require.NotNil(t, client.session()) + + rig.clock.Advance(blePendingIdleTimeout - time.Second) + assert.NotNil(t, client.session()) + rig.clock.Advance(2 * time.Second) + client.waitClosed(t) +} + +func TestBLESession_DisconnectRemovesSession(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + client := rig.client("11:22:33:44:55:66", 7, 185) + chunks, err := apigatt.Chunker{MTU: 23, Tag: 7}.Split([]byte(`{"jsonrpc":"2.0","method":"pair.start","id":1}`)) + require.NoError(t, err) + rig.peripheral.Handler().OnWrite(client.peer, apigatt.RXCharUUID, chunks[0], 23) + require.NotNil(t, client.session()) + + rig.peripheral.Handler().OnDisconnect(client.peer) + client.waitClosed(t) + assert.Empty(t, rig.peripheral.Disconnects(), "a peer that left is not disconnected again") +} + +func TestBLESession_InfoCharacteristic(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + data, err := rig.peripheral.Handler().OnRead(bluez.Peer{}, apigatt.InfoCharUUID) + require.NoError(t, err) + var info apigatt.Info + require.NoError(t, json.Unmarshal(data, &info)) + assert.Equal(t, rig.cfg.DeviceID(), info.DeviceID) + assert.Equal(t, apigatt.ProtocolVersion, info.Version) + assert.Equal(t, apigatt.MaxMessageSize, info.MaxMessage) + + _, err = rig.peripheral.Handler().OnRead(bluez.Peer{}, apigatt.RXCharUUID) + require.Error(t, err) +} + +func TestBLESession_ResponseTooLarge(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + var methodMap MethodMap + require.NoError(t, methodMap.AddMethod("version", func(requests.RequestEnv) (any, error) { + return map[string]string{"blob": strings.Repeat("x", apigatt.MaxMessageSize)}, nil + }, false)) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: &methodMap, encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 512) + + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + + var resp models.ResponseErrorObject + require.NoError(t, json.Unmarshal(decryptS2C(t, first.secrets, client.recv(t), 0), &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPCErrorResponseTooLarge.Code, resp.Error.Code) + data, ok := resp.Error.Data.(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(blePlaintextLimit(apigatt.MaxMessageSize)), data["limit"], 0) + assert.Greater(t, data["size"], data["limit"]) +} + +func TestBLESession_NotificationsOnlyAfterAuth(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + // Open a pending session with a partial message. + chunks, err := apigatt.Chunker{MTU: 23, Tag: 7}.Split([]byte(`{"jsonrpc":"2.0","method":"pair.start","id":1}`)) + require.NoError(t, err) + rig.peripheral.Handler().OnWrite(client.peer, apigatt.RXCharUUID, chunks[0], 23) + s := client.session() + require.NotNil(t, s) + + notif := []byte(`{"jsonrpc":"2.0","method":"media.started","params":{}}`) + s.sendNotification(models.NotificationStarted, notif) + client.expectNothing(t) + + // Authenticate, then the same notification goes out encrypted. + client.reasm = apigatt.NewReassembler(clockwork.NewRealClock(), 0) + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + client.recv(t) + + s.sendNotification(models.NotificationStarted, notif) + assert.JSONEq(t, string(notif), string(decryptS2C(t, first.secrets, client.recv(t), 1))) + + // Oversize notifications are dropped rather than desyncing the session. + s.sendNotification(models.NotificationStarted, []byte(strings.Repeat("y", apigatt.MaxMessageSize))) + client.expectNothing(t) + s.sendNotification(models.NotificationStarted, notif) + assert.JSONEq(t, string(notif), string(decryptS2C(t, first.secrets, client.recv(t), 2))) +} + +// authenticate sends the fixture's first frame and consumes the response. +func (c *bleTestClient) authenticate(t *testing.T, first *testEncryptionFirstFrame) { + t.Helper() + frameJSON, err := json.Marshal(first.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + c.send(t, frameJSON) + c.recv(t) +} + +func TestBLESession_TwoClientsAreIsolated(t *testing.T) { + t.Parallel() + + // Two paired clients share one gateway; each has its own key and tag. + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + second := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + second.frame.AuthToken = "second-token" + secondClient := &database.Client{ + ClientID: "second-client", + ClientName: "Second", + AuthToken: "second-token", + Role: string(permissions.RoleMember), + PairingKey: second.pairingKey, + } + first.db.On("GetClientByToken", "second-token").Return(secondClient, nil) + second.secrets.aad = []byte("second-token:" + apimiddleware.TransportBLE) + secondFrame := second.reencrypt(t, `{"jsonrpc":"2.0","method":"version","id":1}`) + + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + a := rig.client("11:22:33:44:55:66", 0x0a0a, 185) + b := rig.client("77:88:99:AA:BB:CC", 0x0b0b, 185) + + a.authenticate(t, first) + b.send(t, secondFrame) + bReply := b.recv(t) + var resp models.ResponseObject + require.NoError(t, json.Unmarshal(decryptS2C(t, second.secrets, bReply, 0), &resp)) + assert.Equal(t, map[string]any{"version": "test"}, resp.Result) + + // B's reply is unreadable under A's keys, and vice versa. + var frame apimiddleware.EncryptedFrame + require.NoError(t, json.Unmarshal(bReply, &frame)) + ct, err := base64.StdEncoding.DecodeString(frame.Ciphertext) + require.NoError(t, err) + _, err = crypto.Decrypt(first.secrets.s2cGCM, first.secrets.s2cNonce, 1, ct, first.secrets.aad) + require.Error(t, err) + + // Requests interleave without crossing sessions: each reply carries + // the requester's tag and decrypts only with its keys. + a.send(t, first.secrets.encryptSubsequent(t, []byte(`{"jsonrpc":"2.0","method":"version","id":"a2"}`), 1)) + b.send(t, second.secrets.encryptSubsequent(t, []byte(`{"jsonrpc":"2.0","method":"version","id":"b2"}`), 1)) + require.NoError(t, json.Unmarshal(decryptS2C(t, first.secrets, a.recv(t), 1), &resp)) + assert.Equal(t, models.NewStringID("a2"), resp.ID) + require.NoError(t, json.Unmarshal(decryptS2C(t, second.secrets, b.recv(t), 1), &resp)) + assert.Equal(t, models.NewStringID("b2"), resp.ID) + + rig.transport.mu.Lock() + sessionCount := len(rig.transport.sessions) + rig.transport.mu.Unlock() + assert.Equal(t, 2, sessionCount, "sessions are keyed by peer") +} + +func TestBLETransport_BroadcastsThroughBroker(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + source := make(chan models.Notification) + b := broker.NewBroker(t.Context(), source) + b.Start() + t.Cleanup(b.Stop) + + rig := newBLETestRig(t, bleTestRigOptions{ + methodMap: versionMethodMap(t), encGateway: first.gateway, notifBroker: b, + }) + pending := rig.client("11:22:33:44:55:66", 1, 185) + chunks, err := apigatt.Chunker{MTU: 23, Tag: 1}.Split([]byte(`{"jsonrpc":"2.0","method":"pair.start","id":1}`)) + require.NoError(t, err) + rig.peripheral.Handler().OnWrite(pending.peer, apigatt.RXCharUUID, chunks[0], 23) + authed := rig.client("77:88:99:AA:BB:CC", 2, 185) + authed.authenticate(t, first) + + b.Publish(models.Notification{Method: models.NotificationStarted, Params: json.RawMessage(`{"x":1}`)}) + + got := decryptS2C(t, first.secrets, authed.recv(t), 1) + assert.JSONEq(t, `{"jsonrpc":"2.0","method":"media.started","params":{"x":1}}`, string(got)) + pending.expectNothing(t) +} + +func TestBLESession_UnsupportedVersionIsAnswered(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + rig := newBLETestRig(t, bleTestRigOptions{encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 185) + + frame := first.frame + frame.Version = apimiddleware.EncryptionProtoVersion + 1 + frameJSON, err := json.Marshal(frame) //nolint:gosec // test fixture token + require.NoError(t, err) + client.send(t, frameJSON) + + var resp models.ResponseErrorObject + require.NoError(t, json.Unmarshal(client.recv(t), &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, -32001, resp.Error.Code) + client.waitClosed(t) +} + +func TestBLESession_OutboundOverflowClosesSession(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + client := rig.client("11:22:33:44:55:66", 7, 185) + chunks, err := apigatt.Chunker{MTU: 23, Tag: 7}.Split([]byte(`{"jsonrpc":"2.0","method":"pair.start","id":1}`)) + require.NoError(t, err) + rig.peripheral.Handler().OnWrite(client.peer, apigatt.RXCharUUID, chunks[0], 23) + s := client.session() + require.NotNil(t, s) + + require.ErrorIs(t, s.Write(make([]byte, bleOutboundLimit+1)), errBLEOutboundFull) + client.waitClosed(t) + require.ErrorIs(t, s.Write([]byte("late")), errBLESessionClosed) +} + +func TestBLESession_AuthenticatedIdleTimeout(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrameFor(t, apimiddleware.TransportBLE) + rig := newBLETestRig(t, bleTestRigOptions{methodMap: versionMethodMap(t), encGateway: first.gateway}) + client := rig.client("11:22:33:44:55:66", 7, 185) + client.authenticate(t, first) + + // The pending timeout no longer applies once authenticated. + rig.clock.Advance(blePendingIdleTimeout + time.Second) + require.NotNil(t, client.session()) + + // Traffic keeps the session alive; silence past the longer timeout ends it. + client.send(t, first.secrets.encryptSubsequent(t, []byte("ping"), 1)) + client.recv(t) + rig.clock.Advance(bleAuthenticatedIdleTimeout - time.Second) + require.NotNil(t, client.session()) + rig.clock.Advance(2 * time.Second) + client.waitClosed(t) +} + +// blePairingClient is the phone side of the PAKE exchange over BLE. +type blePairingClient struct { + pake *pake.Pake + name string + msgA []byte + msgB []byte +} + +func newBLEPairingClient(t *testing.T, pin, name string) *blePairingClient { + t.Helper() + p, err := pake.InitCurve([]byte(pin), 0, pairingCurve) + require.NoError(t, err) + msgA, err := crypto.EncodePakeMessage(p.Bytes()) + require.NoError(t, err) + return &blePairingClient{pake: p, msgA: msgA, name: name} +} + +func (c *blePairingClient) startRequest(t *testing.T, id int) []byte { + t.Helper() + req, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": "pair.start", + "params": pairStartRequest{PAKE: base64.StdEncoding.EncodeToString(c.msgA), Name: c.name}, + }) + require.NoError(t, err) + return req +} + +// finishRequest consumes the start response and builds the finish request, +// returning it with the pairing key and server HMAC the client expects. +func (c *blePairingClient) finishRequest( + t *testing.T, id int, startResp pairStartResponse, wrongPIN bool, +) (req, pairingKey, expectedServerHMAC []byte) { + t.Helper() + msgB, err := base64.StdEncoding.DecodeString(startResp.PAKE) + require.NoError(t, err) + c.msgB = msgB + msgBInternal, err := crypto.DecodePakeMessage(msgB) + require.NoError(t, err) + require.NoError(t, c.pake.Update(msgBInternal)) + sessionKey, err := c.pake.SessionKey() + require.NoError(t, err) + + prk, err := hkdf.Extract(sha256.New, sessionKey, slices.Concat(c.msgA, c.msgB)) + require.NoError(t, err) + confirmKeyA, err := hkdf.Expand(sha256.New, prk, pairingInfoConfirmA, sha256.Size) + require.NoError(t, err) + confirmKeyB, err := hkdf.Expand(sha256.New, prk, pairingInfoConfirmB, sha256.Size) + require.NoError(t, err) + pairingKey, err = hkdf.Expand(sha256.New, prk, pairingInfoPairing, crypto.PairingKeySize) + require.NoError(t, err) + + clientHMAC := computePairingHMAC(confirmKeyA, "client", c.name, c.msgA, c.msgB) + if wrongPIN { + clientHMAC[0] ^= 0xff + } + req, err = json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": "pair.finish", + "params": pairFinishRequest{ + Session: startResp.Session, + Confirm: base64.StdEncoding.EncodeToString(clientHMAC), + }, + }) + require.NoError(t, err) + return req, pairingKey, computePairingHMAC(confirmKeyB, "server", c.name, c.msgA, c.msgB) +} + +func unmarshalResult[T any](t *testing.T, wire []byte) (T, models.RPCID) { + t.Helper() + var envelope struct { + Result json.RawMessage `json:"result"` + Error *models.ErrorObject `json:"error"` + ID models.RPCID `json:"id"` + } + require.NoError(t, json.Unmarshal(wire, &envelope)) + require.Nil(t, envelope.Error, "unexpected error response") + var out T + require.NoError(t, json.Unmarshal(envelope.Result, &out)) + return out, envelope.ID +} + +func TestBLESession_PairThenAuthenticateOnSameConnection(t *testing.T) { + t.Parallel() + + harness := newPairingHarness(t) + pin, _, err := harness.mgr.StartPairing("member") + require.NoError(t, err) + + // The encryption gateway resolves whichever client pairing creates. + gatewayDB := helpers.NewMockUserDBI() + gateway := apimiddleware.NewEncryptionGateway(gatewayDB) + rig := newBLETestRig(t, bleTestRigOptions{ + methodMap: versionMethodMap(t), encGateway: gateway, pairing: harness.mgr, + }) + client := rig.client("11:22:33:44:55:66", 0x4242, 185) + phone := newBLEPairingClient(t, pin, "Phone") + + client.send(t, phone.startRequest(t, 1)) + startResp, id := unmarshalResult[pairStartResponse](t, client.recv(t)) + assert.Equal(t, models.NewNumberID(1), id) + require.NotEmpty(t, startResp.Session) + + finishReq, pairingKey, expectedServerHMAC := phone.finishRequest(t, 2, startResp, false) + client.send(t, finishReq) + finishResp, id := unmarshalResult[pairFinishResponse](t, client.recv(t)) + assert.Equal(t, models.NewNumberID(2), id) + require.NotEmpty(t, finishResp.AuthToken) + serverHMAC, err := base64.StdEncoding.DecodeString(finishResp.Confirm) + require.NoError(t, err) + assert.Equal(t, expectedServerHMAC, serverHMAC) + + created := harness.created.Load() + require.NotNil(t, created) + assert.Equal(t, finishResp.AuthToken, created.AuthToken) + assert.Equal(t, pairingKey, created.PairingKey) + gatewayDB.On("GetClientByToken", created.AuthToken).Return(created, nil) + + // Still pending: an encrypted first frame with the freshly derived key + // authenticates without reconnecting. + salt := make([]byte, crypto.SessionSaltSize) + for i := range salt { + salt[i] = byte(i) + } + keys, err := crypto.DeriveSessionKeys(pairingKey, salt) + require.NoError(t, err) + c2s, err := crypto.NewAEAD(keys.C2SKey) + require.NoError(t, err) + s2c, err := crypto.NewAEAD(keys.S2CKey) + require.NoError(t, err) + aad := []byte(created.AuthToken + ":" + apimiddleware.TransportBLE) + ct, err := crypto.Encrypt(c2s, keys.C2SNonce, 0, []byte(`{"jsonrpc":"2.0","method":"version","id":3}`), aad) + require.NoError(t, err) + firstFrame, err := json.Marshal(apimiddleware.EncryptedFirstFrame{ //nolint:gosec // test token + Version: apimiddleware.EncryptionProtoVersion, + Ciphertext: base64.StdEncoding.EncodeToString(ct), + AuthToken: created.AuthToken, + SessionSalt: base64.StdEncoding.EncodeToString(salt), + }) + require.NoError(t, err) + client.send(t, firstFrame) + + secrets := &testEncryptionPeerSecrets{s2cGCM: s2c, s2cNonce: keys.S2CNonce, aad: aad} + var resp models.ResponseObject + require.NoError(t, json.Unmarshal(decryptS2C(t, secrets, client.recv(t), 0), &resp)) + assert.Equal(t, models.NewNumberID(3), resp.ID) + assert.Equal(t, map[string]any{"version": "test"}, resp.Result) +} + +func TestBLESession_WrongPINIsReportedAndCounted(t *testing.T) { + t.Parallel() + + harness := newPairingHarness(t) + pin, _, err := harness.mgr.StartPairing("member") + require.NoError(t, err) + rig := newBLETestRig(t, bleTestRigOptions{pairing: harness.mgr}) + client := rig.client("11:22:33:44:55:66", 9, 185) + phone := newBLEPairingClient(t, pin, "Phone") + + client.send(t, phone.startRequest(t, 1)) + startResp, _ := unmarshalResult[pairStartResponse](t, client.recv(t)) + + finishReq, _, _ := phone.finishRequest(t, 2, startResp, true) + client.send(t, finishReq) + + var resp models.ResponseErrorObject + require.NoError(t, json.Unmarshal(client.recv(t), &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPCErrorPairingFailed.Code, resp.Error.Code) + data, ok := resp.Error.Data.(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(401), data["status"], 0) + assert.Equal(t, "wrong PIN", data["message"]) + + harness.mgr.mu.Lock() + attempts := harness.mgr.pinAttempts + harness.mgr.mu.Unlock() + assert.Equal(t, 1, attempts) + assert.NotNil(t, client.session(), "a wrong PIN does not end the connection") +} + +func TestBLESession_PairingWithoutPINAndRateLimit(t *testing.T) { + t.Parallel() + + harness := newPairingHarness(t) + rig := newBLETestRig(t, bleTestRigOptions{pairing: harness.mgr}) + client := rig.client("11:22:33:44:55:66", 9, 185) + phone := newBLEPairingClient(t, "000000", "Phone") + + client.send(t, phone.startRequest(t, 1)) + var resp models.ResponseErrorObject + require.NoError(t, json.Unmarshal(client.recv(t), &resp)) + require.NotNil(t, resp.Error) + data, ok := resp.Error.Data.(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(400), data["status"], 0) + assert.Equal(t, "no pairing in progress", data["message"]) + + // A start and finish may arrive back to back; a third request within + // the same second hits the per-connection limiter. + client.send(t, phone.startRequest(t, 2)) + require.NoError(t, json.Unmarshal(client.recv(t), &resp)) + require.NotNil(t, resp.Error) + data, ok = resp.Error.Data.(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(400), data["status"], 0) + + client.send(t, phone.startRequest(t, 3)) + require.NoError(t, json.Unmarshal(client.recv(t), &resp)) + require.NotNil(t, resp.Error) + data, ok = resp.Error.Data.(map[string]any) + require.True(t, ok) + assert.InDelta(t, float64(429), data["status"], 0) +} + +func TestBLEPairingMethodsAreNotOnTheMethodMap(t *testing.T) { + t.Parallel() + + methodMap := NewMethodMap() + for _, method := range []string{blePairStartMethod, blePairFinishMethod} { + result := processRequestObject(methodMap, requests.RequestEnv{IsLocal: true}, + []byte(`{"jsonrpc":"2.0","id":1,"method":"`+method+`"}`)) + require.NotNil(t, result.Error, method) + assert.Equal(t, JSONRPCErrorMethodNotFound.Code, result.Error.Code, method) + } +} + +func TestBLETransport_Application(t *testing.T) { + t.Parallel() + + rig := newBLETestRig(t, bleTestRigOptions{}) + app := rig.peripheral.Application() + require.Len(t, app.Services, 1) + assert.Equal(t, apigatt.ServiceUUID, app.Services[0].UUID) + assert.True(t, app.Services[0].Primary) + uuids := make([]string, 0, 3) + for _, c := range app.Services[0].Characteristics { + uuids = append(uuids, c.UUID) + } + assert.ElementsMatch(t, []string{apigatt.RXCharUUID, apigatt.TXCharUUID, apigatt.InfoCharUUID}, uuids) + + adv := rig.peripheral.Advertisement() + assert.Equal(t, []string{apigatt.ServiceUUID}, adv.ServiceUUIDs) + assert.NotEmpty(t, adv.LocalName) +} + +func TestBLETransport_AdvertisesConfiguredName(t *testing.T) { + t.Parallel() + + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + st, _ := state.NewState(nil, "test-boot") + t.Cleanup(st.StopService) + transport := newBLETransport(&bleTransportDeps{core: &requestDeps{cfg: cfg, st: st}, methodMap: &MethodMap{}}) + t.Cleanup(transport.stop) + + cfg.SetDiscoveryInstanceName("Lounge") + peripheral := mocks.NewFakePeripheral() + transport.serve(peripheral) + require.Eventually(t, func() bool { return peripheral.Handler() != nil }, bleTestTimeout, 5*time.Millisecond) + assert.Equal(t, "Lounge", peripheral.Advertisement().LocalName, "discovery name is the fallback") + + // The BLE name wins, and is read when advertising starts. + cfg.SetBLEName("Den") + replacement := mocks.NewFakePeripheral() + transport.serve(replacement) + require.Eventually(t, func() bool { return replacement.Handler() != nil }, bleTestTimeout, 5*time.Millisecond) + assert.Equal(t, "Den", replacement.Advertisement().LocalName) +} diff --git a/pkg/api/ble_transport.go b/pkg/api/ble_transport.go new file mode 100644 index 000000000..348045788 --- /dev/null +++ b/pkg/api/ble_transport.go @@ -0,0 +1,301 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/apigatt" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/broker" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/discovery" + "github.com/jonboulle/clockwork" + "github.com/rs/zerolog/log" + "golang.org/x/time/rate" +) + +// bleTransportDeps is everything the BLE transport shares with the other +// transports. +type bleTransportDeps struct { + core *requestDeps + methodMap *MethodMap + encGateway *apimiddleware.EncryptionGateway + lastSeen *apimiddleware.LastSeenTracker + tracker RequestTracker + pairing *PairingManager + notifBroker *broker.Broker + clock clockwork.Clock +} + +// bleTransport serves the JSON-RPC API over the Zaparoo GATT service. Each +// connected central gets a bleSession; the transport routes GATT events to +// sessions and fans notifications out to them. +type bleTransport struct { + bleTransportDeps + // ctx ends with the service or with stop, whichever comes first, and + // bounds every goroutine the transport starts. + ctx context.Context + cancel context.CancelFunc + // pairLimiter bounds pairing requests across every connection. The + // per-session limiter resets whenever a peer reconnects under a fresh + // private address, so it cannot be the only one. + pairLimiter *rate.Limiter + sessions map[string]*bleSession + peripheral bluez.Peripheral + serveCancel context.CancelFunc + // unregister detaches serve from the bluetooth manager on stop. + unregister func() + wg sync.WaitGroup + mu syncutil.Mutex + stopped bool +} + +func newBLETransport(d *bleTransportDeps) *bleTransport { + t := &bleTransport{ + bleTransportDeps: *d, + pairLimiter: rate.NewLimiter(bleTransportPairingRate, bleTransportPairingBurst), + sessions: make(map[string]*bleSession), + } + t.ctx, t.cancel = context.WithCancel(d.core.st.GetContext()) + if t.clock == nil { + t.clock = clockwork.NewRealClock() + } + if d.notifBroker != nil { + notifs, subID := d.notifBroker.Subscribe(100) + t.wg.Add(1) + go func() { + defer t.wg.Done() + t.broadcast(notifs) + d.notifBroker.Unsubscribe(subID) + }() + } + return t +} + +// application is the GATT layout the app expects. +func (*bleTransport) application() bluez.Application { + return bluez.Application{Services: []bluez.Service{{ + UUID: apigatt.ServiceUUID, + Primary: true, + Characteristics: []bluez.Characteristic{ + {UUID: apigatt.RXCharUUID, Flags: []string{bluez.FlagWrite, bluez.FlagWriteWithoutResponse}}, + {UUID: apigatt.TXCharUUID, Flags: []string{bluez.FlagNotify}}, + {UUID: apigatt.InfoCharUUID, Flags: []string{bluez.FlagRead}}, + }, + }}} +} + +// localName is what the device is called in a phone's scan list. +func (t *bleTransport) localName() string { + if name := t.core.cfg.BLEName(); name != "" { + return name + } + return discovery.ResolveInstanceName(t.core.cfg) +} + +// serve starts serving on a peripheral. The bluetooth manager calls it each +// time an adapter becomes ready; it returns at once and serves in the +// background until the peripheral goes away or the transport stops. +func (t *bleTransport) serve(p bluez.Peripheral) { + t.mu.Lock() + if t.stopped { + t.mu.Unlock() + return + } + if t.serveCancel != nil { + t.serveCancel() + } + ctx, cancel := context.WithCancel(t.ctx) + t.serveCancel = cancel + t.peripheral = p + // Counted under the lock so stop, which sets stopped under the same + // lock before waiting, can never miss this goroutine. + t.wg.Add(1) + t.mu.Unlock() + + // The name is read once here: renaming the device takes effect the + // next time advertising starts. + adv := bluez.Advertisement{LocalName: t.localName(), ServiceUUIDs: []string{apigatt.ServiceUUID}} + go func() { + defer t.wg.Done() + err := p.Serve(ctx, t.application(), adv, t) + switch { + case err != nil && ctx.Err() == nil: + log.Warn().Err(err).Msg("bluetooth api transport stopped") + default: + log.Info().Msg("bluetooth api transport stopped") + } + // Only this peripheral's sessions: a replacement may already be + // serving clients of its own. + t.closeSessions("transport stopped", p) + }() +} + +// stop ends serving and every session, then waits for the background work. +func (t *bleTransport) stop() { + t.mu.Lock() + t.stopped = true + unregister := t.unregister + t.mu.Unlock() + if unregister != nil { + unregister() + } + t.cancel() + t.closeSessions("transport stopped", nil) + t.wg.Wait() +} + +// closeSessions ends every session served by p, or every session when p is +// nil. +func (t *bleTransport) closeSessions(reason string, p bluez.Peripheral) { + t.mu.Lock() + sessions := make([]*bleSession, 0, len(t.sessions)) + for _, s := range t.sessions { + if p == nil || s.p == p { + sessions = append(sessions, s) + } + } + t.mu.Unlock() + for _, s := range sessions { + s.shutdown(reason) + } +} + +// sessionFor returns the session for a peer, creating it on first contact. +func (t *bleTransport) sessionFor(peer bluez.Peer) *bleSession { + if peer.Path == "" { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + if t.stopped { + return nil + } + if s, ok := t.sessions[peer.Path]; ok { + return s + } + if t.peripheral == nil { + return nil + } + s := newBLESession(t, peer, t.peripheral) + t.sessions[peer.Path] = s + log.Info().Str("peer", peer.Address).Msg("bluetooth client connected") + return s +} + +// forget removes a session from the table once it has closed. +func (t *bleTransport) forget(s *bleSession) { + t.mu.Lock() + defer t.mu.Unlock() + if t.sessions[s.peer.Path] == s { + delete(t.sessions, s.peer.Path) + } +} + +// OnWrite implements bluez.PeripheralHandler: every write to RX is a chunk. +func (t *bleTransport) OnWrite(peer bluez.Peer, charUUID string, value []byte, mtu int) { + if !strings.EqualFold(charUUID, apigatt.RXCharUUID) { + return + } + if s := t.sessionFor(peer); s != nil { + s.handleChunk(value, mtu) + } +} + +// OnRead implements bluez.PeripheralHandler: only Info is readable. +func (t *bleTransport) OnRead(_ bluez.Peer, charUUID string) ([]byte, error) { + if !strings.EqualFold(charUUID, apigatt.InfoCharUUID) { + return nil, bluez.ErrNotFound + } + data, err := json.Marshal(apigatt.NewInfo(t.core.cfg.DeviceID())) + if err != nil { + return nil, fmt.Errorf("marshal info: %w", err) + } + return data, nil +} + +// OnSubscribe implements bluez.PeripheralHandler. BlueZ does not say which +// peer subscribed, so there is nothing to act on: sessions start on the +// first write and end when the peer disconnects. +func (*bleTransport) OnSubscribe(_ bluez.Peer, charUUID string, subscribed bool) { + log.Trace().Str("characteristic", charUUID).Bool("subscribed", subscribed).Msg("ble: subscription changed") +} + +// OnDisconnect implements bluez.PeripheralHandler. +func (t *bleTransport) OnDisconnect(peer bluez.Peer) { + t.mu.Lock() + s := t.sessions[peer.Path] + t.mu.Unlock() + if s != nil { + s.shutdownWith("client disconnected", false) + } +} + +// broadcast fans notifications out to every authenticated session. Each +// session decides what it can afford to send. +func (t *bleTransport) broadcast(notifs <-chan models.Notification) { + for { + select { + case <-t.ctx.Done(): + return + case notif := <-notifs: + // Most devices never have a BLE client, so look before + // spending a marshal on every notification. + t.mu.Lock() + sessions := make([]*bleSession, 0, len(t.sessions)) + for _, s := range t.sessions { + sessions = append(sessions, s) + } + t.mu.Unlock() + if len(sessions) == 0 { + continue + } + data, err := json.Marshal(models.NotificationObject{ + JSONRPC: "2.0", + Method: notif.Method, + Params: notif.Params, + }) + if err != nil { + log.Error().Err(err).Msg("marshalling notification for bluetooth") + continue + } + for _, s := range sessions { + s.sendNotification(notif.Method, data) + } + } + } +} + +// disconnectPeer asks the peripheral to drop a peer, best effort. +func disconnectPeer(p bluez.Peripheral, peer bluez.Peer) { + ctx, cancel := context.WithTimeout(context.Background(), bluez.DefaultCallTimeout) + defer cancel() + if err := p.Disconnect(ctx, peer); err != nil && !errors.Is(err, bluez.ErrNotFound) { + log.Debug().Err(err).Str("peer", peer.Address).Msg("bluetooth disconnect failed") + } +} diff --git a/pkg/api/decrypt_frame_test.go b/pkg/api/decrypt_frame_test.go new file mode 100644 index 000000000..714db360f --- /dev/null +++ b/pkg/api/decrypt_frame_test.go @@ -0,0 +1,131 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "encoding/json" + "testing" + + apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func marshalFirstFrame(t *testing.T, frame apimiddleware.EncryptedFirstFrame) []byte { + t.Helper() + data, err := json.Marshal(frame) //nolint:gosec // test fixture; the token is opaque test data + require.NoError(t, err) + return data +} + +func TestDecryptFrame_Plaintext(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrame(t) + msg := []byte(`{"jsonrpc":"2.0","method":"version","id":1}`) + + frame, err := decryptFrame(nil, msg, first.gateway, testEncryptionSourceIP, apimiddleware.TransportWebSocket) + require.NoError(t, err) + assert.Equal(t, framePlaintext, frame.outcome) + assert.Nil(t, frame.session) + assert.Equal(t, msg, frame.plaintext) +} + +func TestDecryptFrame_EstablishesThenDecrypts(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrame(t) + + established, err := decryptFrame( + nil, marshalFirstFrame(t, first.frame), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportWebSocket, + ) + require.NoError(t, err) + assert.Equal(t, frameEstablished, established.outcome) + require.NotNil(t, established.session) + assert.JSONEq(t, `{"jsonrpc":"2.0","method":"version","id":1}`, string(established.plaintext)) + + second := []byte(`{"jsonrpc":"2.0","method":"media","id":2}`) + decrypted, err := decryptFrame( + established.session, first.secrets.encryptSubsequent(t, second, 1), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportWebSocket, + ) + require.NoError(t, err) + assert.Equal(t, frameDecrypted, decrypted.outcome) + assert.Nil(t, decrypted.session, "an established session must not be replaced") + assert.Equal(t, second, decrypted.plaintext) +} + +func TestDecryptFrame_MalformedOnEstablishedSession(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrame(t) + established, err := decryptFrame( + nil, marshalFirstFrame(t, first.frame), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportWebSocket, + ) + require.NoError(t, err) + require.NotNil(t, established.session) + + for _, msg := range []string{`{"e":""}`, `not json`, `{"jsonrpc":"2.0","method":"version","id":1}`} { + frame, err := decryptFrame( + established.session, []byte(msg), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportWebSocket, + ) + require.ErrorIs(t, err, apimiddleware.ErrInvalidFrame, msg) + assert.Equal(t, frameDecrypted, frame.outcome, msg) + assert.Nil(t, frame.plaintext, msg) + assert.Nil(t, frame.session, msg) + } +} + +func TestDecryptFrame_UnsupportedVersion(t *testing.T) { + t.Parallel() + + first := newTestEncryptionFirstFrame(t) + firstFrame := first.frame + firstFrame.Version = apimiddleware.EncryptionProtoVersion + 1 + + frame, err := decryptFrame( + nil, marshalFirstFrame(t, firstFrame), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportWebSocket, + ) + require.ErrorIs(t, err, apimiddleware.ErrUnsupportedVersion) + assert.Equal(t, frameUnsupportedVersion, frame.outcome) + assert.Nil(t, frame.plaintext) + assert.Nil(t, frame.session) +} + +func TestDecryptFrame_TransportMismatchFailsToEstablish(t *testing.T) { + t.Parallel() + + // The fixture encrypts its first frame for the WebSocket transport, so + // presenting it as a BLE frame must fail the AEAD check. + first := newTestEncryptionFirstFrame(t) + + frame, err := decryptFrame( + nil, marshalFirstFrame(t, first.frame), first.gateway, + testEncryptionSourceIP, apimiddleware.TransportBLE, + ) + require.Error(t, err) + assert.Equal(t, frameEstablished, frame.outcome) + assert.Nil(t, frame.plaintext) + assert.Nil(t, frame.session) +} diff --git a/pkg/api/methods/settings.go b/pkg/api/methods/settings.go index a7c36b775..97471dbae 100644 --- a/pkg/api/methods/settings.go +++ b/pkg/api/methods/settings.go @@ -76,6 +76,7 @@ func HandleSettings(env requests.RequestEnv) (any, error) { //nolint:gocritic // SystemDefaults: systemDefaults, ErrorReporting: env.Config.ErrorReporting(), Encryption: env.Config.EncryptionEnabled(), + BLEEnabled: env.Config.BLEEnabled(), LaunchGuardEnabled: env.Config.LaunchGuardEnabled(), LaunchGuardTimeout: env.Config.LaunchGuardTimeout(), LaunchGuardDelay: env.Config.LaunchGuardDelay(), @@ -258,6 +259,11 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) { env.Config.SetEncryptionEnabled(*params.Encryption) } + if params.BLEEnabled != nil { + log.Debug().Bool("bleEnabled", *params.BLEEnabled).Msg("updating setting") + env.Config.SetBLEEnabled(*params.BLEEnabled) + } + if params.BackupRemoteEnabled != nil { log.Debug().Bool("backupRemoteEnabled", *params.BackupRemoteEnabled).Msg("updating setting") env.Config.SetBackupRemoteEnabled(*params.BackupRemoteEnabled) diff --git a/pkg/api/methods/settings_test.go b/pkg/api/methods/settings_test.go index 94d5414fd..8273d8cda 100644 --- a/pkg/api/methods/settings_test.go +++ b/pkg/api/methods/settings_test.go @@ -327,6 +327,56 @@ func TestHandleSettingsUpdate_RemoteMemberCannotChangeProfileGate(t *testing.T) require.ErrorIs(t, err, ErrForbidden) } +func TestHandleSettings_ReportsBLESetting(t *testing.T) { + t.Parallel() + + enabled := true + cfg, err := config.NewConfig(t.TempDir(), config.Values{ + Service: config.Service{BLE: config.BLE{Enabled: &enabled}}, + }) + require.NoError(t, err) + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("ManagedByPackageManager").Return(false).Maybe() + appState, ns := state.NewState(mockPlatform, "test-boot-uuid") + t.Cleanup(func() { drainCh(ns) }) + + result, err := HandleSettings(requests.RequestEnv{Platform: mockPlatform, Config: cfg, State: appState}) + require.NoError(t, err) + resp, ok := result.(models.SettingsResponse) + require.True(t, ok) + assert.True(t, resp.BLEEnabled) +} + +func TestHandleSettingsUpdate_BLEEnabled(t *testing.T) { + t.Parallel() + + enabled := true + params, err := json.Marshal(models.UpdateSettingsParams{BLEEnabled: &enabled}) + require.NoError(t, err) + + cfg, err := config.NewConfig(t.TempDir(), config.Values{}) + require.NoError(t, err) + require.False(t, cfg.BLEEnabled()) + _, err = HandleSettingsUpdate(requests.RequestEnv{ + Config: cfg, + IsLocal: true, + Params: params, + }) + require.NoError(t, err) + assert.True(t, cfg.BLEEnabled()) + + disabled := false + params, err = json.Marshal(models.UpdateSettingsParams{BLEEnabled: &disabled}) + require.NoError(t, err) + _, err = HandleSettingsUpdate(requests.RequestEnv{ + Config: cfg, + IsLocal: true, + Params: params, + }) + require.NoError(t, err) + assert.False(t, cfg.BLEEnabled()) +} + func TestHandleSettingsUpdate_EncryptionLocalOnly(t *testing.T) { t.Parallel() diff --git a/pkg/api/middleware/encryption.go b/pkg/api/middleware/encryption.go index c7c6bc887..612dba8db 100644 --- a/pkg/api/middleware/encryption.go +++ b/pkg/api/middleware/encryption.go @@ -68,8 +68,15 @@ var ( ErrSessionNotEstablished = errors.New("encryption session not established") ) -// EncryptedFirstFrame is the JSON payload sent on the first WebSocket frame -// to establish an encrypted session. +// Transport labels bound into the AEAD associated data so a frame encrypted +// for one transport cannot be replayed on another. +const ( + TransportWebSocket = "ws" + TransportBLE = "ble" +) + +// EncryptedFirstFrame is the JSON payload sent on the first frame of a +// connection to establish an encrypted session. type EncryptedFirstFrame struct { Ciphertext string `json:"e"` AuthToken string `json:"t"` @@ -105,7 +112,7 @@ func IsEncryptedFirstFrame(data []byte) bool { return probe.V > 0 && probe.E != "" && probe.T != "" && probe.S != "" } -// ClientSession holds per-WebSocket encryption state. All AEAD calls +// ClientSession holds per-connection encryption state. All AEAD calls // MUST happen inside the mutex (golang/go#25882, golang-fips/go#187). type ClientSession struct { client *database.Client @@ -319,17 +326,29 @@ func (m *EncryptionGateway) StartCleanup(ctx context.Context) { }() } -// EstablishSession validates, decrypts, and returns a ClientSession for the -// first encrypted frame. Failures increment the (authToken, sourceIP) rate -// limiter; callers should close the WebSocket on error. +// EstablishSession is EstablishSessionForTransport for the WebSocket +// transport. +func (m *EncryptionGateway) EstablishSession( + frame EncryptedFirstFrame, + sourceIP string, +) (*ClientSession, []byte, error) { + return m.EstablishSessionForTransport(frame, sourceIP, TransportWebSocket) +} + +// EstablishSessionForTransport validates, decrypts, and returns a +// ClientSession for the first encrypted frame. transport is bound into the +// AEAD associated data, so the client must use the same label. Failures +// increment the (authToken, sourceIP) rate limiter; callers should close the +// connection on error. // // Non-constant-time: auth token validity is distinguishable by timing, but // tokens are already plaintext on the wire and grant no capability without // the 32-byte pairing key. If a future credential is NOT public on the // wire, these branches MUST be refactored to constant-time. -func (m *EncryptionGateway) EstablishSession( +func (m *EncryptionGateway) EstablishSessionForTransport( frame EncryptedFirstFrame, sourceIP string, + transport string, ) (*ClientSession, []byte, error) { if frame.Version != EncryptionProtoVersion { return nil, nil, ErrUnsupportedVersion @@ -400,8 +419,9 @@ func (m *EncryptionGateway) EstablishSession( s2cGCM: s2cGCM, c2sNonce: keys.C2SNonce, s2cNonce: keys.S2CNonce, - // AAD bound to DB-resolved token (resilient to future canonicalization). - aad: []byte(c.AuthToken + ":ws"), + // AAD bound to DB-resolved token (resilient to future canonicalization) + // and to the transport the frame arrived on. + aad: []byte(c.AuthToken + ":" + transport), recvCounter: 0, sendCounter: 0, } diff --git a/pkg/api/middleware/encryption_transport_test.go b/pkg/api/middleware/encryption_transport_test.go new file mode 100644 index 000000000..a781763b4 --- /dev/null +++ b/pkg/api/middleware/encryption_transport_test.go @@ -0,0 +1,130 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package middleware_test + +import ( + "encoding/base64" + "testing" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/crypto" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// encryptForTransport encrypts a client-to-server frame with the AAD the +// given transport label produces. +func encryptForTransport( + t *testing.T, c *database.Client, salt, plaintext []byte, counter uint64, transport string, +) []byte { + t.Helper() + keys, err := crypto.DeriveSessionKeys(c.PairingKey, salt) + require.NoError(t, err) + gcm, err := crypto.NewAEAD(keys.C2SKey) + require.NoError(t, err) + ct, err := crypto.Encrypt(gcm, keys.C2SNonce, counter, plaintext, []byte(c.AuthToken+":"+transport)) + require.NoError(t, err) + return ct +} + +func firstFrameFor(c *database.Client, salt, ct []byte) middleware.EncryptedFirstFrame { + return middleware.EncryptedFirstFrame{ + Version: middleware.EncryptionProtoVersion, + Ciphertext: base64.StdEncoding.EncodeToString(ct), + AuthToken: c.AuthToken, + SessionSalt: base64.StdEncoding.EncodeToString(salt), + } +} + +func TestEstablishSessionForTransport_BLE(t *testing.T) { + t.Parallel() + + c, _ := pairedClient(t) + db := helpers.NewMockUserDBI() + db.On("GetClientByToken", c.AuthToken).Return(c, nil) + mgr := middleware.NewEncryptionGateway(db) + + salt := randomSalt(t) + plaintext := []byte(`{"jsonrpc":"2.0","method":"version","id":1}`) + ct := encryptForTransport(t, c, salt, plaintext, 0, middleware.TransportBLE) + + cs, decrypted, err := mgr.EstablishSessionForTransport( + firstFrameFor(c, salt, ct), "ble:AA:BB:CC:DD:EE:FF", middleware.TransportBLE, + ) + require.NoError(t, err) + require.NotNil(t, cs) + assert.Equal(t, plaintext, decrypted) + + // Subsequent frames on the session keep the BLE binding. + second := []byte(`{"jsonrpc":"2.0","method":"media","id":2}`) + pt, err := cs.DecryptIncoming(encryptForTransport(t, c, salt, second, 1, middleware.TransportBLE)) + require.NoError(t, err) + assert.Equal(t, second, pt) +} + +func TestEstablishSessionForTransport_RejectsOtherTransportsFrame(t *testing.T) { + t.Parallel() + + c, _ := pairedClient(t) + db := helpers.NewMockUserDBI() + db.On("GetClientByToken", c.AuthToken).Return(c, nil) + mgr := middleware.NewEncryptionGateway(db) + + plaintext := []byte(`{"jsonrpc":"2.0","method":"version","id":1}`) + + tests := []struct { + name string + encrypted string + presented string + }{ + {name: "ws frame on ble", encrypted: middleware.TransportWebSocket, presented: middleware.TransportBLE}, + {name: "ble frame on ws", encrypted: middleware.TransportBLE, presented: middleware.TransportWebSocket}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + salt := randomSalt(t) + ct := encryptForTransport(t, c, salt, plaintext, 0, tt.encrypted) + cs, _, err := mgr.EstablishSessionForTransport(firstFrameFor(c, salt, ct), "192.168.1.50", tt.presented) + require.Error(t, err) + assert.Nil(t, cs) + }) + } +} + +func TestEstablishSession_DefaultsToWebSocketTransport(t *testing.T) { + t.Parallel() + + c, _ := pairedClient(t) + db := helpers.NewMockUserDBI() + db.On("GetClientByToken", c.AuthToken).Return(c, nil) + mgr := middleware.NewEncryptionGateway(db) + + salt := randomSalt(t) + plaintext := []byte(`{"jsonrpc":"2.0","method":"version","id":1}`) + ct := encryptForTransport(t, c, salt, plaintext, 0, middleware.TransportWebSocket) + + cs, decrypted, err := mgr.EstablishSession(firstFrameFor(c, salt, ct), "192.168.1.50") + require.NoError(t, err) + require.NotNil(t, cs) + assert.Equal(t, plaintext, decrypted) +} diff --git a/pkg/api/models/params.go b/pkg/api/models/params.go index e79653f07..3387a9f15 100644 --- a/pkg/api/models/params.go +++ b/pkg/api/models/params.go @@ -194,6 +194,7 @@ type UpdateSettingsParams struct { ReadersAutoDetect *bool `json:"readersAutoDetect"` ErrorReporting *bool `json:"errorReporting"` Encryption *bool `json:"encryption"` + BLEEnabled *bool `json:"bleEnabled"` BackupRemoteEnabled *bool `json:"backupRemoteEnabled"` PlaytimeSyncEnabled *bool `json:"playtimeSyncEnabled"` RemoteControlEnabled *bool `json:"remoteControlEnabled"` diff --git a/pkg/api/models/responses.go b/pkg/api/models/responses.go index 22625ad1e..d6e06c0c9 100644 --- a/pkg/api/models/responses.go +++ b/pkg/api/models/responses.go @@ -160,6 +160,7 @@ type SettingsResponse struct { ReadersAutoDetect bool `json:"readersAutoDetect"` ErrorReporting bool `json:"errorReporting"` Encryption bool `json:"encryption"` + BLEEnabled bool `json:"bleEnabled"` LaunchGuardEnabled bool `json:"launchGuardEnabled"` LaunchGuardRequireConfirm bool `json:"launchGuardRequireConfirm"` ProfilesRequireForLaunch bool `json:"profilesRequireForLaunch"` diff --git a/pkg/api/pairing.go b/pkg/api/pairing.go index a6e9a902f..b839cec2c 100644 --- a/pkg/api/pairing.go +++ b/pkg/api/pairing.go @@ -97,6 +97,8 @@ var ( errTooManyClients = errors.New("maximum number of paired clients reached") errPairingHMACMismatch = errors.New("pairing confirmation HMAC mismatch") errPairingMessageTooLong = errors.New("pairing PAKE message too long") + errPairingInvalidPake = errors.New("invalid pake message") + errPairingInvalidConfirm = errors.New("invalid confirmation") ) // HKDF info strings used to derive confirmation keys and the long-term @@ -538,6 +540,49 @@ func writeLP(h io.Writer, b []byte) { _, _ = h.Write(b) } +// pairStart decodes a start request and runs the server side of the PAKE +// exchange. It is shared by every transport that carries pairing. +func (m *PairingManager) pairStart(req pairStartRequest) (pairStartResponse, error) { + msgA, err := base64.StdEncoding.DecodeString(req.PAKE) + if err != nil || len(msgA) == 0 { + return pairStartResponse{}, errPairingInvalidPake + } + + sessionID, msgB, err := m.startSession(req.Name, msgA) + if err != nil { + return pairStartResponse{}, err + } + + return pairStartResponse{ + Session: sessionID, + PAKE: base64.StdEncoding.EncodeToString(msgB), + }, nil +} + +// pairFinish decodes a finish request, verifies the client's HMAC and +// persists the new client. source names the caller for the audit log. +func (m *PairingManager) pairFinish(req pairFinishRequest, source string) (pairFinishResponse, error) { + clientHMAC, err := base64.StdEncoding.DecodeString(req.Confirm) + if err != nil || len(clientHMAC) == 0 { + return pairFinishResponse{}, errPairingInvalidConfirm + } + + result, err := m.finishSession(req.Session, clientHMAC) + if err != nil { + // Audit-log security-relevant failures with the source. Other + // errors (expired session, unknown session, etc.) are handled by + // the generic mapping in the caller. + logFailedPairingAttempt(source, err) + return pairFinishResponse{}, err + } + + return pairFinishResponse{ + AuthToken: result.Client.AuthToken, + ClientID: result.Client.ClientID, + Confirm: base64.StdEncoding.EncodeToString(result.ServerHMAC), + }, nil +} + // HandlePairStart runs the PAKE exchange and returns sessionID + server message. func (m *PairingManager) HandlePairStart() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -549,23 +594,15 @@ func (m *PairingManager) HandlePairStart() http.HandlerFunc { pairingErrorResponse(w, http.StatusBadRequest, "invalid request body") return } - msgA, decErr := base64.StdEncoding.DecodeString(req.PAKE) - if decErr != nil || len(msgA) == 0 { - pairingErrorResponse(w, http.StatusBadRequest, "invalid pake message") - return - } - sessionID, msgB, err := m.startSession(req.Name, msgA) + resp, err := m.pairStart(req) if err != nil { status, msg := pairingErrorStatus(err) pairingErrorResponse(w, status, msg) return } - writeJSON(w, http.StatusOK, pairStartResponse{ - Session: sessionID, - PAKE: base64.StdEncoding.EncodeToString(msgB), - }) + writeJSON(w, http.StatusOK, resp) } } @@ -580,42 +617,29 @@ func (m *PairingManager) HandlePairFinish() http.HandlerFunc { pairingErrorResponse(w, http.StatusBadRequest, "invalid request body") return } - clientHMAC, decErr := base64.StdEncoding.DecodeString(req.Confirm) - if decErr != nil || len(clientHMAC) == 0 { - pairingErrorResponse(w, http.StatusBadRequest, "invalid confirmation") - return - } - result, err := m.finishSession(req.Session, clientHMAC) + resp, err := m.pairFinish(req, sourceIPForAudit(r)) if err != nil { - // Audit-log security-relevant failures with the source IP. - // Other errors (expired session, unknown session, etc.) are - // handled by the generic mapping below. - logFailedPairingAttempt(r, err) status, msg := pairingErrorStatus(err) pairingErrorResponse(w, status, msg) return } - writeJSON(w, http.StatusOK, pairFinishResponse{ - AuthToken: result.Client.AuthToken, - ClientID: result.Client.ClientID, - Confirm: base64.StdEncoding.EncodeToString(result.ServerHMAC), - }) + writeJSON(w, http.StatusOK, resp) } } // logFailedPairingAttempt logs HMAC mismatch and exhaustion (not operational errors). -func logFailedPairingAttempt(r *http.Request, err error) { +func logFailedPairingAttempt(source string, err error) { switch { case errors.Is(err, errPairingHMACMismatch): log.Warn(). - Str("source_ip", sourceIPForAudit(r)). + Str("source_ip", source). Str("event", "pairing_hmac_mismatch"). Msg("pairing: failed PIN verification") case errors.Is(err, errPairingExhausted): log.Warn(). - Str("source_ip", sourceIPForAudit(r)). + Str("source_ip", source). Str("event", "pairing_attempts_exhausted"). Msg("pairing: PIN attempts exhausted, PIN invalidated") } @@ -646,6 +670,10 @@ func pairingErrorStatus(err error) (status int, msg string) { return http.StatusBadRequest, "client name required" case errors.Is(err, errPairingMessageTooLong): return http.StatusBadRequest, "PAKE message too long" + case errors.Is(err, errPairingInvalidPake): + return http.StatusBadRequest, "invalid pake message" + case errors.Is(err, errPairingInvalidConfirm): + return http.StatusBadRequest, "invalid confirmation" case errors.Is(err, crypto.ErrInvalidPakeMessage): return http.StatusBadRequest, "invalid PAKE message" case errors.Is(err, errTooManyClients): diff --git a/pkg/api/request_deps.go b/pkg/api/request_deps.go new file mode 100644 index 000000000..a7d48cfd8 --- /dev/null +++ b/pkg/api/request_deps.go @@ -0,0 +1,122 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "context" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" +) + +// requestDeps is the set of service dependencies every transport hands to +// method handlers through requests.RequestEnv. Transports differ only in how +// they identify the client, so that part is passed per request. +type requestDeps struct { + platform platforms.Platform + cfg *config.Instance + st *state.State + inTokenQueue chan<- tokens.Token + confirmQueue chan<- chan error + db *database.Database + limitsManager *playtime.LimitsManager + profilesSvc *profiles.Service + player audio.Player + playbackManager audio.PlaybackManager + indexPauser *syncutil.Pauser + scrapePauser *syncutil.Pauser + backupPauser *syncutil.Pauser +} + +func newRequestDeps( + platform platforms.Platform, + cfg *config.Instance, + st *state.State, + inTokenQueue chan<- tokens.Token, + confirmQueue chan<- chan error, + db *database.Database, + limitsManager *playtime.LimitsManager, + profilesSvc *profiles.Service, + player audio.Player, + playbackManager audio.PlaybackManager, + indexPauser *syncutil.Pauser, + scrapePauser *syncutil.Pauser, + backupPauser *syncutil.Pauser, +) *requestDeps { + return &requestDeps{ + platform: platform, + cfg: cfg, + st: st, + inTokenQueue: inTokenQueue, + confirmQueue: confirmQueue, + db: db, + limitsManager: limitsManager, + profilesSvc: profilesSvc, + player: player, + playbackManager: playbackManager, + indexPauser: indexPauser, + scrapePauser: scrapePauser, + backupPauser: backupPauser, + } +} + +// newRequestEnv builds the RequestEnv for one request. inputSession is nil +// for transports that cannot hold input across requests. Callers set the +// authentication fields (ClientRole, APIKeyAuthenticated) afterwards because +// their source differs per transport. +func (d *requestDeps) newRequestEnv( + ctx context.Context, + inputSession platforms.InputSession, + clientID string, + platformID string, + isLocal bool, +) requests.RequestEnv { + return requests.RequestEnv{ + Context: ctx, + Platform: d.platform, + Config: d.cfg, + State: d.st, + Database: d.db, + LimitsManager: d.limitsManager, + Profiles: d.profilesSvc, + LauncherCache: helpers.GlobalLauncherCache, + Player: d.player, + PlaybackManager: d.playbackManager, + UI: d.st.UIEvents(), + TokenQueue: d.inTokenQueue, + ConfirmQueue: d.confirmQueue, + IndexPauser: d.indexPauser, + ScrapePauser: d.scrapePauser, + BackupPauser: d.backupPauser, + InputSession: inputSession, + PlatformID: platformID, + IsLocal: isLocal, + ClientID: clientID, + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go index de597e048..5200ce7e4 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -46,6 +46,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/assets" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" @@ -110,6 +111,21 @@ var JSONRPCErrorServerBusy = models.ErrorObject{ Message: "Server busy", } +// JSONRPCErrorResponseTooLarge replaces a response the transport cannot +// carry. Data holds the limit and the size that exceeded it. +var JSONRPCErrorResponseTooLarge = models.ErrorObject{ + Code: -32004, + Message: "response too large for transport", +} + +// JSONRPCErrorPairingFailed is returned by the pre-auth pairing methods. +// Data holds the HTTP status the pairing endpoints would have used and its +// public message, so clients can share their handling. +var JSONRPCErrorPairingFailed = models.ErrorObject{ + Code: -32005, + Message: "pairing failed", +} + func makeJSONRPCError(code int, message string) models.ErrorObject { return models.ErrorObject{ Code: code, @@ -614,7 +630,7 @@ func logWebSocketTransportTiming( } // sendWSResponse marshals a method result and sends it to the client. -func sendWSResponse(session *melody.Session, id models.RPCID, result any) error { +func sendWSResponse(session sessionWriter, id models.RPCID, result any) error { logSafeResponse(result) resp := models.ResponseObject{ @@ -642,7 +658,7 @@ func sendWSResponse(session *melody.Session, id models.RPCID, result any) error } // sendWSError sends a JSON-RPC error object response to the client. -func sendWSError(session *melody.Session, id models.RPCID, errObj models.ErrorObject) error { +func sendWSError(session sessionWriter, id models.RPCID, errObj models.ErrorObject) error { log.Debug().Int("code", errObj.Code).Str("message", errObj.Message).Msg("sending error") resp := models.ResponseErrorObject{ @@ -1114,7 +1130,7 @@ func writeNotificationToSession(s *melody.Session, plaintext []byte) { } if err := writeNotificationFrame(s.Write, cs, getWebSocketAuthState(s), plaintext); err != nil { logWSWriteError(err, "broadcasting notification") - closeMelodySession(s) + closeSession(s) } } @@ -1278,6 +1294,10 @@ func handleWSMessage( lastSeenTracker *apimiddleware.LastSeenTracker, tracker RequestTracker, ) func(session *melody.Session, msg []byte) { + deps := newRequestDeps( + platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, profilesSvc, + player, playbackManager, indexPauser, scrapePauser, backupPauser, + ) return func(session *melody.Session, msg []byte) { trackerActive := false defer func() { @@ -1337,7 +1357,7 @@ func handleWSMessage( log.Warn(). Str("remote_addr", session.Request.RemoteAddr). Msg("ws: rejecting encrypted connection from unparseable remote addr") - closeMelodySession(session) + closeSession(session) endTrackedRequest() return } @@ -1370,35 +1390,16 @@ func handleWSMessage( if err := dispatcher.enqueuePong(cs, tracker); err != nil { logWSWriteError(err, "queueing pong") endTrackedRequest() - closeMelodySession(session) + closeSession(session) return } handoffTrackedRequest() return } - env := requests.RequestEnv{ - Context: st.GetContext(), - Platform: platform, - Config: cfg, - State: st, - Database: db, - LimitsManager: limitsManager, - Profiles: profilesSvc, - LauncherCache: helpers.GlobalLauncherCache, - Player: player, - PlaybackManager: playbackManager, - UI: st.UIEvents(), - TokenQueue: inTokenQueue, - ConfirmQueue: confirmQueue, - IndexPauser: indexPauser, - ScrapePauser: scrapePauser, - BackupPauser: backupPauser, - InputSession: dispatcher.inputSession, - PlatformID: platformID, - IsLocal: isLocal, - ClientID: session.Request.RemoteAddr, - } + env := deps.newRequestEnv( + st.GetContext(), dispatcher.inputSession, session.Request.RemoteAddr, platformID, isLocal, + ) if cs != nil { env.ClientRole = cs.ClientRole() } @@ -1438,7 +1439,7 @@ func handleWSMessage( session, cs, models.NullRPCID, JSONRPCErrorInternalError, ); sendErr != nil { logWSWriteError(sendErr, "error sending queue failure response") - closeMelodySession(session) + closeSession(session) } return } @@ -1446,6 +1447,76 @@ func handleWSMessage( } } +// frameOutcome classifies what decryptFrame found on the wire. +type frameOutcome uint8 + +const ( + // frameDecrypted is a subsequent frame on an established session. + frameDecrypted frameOutcome = iota + 1 + // frameEstablished is an encrypted first frame; on success a new session + // was created. + frameEstablished + // frameUnsupportedVersion is an encrypted first frame with a protocol + // version this server does not speak. + frameUnsupportedVersion + // framePlaintext is a frame that is not encrypted at all. + framePlaintext +) + +// decryptedFrame is what decryptFrame found on the wire. session is set only +// when an encrypted first frame established a new session. +type decryptedFrame struct { + session *apimiddleware.ClientSession + plaintext []byte + outcome frameOutcome +} + +// decryptFrame is the transport-agnostic half of the encryption decision: +// decrypt on an established session, establish a session from an encrypted +// first frame, or report plaintext. Whether plaintext is acceptable and what +// to do with the connection on failure are the caller's decisions. +// +// The outcome is always set, even on error, so the caller can tell a frame +// that failed to decrypt from one that failed to establish a session. +func decryptFrame( + cs *apimiddleware.ClientSession, + msg []byte, + encGateway *apimiddleware.EncryptionGateway, + sourceIP string, + transport string, +) (decryptedFrame, error) { + if cs != nil { + var frame apimiddleware.EncryptedFrame + if unmarshalErr := json.Unmarshal(msg, &frame); unmarshalErr != nil || frame.Ciphertext == "" { + return decryptedFrame{outcome: frameDecrypted}, + fmt.Errorf("%w: malformed encrypted frame", apimiddleware.ErrInvalidFrame) + } + pt, decryptErr := cs.DecryptSubsequent(frame) + if decryptErr != nil { + return decryptedFrame{outcome: frameDecrypted}, fmt.Errorf("decrypt frame: %w", decryptErr) + } + return decryptedFrame{plaintext: pt, outcome: frameDecrypted}, nil + } + + if !apimiddleware.IsEncryptedFirstFrame(msg) { + return decryptedFrame{plaintext: msg, outcome: framePlaintext}, nil + } + + var frame apimiddleware.EncryptedFirstFrame + if unmarshalErr := json.Unmarshal(msg, &frame); unmarshalErr != nil { + return decryptedFrame{outcome: frameEstablished}, + fmt.Errorf("%w: malformed encrypted first frame", apimiddleware.ErrInvalidFrame) + } + if frame.Version != apimiddleware.EncryptionProtoVersion { + return decryptedFrame{outcome: frameUnsupportedVersion}, apimiddleware.ErrUnsupportedVersion + } + newCS, pt, establishErr := encGateway.EstablishSessionForTransport(frame, sourceIP, transport) + if establishErr != nil { + return decryptedFrame{outcome: frameEstablished}, fmt.Errorf("establish session: %w", establishErr) + } + return decryptedFrame{plaintext: pt, session: newCS, outcome: frameEstablished}, nil +} + // decryptIncomingFrame is the encryption decision point for WebSocket frames. // It handles three cases: // @@ -1466,72 +1537,56 @@ func decryptIncomingFrame( isLocal bool, sourceIP string, ) (plaintext []byte, cs *apimiddleware.ClientSession, ok bool) { - // Already-established encrypted session: decrypt with the stored state. - if cs = getClientSession(session); cs != nil { - var frame apimiddleware.EncryptedFrame - if err := json.Unmarshal(msg, &frame); err != nil || frame.Ciphertext == "" { - log.Warn().Err(err).Msg("ws: malformed encrypted frame on established session") - closeMelodySession(session) - return nil, nil, false - } - pt, err := cs.DecryptSubsequent(frame) + cs = getClientSession(session) + frame, err := decryptFrame(cs, msg, encGateway, sourceIP, apimiddleware.TransportWebSocket) + switch frame.outcome { + case frameDecrypted: if err != nil { log.Warn().Err(err).Msg("ws: decryption failed on established session") - closeMelodySession(session) + closeSession(session) return nil, nil, false } - return pt, cs, true - } + return frame.plaintext, cs, true - // No session yet: detect whether this is an encrypted first frame. - if apimiddleware.IsEncryptedFirstFrame(msg) { - var frame apimiddleware.EncryptedFirstFrame - if err := json.Unmarshal(msg, &frame); err != nil { - log.Warn().Err(err).Msg("ws: malformed encrypted first frame") - closeMelodySession(session) - return nil, nil, false - } - if frame.Version != apimiddleware.EncryptionProtoVersion { - data, marshalErr := unsupportedEncryptionVersionResponse() - if marshalErr == nil { - sendWSPlaintext(session, data) - } - closeMelodySession(session) - return nil, nil, false - } - newSession, pt, err := encGateway.EstablishSession(frame, sourceIP) + case frameEstablished: if err != nil { log.Warn().Err(err).Msg("ws: failed to establish encrypted session") - closeMelodySession(session) + closeSession(session) return nil, nil, false } - setClientSession(session, newSession) + setClientSession(session, frame.session) settleWebSocketTransport(session, webSocketAuthEncrypted, false) - return pt, newSession, true - } + return frame.plaintext, frame.session, true - // Plaintext frame: only allowed when encryption is disabled, or from - // loopback (localhost is always exempt so the TUI / local clients keep - // working without pairing). - if encryptionEnabled && !isLocal { - data, marshalErr := encryptionRequiredErrorResponse() + case frameUnsupportedVersion: + data, marshalErr := unsupportedEncryptionVersionResponse() if marshalErr == nil { sendWSPlaintext(session, data) } - closeMelodySession(session) + closeSession(session) return nil, nil, false - } - // The client spoke plaintext, so the transport mode is now settled and - // anything queued while it was unknown can be released in the clear. - settleWebSocketTransport(session, webSocketAuthPlaintext, false) - return msg, nil, true -} -// closeMelodySession best-effort closes a melody WebSocket session, logging -// any error at debug level (the connection may already be closed). -func closeMelodySession(session *melody.Session) { - if err := session.Close(); err != nil { - log.Debug().Err(err).Msg("ws: failed to close session") + case framePlaintext: + // Plaintext frame: only allowed when encryption is disabled, or from + // loopback (localhost is always exempt so the TUI / local clients keep + // working without pairing). + if encryptionEnabled && !isLocal { + data, marshalErr := encryptionRequiredErrorResponse() + if marshalErr == nil { + sendWSPlaintext(session, data) + } + closeSession(session) + return nil, nil, false + } + // The client spoke plaintext, so the transport mode is now settled and + // anything queued while it was unknown can be released in the clear. + settleWebSocketTransport(session, webSocketAuthPlaintext, false) + return msg, nil, true + + default: + log.Error().Uint8("outcome", uint8(frame.outcome)).Msg("ws: unhandled frame outcome") + closeSession(session) + return nil, nil, false } } @@ -1564,7 +1619,7 @@ func writePong(writeFn func([]byte) error, cs *apimiddleware.ClientSession) erro // SendEncryptedFrame so concurrent writers cannot reorder counters on the // wire. func sendWSEncryptedResponse( - session *melody.Session, + session sessionWriter, cs *apimiddleware.ClientSession, id models.RPCID, result any, @@ -1599,7 +1654,7 @@ func sendWSEncryptedResponse( // the per-session mutex via SendEncryptedFrame so concurrent writers // cannot reorder counters on the wire. func sendWSEncryptedError( - session *melody.Session, + session sessionWriter, cs *apimiddleware.ClientSession, id models.RPCID, rpcErr models.ErrorObject, @@ -1646,6 +1701,10 @@ func handlePostRequest( backupPauser *syncutil.Pauser, tracker RequestTracker, ) func(w http.ResponseWriter, r *http.Request) { + deps := newRequestDeps( + platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, profilesSvc, + player, playbackManager, indexPauser, scrapePauser, backupPauser, + ) return func(w http.ResponseWriter, r *http.Request) { // Bracket the entire request lifecycle (read body → dispatch → // marshal → Write → Flush → AfterWrite) so the idle scheduler @@ -1697,28 +1756,8 @@ func handlePostRequest( if !isLocal { platformID = platform.ID() } - env := requests.RequestEnv{ - Context: reqCtx, - Platform: platform, - Config: cfg, - State: st, - Database: db, - LimitsManager: limitsManager, - Profiles: profilesSvc, - LauncherCache: helpers.GlobalLauncherCache, - Player: player, - PlaybackManager: playbackManager, - UI: st.UIEvents(), - TokenQueue: inTokenQueue, - ConfirmQueue: confirmQueue, - IndexPauser: indexPauser, - ScrapePauser: scrapePauser, - BackupPauser: backupPauser, - PlatformID: platformID, - IsLocal: isLocal, - ClientID: r.RemoteAddr, - APIKeyAuthenticated: apimiddleware.APIKeyAuthenticated(r), - } + env := deps.newRequestEnv(reqCtx, nil, r.RemoteAddr, platformID, isLocal) + env.APIKeyAuthenticated = apimiddleware.APIKeyAuthenticated(r) result := processRequestObject(methodMap, env, body) if !result.ShouldReply { @@ -1784,6 +1823,19 @@ func handlePostRequest( } } +// ServerOption configures optional parts of the API server. +type ServerOption func(*serverOptions) + +type serverOptions struct { + bluetooth *bluetooth.Manager +} + +// WithBluetooth serves the API over Bluetooth Low Energy whenever the +// manager has an adapter ready. +func WithBluetooth(m *bluetooth.Manager) ServerOption { + return func(o *serverOptions) { o.bluetooth = m } +} + // Start starts the API web server and blocks until it shuts down. func Start( platform platforms.Platform, @@ -1828,7 +1880,13 @@ func StartWithReady( backupPauser *syncutil.Pauser, tracker RequestTracker, ready chan<- error, + opts ...ServerOption, ) error { + var o serverOptions + for _, opt := range opts { + opt(&o) + } + notifyReady := func(err error) { if ready != nil { ready <- err @@ -1971,6 +2029,23 @@ func StartWithReady( <-lastSeenDone }() + if o.bluetooth != nil { + bt := newBLETransport(&bleTransportDeps{ + core: newRequestDeps( + platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, profilesSvc, + player, playbackManager, indexPauser, scrapePauser, backupPauser, + ), + methodMap: methodMap, + encGateway: encGateway, + lastSeen: lastSeenTracker, + tracker: tracker, + pairing: pairingMgr, + notifBroker: notifBroker, + }) + bt.unregister = o.bluetooth.OnPeripheral(bt.serve) + defer bt.stop() + } + session := newWebSocketSession() defer func() { if err := session.Close(); err != nil { diff --git a/pkg/api/server_encryption.go b/pkg/api/server_encryption.go index 391fc8bc4..ca12ebbf7 100644 --- a/pkg/api/server_encryption.go +++ b/pkg/api/server_encryption.go @@ -107,7 +107,7 @@ func startWebSocketAuthDeadline(session *melody.Session, timeout time.Duration) } timer := time.AfterFunc(timeout, func() { if getWebSocketAuthState(session) == webSocketAuthPending { - closeMelodySession(session) + closeSession(session) } }) session.Set(melodySessionAuthDeadlineKey, timer) @@ -290,7 +290,7 @@ func unsupportedEncryptionVersionResponse() ([]byte, error) { } // sendWSPlaintext sends plaintext before encryption handshake completes. -func sendWSPlaintext(session *melody.Session, data []byte) { +func sendWSPlaintext(session sessionWriter, data []byte) { if err := session.Write(data); err != nil { log.Debug().Err(err).Msg("failed to write plaintext WS message") } diff --git a/pkg/api/server_encryption_test.go b/pkg/api/server_encryption_test.go index e75de223f..5b20a608d 100644 --- a/pkg/api/server_encryption_test.go +++ b/pkg/api/server_encryption_test.go @@ -32,6 +32,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/crypto" apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" "github.com/gorilla/websocket" @@ -121,11 +122,26 @@ func TestWritePong_Encrypted(t *testing.T) { // established session needs to decrypt server messages. Tests use it to // verify wire shape end-to-end. type testEncryptionPeerSecrets struct { + c2sGCM cipher.AEAD + c2sNonce []byte s2cGCM cipher.AEAD s2cNonce []byte aad []byte } +// encryptSubsequent builds the {"e":...} frame a client sends after the first +// frame, with the given client-to-server counter. +func (s *testEncryptionPeerSecrets) encryptSubsequent(t *testing.T, plaintext []byte, counter uint64) []byte { + t.Helper() + ct, err := crypto.Encrypt(s.c2sGCM, s.c2sNonce, counter, plaintext, s.aad) + require.NoError(t, err) + data, err := json.Marshal(apimiddleware.EncryptedFrame{ + Ciphertext: base64.StdEncoding.EncodeToString(ct), + }) + require.NoError(t, err) + return data +} + // testEncryptionSourceIP is the client address the test frames are built for. // EstablishSession binds a session to it, so callers driving the frame through // the server have to present the same one. @@ -135,9 +151,32 @@ const testEncryptionSourceIP = "192.168.1.50" // first frame through the server: the gateway that will accept it, the frame // itself, and the client-side cipher state for reading what comes back. type testEncryptionFirstFrame struct { - gateway *apimiddleware.EncryptionGateway - secrets *testEncryptionPeerSecrets - frame apimiddleware.EncryptedFirstFrame + gateway *apimiddleware.EncryptionGateway + secrets *testEncryptionPeerSecrets + db *helpers.MockUserDBI + pairingKey []byte + salt []byte + transport string + frame apimiddleware.EncryptedFirstFrame +} + +// reencrypt rebuilds the first frame's ciphertext for a different plaintext +// with the frame's current AuthToken, for fixtures that stand in for a +// second paired client. +func (f *testEncryptionFirstFrame) reencrypt(t *testing.T, plaintext string) []byte { + t.Helper() + keys, err := crypto.DeriveSessionKeys(f.pairingKey, f.salt) + require.NoError(t, err) + c2s, err := crypto.NewAEAD(keys.C2SKey) + require.NoError(t, err) + aad := []byte(f.frame.AuthToken + ":" + f.transport) + ct, err := crypto.Encrypt(c2s, keys.C2SNonce, 0, []byte(plaintext), aad) + require.NoError(t, err) + f.frame.Ciphertext = base64.StdEncoding.EncodeToString(ct) + f.secrets.aad = aad + data, err := json.Marshal(f.frame) //nolint:gosec // test fixture token + require.NoError(t, err) + return data } // establishTestEncryptionSession constructs a real *apimiddleware.ClientSession @@ -159,6 +198,13 @@ func establishTestEncryptionSession(t *testing.T) (*apimiddleware.ClientSession, // hand the frame to the code under test instead of the gateway. func newTestEncryptionFirstFrame(t *testing.T) *testEncryptionFirstFrame { t.Helper() + return newTestEncryptionFirstFrameFor(t, apimiddleware.TransportWebSocket) +} + +// newTestEncryptionFirstFrameFor is newTestEncryptionFirstFrame with the +// frame bound to the given transport label. +func newTestEncryptionFirstFrameFor(t *testing.T, transport string) *testEncryptionFirstFrame { + t.Helper() pairingKey := make([]byte, crypto.PairingKeySize) _, err := cryptorand.Read(pairingKey) @@ -169,6 +215,7 @@ func newTestEncryptionFirstFrame(t *testing.T) *testEncryptionFirstFrame { ClientID: "test-client", ClientName: "Test", AuthToken: "test-auth-token", + Role: string(permissions.RoleMember), PairingKey: pairingKey, } @@ -188,13 +235,17 @@ func newTestEncryptionFirstFrame(t *testing.T) *testEncryptionFirstFrame { clientS2C, err := crypto.NewAEAD(keys.S2CKey) require.NoError(t, err) - aad := []byte(c.AuthToken + ":ws") + aad := []byte(c.AuthToken + ":" + transport) plaintextReq := []byte(`{"jsonrpc":"2.0","method":"version","id":1}`) ct, err := crypto.Encrypt(clientC2S, keys.C2SNonce, 0, plaintextReq, aad) require.NoError(t, err) return &testEncryptionFirstFrame{ - gateway: mgr, + gateway: mgr, + db: db, + pairingKey: pairingKey, + salt: salt, + transport: transport, frame: apimiddleware.EncryptedFirstFrame{ Version: apimiddleware.EncryptionProtoVersion, Ciphertext: base64.StdEncoding.EncodeToString(ct), @@ -202,6 +253,8 @@ func newTestEncryptionFirstFrame(t *testing.T) *testEncryptionFirstFrame { SessionSalt: base64.StdEncoding.EncodeToString(salt), }, secrets: &testEncryptionPeerSecrets{ + c2sGCM: clientC2S, + c2sNonce: keys.C2SNonce, s2cGCM: clientS2C, s2cNonce: keys.S2CNonce, aad: aad, diff --git a/pkg/api/session_writer.go b/pkg/api/session_writer.go new file mode 100644 index 000000000..205da399c --- /dev/null +++ b/pkg/api/session_writer.go @@ -0,0 +1,39 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import "github.com/rs/zerolog/log" + +// sessionWriter is what the dispatcher and the response helpers need from a +// client connection: a way to write one complete message and a way to close +// it. *melody.Session satisfies it unchanged; other transports provide their +// own implementation. +type sessionWriter interface { + Write([]byte) error + Close() error +} + +// closeSession best-effort closes a client session, logging any error at +// debug level (the connection may already be closed). +func closeSession(session sessionWriter) { + if err := session.Close(); err != nil { + log.Debug().Err(err).Msg("failed to close session") + } +} diff --git a/pkg/api/ws_dispatcher.go b/pkg/api/ws_dispatcher.go index 6263af880..261f13baa 100644 --- a/pkg/api/ws_dispatcher.go +++ b/pkg/api/ws_dispatcher.go @@ -21,6 +21,7 @@ package api import ( "context" + "encoding/json" "errors" "fmt" "sync" @@ -112,7 +113,7 @@ type wsResponseJob struct { type wsSessionDispatcher struct { ctx context.Context cancel context.CancelFunc - session *melody.Session + session sessionWriter inputSession platforms.InputSession high chan *wsRequestJob run chan *wsRequestJob @@ -121,7 +122,11 @@ type wsSessionDispatcher struct { low chan *wsRequestJob responses chan *wsResponseJob inputDone chan struct{} - closeOnce sync.Once + // maxResponseSize, when positive, replaces any result whose JSON is + // larger with JSONRPCErrorResponseTooLarge. Transports with a small + // per-message limit set it; WebSocket leaves it at zero. + maxResponseSize int + closeOnce sync.Once } func getOrCreateWSDispatcher( @@ -135,6 +140,19 @@ func getOrCreateWSDispatcher( } } + d := newSessionDispatcher(parent, session, platform) + session.Set(wsDispatcherSessionKey, d) + return d +} + +// newSessionDispatcher builds and starts a per-connection dispatcher for any +// transport that delivers whole messages. The caller owns its lifetime and +// must call close when the connection ends. +func newSessionDispatcher( + parent context.Context, + session sessionWriter, + platform platforms.Platform, +) *wsSessionDispatcher { ctx, cancel := context.WithCancel(parent) var inputSession platforms.InputSession if provider, ok := platform.(platforms.InputSessionProvider); ok { @@ -153,7 +171,6 @@ func getOrCreateWSDispatcher( responses: make(chan *wsResponseJob, wsResponseQueueSize), inputDone: make(chan struct{}), } - session.Set(wsDispatcherSessionKey, d) d.start() return d } @@ -452,7 +469,7 @@ func (d *wsSessionDispatcher) writeResponse(resp *wsResponseJob) { if resp.pong { if err := writePong(d.session.Write, resp.cs); err != nil { logWSWriteError(err, "sending pong") - closeMelodySession(d.session) + closeSession(d.session) } return } @@ -461,15 +478,17 @@ func (d *wsSessionDispatcher) writeResponse(resp *wsResponseJob) { return } + d.capResponse(resp) + if resp.result.Error != nil { if err := sendWSEncryptedError(d.session, resp.cs, resp.result.ID, *resp.result.Error); err != nil { logWSWriteError(err, "error sending error response") - closeMelodySession(d.session) + closeSession(d.session) } } else { if err := sendWSEncryptedResponse(d.session, resp.cs, resp.result.ID, resp.result.Result); err != nil { logWSWriteError(err, "error sending response") - closeMelodySession(d.session) + closeSession(d.session) } } if resp.result.AfterWrite != nil { @@ -477,6 +496,45 @@ func (d *wsSessionDispatcher) writeResponse(resp *wsResponseJob) { } } +// capResponse swaps a response the transport cannot carry for an error +// that says so, sized against the plaintext JSON since encryption happens +// later. Error responses are measured too: a message that echoes a large +// request can be just as big. +func (d *wsSessionDispatcher) capResponse(resp *wsResponseJob) { + if d.maxResponseSize <= 0 { + return + } + var ( + data []byte + err error + ) + if resp.result.Error != nil { + data, err = json.Marshal(models.ResponseErrorObject{ + JSONRPC: "2.0", + ID: resp.result.ID, + Error: resp.result.Error, + }) + } else { + data, err = json.Marshal(models.ResponseObject{ + JSONRPC: "2.0", + ID: resp.result.ID, + Result: resp.result.Result, + }) + } + if err != nil || len(data) <= d.maxResponseSize { + return + } + log.Warn(). + Str("method", resp.method). + Int("responseBytes", len(data)). + Int("limit", d.maxResponseSize). + Msg("response exceeds transport limit, replacing with error") + tooLarge := JSONRPCErrorResponseTooLarge + tooLarge.Data = map[string]any{"limit": d.maxResponseSize, "size": len(data)} + resp.result.Result = nil + resp.result.Error = &tooLarge +} + func enqueueWSRequest( d *wsSessionDispatcher, methodMap *MethodMap, diff --git a/pkg/api/ws_dispatcher_writer_test.go b/pkg/api/ws_dispatcher_writer_test.go new file mode 100644 index 000000000..2384f186c --- /dev/null +++ b/pkg/api/ws_dispatcher_writer_test.go @@ -0,0 +1,184 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/crypto" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeSessionWriter stands in for a non-WebSocket transport: it records what +// the dispatcher writes and whether the dispatcher asked to close it. +type fakeSessionWriter struct { + writeErr error + writes chan []byte + closed chan struct{} + closeOnce sync.Once +} + +func newFakeSessionWriter() *fakeSessionWriter { + return &fakeSessionWriter{ + writes: make(chan []byte, 16), + closed: make(chan struct{}), + } +} + +func (f *fakeSessionWriter) Write(p []byte) error { + if f.writeErr != nil { + return f.writeErr + } + f.writes <- append([]byte(nil), p...) + return nil +} + +func (f *fakeSessionWriter) Close() error { + f.closeOnce.Do(func() { close(f.closed) }) + return nil +} + +func (f *fakeSessionWriter) waitWrite(t *testing.T) []byte { + t.Helper() + select { + case data := <-f.writes: + return data + case <-time.After(5 * time.Second): + t.Fatal("dispatcher did not write a response") + return nil + } +} + +func (f *fakeSessionWriter) waitClosed(t *testing.T) { + t.Helper() + select { + case <-f.closed: + case <-time.After(5 * time.Second): + t.Fatal("dispatcher did not close the session") + } +} + +func TestSessionDispatcherWritesThroughSessionWriter(t *testing.T) { + t.Parallel() + + var methodMap MethodMap + require.NoError(t, methodMap.AddMethod("test.echo", func(requests.RequestEnv) (any, error) { + return map[string]string{"kind": "echo"}, nil + }, true)) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writer := newFakeSessionWriter() + d := newSessionDispatcher(ctx, writer, nil) + defer d.close() + + env := &requests.RequestEnv{Context: ctx, IsLocal: true} + require.NoError(t, enqueueWSRequest( + d, &methodMap, env, + []byte(`{"jsonrpc":"2.0","method":"test.echo","id":"writer-id"}`), + nil, nil, + )) + + var resp models.ResponseObject + require.NoError(t, json.Unmarshal(writer.waitWrite(t), &resp)) + assert.Equal(t, models.NewStringID("writer-id"), resp.ID) + assert.Equal(t, map[string]any{"kind": "echo"}, resp.Result) + assert.Nil(t, resp.Error) + + select { + case <-writer.closed: + t.Fatal("successful write must not close the session") + default: + } +} + +func TestSessionDispatcherClosesSessionOnWriteError(t *testing.T) { + t.Parallel() + + var methodMap MethodMap + require.NoError(t, methodMap.AddMethod("test.echo", func(requests.RequestEnv) (any, error) { + return map[string]string{"kind": "echo"}, nil + }, true)) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writer := newFakeSessionWriter() + writer.writeErr = errors.New("transport gone") + d := newSessionDispatcher(ctx, writer, nil) + defer d.close() + + env := &requests.RequestEnv{Context: ctx, IsLocal: true} + require.NoError(t, enqueueWSRequest( + d, &methodMap, env, + []byte(`{"jsonrpc":"2.0","method":"test.echo","id":"writer-id"}`), + nil, nil, + )) + + writer.waitClosed(t) +} + +func TestSessionDispatcherEncryptsResponseForSessionWriter(t *testing.T) { + t.Parallel() + + cs, clientSecrets := establishTestEncryptionSession(t) + + var methodMap MethodMap + require.NoError(t, methodMap.AddMethod("test.echo", func(requests.RequestEnv) (any, error) { + return map[string]string{"kind": "echo"}, nil + }, true)) + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + writer := newFakeSessionWriter() + d := newSessionDispatcher(ctx, writer, nil) + defer d.close() + + env := &requests.RequestEnv{Context: ctx, IsLocal: true} + require.NoError(t, enqueueWSRequest( + d, &methodMap, env, + []byte(`{"jsonrpc":"2.0","method":"test.echo","id":"writer-id"}`), + cs, nil, + )) + + var frame struct { + Ciphertext string `json:"e"` + } + require.NoError(t, json.Unmarshal(writer.waitWrite(t), &frame)) + ciphertext, err := base64.StdEncoding.DecodeString(frame.Ciphertext) + require.NoError(t, err) + plaintext, err := crypto.Decrypt( + clientSecrets.s2cGCM, clientSecrets.s2cNonce, 0, ciphertext, clientSecrets.aad, + ) + require.NoError(t, err) + + var resp models.ResponseObject + require.NoError(t, json.Unmarshal(plaintext, &resp)) + assert.Equal(t, models.NewStringID("writer-id"), resp.ID) + assert.Equal(t, map[string]any{"kind": "echo"}, resp.Result) +} diff --git a/pkg/bluetooth/apigatt/frame.go b/pkg/bluetooth/apigatt/frame.go new file mode 100644 index 000000000..35a492bf3 --- /dev/null +++ b/pkg/bluetooth/apigatt/frame.go @@ -0,0 +1,359 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package apigatt + +import ( + "encoding/binary" + "errors" + "fmt" + "time" + + "github.com/jonboulle/clockwork" +) + +// Chunk layout, both directions: +// +// byte 0 flags bits 7..4 protocol version, bit 1 LAST, bit 0 FIRST, +// bits 3..2 reserved and zero +// byte 1 seq 0 on FIRST, +1 per chunk, wraps at 256 +// byte 2-3 tag session tag, big endian +// byte 4-7 length total message length, big endian, FIRST only +// payload at least one byte +const ( + // HeaderSize is the header on every chunk but the first of a message. + HeaderSize = 4 + // FirstHeaderSize is the header on the first chunk of a message. + FirstHeaderSize = 8 + + flagFirst = 0x01 + flagLast = 0x02 + flagReservedMask = 0x0c + versionShift = 4 +) + +var ( + // ErrMalformedChunk means the chunk cannot be parsed at all: too short, + // wrong version, reserved bits set. The connection should be dropped. + ErrMalformedChunk = errors.New("apigatt: malformed chunk") + // ErrMessageTooLarge means a message declared or accumulated more than + // MaxMessageSize bytes. + ErrMessageTooLarge = errors.New("apigatt: message too large") + // ErrLengthMismatch means the chunks did not add up to the declared + // length. + ErrLengthMismatch = errors.New("apigatt: message length mismatch") + // ErrSequence means a chunk arrived outside the reorder window or + // repeated a sequence number. + ErrSequence = errors.New("apigatt: chunk out of sequence") + // ErrTooManyErrors means the peer made more recoverable mistakes than + // MaxProtocolErrors allows. + ErrTooManyErrors = errors.New("apigatt: too many protocol errors") + // ErrEmptyMessage means there is nothing to send. + ErrEmptyMessage = errors.New("apigatt: empty message") +) + +// Header is the decoded chunk header. +type Header struct { + Length uint32 // valid only when First + Tag uint16 + Version uint8 + Seq uint8 + First bool + Last bool +} + +// ParseChunk decodes one chunk into its header and payload. The payload +// aliases chunk. +func ParseChunk(chunk []byte) (Header, []byte, error) { + if len(chunk) < HeaderSize { + return Header{}, nil, fmt.Errorf("%w: %d bytes", ErrMalformedChunk, len(chunk)) + } + flags := chunk[0] + h := Header{ + Version: flags >> versionShift, + First: flags&flagFirst != 0, + Last: flags&flagLast != 0, + Seq: chunk[1], + Tag: binary.BigEndian.Uint16(chunk[2:4]), + } + if h.Version != ProtocolVersion { + return Header{}, nil, fmt.Errorf("%w: version %d", ErrMalformedChunk, h.Version) + } + if flags&flagReservedMask != 0 { + return Header{}, nil, fmt.Errorf("%w: reserved flag bits set", ErrMalformedChunk) + } + headerSize := HeaderSize + if h.First { + if h.Seq != 0 { + return Header{}, nil, fmt.Errorf("%w: first chunk has sequence %d", ErrMalformedChunk, h.Seq) + } + if len(chunk) < FirstHeaderSize { + return Header{}, nil, fmt.Errorf("%w: first chunk is %d bytes", ErrMalformedChunk, len(chunk)) + } + h.Length = binary.BigEndian.Uint32(chunk[4:8]) + headerSize = FirstHeaderSize + if h.Length == 0 { + return Header{}, nil, fmt.Errorf("%w: zero length", ErrMalformedChunk) + } + if h.Length > MaxMessageSize { + return Header{}, nil, fmt.Errorf("%w: declared %d bytes", ErrMessageTooLarge, h.Length) + } + } + if len(chunk) == headerSize { + return Header{}, nil, fmt.Errorf("%w: empty payload", ErrMalformedChunk) + } + return h, chunk[headerSize:], nil +} + +// EncodeChunk builds one chunk. Length is written only for a first chunk. +func EncodeChunk(h Header, payload []byte) []byte { + size := HeaderSize + if h.First { + size = FirstHeaderSize + } + out := make([]byte, size, size+len(payload)) + flags := byte(ProtocolVersion) << versionShift + if h.First { + flags |= flagFirst + } + if h.Last { + flags |= flagLast + } + out[0] = flags + out[1] = h.Seq + binary.BigEndian.PutUint16(out[2:4], h.Tag) + if h.First { + binary.BigEndian.PutUint32(out[4:8], h.Length) + } + return append(out, payload...) +} + +// Chunker splits messages into chunks that fit the link's ATT MTU. +type Chunker struct { + // MTU is the negotiated ATT MTU; anything below DefaultMTU is treated + // as DefaultMTU. + MTU int + // Tag is stamped on every chunk. + Tag uint16 +} + +// chunkPayload is how many payload bytes fit after a header of the given +// size. +func (c Chunker) chunkPayload(headerSize int) int { + mtu := c.MTU + if mtu < DefaultMTU { + mtu = DefaultMTU + } + return mtu - attHeaderSize - headerSize +} + +// Split cuts msg into chunks in transmission order. +func (c Chunker) Split(msg []byte) ([][]byte, error) { + if len(msg) == 0 { + return nil, ErrEmptyMessage + } + if len(msg) > MaxMessageSize { + return nil, fmt.Errorf("%w: %d bytes", ErrMessageTooLarge, len(msg)) + } + + var chunks [][]byte + var seq uint8 + offset := 0 + for offset < len(msg) { + first := offset == 0 + headerSize := HeaderSize + if first { + headerSize = FirstHeaderSize + } + n := min(c.chunkPayload(headerSize), len(msg)-offset) + h := Header{ + Version: ProtocolVersion, + Seq: seq, + Tag: c.Tag, + First: first, + Last: offset+n == len(msg), + } + if first { + h.Length = uint32(len(msg)) //nolint:gosec // bounded by MaxMessageSize above + } + chunks = append(chunks, EncodeChunk(h, msg[offset:offset+n])) + offset += n + seq++ + } + return chunks, nil +} + +// heldChunk is a chunk waiting for the ones before it. +type heldChunk struct { + payload []byte + last bool +} + +// Reassembler rebuilds messages from chunks for one connection. It +// tolerates chunks arriving slightly out of order, caps memory, and drops a +// half-received message that stalls. +type Reassembler struct { + clock clockwork.Clock + held map[uint8]heldChunk + lastChunkAt time.Time + buf []byte + maxMessage int + heldBytes int + errors int + expected uint32 + nextSeq uint8 + inProgress bool +} + +// NewReassembler returns a reassembler; maxMessage of zero or less means +// MaxMessageSize. +func NewReassembler(clock clockwork.Clock, maxMessage int) *Reassembler { + if maxMessage <= 0 || maxMessage > MaxMessageSize { + maxMessage = MaxMessageSize + } + if clock == nil { + clock = clockwork.NewRealClock() + } + return &Reassembler{clock: clock, held: make(map[uint8]heldChunk), maxMessage: maxMessage} +} + +// Push consumes one chunk. It returns the completed message when this chunk +// finished one. A non-nil error means the connection should be dropped; +// the reassembler is unusable afterwards. +func (r *Reassembler) Push(chunk []byte) ([]byte, error) { + h, payload, err := ParseChunk(chunk) + if err != nil { + return nil, err + } + + now := r.clock.Now() + if (r.inProgress || len(r.held) > 0) && now.Sub(r.lastChunkAt) > PartialIdleTimeout { + r.reset() + } + r.lastChunkAt = now + + if h.First { + if r.inProgress { + // The peer started over mid-message: a restarted client, or + // one that gave up on a message. Recoverable, but counted, and + // whatever was held belonged to the abandoned message. + if err := r.recoverable(); err != nil { + return nil, err + } + r.dropHeld() + } + r.startMessage(h.Length) + return r.apply(payload, h.Last) + } + + distance := h.Seq - r.nextSeq + switch { + case distance == 0 && !r.inProgress: + // Sequence zero without the FIRST flag can never be applied. + return nil, r.recoverable() + case distance == 0: + return r.apply(payload, h.Last) + case distance < ReorderWindow: + if _, dup := r.held[h.Seq]; dup { + return nil, fmt.Errorf("%w: duplicate chunk %d", ErrSequence, h.Seq) + } + if err := r.hold(h.Seq, payload, h.Last); err != nil { + return nil, err + } + return nil, nil + case !r.inProgress: + // A stray chunk far from any message we are receiving. + return nil, r.recoverable() + default: + return nil, fmt.Errorf("%w: chunk %d, expected %d", ErrSequence, h.Seq, r.nextSeq) + } +} + +// recoverable counts a protocol mistake and fails once the budget is used. +func (r *Reassembler) recoverable() error { + r.errors++ + if r.errors >= MaxProtocolErrors { + return ErrTooManyErrors + } + return nil +} + +func (r *Reassembler) startMessage(length uint32) { + r.buf = r.buf[:0] + r.expected = length + r.nextSeq = 0 + r.inProgress = true +} + +func (r *Reassembler) reset() { + r.buf = r.buf[:0] + r.expected = 0 + r.nextSeq = 0 + r.inProgress = false + r.dropHeld() +} + +func (r *Reassembler) dropHeld() { + r.held = make(map[uint8]heldChunk) + r.heldBytes = 0 +} + +// hold keeps an early chunk until its predecessors arrive. +func (r *Reassembler) hold(seq uint8, payload []byte, last bool) error { + if len(r.buf)+r.heldBytes+len(payload) > r.maxMessage { + return fmt.Errorf("%w: held chunks exceed %d bytes", ErrMessageTooLarge, r.maxMessage) + } + r.held[seq] = heldChunk{payload: append([]byte(nil), payload...), last: last} + r.heldBytes += len(payload) + return nil +} + +// apply appends the expected chunk, then any held chunks that now follow +// in sequence, and returns the message once the last chunk lands. +func (r *Reassembler) apply(payload []byte, last bool) ([]byte, error) { + for { + if int(r.expected) > r.maxMessage { + return nil, fmt.Errorf("%w: declared %d bytes", ErrMessageTooLarge, r.expected) + } + if len(r.buf)+len(payload) > int(r.expected) { + return nil, fmt.Errorf("%w: %d bytes exceeds declared %d", + ErrLengthMismatch, len(r.buf)+len(payload), r.expected) + } + r.buf = append(r.buf, payload...) + r.nextSeq++ + if last { + if len(r.buf) != int(r.expected) { + return nil, fmt.Errorf("%w: got %d bytes, declared %d", ErrLengthMismatch, len(r.buf), r.expected) + } + if len(r.held) > 0 { + return nil, fmt.Errorf("%w: chunks beyond the last one", ErrSequence) + } + msg := append([]byte(nil), r.buf...) + r.reset() + return msg, nil + } + next, ok := r.held[r.nextSeq] + if !ok { + return nil, nil + } + delete(r.held, r.nextSeq) + r.heldBytes -= len(next.payload) + payload, last = next.payload, next.last + } +} diff --git a/pkg/bluetooth/apigatt/frame_fuzz_test.go b/pkg/bluetooth/apigatt/frame_fuzz_test.go new file mode 100644 index 000000000..ab170503e --- /dev/null +++ b/pkg/bluetooth/apigatt/frame_fuzz_test.go @@ -0,0 +1,90 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package apigatt + +import ( + "bytes" + "testing" + + "github.com/jonboulle/clockwork" +) + +// FuzzParseChunk checks that no input makes the header parser panic and that +// every accepted chunk survives an encode round trip. +func FuzzParseChunk(f *testing.F) { + good, _ := Chunker{MTU: 100, Tag: 7}.Split([]byte("seed message")) + for _, c := range good { + f.Add(c) + } + f.Add([]byte{}) + f.Add([]byte{0x10, 0, 0, 0}) + f.Add([]byte{0x11, 0, 0, 0, 0, 0, 0, 1, 'x'}) + + f.Fuzz(func(t *testing.T, chunk []byte) { + h, payload, err := ParseChunk(chunk) + if err != nil { + return + } + again, payload2, err := ParseChunk(EncodeChunk(h, payload)) + if err != nil { + t.Fatalf("re-encoded chunk failed to parse: %v", err) + } + if again != h || !bytes.Equal(payload2, payload) { + t.Fatalf("round trip changed the chunk: %+v vs %+v", again, h) + } + }) +} + +// FuzzReassembler feeds arbitrary chunk streams and checks the reassembler +// never panics, never holds more than its cap, and either fails or keeps +// going without contradiction. +func FuzzReassembler(f *testing.F) { + chunks, _ := Chunker{MTU: 23}.Split([]byte("a longer seed message that needs several chunks to carry")) + var stream []byte + for _, c := range chunks { + stream = append(stream, byte(len(c))) //nolint:gosec // an MTU-23 chunk is at most 20 bytes + stream = append(stream, c...) + } + f.Add(stream) + f.Add([]byte{9, 0x11, 0, 0, 0, 0, 0, 0, 1, 'x'}) + + f.Fuzz(func(t *testing.T, data []byte) { + r := NewReassembler(clockwork.NewFakeClock(), 4096) + for len(data) > 0 { + n := int(data[0]) + data = data[1:] + if n > len(data) { + n = len(data) + } + chunk := data[:n] + data = data[n:] + msg, err := r.Push(chunk) + if err != nil { + return + } + if len(msg) > 4096 { + t.Fatalf("reassembled %d bytes over the cap", len(msg)) + } + if len(r.buf)+r.heldBytes > 4096 { + t.Fatalf("holding %d bytes over the cap", len(r.buf)+r.heldBytes) + } + } + }) +} diff --git a/pkg/bluetooth/apigatt/frame_test.go b/pkg/bluetooth/apigatt/frame_test.go new file mode 100644 index 000000000..da601d39a --- /dev/null +++ b/pkg/bluetooth/apigatt/frame_test.go @@ -0,0 +1,427 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package apigatt + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestReassembler(t *testing.T) (*Reassembler, *clockwork.FakeClock) { + t.Helper() + clock := clockwork.NewFakeClock() + return NewReassembler(clock, 0), clock +} + +// feed pushes every chunk and returns the messages completed along the way. +func feed(t *testing.T, r *Reassembler, chunks [][]byte) [][]byte { + t.Helper() + var msgs [][]byte + for i, c := range chunks { + msg, err := r.Push(c) + require.NoError(t, err, "chunk %d", i) + if msg != nil { + msgs = append(msgs, msg) + } + } + return msgs +} + +func message(n int) []byte { + msg := make([]byte, n) + for i := range msg { + msg[i] = byte(i * 7) + } + return msg +} + +func TestChunkerRoundTrip(t *testing.T) { + t.Parallel() + + sizes := []int{1, 12, 13, 100, 1000, 65_000} + mtus := []int{0, 23, 185, 512} + for _, mtu := range mtus { + for _, size := range sizes { + t.Run(fmt.Sprintf("mtu%d_size%d", mtu, size), func(t *testing.T) { + t.Parallel() + msg := message(size) + chunks, err := Chunker{MTU: mtu, Tag: 0xbeef}.Split(msg) + require.NoError(t, err) + + effective := max(mtu, DefaultMTU) + for i, c := range chunks { + assert.LessOrEqual(t, len(c), effective-attHeaderSize, "chunk %d exceeds MTU", i) + h, _, err := ParseChunk(c) + require.NoError(t, err) + assert.Equal(t, uint16(0xbeef), h.Tag) + assert.Equal(t, uint8(i), h.Seq) + assert.Equal(t, i == 0, h.First) + assert.Equal(t, i == len(chunks)-1, h.Last) + } + + r, _ := newTestReassembler(t) + msgs := feed(t, r, chunks) + require.Len(t, msgs, 1) + assert.Equal(t, msg, msgs[0]) + assert.Equal(t, 0, r.errors) + }) + } + } +} + +func TestChunkerRejectsEmptyAndOversize(t *testing.T) { + t.Parallel() + + _, err := Chunker{}.Split(nil) + require.ErrorIs(t, err, ErrEmptyMessage) + _, err = Chunker{}.Split(make([]byte, MaxMessageSize+1)) + require.ErrorIs(t, err, ErrMessageTooLarge) + chunks, err := Chunker{MTU: 512}.Split(make([]byte, MaxMessageSize)) + require.NoError(t, err) + assert.NotEmpty(t, chunks) +} + +func TestChunkerSequenceWraps(t *testing.T) { + t.Parallel() + + // At MTU 23 a 12-byte first payload and 16-byte follow-ups need well + // over 256 chunks for 8 KiB, so the sequence byte wraps mid-message. + msg := message(8 * 1024) + chunks, err := Chunker{MTU: 23}.Split(msg) + require.NoError(t, err) + require.Greater(t, len(chunks), 256) + r, _ := newTestReassembler(t) + msgs := feed(t, r, chunks) + require.Len(t, msgs, 1) + assert.Equal(t, msg, msgs[0]) +} + +func TestReassemblerBackToBackMessages(t *testing.T) { + t.Parallel() + + r, _ := newTestReassembler(t) + var all [][]byte + for _, size := range []int{5, 300, 1, 40} { + chunks, err := Chunker{MTU: 50}.Split(message(size)) + require.NoError(t, err) + all = append(all, chunks...) + } + msgs := feed(t, r, all) + require.Len(t, msgs, 4) + assert.Equal(t, message(300), msgs[1]) + assert.Equal(t, message(40), msgs[3]) +} + +func TestReassemblerReorderWithinWindow(t *testing.T) { + t.Parallel() + + msg := message(200) + chunks, err := Chunker{MTU: 30}.Split(msg) + require.NoError(t, err) + require.GreaterOrEqual(t, len(chunks), 5) + + // Deliver 2,1,0 then 4,3 then the rest: every displacement stays inside + // the window. + order := make([]int, 0, len(chunks)) + order = append(order, 2, 1, 0, 4, 3) + for i := 5; i < len(chunks); i++ { + order = append(order, i) + } + reordered := make([][]byte, 0, len(chunks)) + for _, i := range order { + reordered = append(reordered, chunks[i]) + } + + r, _ := newTestReassembler(t) + msgs := feed(t, r, reordered) + require.Len(t, msgs, 1) + assert.Equal(t, msg, msgs[0]) + assert.Equal(t, 0, r.errors) +} + +func TestReassemblerRejectsBeyondWindow(t *testing.T) { + t.Parallel() + + chunks, err := Chunker{MTU: 23}.Split(message(2000)) + require.NoError(t, err) + + r, _ := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + // Chunk 0 was applied, so chunk 1 is expected next. + inside := EncodeChunk(Header{Version: ProtocolVersion, Seq: 1 + ReorderWindow - 1}, []byte("x")) + _, err = r.Push(inside) + require.NoError(t, err, "the last position inside the window is held") + beyond := EncodeChunk(Header{Version: ProtocolVersion, Seq: 1 + ReorderWindow}, []byte("x")) + _, err = r.Push(beyond) + require.ErrorIs(t, err, ErrSequence) +} + +func TestReassemblerRestartDropsHeldChunks(t *testing.T) { + t.Parallel() + + // Chunk 2 of an abandoned message must not be spliced into, or fail, + // the message the peer starts afresh. + chunks, err := Chunker{MTU: 23}.Split(message(100)) + require.NoError(t, err) + r, _ := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + _, err = r.Push(chunks[2]) + require.NoError(t, err) + require.Len(t, r.held, 1) + + fresh, err := Chunker{MTU: 23}.Split(message(40)) + require.NoError(t, err) + require.Len(t, fresh, 3) + msgs := feed(t, r, fresh) + require.Len(t, msgs, 1) + assert.Equal(t, message(40), msgs[0]) + assert.Equal(t, 1, r.errors) + assert.Empty(t, r.held) +} + +func TestReassemblerRejectsDuplicateChunk(t *testing.T) { + t.Parallel() + + chunks, err := Chunker{MTU: 23}.Split(message(200)) + require.NoError(t, err) + + r, _ := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + _, err = r.Push(chunks[2]) + require.NoError(t, err) + _, err = r.Push(chunks[2]) + require.ErrorIs(t, err, ErrSequence) + + r, _ = newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + _, err = r.Push(chunks[1]) + require.NoError(t, err) + _, err = r.Push(chunks[1]) + require.ErrorIs(t, err, ErrSequence, "a chunk already applied is behind the window") +} + +func TestReassemblerLengthMismatch(t *testing.T) { + t.Parallel() + + t.Run("last chunk arrives short", func(t *testing.T) { + t.Parallel() + chunks, err := Chunker{MTU: 23}.Split(message(100)) + require.NoError(t, err) + r, _ := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + // Forge a LAST chunk with sequence 1 that ends the message early. + short := EncodeChunk(Header{Version: ProtocolVersion, Seq: 1, Last: true}, []byte("x")) + _, err = r.Push(short) + require.ErrorIs(t, err, ErrLengthMismatch) + }) + + t.Run("payload overruns declared length", func(t *testing.T) { + t.Parallel() + r, _ := newTestReassembler(t) + first := EncodeChunk(Header{Version: ProtocolVersion, Seq: 0, First: true, Length: 3}, []byte("ab")) + _, err := r.Push(first) + require.NoError(t, err) + over := EncodeChunk(Header{Version: ProtocolVersion, Seq: 1}, []byte("cde")) + _, err = r.Push(over) + require.ErrorIs(t, err, ErrLengthMismatch) + }) +} + +func TestReassemblerCaps(t *testing.T) { + t.Parallel() + + t.Run("declared length over the cap", func(t *testing.T) { + t.Parallel() + r := NewReassembler(clockwork.NewFakeClock(), 64) + first := EncodeChunk(Header{Version: ProtocolVersion, First: true, Length: 65}, []byte("a")) + _, err := r.Push(first) + require.ErrorIs(t, err, ErrMessageTooLarge) + }) + + t.Run("declared length over the protocol maximum is malformed at parse", func(t *testing.T) { + t.Parallel() + r, _ := newTestReassembler(t) + first := EncodeChunk(Header{Version: ProtocolVersion, First: true, Length: MaxMessageSize + 1}, []byte("a")) + _, err := r.Push(first) + require.ErrorIs(t, err, ErrMessageTooLarge) + }) + + t.Run("held chunks count toward the cap", func(t *testing.T) { + t.Parallel() + r := NewReassembler(clockwork.NewFakeClock(), 32) + first := EncodeChunk(Header{Version: ProtocolVersion, First: true, Length: 32}, bytes.Repeat([]byte("a"), 10)) + _, err := r.Push(first) + require.NoError(t, err) + held := EncodeChunk(Header{Version: ProtocolVersion, Seq: 2}, bytes.Repeat([]byte("b"), 30)) + _, err = r.Push(held) + require.ErrorIs(t, err, ErrMessageTooLarge) + }) +} + +func TestReassemblerMalformedChunks(t *testing.T) { + t.Parallel() + + good := EncodeChunk(Header{Version: ProtocolVersion, First: true, Last: true, Length: 1}, []byte("a")) + tests := []struct { + want error + name string + chunk []byte + }{ + {name: "too short", chunk: good[:3], want: ErrMalformedChunk}, + {name: "first header truncated", chunk: good[:6], want: ErrMalformedChunk}, + {name: "empty payload", chunk: good[:FirstHeaderSize], want: ErrMalformedChunk}, + {name: "wrong version", chunk: append([]byte{0x23}, good[1:]...), want: ErrMalformedChunk}, + {name: "reserved bits", chunk: append([]byte{good[0] | 0x04}, good[1:]...), want: ErrMalformedChunk}, + { + name: "zero length", + chunk: EncodeChunk(Header{Version: ProtocolVersion, First: true, Last: true, Length: 0}, []byte("a")), + want: ErrMalformedChunk, + }, + { + name: "first chunk with non-zero sequence", + chunk: EncodeChunk( + Header{Version: ProtocolVersion, First: true, Last: true, Length: 1, Seq: 5}, []byte("a"), + ), + want: ErrMalformedChunk, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r, _ := newTestReassembler(t) + _, err := r.Push(tt.chunk) + require.ErrorIs(t, err, tt.want) + }) + } +} + +func TestReassemblerRecoverableErrorsAreBudgeted(t *testing.T) { + t.Parallel() + + t.Run("first chunk mid-message restarts", func(t *testing.T) { + t.Parallel() + chunks, err := Chunker{MTU: 23}.Split(message(100)) + require.NoError(t, err) + r, _ := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + // Start over with a complete one-chunk message: it wins. + fresh, err := Chunker{MTU: 23}.Split([]byte("hi")) + require.NoError(t, err) + msg, err := r.Push(fresh[0]) + require.NoError(t, err) + assert.Equal(t, []byte("hi"), msg) + assert.Equal(t, 1, r.errors) + }) + + t.Run("stray chunks exhaust the budget", func(t *testing.T) { + t.Parallel() + r, _ := newTestReassembler(t) + stray := EncodeChunk(Header{Version: ProtocolVersion, Seq: 200}, []byte("x")) + for i := 1; i < MaxProtocolErrors; i++ { + _, err := r.Push(stray) + require.NoError(t, err, "mistake %d is still tolerated", i) + assert.Equal(t, i, r.errors) + } + _, err := r.Push(stray) + require.ErrorIs(t, err, ErrTooManyErrors) + }) + + t.Run("sequence zero without first flag", func(t *testing.T) { + t.Parallel() + r, _ := newTestReassembler(t) + bad := EncodeChunk(Header{Version: ProtocolVersion, Seq: 0}, []byte("x")) + _, err := r.Push(bad) + require.NoError(t, err) + assert.Equal(t, 1, r.errors) + }) +} + +func TestReassemblerEarlyChunkBeforeFirst(t *testing.T) { + t.Parallel() + + // BlueZ may hand us chunk 1 before chunk 0; it must be held, not lost. + chunks, err := Chunker{MTU: 23}.Split(message(40)) + require.NoError(t, err) + require.Len(t, chunks, 3) + + r, _ := newTestReassembler(t) + msgs := feed(t, r, [][]byte{chunks[1], chunks[0], chunks[2]}) + require.Len(t, msgs, 1) + assert.Equal(t, message(40), msgs[0]) + assert.Equal(t, 0, r.errors) +} + +func TestReassemblerIdlePartialIsDiscarded(t *testing.T) { + t.Parallel() + + chunks, err := Chunker{MTU: 23}.Split(message(100)) + require.NoError(t, err) + + r, clock := newTestReassembler(t) + _, err = r.Push(chunks[0]) + require.NoError(t, err) + + clock.Advance(PartialIdleTimeout + time.Millisecond) + + // The stale partial is gone: a fresh message completes cleanly and the + // restart is not charged as a mistake. + fresh, err := Chunker{MTU: 23}.Split([]byte("hello")) + require.NoError(t, err) + msg, err := r.Push(fresh[0]) + require.NoError(t, err) + assert.Equal(t, []byte("hello"), msg) + assert.Equal(t, 0, r.errors) + + // The continuation of the stale message looks like an early chunk of + // the next message, so it is held rather than charged, and forgotten + // once it too goes idle. + msg, err = r.Push(chunks[1]) + require.NoError(t, err) + assert.Nil(t, msg) + assert.Equal(t, 0, r.errors) + assert.Len(t, r.held, 1) + clock.Advance(PartialIdleTimeout + time.Millisecond) + msg, err = r.Push(fresh[0]) + require.NoError(t, err) + assert.Equal(t, []byte("hello"), msg) + assert.Empty(t, r.held) +} + +func TestNewInfo(t *testing.T) { + t.Parallel() + + info := NewInfo("device-1") + assert.Equal(t, "device-1", info.DeviceID) + assert.Equal(t, ProtocolVersion, info.Version) + assert.Equal(t, MaxMessageSize, info.MaxMessage) + assert.Equal(t, PreferredMTU, info.PreferredMTU) +} diff --git a/pkg/bluetooth/apigatt/uuids.go b/pkg/bluetooth/apigatt/uuids.go new file mode 100644 index 000000000..19dbe2317 --- /dev/null +++ b/pkg/bluetooth/apigatt/uuids.go @@ -0,0 +1,95 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package apigatt defines the GATT contract the Zaparoo app uses to reach +// the JSON-RPC API over Bluetooth Low Energy: the service and characteristic +// UUIDs, the limits, and the framing that carries one API message across +// many small ATT packets. It has no dependency on the API itself so the +// framing can be fuzzed and reused on its own. +package apigatt + +import "time" + +// Service and characteristic UUIDs. The app must use the same values; they +// are fixed for the life of protocol version 1. +const ( + // ServiceUUID is the primary service the app scans for. + ServiceUUID = "0da70001-b359-443b-836f-477d34b6a638" + // RXCharUUID carries chunks from the app to Core (write, write without + // response). + RXCharUUID = "0da70002-b359-443b-836f-477d34b6a638" + // TXCharUUID carries chunks from Core to the app (notify). + TXCharUUID = "0da70003-b359-443b-836f-477d34b6a638" + // InfoCharUUID is a read-only JSON description of the endpoint the app + // reads before it speaks (see Info). + InfoCharUUID = "0da70004-b359-443b-836f-477d34b6a638" +) + +const ( + // ProtocolVersion is carried in every chunk header. + ProtocolVersion = 1 + + // MaxMessageSize caps one reassembled message in either direction. It + // is far below the WebSocket limit because a BLE link moves tens of + // kilobytes per second at best. + MaxMessageSize = 256 * 1024 + + // ReorderWindow is how far ahead of the expected chunk a chunk may + // arrive and still be held. BlueZ hands each write to us on its own + // goroutine, so reorders happen; the window is half the sequence space, + // which is the most that still tells "ahead" from "already seen". + // Memory is bounded by MaxMessageSize, not by the window. + ReorderWindow = 128 + + // MaxProtocolErrors is how many recoverable framing mistakes one + // connection may make before it is dropped. + MaxProtocolErrors = 3 + + // PartialIdleTimeout is how long a half-received message is kept before + // the next chunk starts over. + PartialIdleTimeout = 5 * time.Second + + // DefaultMTU is the ATT MTU every link starts with; a peer that never + // negotiated a larger one gets 20-byte chunks. + DefaultMTU = 23 + + // attHeaderSize is what ATT itself takes from every packet. + attHeaderSize = 3 + + // PreferredMTU is what the Info characteristic suggests the app request. + PreferredMTU = 512 +) + +// Info is the JSON document served by InfoCharUUID. +type Info struct { + DeviceID string `json:"deviceId"` + Version int `json:"v"` + MaxMessage int `json:"maxMessage"` + PreferredMTU int `json:"preferredMtu"` +} + +// NewInfo describes this endpoint for the given device. +func NewInfo(deviceID string) Info { + return Info{ + DeviceID: deviceID, + Version: ProtocolVersion, + MaxMessage: MaxMessageSize, + PreferredMTU: PreferredMTU, + } +} diff --git a/pkg/bluetooth/bluez/bluez.go b/pkg/bluetooth/bluez/bluez.go new file mode 100644 index 000000000..0b828c1d6 --- /dev/null +++ b/pkg/bluetooth/bluez/bluez.go @@ -0,0 +1,245 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package bluez is a thin layer over the BlueZ D-Bus API. It exposes the two +// Bluetooth Low Energy roles Core needs: a peripheral (GATT server plus +// advertising, used by the app transport) and a central (scan, connect, +// subscribe, used by reader drivers). Only Linux has an implementation; the +// other platforms report ErrUnsupported. +package bluez + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "time" +) + +var ( + // ErrUnsupported is returned on platforms without a BlueZ implementation. + ErrUnsupported = errors.New("bluez: not supported on this platform") + // ErrUnavailable is returned when the system bus or bluetoothd cannot be + // reached. + ErrUnavailable = errors.New("bluez: bluetoothd not reachable") + // ErrNoAdapter is returned when bluetoothd is running but no adapter is + // plugged in. + ErrNoAdapter = errors.New("bluez: no bluetooth adapter") + // ErrRoleUnsupported is returned when the adapter cannot take the + // requested role. + ErrRoleUnsupported = errors.New("bluez: adapter does not support this role") + // ErrNotFound is returned when a remote device, service, or + // characteristic is not present. + ErrNotFound = errors.New("bluez: not found") +) + +// Role is a BLE link-layer role an adapter can take. +type Role string + +const ( + // RoleCentral scans and initiates connections. + RoleCentral Role = "central" + // RolePeripheral advertises and accepts connections. + RolePeripheral Role = "peripheral" +) + +// Characteristic flags, as BlueZ names them. +const ( + FlagRead = "read" + FlagWrite = "write" + FlagWriteWithoutResponse = "write-without-response" + FlagNotify = "notify" +) + +// DefaultCallTimeout bounds a single D-Bus round trip. +const DefaultCallTimeout = 3 * time.Second + +// NormalizeAddress validates a Bluetooth address and returns it in the +// upper-case colon form BlueZ uses. +func NormalizeAddress(address string) (string, error) { + hw, err := net.ParseMAC(strings.TrimSpace(address)) + if err != nil || len(hw) != 6 { + return "", fmt.Errorf("invalid bluetooth address %q", address) + } + return strings.ToUpper(hw.String()), nil +} + +// SupportsRole reports whether roles allows role. An empty list means the +// adapter did not say, which callers treat as "try it". +func SupportsRole(roles []Role, role Role) bool { + if len(roles) == 0 { + return true + } + for _, r := range roles { + if r == role || r == RoleCentral+"-"+RolePeripheral { + return true + } + } + return false +} + +// Option configures Open. +type Option func(*options) + +type options struct { + busAddress string + callTimeout time.Duration + powerOn bool +} + +// WithBusAddress connects to the bus at addr instead of the system bus. Tests +// use it to point the layer at a private bus hosting a fake bluetoothd. +func WithBusAddress(addr string) Option { + return func(o *options) { o.busAddress = addr } +} + +// WithPowerOn powers the adapter on if it is off. Only a caller acting on +// an explicit user choice should ask for this; a background retry must not +// keep switching a radio back on that the user turned off. +func WithPowerOn() Option { + return func(o *options) { o.powerOn = true } +} + +// WithCallTimeout overrides DefaultCallTimeout. +func WithCallTimeout(d time.Duration) Option { + return func(o *options) { o.callTimeout = d } +} + +func applyOptions(opts []Option) options { + o := options{callTimeout: DefaultCallTimeout} + for _, opt := range opts { + opt(&o) + } + if o.callTimeout <= 0 { + o.callTimeout = DefaultCallTimeout + } + return o +} + +// Adapter is one local Bluetooth controller. +type Adapter interface { + // Address is the controller's own Bluetooth address. + Address() string + // Roles lists what the controller supports; empty when BlueZ did not + // report it. + Roles() []Role + // Peripheral returns the GATT server and advertising side. + Peripheral() (Peripheral, error) + // Central returns the scanning and connecting side. + Central() (Central, error) + // Gone is closed when the adapter disappears or the bus connection + // drops, so owners can start over. + Gone() <-chan struct{} + // Close releases the bus connection. Everything obtained from the + // adapter stops working. + Close() error +} + +// Peer identifies a remote central connected to the local GATT server. +type Peer struct { + // Path is the BlueZ Device1 object path, unique per connection. + Path string + // Address is the peer's Bluetooth address as BlueZ reports it, which + // for phones is usually a rotating private address. + Address string +} + +// Characteristic describes one characteristic of a local service. +type Characteristic struct { + UUID string + Flags []string +} + +// Service describes one local GATT service. +type Service struct { + UUID string + Characteristics []Characteristic + Primary bool +} + +// Application is the set of local services registered together. +type Application struct { + Services []Service +} + +// Advertisement describes what the peripheral broadcasts. It is always a +// connectable advertisement. +type Advertisement struct { + LocalName string + ServiceUUIDs []string +} + +// PeripheralHandler receives GATT server events. Calls for one peer may +// arrive out of order because BlueZ delivers each one on its own goroutine; +// consumers must tolerate that. +type PeripheralHandler interface { + // OnWrite is called once per write to a characteristic. mtu is the + // negotiated ATT MTU when BlueZ reports it, otherwise 0. + OnWrite(peer Peer, charUUID string, value []byte, mtu int) + // OnRead returns the value a peer reads from a characteristic. + OnRead(peer Peer, charUUID string) ([]byte, error) + // OnSubscribe reports notification subscriptions. BlueZ does not say + // which peer subscribed, so peer is zero-valued. + OnSubscribe(peer Peer, charUUID string, subscribed bool) + // OnDisconnect is called when a peer's connection ends. + OnDisconnect(peer Peer) +} + +// Peripheral is the GATT server and advertising side of an adapter. +type Peripheral interface { + // Serve registers the application and advertisement, then blocks until + // ctx ends or the adapter is gone. Both are unregistered on return. + Serve(ctx context.Context, app Application, adv Advertisement, h PeripheralHandler) error + // Notify sends value to every peer subscribed to the characteristic. + // BlueZ fans notifications out; callers tag the payload if they need + // to address one peer. + Notify(charUUID string, value []byte) error + // Disconnect drops a peer's connection. + Disconnect(ctx context.Context, peer Peer) error +} + +// Central is the scanning and connecting side of an adapter. +type Central interface { + // Find scans for the device with the given address, filtering on the + // service UUIDs, until it appears or ctx ends. + Find(ctx context.Context, address string, serviceUUIDs []string) (Device, error) +} + +// Device is a remote peripheral. +type Device interface { + Address() string + // Connect connects and waits for service discovery to finish. + Connect(ctx context.Context) error + Disconnect(ctx context.Context) error + // Characteristic looks up a characteristic of a resolved service. + Characteristic(serviceUUID, charUUID string) (RemoteCharacteristic, error) + // Disconnected is closed once the connection has ended. + Disconnected() <-chan struct{} +} + +// RemoteCharacteristic is one characteristic on a connected device. +type RemoteCharacteristic interface { + // Subscribe enables notifications and streams their values until ctx + // ends or the device disconnects, after which the channel is closed. + Subscribe(ctx context.Context) (<-chan []byte, error) + // Write writes the value, with or without waiting for the peripheral's + // acknowledgement. + Write(ctx context.Context, value []byte, withResponse bool) error +} diff --git a/pkg/bluetooth/bluez/bluez_linux.go b/pkg/bluetooth/bluez/bluez_linux.go new file mode 100644 index 000000000..231c4ea0a --- /dev/null +++ b/pkg/bluetooth/bluez/bluez_linux.go @@ -0,0 +1,451 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/godbus/dbus/v5" + "github.com/rs/zerolog/log" +) + +const ( + bluezService = "org.bluez" + + adapterIface = "org.bluez.Adapter1" + deviceIface = "org.bluez.Device1" + gattServiceIface = "org.bluez.GattService1" + gattCharIface = "org.bluez.GattCharacteristic1" + gattManagerIface = "org.bluez.GattManager1" + advManagerIface = "org.bluez.LEAdvertisingManager1" + advIface = "org.bluez.LEAdvertisement1" + objectManagerIface = "org.freedesktop.DBus.ObjectManager" + propertiesIface = "org.freedesktop.DBus.Properties" + + signalPropertiesChanged = propertiesIface + ".PropertiesChanged" + signalInterfacesAdded = objectManagerIface + ".InterfacesAdded" + signalInterfacesRemoved = objectManagerIface + ".InterfacesRemoved" + + bluezRootPath = dbus.ObjectPath("/") + bluezPathPrefix = dbus.ObjectPath("/org/bluez") + + // signalBuffer is how many signals one subscriber may fall behind before + // the router starts dropping for it. + signalBuffer = 256 +) + +// managedObjects is the shape of ObjectManager.GetManagedObjects. +type managedObjects = map[dbus.ObjectPath]map[string]map[string]dbus.Variant + +// adapter is the Linux Adapter: one private system-bus connection and the +// Adapter1 object it drives. +type adapter struct { + conn *dbus.Conn + obj dbus.BusObject + signals *signalRouter + gone chan struct{} + path dbus.ObjectPath + address string + roles []Role + callTimeout time.Duration + goneOnce sync.Once + closeOnce sync.Once +} + +// Open connects to bluetoothd on a private bus connection and selects an +// adapter: the lowest-numbered powered one, or the lowest-numbered one at +// all when none is powered. With WithPowerOn a powered-off choice is +// switched on. +func Open(ctx context.Context, opts ...Option) (Adapter, error) { + o := applyOptions(opts) + + conn, err := connect(o) + if err != nil { + return nil, err + } + + a := &adapter{ + conn: conn, + gone: make(chan struct{}), + callTimeout: o.callTimeout, + } + if err := a.selectAdapter(ctx, o.powerOn); err != nil { + _ = conn.Close() + return nil, err + } + if err := a.watchSignals(ctx); err != nil { + _ = conn.Close() + return nil, err + } + return a, nil +} + +// connect opens and authenticates a private bus connection with sequential +// signal delivery, so property changes arrive in the order BlueZ sent them. +func connect(o options) (*dbus.Conn, error) { + handler := dbus.WithSignalHandler(dbus.NewSequentialSignalHandler()) + var ( + conn *dbus.Conn + err error + ) + if o.busAddress != "" { + conn, err = dbus.Dial(o.busAddress, handler) + } else { + conn, err = dbus.SystemBusPrivate(handler) + } + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrUnavailable, err) + } + if err := conn.Auth(nil); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("%w: auth: %w", ErrUnavailable, err) + } + if err := conn.Hello(); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("%w: hello: %w", ErrUnavailable, err) + } + return conn, nil +} + +// selectAdapter finds the adapter to use and reads its properties. +func (a *adapter) selectAdapter(ctx context.Context, powerOn bool) error { + objs, err := a.managedObjects(ctx) + if err != nil { + return err + } + + paths := make([]string, 0, len(objs)) + for path, ifaces := range objs { + if _, ok := ifaces[adapterIface]; ok { + paths = append(paths, string(path)) + } + } + if len(paths) == 0 { + return ErrNoAdapter + } + sort.Strings(paths) + chosen := paths[0] + for _, path := range paths { + if powered, ok := objs[dbus.ObjectPath(path)][adapterIface]["Powered"].Value().(bool); ok && powered { + chosen = path + break + } + } + a.path = dbus.ObjectPath(chosen) + a.obj = a.conn.Object(bluezService, a.path) + + props := objs[a.path][adapterIface] + a.address = stringProp(props, "Address") + if roles, ok := props["Roles"].Value().([]string); ok { + for _, r := range roles { + a.roles = append(a.roles, Role(r)) + } + } + + if powered, ok := props["Powered"].Value().(bool); ok && !powered && powerOn { + log.Info().Str("adapter", string(a.path)).Msg("bluetooth adapter is powered off, powering on") + if err := a.setProperty(ctx, a.obj, adapterIface, "Powered", true); err != nil { + log.Warn().Err(err).Msg("failed to power on bluetooth adapter") + } + } + return nil +} + +// watchSignals subscribes to everything the roles need and starts the router. +// The adapter is marked gone when its object is removed or the bus drops. +func (a *adapter) watchSignals(ctx context.Context) error { + matches := [][]dbus.MatchOption{ + { + dbus.WithMatchInterface(propertiesIface), + dbus.WithMatchMember("PropertiesChanged"), + dbus.WithMatchPathNamespace(bluezPathPrefix), + }, + { + dbus.WithMatchInterface(objectManagerIface), + dbus.WithMatchMember("InterfacesAdded"), + dbus.WithMatchObjectPath(bluezRootPath), + }, + { + dbus.WithMatchInterface(objectManagerIface), + dbus.WithMatchMember("InterfacesRemoved"), + dbus.WithMatchObjectPath(bluezRootPath), + }, + } + for _, m := range matches { + if err := a.conn.AddMatchSignalContext(ctx, m...); err != nil { + return fmt.Errorf("add signal match: %w", err) + } + } + + in := make(chan *dbus.Signal, signalBuffer) + a.conn.Signal(in) + a.signals = newSignalRouter() + go a.signals.run(in) + + removed, unsubscribe := a.signals.subscribe(func(sig *dbus.Signal) bool { + path, _, ok := interfacesRemoved(sig) + return ok && path == a.path + }) + go func() { + defer unsubscribe() + select { + case <-removed: + log.Warn().Str("adapter", string(a.path)).Msg("bluetooth adapter removed") + case <-a.conn.Context().Done(): + log.Warn().Msg("bluetooth bus connection closed") + case <-a.gone: + return + } + a.markGone() + }() + return nil +} + +func (a *adapter) markGone() { + a.goneOnce.Do(func() { close(a.gone) }) +} + +func (a *adapter) Address() string { return a.address } + +func (a *adapter) Roles() []Role { return append([]Role(nil), a.roles...) } + +func (a *adapter) Gone() <-chan struct{} { return a.gone } + +func (a *adapter) Peripheral() (Peripheral, error) { + if !SupportsRole(a.roles, RolePeripheral) { + return nil, fmt.Errorf("%w: %s", ErrRoleUnsupported, RolePeripheral) + } + return newPeripheral(a), nil +} + +func (a *adapter) Central() (Central, error) { + if !SupportsRole(a.roles, RoleCentral) { + return nil, fmt.Errorf("%w: %s", ErrRoleUnsupported, RoleCentral) + } + return ¢ral{a: a}, nil +} + +func (a *adapter) Close() error { + var err error + a.closeOnce.Do(func() { + a.markGone() + a.signals.close() + if closeErr := a.conn.Close(); closeErr != nil { + err = fmt.Errorf("close bus connection: %w", closeErr) + } + }) + return err +} + +// callCtx bounds one D-Bus round trip by the caller's context and the call +// timeout, whichever ends first. +func (a *adapter) callCtx(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, a.callTimeout) +} + +// managedObjects fetches everything bluetoothd exports. +func (a *adapter) managedObjects(ctx context.Context) (managedObjects, error) { + cctx, cancel := a.callCtx(ctx) + defer cancel() + var objs managedObjects + call := a.conn.Object(bluezService, bluezRootPath). + CallWithContext(cctx, objectManagerIface+".GetManagedObjects", 0) + if call.Err != nil { + return nil, mapBusError("get managed objects", call.Err) + } + if err := call.Store(&objs); err != nil { + return nil, fmt.Errorf("decode managed objects: %w", err) + } + return objs, nil +} + +// getProperty reads one property of a BlueZ object. +func (a *adapter) getProperty(ctx context.Context, obj dbus.BusObject, iface, name string) (dbus.Variant, error) { + cctx, cancel := a.callCtx(ctx) + defer cancel() + var v dbus.Variant + call := obj.CallWithContext(cctx, propertiesIface+".Get", 0, iface, name) + if call.Err != nil { + return dbus.Variant{}, mapBusError("get "+iface+"."+name, call.Err) + } + if err := call.Store(&v); err != nil { + return dbus.Variant{}, fmt.Errorf("decode %s.%s: %w", iface, name, err) + } + return v, nil +} + +// setProperty writes one property of a BlueZ object. +func (a *adapter) setProperty(ctx context.Context, obj dbus.BusObject, iface, name string, value any) error { + cctx, cancel := a.callCtx(ctx) + defer cancel() + call := obj.CallWithContext(cctx, propertiesIface+".Set", 0, iface, name, dbus.MakeVariant(value)) + if call.Err != nil { + return mapBusError("set "+iface+"."+name, call.Err) + } + return nil +} + +// call invokes a method on a BlueZ object with the standard timeout. +func (a *adapter) call(ctx context.Context, obj dbus.BusObject, method string, args ...any) error { + cctx, cancel := a.callCtx(ctx) + defer cancel() + if call := obj.CallWithContext(cctx, method, 0, args...); call.Err != nil { + return mapBusError(method, call.Err) + } + return nil +} + +// mapBusError turns the D-Bus errors that mean "bluetoothd is not there" +// into ErrUnavailable so callers can back off instead of retrying hot. +func mapBusError(op string, err error) error { + var dbusErr dbus.Error + if errors.As(err, &dbusErr) { + switch dbusErr.Name { + case "org.freedesktop.DBus.Error.ServiceUnknown", + "org.freedesktop.DBus.Error.NameHasNoOwner", + "org.freedesktop.DBus.Error.NoReply": + return fmt.Errorf("%w: %s: %w", ErrUnavailable, op, err) + case "org.freedesktop.DBus.Error.UnknownObject": + return fmt.Errorf("%w: %s: %w", ErrNotFound, op, err) + } + } + return fmt.Errorf("%s: %w", op, err) +} + +// signalRouter fans one bus signal stream out to interested subscribers. +type signalRouter struct { + subs map[int]*signalSub + mu syncutil.Mutex + next int + closed bool +} + +type signalSub struct { + ch chan *dbus.Signal + match func(*dbus.Signal) bool +} + +func newSignalRouter() *signalRouter { + return &signalRouter{subs: make(map[int]*signalSub)} +} + +// subscribe returns a channel receiving every signal match accepts, and a +// function that ends the subscription and closes the channel. +func (r *signalRouter) subscribe(match func(*dbus.Signal) bool) (events <-chan *dbus.Signal, cancel func()) { + r.mu.Lock() + defer r.mu.Unlock() + id := r.next + r.next++ + sub := &signalSub{ch: make(chan *dbus.Signal, signalBuffer), match: match} + if r.closed { + close(sub.ch) + return sub.ch, func() {} + } + r.subs[id] = sub + var once sync.Once + return sub.ch, func() { + once.Do(func() { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.subs[id]; ok { + delete(r.subs, id) + close(sub.ch) + } + }) + } +} + +// run delivers signals until the input channel closes. A subscriber that +// has fallen signalBuffer signals behind loses the newest one rather than +// stalling everyone else. +func (r *signalRouter) run(in <-chan *dbus.Signal) { + for sig := range in { + r.mu.Lock() + for _, sub := range r.subs { + if !sub.match(sig) { + continue + } + select { + case sub.ch <- sig: + default: + log.Warn().Str("signal", sig.Name).Msg("bluetooth signal subscriber is behind, dropping signal") + } + } + r.mu.Unlock() + } + r.close() +} + +// close ends every subscription. +func (r *signalRouter) close() { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + r.closed = true + for id, sub := range r.subs { + delete(r.subs, id) + close(sub.ch) + } +} + +// devicePathPrefix is where BlueZ puts remote devices of this adapter. +func (a *adapter) devicePathPrefix() string { + return string(a.path) + "/dev_" +} + +// addressFromPath recovers the Bluetooth address from a Device1 object path. +func addressFromPath(path dbus.ObjectPath) string { + s := string(path) + idx := strings.LastIndex(s, "/dev_") + if idx < 0 { + return "" + } + return strings.ReplaceAll(s[idx+len("/dev_"):], "_", ":") +} + +// stringProp reads a string property from a property map, or "" when it is +// absent or not a string. +func stringProp(props map[string]dbus.Variant, name string) string { + v, ok := props[name] + if !ok { + return "" + } + s, ok := v.Value().(string) + if !ok { + return "" + } + return s +} + +// peerFromPath builds the Peer for a Device1 object path. +func peerFromPath(path dbus.ObjectPath) Peer { + return Peer{Path: string(path), Address: addressFromPath(path)} +} diff --git a/pkg/bluetooth/bluez/bluez_other.go b/pkg/bluetooth/bluez/bluez_other.go new file mode 100644 index 000000000..fcb5470b1 --- /dev/null +++ b/pkg/bluetooth/bluez/bluez_other.go @@ -0,0 +1,31 @@ +//go:build !linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import "context" + +// Open reports ErrUnsupported: only Linux has a BlueZ implementation. The +// options are still validated so callers see the same errors everywhere. +func Open(_ context.Context, opts ...Option) (Adapter, error) { + _ = applyOptions(opts) + return nil, ErrUnsupported +} diff --git a/pkg/bluetooth/bluez/central_linux.go b/pkg/bluetooth/bluez/central_linux.go new file mode 100644 index 000000000..032846e0d --- /dev/null +++ b/pkg/bluetooth/bluez/central_linux.go @@ -0,0 +1,369 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/godbus/dbus/v5" + "github.com/rs/zerolog/log" +) + +const ( + // connectTimeout bounds Device1.Connect, which bluetoothd may hold for + // its own connection attempt window. + connectTimeout = 30 * time.Second + + // notifyBuffer is how many notifications a subscriber may leave unread + // before the stream blocks bluetoothd's signal delivery to us. + notifyBuffer = 64 +) + +// central is the Linux Central. +type central struct { + a *adapter +} + +// Find scans until the device with the given address appears. +func (c *central) Find(ctx context.Context, address string, serviceUUIDs []string) (Device, error) { + addr, err := NormalizeAddress(address) + if err != nil { + return nil, err + } + + if dev, err := c.knownDevice(ctx, addr); err != nil || dev != nil { + return dev, err + } + + prefix := c.a.devicePathPrefix() + added, unsubscribe := c.a.signals.subscribe(func(sig *dbus.Signal) bool { + path, ifaces, ok := interfacesAdded(sig) + if !ok { + return false + } + _, isDevice := ifaces[deviceIface] + return isDevice && strings.HasPrefix(string(path), prefix) + }) + defer unsubscribe() + + filter := map[string]dbus.Variant{"Transport": dbus.MakeVariant("le")} + if len(serviceUUIDs) > 0 { + filter["UUIDs"] = dbus.MakeVariant(serviceUUIDs) + } + if err := c.a.call(ctx, c.a.obj, adapterIface+".SetDiscoveryFilter", filter); err != nil { + return nil, err + } + if err := c.a.call(ctx, c.a.obj, adapterIface+".StartDiscovery"); err != nil { + return nil, err + } + defer c.stopDiscovery() + + // A device that appeared between the first lookup and the subscription + // would otherwise be missed. + if dev, err := c.knownDevice(ctx, addr); err != nil || dev != nil { + return dev, err + } + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("find %s: %w", addr, ctx.Err()) + case <-c.a.gone: + return nil, ErrUnavailable + case sig, ok := <-added: + if !ok { + return nil, ErrUnavailable + } + path, ifaces, _ := interfacesAdded(sig) + if strings.EqualFold(stringProp(ifaces[deviceIface], "Address"), addr) { + return c.a.newDevice(path, addr), nil + } + } + } +} + +// knownDevice returns the device if bluetoothd already has an object for it. +func (c *central) knownDevice(ctx context.Context, addr string) (Device, error) { + objs, err := c.a.managedObjects(ctx) + if err != nil { + return nil, err + } + prefix := c.a.devicePathPrefix() + for path, ifaces := range objs { + props, ok := ifaces[deviceIface] + if !ok || !strings.HasPrefix(string(path), prefix) { + continue + } + if strings.EqualFold(stringProp(props, "Address"), addr) { + return c.a.newDevice(path, addr), nil + } + } + return nil, nil //nolint:nilnil // nil device means not known yet, not an error +} + +// stopDiscovery releases our discovery session; failures are expected when +// bluetoothd already stopped it. +func (c *central) stopDiscovery() { + ctx, cancel := context.WithTimeout(context.Background(), c.a.callTimeout) + defer cancel() + if err := c.a.call(ctx, c.a.obj, adapterIface+".StopDiscovery"); err != nil { + log.Debug().Err(err).Msg("bluetooth stop discovery failed") + } +} + +// device is the Linux Device. +type device struct { + a *adapter + obj dbus.BusObject + disconnected chan struct{} + path dbus.ObjectPath + address string + dropOnce sync.Once +} + +func (a *adapter) newDevice(path dbus.ObjectPath, address string) *device { + d := &device{ + a: a, + obj: a.conn.Object(bluezService, path), + disconnected: make(chan struct{}), + path: path, + address: address, + } + go d.watch() + return d +} + +// watch closes disconnected once bluetoothd reports the link down or the +// device object vanishes. +func (d *device) watch() { + events, unsubscribe := d.a.signals.subscribe(func(sig *dbus.Signal) bool { + return sig.Path == d.path && (sig.Name == signalPropertiesChanged || sig.Name == signalInterfacesRemoved) + }) + defer unsubscribe() + for { + select { + case <-d.a.gone: + d.drop() + return + case <-d.disconnected: + return + case sig, ok := <-events: + if !ok { + d.drop() + return + } + if iface, changed, ok := propertiesChanged(sig); ok && iface == deviceIface { + if connected, present := changedBool(changed, "Connected"); present && !connected { + d.drop() + return + } + continue + } + if _, _, ok := interfacesRemoved(sig); ok { + d.drop() + return + } + } + } +} + +func (d *device) drop() { + d.dropOnce.Do(func() { close(d.disconnected) }) +} + +func (d *device) Address() string { return d.address } + +func (d *device) Disconnected() <-chan struct{} { return d.disconnected } + +// Connect connects and waits until bluetoothd has resolved the device's +// services, so Characteristic can find them. +func (d *device) Connect(ctx context.Context) error { + changes, unsubscribe := d.a.signals.subscribe(func(sig *dbus.Signal) bool { + return sig.Path == d.path && sig.Name == signalPropertiesChanged + }) + defer unsubscribe() + + cctx, cancel := context.WithTimeout(ctx, connectTimeout) + defer cancel() + if call := d.obj.CallWithContext(cctx, deviceIface+".Connect", 0); call.Err != nil { + return mapBusError("connect "+d.address, call.Err) + } + + resolved, err := d.a.getProperty(ctx, d.obj, deviceIface, "ServicesResolved") + if err != nil { + return err + } + if v, ok := resolved.Value().(bool); ok && v { + return nil + } + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("resolve services of %s: %w", d.address, ctx.Err()) + case <-d.disconnected: + return fmt.Errorf("resolve services of %s: %w", d.address, errDisconnected) + case sig, ok := <-changes: + if !ok { + return ErrUnavailable + } + iface, changed, ok := propertiesChanged(sig) + if !ok || iface != deviceIface { + continue + } + if v, present := changedBool(changed, "ServicesResolved"); present && v { + return nil + } + if v, present := changedBool(changed, "Connected"); present && !v { + return fmt.Errorf("resolve services of %s: %w", d.address, errDisconnected) + } + } + } +} + +var errDisconnected = errors.New("bluez: device disconnected") + +func (d *device) Disconnect(ctx context.Context) error { + return d.a.call(ctx, d.obj, deviceIface+".Disconnect") +} + +// Characteristic finds a characteristic by service and characteristic UUID +// among the device's resolved services. +func (d *device) Characteristic(serviceUUID, charUUID string) (RemoteCharacteristic, error) { + ctx, cancel := context.WithTimeout(context.Background(), d.a.callTimeout) + defer cancel() + objs, err := d.a.managedObjects(ctx) + if err != nil { + return nil, err + } + + devicePrefix := string(d.path) + "/" + var servicePath string + for path, ifaces := range objs { + props, ok := ifaces[gattServiceIface] + if !ok || !strings.HasPrefix(string(path), devicePrefix) { + continue + } + if strings.EqualFold(stringProp(props, "UUID"), serviceUUID) { + servicePath = string(path) + break + } + } + if servicePath == "" { + return nil, fmt.Errorf("%w: service %s on %s", ErrNotFound, serviceUUID, d.address) + } + + for path, ifaces := range objs { + props, ok := ifaces[gattCharIface] + if !ok || !strings.HasPrefix(string(path), servicePath+"/") { + continue + } + if strings.EqualFold(stringProp(props, "UUID"), charUUID) { + return &remoteChar{d: d, obj: d.a.conn.Object(bluezService, path), path: path}, nil + } + } + return nil, fmt.Errorf("%w: characteristic %s in service %s on %s", ErrNotFound, charUUID, serviceUUID, d.address) +} + +// remoteChar is the Linux RemoteCharacteristic. +type remoteChar struct { + d *device + obj dbus.BusObject + path dbus.ObjectPath +} + +// Subscribe enables notifications and streams values until ctx ends or the +// device disconnects. +func (rc *remoteChar) Subscribe(ctx context.Context) (<-chan []byte, error) { + values, unsubscribe := rc.d.a.signals.subscribe(func(sig *dbus.Signal) bool { + return sig.Path == rc.path && sig.Name == signalPropertiesChanged + }) + if err := rc.d.a.call(ctx, rc.obj, gattCharIface+".StartNotify"); err != nil { + unsubscribe() + return nil, err + } + + out := make(chan []byte, notifyBuffer) + go func() { + defer close(out) + defer unsubscribe() + defer rc.stopNotify() + for { + select { + case <-ctx.Done(): + return + case <-rc.d.disconnected: + return + case sig, ok := <-values: + if !ok { + return + } + iface, changed, ok := propertiesChanged(sig) + if !ok || iface != gattCharIface { + continue + } + value, present := changedBytes(changed, "Value") + if !present { + continue + } + select { + case out <- append([]byte(nil), value...): + case <-ctx.Done(): + return + case <-rc.d.disconnected: + return + } + } + } + }() + return out, nil +} + +// stopNotify is best effort: a disconnected device has already stopped. +func (rc *remoteChar) stopNotify() { + select { + case <-rc.d.disconnected: + return + default: + } + ctx, cancel := context.WithTimeout(context.Background(), rc.d.a.callTimeout) + defer cancel() + if err := rc.d.a.call(ctx, rc.obj, gattCharIface+".StopNotify"); err != nil { + log.Debug().Err(err).Msg("bluetooth stop notify failed") + } +} + +// Write sends value as a write command or, with withResponse, a write +// request that waits for the peripheral's acknowledgement. +func (rc *remoteChar) Write(ctx context.Context, value []byte, withResponse bool) error { + kind := "command" + if withResponse { + kind = "request" + } + options := map[string]dbus.Variant{"type": dbus.MakeVariant(kind)} + return rc.d.a.call(ctx, rc.obj, gattCharIface+".WriteValue", value, options) +} diff --git a/pkg/bluetooth/bluez/integration_linux_test.go b/pkg/bluetooth/bluez/integration_linux_test.go new file mode 100644 index 000000000..620228d16 --- /dev/null +++ b/pkg/bluetooth/bluez/integration_linux_test.go @@ -0,0 +1,556 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import ( + "bufio" + "bytes" + "context" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests run the real BlueZ layer against a fake bluetoothd exported on +// a private session bus, so the D-Bus mechanics (object export, property +// emission, argument encoding, signal routing) are exercised end to end +// without hardware or a system bluetoothd. + +const ( + fakeAdapterPath = dbus.ObjectPath("/org/bluez/hci0") + fakeDevicePath = dbus.ObjectPath("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_01") + fakeDeviceAddr = "AA:BB:CC:DD:EE:01" + fakeServicePath = fakeDevicePath + "/service0001" + fakeCharPath = fakeServicePath + "/char0002" + fakeServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" + fakeCharUUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" + integrationWait = 5 * time.Second +) + +// startSessionBus launches a private dbus-daemon and returns its address. +func startSessionBus(t *testing.T) string { + t.Helper() + if testing.Short() { + t.Skip("skipping D-Bus integration test in short mode") + } + // On CI the daemon is a declared dependency of the workflow, so its + // absence is a broken pipeline, not a reason to skip. + onCI := os.Getenv("CI") != "" + if _, err := exec.LookPath("dbus-daemon"); err != nil { + if onCI { + t.Fatal("dbus-daemon is not installed on this CI runner") + } + t.Skip("dbus-daemon not installed") + } + + cmd := exec.CommandContext(t.Context(), "dbus-daemon", "--session", "--print-address", "--nofork", "--nopidfile") + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + var stderr bytes.Buffer + cmd.Stderr = &stderr + require.NoError(t, cmd.Start()) + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + addrCh := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + addrCh <- strings.TrimSpace(scanner.Text()) + } + close(addrCh) + }() + select { + case addr, ok := <-addrCh: + if !ok || addr == "" { + reason := "dbus-daemon exited before printing an address: " + strings.TrimSpace(stderr.String()) + if onCI { + t.Fatal(reason) + } + t.Skip(reason) + } + return addr + case <-time.After(integrationWait): + t.Fatal("dbus-daemon did not print its address") + return "" + } +} + +// fakeBluez is enough of bluetoothd for the layer to talk to. +type fakeBluez struct { + conn *dbus.Conn + objects managedObjects + adapterProp *prop.Properties + deviceProp *prop.Properties + charProp *prop.Properties + calls chan string + appRoot chan appRegistration + mu syncutil.Mutex +} + +type appRegistration struct { + sender dbus.Sender + path dbus.ObjectPath +} + +func (f *fakeBluez) GetManagedObjects() (managedObjects, *dbus.Error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make(managedObjects, len(f.objects)) + for path, ifaces := range f.objects { + out[path] = ifaces + } + return out, nil +} + +func (f *fakeBluez) record(call string) { + select { + case f.calls <- call: + default: + } +} + +// fakeAdapter implements the adapter-side interfaces. +type fakeAdapter struct{ f *fakeBluez } + +func (a *fakeAdapter) SetDiscoveryFilter(filter map[string]dbus.Variant) *dbus.Error { + a.f.record("SetDiscoveryFilter:" + stringProp(filter, "Transport")) + return nil +} + +func (a *fakeAdapter) StartDiscovery() *dbus.Error { + a.f.record("StartDiscovery") + // Discovery "finds" the device: publish it and announce it. + a.f.mu.Lock() + a.f.objects[fakeDevicePath] = map[string]map[string]dbus.Variant{ + deviceIface: { + "Address": dbus.MakeVariant(fakeDeviceAddr), + "Connected": dbus.MakeVariant(false), + "ServicesResolved": dbus.MakeVariant(false), + }, + } + a.f.mu.Unlock() + _ = a.f.conn.Emit(bluezRootPath, signalInterfacesAdded, fakeDevicePath, a.f.objects[fakeDevicePath]) + return nil +} + +func (a *fakeAdapter) StopDiscovery() *dbus.Error { + a.f.record("StopDiscovery") + return nil +} + +func (a *fakeAdapter) RegisterApplication( + sender dbus.Sender, path dbus.ObjectPath, _ map[string]dbus.Variant, +) *dbus.Error { + a.f.record("RegisterApplication") + a.f.appRoot <- appRegistration{sender: sender, path: path} + return nil +} + +func (a *fakeAdapter) UnregisterApplication(_ dbus.ObjectPath) *dbus.Error { + a.f.record("UnregisterApplication") + return nil +} + +func (a *fakeAdapter) RegisterAdvertisement(_ dbus.ObjectPath, _ map[string]dbus.Variant) *dbus.Error { + a.f.record("RegisterAdvertisement") + return nil +} + +func (a *fakeAdapter) UnregisterAdvertisement(_ dbus.ObjectPath) *dbus.Error { + a.f.record("UnregisterAdvertisement") + return nil +} + +// fakeDevice implements org.bluez.Device1 for the discovered device. +type fakeDevice struct{ f *fakeBluez } + +func (d *fakeDevice) Connect() *dbus.Error { + d.f.record("Connect") + d.f.mu.Lock() + d.f.objects[fakeServicePath] = map[string]map[string]dbus.Variant{ + gattServiceIface: {"UUID": dbus.MakeVariant(fakeServiceUUID), "Primary": dbus.MakeVariant(true)}, + } + d.f.objects[fakeCharPath] = map[string]map[string]dbus.Variant{ + gattCharIface: {"UUID": dbus.MakeVariant(fakeCharUUID), "Service": dbus.MakeVariant(fakeServicePath)}, + } + d.f.mu.Unlock() + d.f.deviceProp.SetMust(deviceIface, "Connected", true) + d.f.deviceProp.SetMust(deviceIface, "ServicesResolved", true) + return nil +} + +func (d *fakeDevice) Disconnect() *dbus.Error { + d.f.record("Disconnect") + d.f.deviceProp.SetMust(deviceIface, "Connected", false) + return nil +} + +// fakeChar implements org.bluez.GattCharacteristic1 for the device's TX. +type fakeChar struct{ f *fakeBluez } + +func (c *fakeChar) StartNotify() *dbus.Error { + c.f.record("StartNotify") + return nil +} + +func (c *fakeChar) StopNotify() *dbus.Error { + c.f.record("StopNotify") + return nil +} + +func (c *fakeChar) WriteValue(value []byte, options map[string]dbus.Variant) *dbus.Error { + c.f.record("WriteValue:" + string(value) + ":" + stringProp(options, "type")) + return nil +} + +// newFakeBluez exports the fake on a fresh connection to the bus. +func newFakeBluez(t *testing.T, addr string) *fakeBluez { + t.Helper() + conn, err := dbus.Connect(addr) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + reply, err := conn.RequestName(bluezService, dbus.NameFlagDoNotQueue) + require.NoError(t, err) + require.Equal(t, dbus.RequestNameReplyPrimaryOwner, reply) + + f := &fakeBluez{ + conn: conn, + calls: make(chan string, 64), + appRoot: make(chan appRegistration, 1), + } + adapterSpec := prop.Map{adapterIface: { + "Address": {Value: "00:11:22:33:44:55", Emit: prop.EmitConst}, + "Powered": {Value: true, Writable: true, Emit: prop.EmitTrue}, + "Roles": {Value: []string{"central", "peripheral", "central-peripheral"}, Emit: prop.EmitConst}, + }} + f.objects = managedObjects{ + fakeAdapterPath: { + adapterIface: variantsOf(adapterSpec)[adapterIface], + gattManagerIface: {}, + advManagerIface: {}, + }, + } + f.adapterProp, err = prop.Export(conn, fakeAdapterPath, adapterSpec) + require.NoError(t, err) + require.NoError(t, conn.Export(f, bluezRootPath, objectManagerIface)) + adapter := &fakeAdapter{f: f} + require.NoError(t, conn.Export(adapter, fakeAdapterPath, adapterIface)) + require.NoError(t, conn.Export(adapter, fakeAdapterPath, gattManagerIface)) + require.NoError(t, conn.Export(adapter, fakeAdapterPath, advManagerIface)) + + f.deviceProp, err = prop.Export(conn, fakeDevicePath, prop.Map{deviceIface: { + "Address": {Value: fakeDeviceAddr, Emit: prop.EmitConst}, + "Connected": {Value: false, Writable: true, Emit: prop.EmitTrue}, + "ServicesResolved": {Value: false, Writable: true, Emit: prop.EmitTrue}, + }}) + require.NoError(t, err) + require.NoError(t, conn.Export(&fakeDevice{f: f}, fakeDevicePath, deviceIface)) + + f.charProp, err = prop.Export(conn, fakeCharPath, prop.Map{gattCharIface: { + "UUID": {Value: fakeCharUUID, Emit: prop.EmitConst}, + "Value": {Value: []byte{}, Writable: true, Emit: prop.EmitTrue}, + }}) + require.NoError(t, err) + require.NoError(t, conn.Export(&fakeChar{f: f}, fakeCharPath, gattCharIface)) + return f +} + +func (f *fakeBluez) expectCall(t *testing.T, want string) { + t.Helper() + deadline := time.After(integrationWait) + for { + select { + case got := <-f.calls: + if got == want { + return + } + case <-deadline: + t.Fatalf("fake bluetoothd never saw %q", want) + } + } +} + +func TestIntegration_OpenReadsAdapter(t *testing.T) { + addr := startSessionBus(t) + _ = newFakeBluez(t, addr) + + adapter, err := Open(t.Context(), WithBusAddress(addr)) + require.NoError(t, err) + defer func() { _ = adapter.Close() }() + + assert.Equal(t, "00:11:22:33:44:55", adapter.Address()) + assert.ElementsMatch(t, []Role{"central", "peripheral", "central-peripheral"}, adapter.Roles()) + _, err = adapter.Peripheral() + require.NoError(t, err) + _, err = adapter.Central() + require.NoError(t, err) + + require.NoError(t, adapter.Close()) + select { + case <-adapter.Gone(): + case <-time.After(integrationWait): + t.Fatal("Close did not mark the adapter gone") + } +} + +func TestIntegration_OpenWithoutBluetoothd(t *testing.T) { + addr := startSessionBus(t) + + _, err := Open(t.Context(), WithBusAddress(addr), WithCallTimeout(time.Second)) + require.ErrorIs(t, err, ErrUnavailable) +} + +// recordingHandler collects peripheral events. +type recordingHandler struct { + writes chan string + disconnects chan Peer + subscribes chan bool + info []byte +} + +func (h *recordingHandler) OnWrite(peer Peer, charUUID string, value []byte, mtu int) { + h.writes <- peer.Address + "|" + charUUID + "|" + string(value) + "|" + strconv.Itoa(mtu) +} + +func (h *recordingHandler) OnRead(Peer, string) ([]byte, error) { return h.info, nil } + +func (h *recordingHandler) OnSubscribe(_ Peer, _ string, subscribed bool) { h.subscribes <- subscribed } + +func (h *recordingHandler) OnDisconnect(peer Peer) { h.disconnects <- peer } + +func TestIntegration_PeripheralServesApplication(t *testing.T) { + addr := startSessionBus(t) + fake := newFakeBluez(t, addr) + + adapter, err := Open(t.Context(), WithBusAddress(addr)) + require.NoError(t, err) + defer func() { _ = adapter.Close() }() + peripheral, err := adapter.Peripheral() + require.NoError(t, err) + + handler := &recordingHandler{ + writes: make(chan string, 8), + disconnects: make(chan Peer, 8), + subscribes: make(chan bool, 8), + info: []byte(`{"v":1}`), + } + app := Application{Services: []Service{{ + UUID: "0da70001-b359-443b-836f-477d34b6a638", + Primary: true, + Characteristics: []Characteristic{ + {UUID: "0da70002-b359-443b-836f-477d34b6a638", Flags: []string{FlagWrite, FlagWriteWithoutResponse}}, + {UUID: "0da70003-b359-443b-836f-477d34b6a638", Flags: []string{FlagNotify}}, + {UUID: "0da70004-b359-443b-836f-477d34b6a638", Flags: []string{FlagRead}}, + }, + }}} + adv := Advertisement{LocalName: "Test Zaparoo", ServiceUUIDs: []string{app.Services[0].UUID}} + + ctx, cancel := context.WithCancel(t.Context()) + var wg sync.WaitGroup + wg.Add(1) + serveErr := make(chan error, 1) + go func() { + defer wg.Done() + serveErr <- peripheral.Serve(ctx, app, adv, handler) + }() + + // bluetoothd reads the application tree on registration. + var reg appRegistration + select { + case reg = <-fake.appRoot: + case <-time.After(integrationWait): + t.Fatal("application was never registered") + } + fake.expectCall(t, "RegisterAdvertisement") + + var tree managedObjects + require.NoError(t, fake.conn.Object(string(reg.sender), reg.path). + Call(objectManagerIface+".GetManagedObjects", 0).Store(&tree)) + servicePath := reg.path + "/service0" + require.Contains(t, tree, servicePath) + assert.Equal(t, app.Services[0].UUID, stringProp(tree[servicePath][gattServiceIface], "UUID")) + rxPath, txPath, infoPath := servicePath+"/char0", servicePath+"/char1", servicePath+"/char2" + for _, p := range []dbus.ObjectPath{rxPath, txPath, infoPath} { + require.Contains(t, tree, p) + assert.Equal(t, servicePath, tree[p][gattCharIface]["Service"].Value()) + } + flags, ok := tree[txPath][gattCharIface]["Flags"].Value().([]string) + require.True(t, ok) + assert.Equal(t, []string{FlagNotify}, flags) + + // The advertisement is readable the way bluetoothd reads it. + var advType dbus.Variant + require.NoError(t, fake.conn.Object(string(reg.sender), "/org/zaparoo/ble/adv1"). + Call(propertiesIface+".Get", 0, advIface, "Type").Store(&advType)) + assert.Equal(t, "peripheral", advType.Value()) + + appObj := func(p dbus.ObjectPath) dbus.BusObject { return fake.conn.Object(string(reg.sender), p) } + + // A write from a peer arrives with its device path and MTU. + writeOpts := map[string]dbus.Variant{ + "device": dbus.MakeVariant(fakeDevicePath), + "mtu": dbus.MakeVariant(uint16(185)), + } + require.NoError(t, appObj(rxPath).Call(gattCharIface+".WriteValue", 0, []byte("chunk"), writeOpts).Err) + select { + case got := <-handler.writes: + assert.Equal(t, fakeDeviceAddr+"|0da70002-b359-443b-836f-477d34b6a638|chunk|185", got) + case <-time.After(integrationWait): + t.Fatal("write never reached the handler") + } + + // Reads are answered by the handler. + var value []byte + require.NoError(t, appObj(infoPath).Call(gattCharIface+".ReadValue", 0, map[string]dbus.Variant{}).Store(&value)) + assert.Equal(t, `{"v":1}`, string(value)) + + // Subscriptions are reported. + require.NoError(t, appObj(txPath).Call(gattCharIface+".StartNotify", 0).Err) + select { + case subscribed := <-handler.subscribes: + assert.True(t, subscribed) + case <-time.After(integrationWait): + t.Fatal("subscribe never reached the handler") + } + + // Notify emits a Value change bluetoothd would forward to subscribers. + changes := make(chan *dbus.Signal, 8) + fake.conn.Signal(changes) + require.NoError(t, fake.conn.AddMatchSignal( + dbus.WithMatchInterface(propertiesIface), dbus.WithMatchMember("PropertiesChanged"), + dbus.WithMatchObjectPath(txPath), + )) + require.NoError(t, peripheral.Notify(app.Services[0].Characteristics[1].UUID, []byte("reply"))) + select { + case sig := <-changes: + iface, changed, ok := propertiesChanged(sig) + require.True(t, ok) + assert.Equal(t, gattCharIface, iface) + got, _ := changedBytes(changed, "Value") + assert.Equal(t, []byte("reply"), got) + case <-time.After(integrationWait): + t.Fatal("notify emitted no property change") + } + require.ErrorIs(t, peripheral.Notify("00000000-0000-0000-0000-000000000000", []byte("x")), ErrNotFound) + + // A peer dropping its link is reported. + fake.deviceProp.SetMust(deviceIface, "Connected", true) + fake.deviceProp.SetMust(deviceIface, "Connected", false) + select { + case peer := <-handler.disconnects: + assert.Equal(t, string(fakeDevicePath), peer.Path) + assert.Equal(t, fakeDeviceAddr, peer.Address) + case <-time.After(integrationWait): + t.Fatal("disconnect never reached the handler") + } + + // Disconnect asks bluetoothd to drop the peer. + require.NoError(t, peripheral.Disconnect(t.Context(), Peer{Path: string(fakeDevicePath)})) + fake.expectCall(t, "Disconnect") + + cancel() + wg.Wait() + require.NoError(t, <-serveErr) + fake.expectCall(t, "UnregisterAdvertisement") + fake.expectCall(t, "UnregisterApplication") +} + +func TestIntegration_CentralFindsConnectsAndSubscribes(t *testing.T) { + addr := startSessionBus(t) + fake := newFakeBluez(t, addr) + + adapter, err := Open(t.Context(), WithBusAddress(addr)) + require.NoError(t, err) + defer func() { _ = adapter.Close() }() + central, err := adapter.Central() + require.NoError(t, err) + + findCtx, cancelFind := context.WithTimeout(t.Context(), integrationWait) + defer cancelFind() + dev, err := central.Find(findCtx, strings.ToLower(fakeDeviceAddr), []string{fakeServiceUUID}) + require.NoError(t, err) + assert.Equal(t, fakeDeviceAddr, dev.Address()) + fake.expectCall(t, "SetDiscoveryFilter:le") + fake.expectCall(t, "StartDiscovery") + fake.expectCall(t, "StopDiscovery") + + require.NoError(t, dev.Connect(t.Context())) + fake.expectCall(t, "Connect") + + _, err = dev.Characteristic(fakeServiceUUID, "00000000-0000-0000-0000-000000000000") + require.ErrorIs(t, err, ErrNotFound) + tx, err := dev.Characteristic(strings.ToUpper(fakeServiceUUID), fakeCharUUID) + require.NoError(t, err) + + subCtx, cancelSub := context.WithCancel(t.Context()) + defer cancelSub() + values, err := tx.Subscribe(subCtx) + require.NoError(t, err) + fake.expectCall(t, "StartNotify") + + fake.charProp.SetMust(gattCharIface, "Value", []byte("SCAN\tuid=1\n")) + select { + case v := <-values: + assert.Equal(t, "SCAN\tuid=1\n", string(v)) + case <-time.After(integrationWait): + t.Fatal("notification never arrived") + } + + require.NoError(t, tx.Write(t.Context(), []byte("hello"), false)) + fake.expectCall(t, "WriteValue:hello:command") + require.NoError(t, tx.Write(t.Context(), []byte("ack"), true)) + fake.expectCall(t, "WriteValue:ack:request") + + // The link dropping ends the stream and is visible on Disconnected. + fake.deviceProp.SetMust(deviceIface, "Connected", false) + select { + case <-dev.Disconnected(): + case <-time.After(integrationWait): + t.Fatal("disconnect was not noticed") + } + select { + case _, open := <-values: + assert.False(t, open, "stream closes after disconnect") + case <-time.After(integrationWait): + t.Fatal("stream did not close") + } + + // A device that never shows up times out cleanly. + missingCtx, cancelMissing := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancelMissing() + _, err = central.Find(missingCtx, "AA:BB:CC:DD:EE:02", nil) + require.ErrorIs(t, err, context.DeadlineExceeded) +} diff --git a/pkg/bluetooth/bluez/peripheral_linux.go b/pkg/bluetooth/bluez/peripheral_linux.go new file mode 100644 index 000000000..cd29c8e0e --- /dev/null +++ b/pkg/bluetooth/bluez/peripheral_linux.go @@ -0,0 +1,409 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/prop" + "github.com/rs/zerolog/log" +) + +const ( + // exportRoot is where this process publishes its GATT objects. Each + // Serve call gets its own numbered subtree so a restart never reuses a + // path bluetoothd may still be tearing down. + exportRoot = "/org/zaparoo/ble" + + // registerTimeout bounds RegisterApplication and RegisterAdvertisement, + // which make bluetoothd walk our object tree before replying. + registerTimeout = 10 * time.Second + + bluezErrFailed = "org.bluez.Error.Failed" +) + +var errAlreadyServing = errors.New("bluez: peripheral is already serving") + +// serveCounter numbers Serve calls for unique object paths. +var serveCounter atomic.Uint64 + +// peripheral is the Linux Peripheral. +type peripheral struct { + a *adapter + handler PeripheralHandler + chars map[string]*gattChar + mu syncutil.Mutex + serving bool +} + +func newPeripheral(a *adapter) *peripheral { + return &peripheral{a: a, chars: make(map[string]*gattChar)} +} + +// objectManager implements org.freedesktop.DBus.ObjectManager for the +// application root, which is how bluetoothd discovers our services. +type objectManager struct { + objects managedObjects +} + +func (om *objectManager) GetManagedObjects() (managedObjects, *dbus.Error) { + return om.objects, nil +} + +// gattChar implements org.bluez.GattCharacteristic1 for one local +// characteristic. Only D-Bus methods may be exported on this type. +type gattChar struct { + p *peripheral + props *prop.Properties + uuid string +} + +func (c *gattChar) ReadValue(options map[string]dbus.Variant) ([]byte, *dbus.Error) { + value, err := c.p.currentHandler().OnRead(peerFromOptions(options), c.uuid) + if err != nil { + return nil, dbus.NewError(bluezErrFailed, []any{err.Error()}) + } + return value, nil +} + +func (c *gattChar) WriteValue(value []byte, options map[string]dbus.Variant) *dbus.Error { + mtu := 0 + if v, ok := options["mtu"]; ok { + if m, ok := v.Value().(uint16); ok { + mtu = int(m) + } + } + c.p.currentHandler().OnWrite(peerFromOptions(options), c.uuid, append([]byte(nil), value...), mtu) + return nil +} + +func (c *gattChar) StartNotify() *dbus.Error { + c.p.currentHandler().OnSubscribe(Peer{}, c.uuid, true) + return nil +} + +func (c *gattChar) StopNotify() *dbus.Error { + c.p.currentHandler().OnSubscribe(Peer{}, c.uuid, false) + return nil +} + +// advertisement implements org.bluez.LEAdvertisement1. +type advertisement struct { + path dbus.ObjectPath +} + +// Release is called by bluetoothd when it drops the advertisement on its +// own, for example because the adapter went away. Serve notices the adapter +// going with it, so there is nothing to do but note it. +func (adv *advertisement) Release() *dbus.Error { + log.Debug().Str("path", string(adv.path)).Msg("bluetooth advertisement released by bluetoothd") + return nil +} + +// peerFromOptions reads the writing or reading device from the options +// bluetoothd passes with every server-side ReadValue and WriteValue. +func peerFromOptions(options map[string]dbus.Variant) Peer { + v, ok := options["device"] + if !ok { + return Peer{} + } + path, ok := v.Value().(dbus.ObjectPath) + if !ok { + return Peer{} + } + return peerFromPath(path) +} + +// noopHandler stands in between Serve calls so a late callback from +// bluetoothd never hits a nil handler. +type noopHandler struct{} + +func (noopHandler) OnWrite(Peer, string, []byte, int) {} +func (noopHandler) OnRead(Peer, string) ([]byte, error) { return nil, ErrNotFound } +func (noopHandler) OnSubscribe(Peer, string, bool) {} +func (noopHandler) OnDisconnect(Peer) {} + +func (p *peripheral) currentHandler() PeripheralHandler { + p.mu.Lock() + defer p.mu.Unlock() + if p.handler == nil { + return noopHandler{} + } + return p.handler +} + +// exportedObject is one (path, interface) pair Serve put on the bus. +type exportedObject struct { + path dbus.ObjectPath + iface string +} + +// exported tracks what Serve put on the bus so it can take it all down. +type exported struct { + conn *dbus.Conn + paths []exportedObject +} + +func (e *exported) add(path dbus.ObjectPath, iface string) { + e.paths = append(e.paths, exportedObject{path: path, iface: iface}) +} + +func (e *exported) export(v any, path dbus.ObjectPath, iface string) error { + if err := e.conn.Export(v, path, iface); err != nil { + return fmt.Errorf("export %s at %s: %w", iface, path, err) + } + e.add(path, iface) + return nil +} + +func (e *exported) exportProps(path dbus.ObjectPath, spec prop.Map) (*prop.Properties, error) { + props, err := prop.Export(e.conn, path, spec) + if err != nil { + return nil, fmt.Errorf("export properties at %s: %w", path, err) + } + e.add(path, propertiesIface) + return props, nil +} + +func (e *exported) unexportAll() { + for i := len(e.paths) - 1; i >= 0; i-- { + _ = e.conn.Export(nil, e.paths[i].path, e.paths[i].iface) + } + e.paths = nil +} + +// Serve publishes the application and advertisement and blocks until ctx +// ends or the adapter is gone. +func (p *peripheral) Serve(ctx context.Context, app Application, adv Advertisement, h PeripheralHandler) error { + p.mu.Lock() + if p.serving { + p.mu.Unlock() + return errAlreadyServing + } + p.serving = true + p.handler = h + p.chars = make(map[string]*gattChar) + p.mu.Unlock() + defer func() { + p.mu.Lock() + p.serving = false + p.handler = nil + p.chars = make(map[string]*gattChar) + p.mu.Unlock() + }() + + n := serveCounter.Add(1) + root := dbus.ObjectPath(fmt.Sprintf("%s/app%d", exportRoot, n)) + advPath := dbus.ObjectPath(fmt.Sprintf("%s/adv%d", exportRoot, n)) + + exp := &exported{conn: p.a.conn} + defer exp.unexportAll() + + if err := p.exportApplication(exp, root, app); err != nil { + return err + } + if err := p.register(ctx, gattManagerIface+".RegisterApplication", root); err != nil { + return err + } + defer p.unregister(gattManagerIface+".UnregisterApplication", root) + + if err := exportAdvertisement(exp, advPath, adv); err != nil { + return err + } + if err := p.register(ctx, advManagerIface+".RegisterAdvertisement", advPath); err != nil { + return err + } + defer p.unregister(advManagerIface+".UnregisterAdvertisement", advPath) + + log.Info(). + Str("name", adv.LocalName). + Strs("services", adv.ServiceUUIDs). + Msg("bluetooth peripheral advertising") + + p.watchPeers(ctx) + return nil +} + +// exportApplication publishes the ObjectManager root, services and +// characteristics bluetoothd will read on RegisterApplication. +func (p *peripheral) exportApplication(exp *exported, root dbus.ObjectPath, app Application) error { + om := &objectManager{objects: managedObjects{}} + for si, svc := range app.Services { + svcPath := dbus.ObjectPath(fmt.Sprintf("%s/service%d", root, si)) + svcSpec := prop.Map{gattServiceIface: { + "UUID": {Value: svc.UUID, Emit: prop.EmitConst}, + "Primary": {Value: svc.Primary, Emit: prop.EmitConst}, + }} + if _, err := exp.exportProps(svcPath, svcSpec); err != nil { + return err + } + om.objects[svcPath] = variantsOf(svcSpec) + + for ci, ch := range svc.Characteristics { + charPath := dbus.ObjectPath(fmt.Sprintf("%s/char%d", svcPath, ci)) + charSpec := prop.Map{gattCharIface: { + "UUID": {Value: ch.UUID, Emit: prop.EmitConst}, + "Service": {Value: svcPath, Emit: prop.EmitConst}, + "Flags": {Value: append([]string(nil), ch.Flags...), Emit: prop.EmitConst}, + // Writable so Notify can use the error-returning Set instead + // of the panicking SetMust; only bluetoothd is on this bus. + "Value": {Value: []byte{}, Writable: true, Emit: prop.EmitTrue}, + }} + props, err := exp.exportProps(charPath, charSpec) + if err != nil { + return err + } + c := &gattChar{p: p, props: props, uuid: ch.UUID} + if err := exp.export(c, charPath, gattCharIface); err != nil { + return err + } + om.objects[charPath] = variantsOf(charSpec) + p.mu.Lock() + p.chars[strings.ToLower(ch.UUID)] = c + p.mu.Unlock() + } + } + return exp.export(om, root, objectManagerIface) +} + +// exportAdvertisement publishes the LEAdvertisement1 object. +func exportAdvertisement(exp *exported, path dbus.ObjectPath, adv Advertisement) error { + spec := prop.Map{advIface: { + "Type": {Value: "peripheral", Emit: prop.EmitConst}, + "ServiceUUIDs": {Value: append([]string(nil), adv.ServiceUUIDs...), Emit: prop.EmitConst}, + "LocalName": {Value: adv.LocalName, Emit: prop.EmitConst}, + // Advertise as generally discoverable without touching the adapter's + // global Discoverable flag, which MiSTer's controller pairing owns. + "Discoverable": {Value: true, Emit: prop.EmitConst}, + }} + if _, err := exp.exportProps(path, spec); err != nil { + return err + } + return exp.export(&advertisement{path: path}, path, advIface) +} + +// variantsOf converts a property spec into the map shape GetManagedObjects +// returns for it. +func variantsOf(spec prop.Map) map[string]map[string]dbus.Variant { + out := make(map[string]map[string]dbus.Variant, len(spec)) + for iface, props := range spec { + vals := make(map[string]dbus.Variant, len(props)) + for name, pr := range props { + vals[name] = dbus.MakeVariant(pr.Value) + } + out[iface] = vals + } + return out +} + +// register calls a Register* method on the adapter with the longer +// registration timeout. +func (p *peripheral) register(ctx context.Context, method string, path dbus.ObjectPath) error { + cctx, cancel := context.WithTimeout(ctx, registerTimeout) + defer cancel() + if call := p.a.obj.CallWithContext(cctx, method, 0, path, map[string]dbus.Variant{}); call.Err != nil { + return mapBusError(method, call.Err) + } + return nil +} + +// unregister is best effort: bluetoothd also drops registrations when our +// bus connection closes, so a failure here only matters for the log. +func (p *peripheral) unregister(method string, path dbus.ObjectPath) { + ctx, cancel := context.WithTimeout(context.Background(), p.a.callTimeout) + defer cancel() + if err := p.a.call(ctx, p.a.obj, method, path); err != nil && !errors.Is(err, ErrUnavailable) { + log.Debug().Err(err).Str("method", method).Msg("bluetooth unregister failed") + } +} + +// watchPeers reports peer disconnections until ctx ends or the adapter is +// gone. +func (p *peripheral) watchPeers(ctx context.Context) { + prefix := p.a.devicePathPrefix() + events, unsubscribe := p.a.signals.subscribe(func(sig *dbus.Signal) bool { + return strings.HasPrefix(string(sig.Path), prefix) && + (sig.Name == signalPropertiesChanged || sig.Name == signalInterfacesRemoved) + }) + defer unsubscribe() + + for { + select { + case <-ctx.Done(): + return + case <-p.a.gone: + return + case sig, ok := <-events: + if !ok { + return + } + if iface, changed, ok := propertiesChanged(sig); ok { + if iface != deviceIface { + continue + } + if connected, present := changedBool(changed, "Connected"); present && !connected { + p.currentHandler().OnDisconnect(peerFromPath(sig.Path)) + } + continue + } + if _, ifaces, ok := interfacesRemoved(sig); ok { + for _, iface := range ifaces { + if iface == deviceIface { + p.currentHandler().OnDisconnect(peerFromPath(sig.Path)) + break + } + } + } + } + } +} + +// Notify updates the characteristic value; bluetoothd turns the property +// change into an ATT notification for every subscribed peer. +func (p *peripheral) Notify(charUUID string, value []byte) error { + p.mu.Lock() + c := p.chars[strings.ToLower(charUUID)] + p.mu.Unlock() + if c == nil { + return fmt.Errorf("%w: characteristic %s", ErrNotFound, charUUID) + } + if dbusErr := c.props.Set(gattCharIface, "Value", dbus.MakeVariant(append([]byte(nil), value...))); dbusErr != nil { + return fmt.Errorf("notify %s: %w", charUUID, *dbusErr) + } + return nil +} + +// Disconnect drops the peer's link. +func (p *peripheral) Disconnect(ctx context.Context, peer Peer) error { + if peer.Path == "" { + return fmt.Errorf("%w: peer has no path", ErrNotFound) + } + obj := p.a.conn.Object(bluezService, dbus.ObjectPath(peer.Path)) + return p.a.call(ctx, obj, deviceIface+".Disconnect") +} diff --git a/pkg/bluetooth/bluez/signals_linux.go b/pkg/bluetooth/bluez/signals_linux.go new file mode 100644 index 000000000..d1bced177 --- /dev/null +++ b/pkg/bluetooth/bluez/signals_linux.go @@ -0,0 +1,95 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import "github.com/godbus/dbus/v5" + +// propertiesChanged decodes a PropertiesChanged signal into the interface it +// concerns and the changed values. +func propertiesChanged(sig *dbus.Signal) (iface string, changed map[string]dbus.Variant, ok bool) { + if sig == nil || sig.Name != signalPropertiesChanged || len(sig.Body) < 2 { + return "", nil, false + } + iface, ok = sig.Body[0].(string) + if !ok { + return "", nil, false + } + changed, ok = sig.Body[1].(map[string]dbus.Variant) + if !ok { + return "", nil, false + } + return iface, changed, true +} + +// interfacesAdded decodes an InterfacesAdded signal into the object path and +// the interfaces (with their properties) that appeared on it. +func interfacesAdded(sig *dbus.Signal) (path dbus.ObjectPath, ifaces map[string]map[string]dbus.Variant, ok bool) { + if sig == nil || sig.Name != signalInterfacesAdded || len(sig.Body) < 2 { + return "", nil, false + } + path, ok = sig.Body[0].(dbus.ObjectPath) + if !ok { + return "", nil, false + } + ifaces, ok = sig.Body[1].(map[string]map[string]dbus.Variant) + if !ok { + return "", nil, false + } + return path, ifaces, true +} + +// interfacesRemoved decodes an InterfacesRemoved signal into the object path +// and the interface names that vanished from it. +func interfacesRemoved(sig *dbus.Signal) (path dbus.ObjectPath, ifaces []string, ok bool) { + if sig == nil || sig.Name != signalInterfacesRemoved || len(sig.Body) < 2 { + return "", nil, false + } + path, ok = sig.Body[0].(dbus.ObjectPath) + if !ok { + return "", nil, false + } + ifaces, ok = sig.Body[1].([]string) + if !ok { + return "", nil, false + } + return path, ifaces, true +} + +// changedBool reports a boolean property from a PropertiesChanged payload. +func changedBool(changed map[string]dbus.Variant, name string) (value, present bool) { + v, ok := changed[name] + if !ok { + return false, false + } + value, ok = v.Value().(bool) + return value, ok +} + +// changedBytes reports a byte-array property from a PropertiesChanged payload. +func changedBytes(changed map[string]dbus.Variant, name string) ([]byte, bool) { + v, ok := changed[name] + if !ok { + return nil, false + } + b, ok := v.Value().([]byte) + return b, ok +} diff --git a/pkg/bluetooth/bluez/signals_linux_test.go b/pkg/bluetooth/bluez/signals_linux_test.go new file mode 100644 index 000000000..cabbd25b6 --- /dev/null +++ b/pkg/bluetooth/bluez/signals_linux_test.go @@ -0,0 +1,182 @@ +//go:build linux + +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluez + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPropertiesChangedDecoder(t *testing.T) { + t.Parallel() + + sig := &dbus.Signal{ + Name: signalPropertiesChanged, + Path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", + Body: []any{ + deviceIface, + map[string]dbus.Variant{"Connected": dbus.MakeVariant(false)}, + []string{}, + }, + } + iface, changed, ok := propertiesChanged(sig) + require.True(t, ok) + assert.Equal(t, deviceIface, iface) + connected, present := changedBool(changed, "Connected") + assert.True(t, present) + assert.False(t, connected) + _, present = changedBool(changed, "ServicesResolved") + assert.False(t, present) + + _, _, ok = propertiesChanged(&dbus.Signal{Name: signalInterfacesAdded, Body: sig.Body}) + assert.False(t, ok, "wrong signal name") + _, _, ok = propertiesChanged(&dbus.Signal{Name: signalPropertiesChanged, Body: []any{deviceIface}}) + assert.False(t, ok, "short body") + _, _, ok = propertiesChanged(&dbus.Signal{Name: signalPropertiesChanged, Body: []any{1, 2, 3}}) + assert.False(t, ok, "wrong types") + _, _, ok = propertiesChanged(nil) + assert.False(t, ok) +} + +func TestChangedBytes(t *testing.T) { + t.Parallel() + + changed := map[string]dbus.Variant{ + "Value": dbus.MakeVariant([]byte("SCAN\tuid=1\n")), + "MTU": dbus.MakeVariant(uint16(185)), + } + value, ok := changedBytes(changed, "Value") + require.True(t, ok) + assert.Equal(t, []byte("SCAN\tuid=1\n"), value) + _, ok = changedBytes(changed, "MTU") + assert.False(t, ok, "not a byte array") + _, ok = changedBytes(changed, "Missing") + assert.False(t, ok) +} + +func TestInterfacesAddedDecoder(t *testing.T) { + t.Parallel() + + path := dbus.ObjectPath("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + sig := &dbus.Signal{ + Name: signalInterfacesAdded, + Path: "/", + Body: []any{ + path, + map[string]map[string]dbus.Variant{ + deviceIface: {"Address": dbus.MakeVariant("AA:BB:CC:DD:EE:FF")}, + }, + }, + } + gotPath, ifaces, ok := interfacesAdded(sig) + require.True(t, ok) + assert.Equal(t, path, gotPath) + assert.Equal(t, "AA:BB:CC:DD:EE:FF", stringProp(ifaces[deviceIface], "Address")) + assert.Empty(t, stringProp(ifaces[deviceIface], "Name")) + assert.Empty(t, stringProp(nil, "Address")) + + _, _, ok = interfacesAdded(&dbus.Signal{Name: signalInterfacesAdded, Body: []any{"not a path", 1}}) + assert.False(t, ok) +} + +func TestInterfacesRemovedDecoder(t *testing.T) { + t.Parallel() + + path := dbus.ObjectPath("/org/bluez/hci0") + sig := &dbus.Signal{ + Name: signalInterfacesRemoved, + Path: "/", + Body: []any{path, []string{adapterIface, gattManagerIface}}, + } + gotPath, ifaces, ok := interfacesRemoved(sig) + require.True(t, ok) + assert.Equal(t, path, gotPath) + assert.Contains(t, ifaces, adapterIface) + + _, _, ok = interfacesRemoved(&dbus.Signal{Name: signalInterfacesRemoved, Body: []any{path}}) + assert.False(t, ok, "short body") +} + +func TestPeerFromPath(t *testing.T) { + t.Parallel() + + peer := peerFromPath("/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF") + assert.Equal(t, "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", peer.Path) + assert.Equal(t, "AA:BB:CC:DD:EE:FF", peer.Address) + + assert.Empty(t, addressFromPath("/org/bluez/hci0")) + assert.Equal(t, Peer{}, peerFromOptions(nil)) + assert.Equal(t, Peer{}, peerFromOptions(map[string]dbus.Variant{"device": dbus.MakeVariant("string not path")})) + assert.Equal(t, peer, peerFromOptions(map[string]dbus.Variant{ + "device": dbus.MakeVariant(dbus.ObjectPath(peer.Path)), + })) +} + +func TestMapBusError(t *testing.T) { + t.Parallel() + + unknown := dbus.Error{Name: "org.freedesktop.DBus.Error.ServiceUnknown"} + require.ErrorIs(t, mapBusError("op", unknown), ErrUnavailable) + noObject := dbus.Error{Name: "org.freedesktop.DBus.Error.UnknownObject"} + require.ErrorIs(t, mapBusError("op", noObject), ErrNotFound) + other := dbus.Error{Name: "org.bluez.Error.Failed"} + err := mapBusError("op", other) + require.NotErrorIs(t, err, ErrUnavailable) + var dbusErr dbus.Error + require.ErrorAs(t, err, &dbusErr) + assert.Equal(t, other.Name, dbusErr.Name) +} + +func TestSignalRouter(t *testing.T) { + t.Parallel() + + r := newSignalRouter() + in := make(chan *dbus.Signal, 4) + go r.run(in) + + all, cancelAll := r.subscribe(func(*dbus.Signal) bool { return true }) + added, cancelAdded := r.subscribe(func(sig *dbus.Signal) bool { return sig.Name == signalInterfacesAdded }) + + in <- &dbus.Signal{Name: signalPropertiesChanged} + in <- &dbus.Signal{Name: signalInterfacesAdded} + assert.Equal(t, signalPropertiesChanged, (<-all).Name) + assert.Equal(t, signalInterfacesAdded, (<-all).Name) + assert.Equal(t, signalInterfacesAdded, (<-added).Name) + + cancelAdded() + _, open := <-added + assert.False(t, open, "cancel closes the channel") + cancelAdded() + + close(in) + _, open = <-all + assert.False(t, open, "closing the input closes every subscriber") + cancelAll() + + late, cancelLate := r.subscribe(func(*dbus.Signal) bool { return true }) + _, open = <-late + assert.False(t, open, "subscribing after close yields a closed channel") + cancelLate() +} diff --git a/pkg/bluetooth/manager.go b/pkg/bluetooth/manager.go new file mode 100644 index 000000000..0f545d8ac --- /dev/null +++ b/pkg/bluetooth/manager.go @@ -0,0 +1,252 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package bluetooth owns the lifecycle of the local Bluetooth adapter for +// the app-facing BLE transport: when to open it, when to give up, and when +// to try again after a dongle is plugged in. +package bluetooth + +import ( + "context" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/jonboulle/clockwork" + "github.com/rs/zerolog/log" +) + +// watchInterval is how often the manager re-checks configuration and the +// adapter. A dongle plugged in after boot is picked up within one interval. +const watchInterval = 15 * time.Second + +// PeripheralFunc is called with a ready peripheral each time one becomes +// available. It must not block: start any long-running work on a goroutine. +type PeripheralFunc func(bluez.Peripheral) + +// Manager keeps an adapter open while the BLE transport is enabled and +// hands its peripheral side to whoever registered interest. +type Manager struct { + cfg *config.Instance + clock clockwork.Clock + open func(ctx context.Context) (bluez.Adapter, error) + adapter bluez.Adapter + cancel context.CancelFunc + done chan struct{} + callbacks map[int]PeripheralFunc + mu syncutil.Mutex + nextID int + // unavailableReported and roleReported keep a machine that has no + // usable adapter from logging the same line every interval. + unavailableReported bool + roleReported bool + started bool + stopped bool +} + +// NewManager builds a manager over the real BlueZ layer. +func NewManager(cfg *config.Instance) *Manager { + return newManagerWith(cfg, clockwork.NewRealClock(), func(ctx context.Context) (bluez.Adapter, error) { + // The user enabled the transport, so a powered-off adapter is + // switched on for them. + return bluez.Open(ctx, bluez.WithPowerOn()) + }) +} + +func newManagerWith( + cfg *config.Instance, + clock clockwork.Clock, + open func(ctx context.Context) (bluez.Adapter, error), +) *Manager { + return &Manager{cfg: cfg, clock: clock, open: open, callbacks: make(map[int]PeripheralFunc)} +} + +// Start begins watching. It never fails: an absent bus, daemon or adapter +// is reported once and retried every interval. +func (m *Manager) Start() { + m.mu.Lock() + if m.started || m.stopped { + m.mu.Unlock() + return + } + m.started = true + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.done = make(chan struct{}) + m.mu.Unlock() + + go m.run(ctx) +} + +// Stop closes the adapter and waits for the watch loop to exit. +func (m *Manager) Stop() { + m.mu.Lock() + if m.stopped { + m.mu.Unlock() + return + } + m.stopped = true + cancel, done := m.cancel, m.done + m.mu.Unlock() + + if cancel != nil { + cancel() + <-done + } +} + +// Roles reports what the open adapter supports, or nil without one. +func (m *Manager) Roles() []bluez.Role { + m.mu.Lock() + defer m.mu.Unlock() + if m.adapter == nil { + return nil + } + return m.adapter.Roles() +} + +// OnPeripheral registers fn to receive the peripheral now, if one is ready, +// and again after every re-open. The returned function unregisters it. +func (m *Manager) OnPeripheral(fn PeripheralFunc) func() { + m.mu.Lock() + id := m.nextID + m.nextID++ + m.callbacks[id] = fn + adapter := m.adapter + m.mu.Unlock() + + if adapter != nil { + if p, err := adapter.Peripheral(); err == nil { + fn(p) + } + } + return func() { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.callbacks, id) + } +} + +func (m *Manager) run(ctx context.Context) { + defer close(m.done) + ticker := m.clock.NewTicker(watchInterval) + defer ticker.Stop() + + m.tick(ctx) + for { + select { + case <-ctx.Done(): + m.closeAdapter("stopping") + return + case <-ticker.Chan(): + m.tick(ctx) + } + } +} + +// tick reconciles the adapter with configuration: closed while disabled, +// re-opened after it went away, opened when it first becomes possible. +func (m *Manager) tick(ctx context.Context) { + if !m.cfg.BLEEnabled() { + m.closeAdapter("disabled by configuration") + return + } + + m.mu.Lock() + current := m.adapter + m.mu.Unlock() + if current != nil { + select { + case <-current.Gone(): + m.closeAdapter("adapter went away") + default: + return + } + } + + adapter, err := m.open(ctx) + if err != nil { + m.reportUnavailable(err) + return + } + + peripheral, err := adapter.Peripheral() + if err != nil { + m.mu.Lock() + reported := m.roleReported + m.roleReported = true + m.mu.Unlock() + if !reported { + log.Warn().Err(err). + Str("adapter", adapter.Address()). + Msg("bluetooth adapter cannot act as a peripheral, app transport unavailable") + } + _ = adapter.Close() + return + } + + m.mu.Lock() + m.adapter = adapter + m.unavailableReported = false + m.roleReported = false + callbacks := make([]PeripheralFunc, 0, len(m.callbacks)) + for _, fn := range m.callbacks { + callbacks = append(callbacks, fn) + } + m.mu.Unlock() + + roles := make([]string, 0, len(adapter.Roles())) + for _, r := range adapter.Roles() { + roles = append(roles, string(r)) + } + log.Info().Str("adapter", adapter.Address()).Strs("roles", roles).Msg("bluetooth adapter ready") + + for _, fn := range callbacks { + fn(peripheral) + } +} + +// reportUnavailable logs the first failure at info and later ones at debug, +// so a machine without Bluetooth does not fill the log. +func (m *Manager) reportUnavailable(err error) { + m.mu.Lock() + reported := m.unavailableReported + m.unavailableReported = true + m.mu.Unlock() + if reported { + log.Debug().Err(err).Msg("bluetooth still unavailable") + return + } + log.Info().Err(err).Msg("bluetooth unavailable, will keep checking") +} + +func (m *Manager) closeAdapter(reason string) { + m.mu.Lock() + adapter := m.adapter + m.adapter = nil + m.mu.Unlock() + if adapter == nil { + return + } + log.Info().Str("reason", reason).Msg("closing bluetooth adapter") + if err := adapter.Close(); err != nil { + log.Debug().Err(err).Msg("error closing bluetooth adapter") + } +} diff --git a/pkg/bluetooth/manager_test.go b/pkg/bluetooth/manager_test.go new file mode 100644 index 000000000..de773eb49 --- /dev/null +++ b/pkg/bluetooth/manager_test.go @@ -0,0 +1,244 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package bluetooth + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const eventually = 2 * time.Second + +func newTestConfig(t *testing.T, enabled bool) *config.Instance { + t.Helper() + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + cfg.SetBLEEnabled(enabled) + return cfg +} + +// peripheralRecorder counts the peripherals handed to OnPeripheral. +type peripheralRecorder struct { + last atomic.Pointer[bluez.Peripheral] + calls atomic.Int32 +} + +func (r *peripheralRecorder) fn(p bluez.Peripheral) { + r.last.Store(&p) + r.calls.Add(1) +} + +// advance moves the fake clock past one watch interval once the loop is +// waiting on its ticker. +func advance(t *testing.T, clock *clockwork.FakeClock) { + t.Helper() + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + clock.Advance(watchInterval) +} + +func TestManager_DisabledNeverOpens(t *testing.T) { + t.Parallel() + + var opens atomic.Int32 + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, false), clock, func(context.Context) (bluez.Adapter, error) { + opens.Add(1) + return mocks.NewFakeAdapter(bluez.RolePeripheral), nil + }) + m.Start() + defer m.Stop() + + advance(t, clock) + advance(t, clock) + assert.Equal(t, int32(0), opens.Load()) + assert.Nil(t, m.Roles()) +} + +func TestManager_OpensAndHandsOutPeripheral(t *testing.T) { + t.Parallel() + + adapter := mocks.NewFakeAdapter(bluez.RoleCentral, bluez.RolePeripheral) + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, true), clock, func(context.Context) (bluez.Adapter, error) { + return adapter, nil + }) + var rec peripheralRecorder + m.OnPeripheral(rec.fn) + m.Start() + + require.Eventually(t, func() bool { return rec.calls.Load() == 1 }, eventually, 10*time.Millisecond) + assert.Same(t, adapter.Periph, *rec.last.Load()) + assert.Equal(t, []bluez.Role{bluez.RoleCentral, bluez.RolePeripheral}, m.Roles()) + + m.Stop() + assert.True(t, adapter.Closed(), "Stop must close the adapter") + assert.Nil(t, m.Roles()) +} + +func TestManager_LateRegistrationGetsCurrentPeripheral(t *testing.T) { + t.Parallel() + + var opens atomic.Int32 + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, true), clock, func(context.Context) (bluez.Adapter, error) { + opens.Add(1) + return mocks.NewFakeAdapter(bluez.RolePeripheral), nil + }) + m.Start() + defer m.Stop() + require.Eventually(t, func() bool { return m.Roles() != nil }, eventually, 10*time.Millisecond) + + var rec peripheralRecorder + unregister := m.OnPeripheral(rec.fn) + assert.Equal(t, int32(1), rec.calls.Load()) + + // Once unregistered, a re-open no longer reaches the callback. + unregister() + m.mu.Lock() + m.adapter.(*mocks.FakeAdapter).MarkGone() + m.mu.Unlock() + advance(t, clock) + require.Eventually(t, func() bool { return opens.Load() == 2 }, eventually, 10*time.Millisecond) + assert.Equal(t, int32(1), rec.calls.Load()) +} + +func TestManager_RetriesUntilAdapterAppears(t *testing.T) { + t.Parallel() + + adapter := mocks.NewFakeAdapter(bluez.RolePeripheral) + var opens atomic.Int32 + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, true), clock, func(context.Context) (bluez.Adapter, error) { + if opens.Add(1) < 3 { + return nil, bluez.ErrNoAdapter + } + return adapter, nil + }) + var rec peripheralRecorder + m.OnPeripheral(rec.fn) + m.Start() + defer m.Stop() + + require.Eventually(t, func() bool { return opens.Load() == 1 }, eventually, 10*time.Millisecond) + assert.Equal(t, int32(0), rec.calls.Load()) + advance(t, clock) + require.Eventually(t, func() bool { return opens.Load() == 2 }, eventually, 10*time.Millisecond) + assert.Equal(t, int32(0), rec.calls.Load()) + advance(t, clock) + require.Eventually(t, func() bool { return rec.calls.Load() == 1 }, eventually, 10*time.Millisecond) + assert.Equal(t, int32(3), opens.Load()) + + m.mu.Lock() + reported := m.unavailableReported + m.mu.Unlock() + assert.False(t, reported, "a successful open resets the unavailable report") +} + +func TestManager_ReopensAfterAdapterGone(t *testing.T) { + t.Parallel() + + first := mocks.NewFakeAdapter(bluez.RolePeripheral) + second := mocks.NewFakeAdapter(bluez.RolePeripheral) + var opens atomic.Int32 + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, true), clock, func(context.Context) (bluez.Adapter, error) { + if opens.Add(1) == 1 { + return first, nil + } + return second, nil + }) + var rec peripheralRecorder + m.OnPeripheral(rec.fn) + m.Start() + defer m.Stop() + require.Eventually(t, func() bool { return rec.calls.Load() == 1 }, eventually, 10*time.Millisecond) + + first.MarkGone() + advance(t, clock) + require.Eventually(t, func() bool { return rec.calls.Load() == 2 }, eventually, 10*time.Millisecond) + assert.True(t, first.Closed()) + assert.Same(t, second.Periph, *rec.last.Load()) +} + +func TestManager_ClosesWhenDisabledAtRuntime(t *testing.T) { + t.Parallel() + + adapter := mocks.NewFakeAdapter(bluez.RolePeripheral) + cfg := newTestConfig(t, true) + clock := clockwork.NewFakeClock() + m := newManagerWith(cfg, clock, func(context.Context) (bluez.Adapter, error) { + return adapter, nil + }) + m.Start() + defer m.Stop() + require.Eventually(t, func() bool { return m.Roles() != nil }, eventually, 10*time.Millisecond) + + cfg.SetBLEEnabled(false) + advance(t, clock) + require.Eventually(t, adapter.Closed, eventually, 10*time.Millisecond) + assert.Nil(t, m.Roles()) +} + +func TestManager_AdapterWithoutPeripheralRoleIsClosed(t *testing.T) { + t.Parallel() + + adapter := mocks.NewFakeAdapter(bluez.RoleCentral) + var opens atomic.Int32 + clock := clockwork.NewFakeClock() + m := newManagerWith(newTestConfig(t, true), clock, func(context.Context) (bluez.Adapter, error) { + opens.Add(1) + return adapter, nil + }) + var rec peripheralRecorder + m.OnPeripheral(rec.fn) + m.Start() + defer m.Stop() + + require.Eventually(t, adapter.Closed, eventually, 10*time.Millisecond) + assert.Equal(t, int32(0), rec.calls.Load()) + assert.Nil(t, m.Roles()) + + m.mu.Lock() + reported := m.roleReported + m.mu.Unlock() + assert.True(t, reported) +} + +func TestManager_StopBeforeStartAndTwice(t *testing.T) { + t.Parallel() + + m := newManagerWith(newTestConfig(t, true), clockwork.NewFakeClock(), func(context.Context) (bluez.Adapter, error) { + return nil, errors.New("must not be called") + }) + m.Stop() + m.Start() + m.Stop() + m.Stop() +} diff --git a/pkg/config/configservice.go b/pkg/config/configservice.go index 9484cc0a0..0bfc173dd 100644 --- a/pkg/config/configservice.go +++ b/pkg/config/configservice.go @@ -43,6 +43,7 @@ func isValidAPIPort(port int) bool { type Service struct { APIPort *int `toml:"api_port,omitempty"` Discovery Discovery `toml:"discovery,omitempty"` + BLE BLE `toml:"ble,omitempty"` RemoteControl RemoteControl `toml:"remote_control,omitempty"` DeviceID string `toml:"device_id"` APIListen string `toml:"api_listen,omitempty"` @@ -90,6 +91,14 @@ type Discovery struct { InstanceName string `toml:"instance_name,omitempty"` } +// BLE configures the Bluetooth Low Energy API transport. Enabled is off by +// default because advertising is visible to everyone in radio range and +// needs a Bluetooth adapter to be of any use. +type BLE struct { + Enabled *bool `toml:"enabled,omitempty"` + Name string `toml:"name,omitempty"` +} + func (c *Instance) APIPort() int { c.mu.RLock() defer c.mu.RUnlock() @@ -245,6 +254,34 @@ func (c *Instance) SetDiscoveryInstanceName(name string) { c.vals.Service.Discovery.InstanceName = name } +// BLEEnabled reports whether the Bluetooth Low Energy API transport should +// advertise. It defaults to false. +func (c *Instance) BLEEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.vals.Service.BLE.Enabled != nil && *c.vals.Service.BLE.Enabled +} + +func (c *Instance) SetBLEEnabled(enabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.vals.Service.BLE.Enabled = &enabled +} + +// BLEName returns the configured Bluetooth local name, or an empty string +// when the transport should fall back to the discovery instance name. +func (c *Instance) BLEName() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.vals.Service.BLE.Name +} + +func (c *Instance) SetBLEName(name string) { + c.mu.Lock() + defer c.mu.Unlock() + c.vals.Service.BLE.Name = name +} + // RemoteControlEnabled reports whether device owner explicitly consented to // typed remote operations. Linking an account alone never grants consent. func (c *Instance) RemoteControlEnabled() bool { diff --git a/pkg/config/configservice_test.go b/pkg/config/configservice_test.go index 8282e671f..0705fe3a8 100644 --- a/pkg/config/configservice_test.go +++ b/pkg/config/configservice_test.go @@ -122,6 +122,60 @@ func TestDiscoveryEnabled(t *testing.T) { } } +func TestBLEEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + enabled *bool + name string + want bool + }{ + {name: "nil returns false (default disabled)", enabled: nil, want: false}, + {name: "true returns true", enabled: boolPtr(true), want: true}, + {name: "false returns false", enabled: boolPtr(false), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inst := &Instance{ + vals: Values{ + Service: Service{ + BLE: BLE{Enabled: tt.enabled}, + }, + }, + } + + assert.Equal(t, tt.want, inst.BLEEnabled()) + }) + } +} + +func TestSetBLEEnabled(t *testing.T) { + t.Parallel() + + inst := &Instance{} + assert.False(t, inst.BLEEnabled()) + inst.SetBLEEnabled(true) + assert.True(t, inst.BLEEnabled()) + inst.SetBLEEnabled(false) + assert.False(t, inst.BLEEnabled()) +} + +func TestBLEName(t *testing.T) { + t.Parallel() + + inst := &Instance{} + assert.Empty(t, inst.BLEName(), "unset name falls back to the discovery name") + + inst = &Instance{vals: Values{Service: Service{BLE: BLE{Name: "Lounge MiSTer"}}}} + assert.Equal(t, "Lounge MiSTer", inst.BLEName()) + + inst.SetBLEName("Den") + assert.Equal(t, "Den", inst.BLEName()) +} + func TestDiscoveryInstanceName(t *testing.T) { t.Parallel() diff --git a/pkg/platforms/batocera/platform.go b/pkg/platforms/batocera/platform.go index eb00c7b1b..6432772d6 100644 --- a/pkg/platforms/batocera/platform.go +++ b/pkg/platforms/batocera/platform.go @@ -36,6 +36,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -81,6 +82,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { libnfc.NewLegacyI2CReader(cfg), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), opticaldrive.NewReader(cfg), mqtt.NewReader(cfg), diff --git a/pkg/platforms/libreelec/platform.go b/pkg/platforms/libreelec/platform.go index a0f2b7226..7dd56a7c2 100644 --- a/pkg/platforms/libreelec/platform.go +++ b/pkg/platforms/libreelec/platform.go @@ -49,6 +49,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -76,6 +77,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { libnfc.NewLegacyI2CReader(cfg), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), opticaldrive.NewReader(cfg), mqtt.NewReader(cfg), diff --git a/pkg/platforms/mister/platform.go b/pkg/platforms/mister/platform.go index 7b4ae358a..c86a38ff4 100644 --- a/pkg/platforms/mister/platform.go +++ b/pkg/platforms/mister/platform.go @@ -45,6 +45,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -191,6 +192,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { file.NewReader(cfg), newOperatorReader(cfg, p.fs), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), opticaldrive.NewReader(cfg), mqtt.NewReader(cfg), diff --git a/pkg/platforms/mistex/platform.go b/pkg/platforms/mistex/platform.go index a51bf42df..8ca78d9a0 100644 --- a/pkg/platforms/mistex/platform.go +++ b/pkg/platforms/mistex/platform.go @@ -35,6 +35,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -71,6 +72,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { libnfc.NewLegacyI2CReader(cfg), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), tty2oled.NewReader(cfg, p), mqtt.NewReader(cfg), diff --git a/pkg/platforms/recalbox/platform.go b/pkg/platforms/recalbox/platform.go index 2336a7a24..a0e6480ce 100644 --- a/pkg/platforms/recalbox/platform.go +++ b/pkg/platforms/recalbox/platform.go @@ -48,6 +48,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -74,6 +75,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { libnfc.NewLegacyI2CReader(cfg), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), opticaldrive.NewReader(cfg), mqtt.NewReader(cfg), diff --git a/pkg/platforms/retropie/platform.go b/pkg/platforms/retropie/platform.go index 17a3a8cff..c88b6b3cd 100644 --- a/pkg/platforms/retropie/platform.go +++ b/pkg/platforms/retropie/platform.go @@ -48,6 +48,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -74,6 +75,7 @@ func (p *Platform) SupportedReaders(cfg *config.Instance) []readers.Reader { libnfc.NewLegacyI2CReader(cfg), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), opticaldrive.NewReader(cfg), mqtt.NewReader(cfg), diff --git a/pkg/platforms/shared/linuxbase/readers.go b/pkg/platforms/shared/linuxbase/readers.go index ad4995de8..8fd81900d 100644 --- a/pkg/platforms/shared/linuxbase/readers.go +++ b/pkg/platforms/shared/linuxbase/readers.go @@ -34,6 +34,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/pn532" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/rs232barcode" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserial" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/simpleserialble" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" ) @@ -44,6 +45,7 @@ func SupportedReaders(cfg *config.Instance, p platforms.Platform) []readers.Read tty2oled.NewReader(cfg, p), file.NewReader(cfg), simpleserial.NewReader(cfg), + simpleserialble.NewReader(cfg), rs232barcode.NewReader(cfg), pn532.NewReader(cfg), libnfc.NewACR122Reader(cfg), diff --git a/pkg/readers/shared/simpleproto/simpleproto.go b/pkg/readers/shared/simpleproto/simpleproto.go new file mode 100644 index 000000000..71d58ef77 --- /dev/null +++ b/pkg/readers/shared/simpleproto/simpleproto.go @@ -0,0 +1,140 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package simpleproto implements the "simple serial" line protocol shared by +// every reader that streams newline-delimited SCAN lines, whatever the +// transport underneath (serial port, Bluetooth LE, ...). +package simpleproto + +import ( + "strings" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" +) + +const ( + // scanPrefix starts every line that carries a token. + scanPrefix = "SCAN\t" + + // DefaultMaxLineLength bounds one line of input. Anything longer is + // discarded up to the next newline so a misbehaving device cannot grow + // memory without bound. + DefaultMaxLineLength = 4096 +) + +// Line is one parsed protocol line. Removable is set only when the line +// carried a removable= argument, so the caller can keep its previous value +// otherwise. +type Line struct { + Token *tokens.Token + Removable *bool +} + +// ParseLine parses one line of the protocol. It reports false for blank +// lines and for lines that do not carry a token, which are not errors: the +// stream may contain chatter the reader does not understand. +func ParseLine(line, readerID string) (Line, bool) { + line = strings.TrimSpace(line) + line = strings.Trim(line, "\r") + + if line == "" || !strings.HasPrefix(line, scanPrefix) { + return Line{}, false + } + + args := line[len(scanPrefix):] + if args == "" { + return Line{}, false + } + + t := tokens.Token{ + Data: line, + ScanTime: time.Now(), + Source: tokens.SourceReader, + ReaderID: readerID, + } + + var removable *bool + hasArg := false + for arg := range strings.SplitSeq(args, "\t") { + arg = strings.TrimSpace(arg) + switch { + case strings.HasPrefix(arg, "uid="): + t.UID = strings.TrimPrefix(arg, "uid=") + hasArg = true + case strings.HasPrefix(arg, "text="): + t.Text = strings.TrimPrefix(arg, "text=") + hasArg = true + case strings.HasPrefix(arg, "removable="): + value := strings.TrimPrefix(arg, "removable=") != "no" + removable = &value + hasArg = true + } + } + + // Without any named argument the whole payload is the token text. + if !hasArg { + t.Text = args + } + + return Line{Token: &t, Removable: removable}, true +} + +// LineSplitter turns an arbitrary byte stream into complete lines. It keeps +// a partial line between calls and drops any line longer than the limit. +type LineSplitter struct { + buf []byte + maxLine int + dropping bool +} + +// NewLineSplitter returns a splitter with the given line limit; a limit of +// zero or less uses DefaultMaxLineLength. +func NewLineSplitter(maxLine int) *LineSplitter { + if maxLine <= 0 { + maxLine = DefaultMaxLineLength + } + return &LineSplitter{maxLine: maxLine} +} + +// Feed appends data to the stream and returns every line completed by it, +// without their trailing newline. Carriage returns are left for ParseLine. +func (s *LineSplitter) Feed(data []byte) []string { + var lines []string + for _, b := range data { + if b == '\n' { + if !s.dropping { + lines = append(lines, string(s.buf)) + } + s.buf = s.buf[:0] + s.dropping = false + continue + } + if s.dropping { + continue + } + if len(s.buf) >= s.maxLine { + s.buf = s.buf[:0] + s.dropping = true + continue + } + s.buf = append(s.buf, b) + } + return lines +} diff --git a/pkg/readers/shared/simpleproto/simpleproto_test.go b/pkg/readers/shared/simpleproto/simpleproto_test.go new file mode 100644 index 000000000..7875e8d3a --- /dev/null +++ b/pkg/readers/shared/simpleproto/simpleproto_test.go @@ -0,0 +1,127 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package simpleproto + +import ( + "strings" + "testing" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseLine(t *testing.T) { + t.Parallel() + + yes, no := true, false + tests := []struct { + wantRemovable *bool + name string + line string + wantUID string + wantText string + wantToken bool + }{ + {name: "empty line", line: ""}, + {name: "whitespace only", line: " \r\n"}, + {name: "no SCAN prefix", line: "invalid format"}, + {name: "SCAN with no args", line: "SCAN\t"}, + {name: "text only", line: "SCAN\t**launch.system:nes", wantToken: true, wantText: "**launch.system:nes"}, + {name: "uid", line: "SCAN\tuid=abc123", wantToken: true, wantUID: "abc123"}, + {name: "text", line: "SCAN\ttext=hello", wantToken: true, wantText: "hello"}, + { + name: "uid and text", line: "SCAN\tuid=abc123\ttext=hello world", + wantToken: true, wantUID: "abc123", wantText: "hello world", + }, + { + name: "removable=no", line: "SCAN\tuid=abc123\tremovable=no", + wantToken: true, wantUID: "abc123", wantRemovable: &no, + }, + { + name: "removable=yes", line: "SCAN\tuid=abc123\tremovable=yes", + wantToken: true, wantUID: "abc123", wantRemovable: &yes, + }, + { + name: "all args", line: "SCAN\tuid=xyz789\ttext=test message\tremovable=no", + wantToken: true, wantUID: "xyz789", wantText: "test message", wantRemovable: &no, + }, + {name: "trailing carriage return", line: "SCAN\tuid=abc123\r", wantToken: true, wantUID: "abc123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + parsed, ok := ParseLine(tt.line, "reader-1") + if !tt.wantToken { + assert.False(t, ok) + assert.Nil(t, parsed.Token) + return + } + + require.True(t, ok) + require.NotNil(t, parsed.Token) + assert.Equal(t, tt.wantUID, parsed.Token.UID) + assert.Equal(t, tt.wantText, parsed.Token.Text) + assert.Equal(t, tokens.SourceReader, parsed.Token.Source) + assert.Equal(t, "reader-1", parsed.Token.ReaderID) + assert.Equal(t, strings.TrimSpace(tt.line), parsed.Token.Data) + assert.False(t, parsed.Token.ScanTime.IsZero()) + assert.Equal(t, tt.wantRemovable, parsed.Removable) + }) + } +} + +func TestLineSplitter(t *testing.T) { + t.Parallel() + + t.Run("splits complete lines and keeps the remainder", func(t *testing.T) { + t.Parallel() + s := NewLineSplitter(0) + assert.Equal(t, []string{"SCAN\tuid=1"}, s.Feed([]byte("SCAN\tuid=1\nSCAN\tuid=2"))) + assert.Empty(t, s.Feed([]byte("3"))) + assert.Equal(t, []string{"SCAN\tuid=23"}, s.Feed([]byte("\n"))) + }) + + t.Run("keeps carriage returns for the parser", func(t *testing.T) { + t.Parallel() + s := NewLineSplitter(0) + assert.Equal(t, []string{"SCAN\tuid=1\r", ""}, s.Feed([]byte("SCAN\tuid=1\r\n\n"))) + }) + + t.Run("drops a line over the limit and resumes after the newline", func(t *testing.T) { + t.Parallel() + s := NewLineSplitter(8) + assert.Empty(t, s.Feed([]byte("0123456789abcdef"))) + assert.Equal(t, []string{"ok"}, s.Feed([]byte("still dropped\nok\n"))) + }) + + t.Run("bytes are delivered one at a time", func(t *testing.T) { + t.Parallel() + s := NewLineSplitter(0) + stream := []byte("SCAN\ttext=a\nSCAN\ttext=b\n") + got := make([]string, 0, 2) + for _, b := range stream { + got = append(got, s.Feed([]byte{b})...) + } + assert.Equal(t, []string{"SCAN\ttext=a", "SCAN\ttext=b"}, got) + }) +} diff --git a/pkg/readers/simpleserial/simpleserial.go b/pkg/readers/simpleserial/simpleserial.go index cc64ce2ac..b98eb8dbe 100644 --- a/pkg/readers/simpleserial/simpleserial.go +++ b/pkg/readers/simpleserial/simpleserial.go @@ -24,7 +24,6 @@ import ( "fmt" "os" "runtime" - "strings" "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" @@ -32,6 +31,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/shared/simpleproto" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/testutils" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/rs/zerolog/log" @@ -71,55 +71,20 @@ func (*SimpleSerialReader) IDs() []string { return []string{"simpleserial", "simple_serial"} } +// parseLine parses one protocol line and applies a removable= argument to +// the reader's capabilities. A nil token with a nil error means the line +// carried nothing to act on. func (r *SimpleSerialReader) parseLine(line string) (*tokens.Token, error) { - line = strings.TrimSpace(line) - line = strings.Trim(line, "\r") - - if line == "" { - return nil, nil //nolint:nilnil // nil response means empty line, not an error - } - - if !strings.HasPrefix(line, "SCAN\t") { - return nil, nil //nolint:nilnil // nil response means invalid format, not an error - } - - args := line[5:] - if args == "" { - return nil, nil //nolint:nilnil // nil response means no args, not an error - } - - t := tokens.Token{ - Data: line, - ScanTime: time.Now(), - Source: tokens.SourceReader, - ReaderID: r.ReaderID(), - } - - ps := strings.Split(args, "\t") - hasArg := false - for i := 0; i < len(ps); i++ { - ps[i] = strings.TrimSpace(ps[i]) - switch { - case strings.HasPrefix(ps[i], "uid="): - t.UID = ps[i][4:] - hasArg = true - case strings.HasPrefix(ps[i], "text="): - t.Text = ps[i][5:] - hasArg = true - case strings.HasPrefix(ps[i], "removable="): - r.mu.Lock() - r.removable = ps[i][10:] != "no" - r.mu.Unlock() - hasArg = true - } + parsed, ok := simpleproto.ParseLine(line, r.ReaderID()) + if !ok { + return nil, nil //nolint:nilnil // nil response means no token on this line, not an error } - - // if there are no named arguments, whole args becomes text - if !hasArg { - t.Text = args + if parsed.Removable != nil { + r.mu.Lock() + r.removable = *parsed.Removable + r.mu.Unlock() } - - return &t, nil + return parsed.Token, nil } func (r *SimpleSerialReader) Open(device config.ReadersConnect, iq chan<- readers.Scan, _ readers.OpenOpts) error { diff --git a/pkg/readers/simpleserialble/simpleserialble.go b/pkg/readers/simpleserialble/simpleserialble.go new file mode 100644 index 000000000..27a1325dd --- /dev/null +++ b/pkg/readers/simpleserialble/simpleserialble.go @@ -0,0 +1,418 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package simpleserialble is the simple serial reader protocol carried over +// Bluetooth Low Energy. The reader is a peripheral exposing the Nordic UART +// Service; Core connects as a central, subscribes to its TX characteristic +// and reads the same newline-delimited SCAN lines a wired simple serial +// reader would send. +// +// Readers are connected by explicit configuration only: +// +// [[readers.connect]] +// driver = "simpleserial_ble" +// path = "AA:BB:CC:DD:EE:FF" +// +// The service UUID is shared by thousands of unrelated devices, so scanning +// for it and connecting to whatever answers is not an option. Advertising a +// recognisable local name is the natural way to add detection later. +// +// Unlike a serial port, the device may be out of range or switched off for +// long stretches, so the driver owns the link: Open attaches to the adapter +// and returns at once, and a background loop finds, connects to and +// reconnects the device with a growing pause between attempts. Each attempt +// scans, and scanning shares the radio with the BLE API transport's +// advertising, so a reader that is missing makes the device a little +// harder to discover until it turns up. +package simpleserialble + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/shared/simpleproto" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + "github.com/jonboulle/clockwork" + "github.com/rs/zerolog/log" +) + +const ( + // DriverID is the reader driver identifier. + DriverID = "simpleserial_ble" + + // Nordic UART Service: the de facto serial port over BLE. + nusServiceUUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" + nusTXUUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" + + // removalTimeout matches the wired simple serial driver: a token with + // no fresh line for this long is treated as removed. + removalTimeout = 1 * time.Second + removalPoll = 250 * time.Millisecond + + // defaultFindTimeout bounds one scan for the device. + defaultFindTimeout = 15 * time.Second + + // Pauses between attempts to reach a device that is not answering, + // doubling from the first to the last. + initialRetryPause = 1 * time.Second + maxRetryPause = 30 * time.Second +) + +// Reader is a simple serial reader reached over BLE. +type Reader struct { + cfg *config.Instance + open func(ctx context.Context) (bluez.Adapter, error) + clock clockwork.Clock + adapter bluez.Adapter + cancel context.CancelFunc + done chan struct{} + lastToken *tokens.Token + lastSeenAt time.Time + device config.ReadersConnect + address string + findTimeout time.Duration + mu syncutil.RWMutex + attached bool + linked bool + removable bool +} + +// NewReader builds a reader over the real BlueZ layer. It never powers the +// adapter on: a reader that keeps retrying must not keep switching a radio +// back on that the user turned off. +func NewReader(cfg *config.Instance) *Reader { + return newReaderWith(cfg, func(ctx context.Context) (bluez.Adapter, error) { + return bluez.Open(ctx) + }, clockwork.NewRealClock()) +} + +func newReaderWith( + cfg *config.Instance, + open func(ctx context.Context) (bluez.Adapter, error), + clock clockwork.Clock, +) *Reader { + return &Reader{ + cfg: cfg, + open: open, + clock: clock, + findTimeout: defaultFindTimeout, + removable: true, + } +} + +func (*Reader) Metadata() readers.DriverMetadata { + return readers.DriverMetadata{ + ID: DriverID, + DefaultEnabled: true, + DefaultAutoDetect: false, + Description: "Simple serial protocol over Bluetooth LE (Nordic UART Service)", + } +} + +func (*Reader) IDs() []string { + return []string{DriverID} +} + +// Open attaches to the Bluetooth adapter and starts looking for the device. +// It fails only for what cannot be retried by waiting: a bad address, no +// adapter, an adapter that cannot act as a central. Whether the device is +// in range is the background loop's business, so the reader manager, which +// calls Open on its own tick, is never held up by a device that is off. +func (r *Reader) Open(device config.ReadersConnect, iq chan<- readers.Scan, _ readers.OpenOpts) error { + if !readers.MatchesDriverID(r.IDs(), device.Driver) { + return errors.New("invalid reader id: " + device.Driver) + } + address, err := bluez.NormalizeAddress(device.Path) + if err != nil { + return fmt.Errorf("reader path: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + adapter, err := r.open(ctx) + if err != nil { + cancel() + return fmt.Errorf("open bluetooth adapter: %w", err) + } + central, err := adapter.Central() + if err != nil { + _ = adapter.Close() + cancel() + return fmt.Errorf("bluetooth central: %w", err) + } + + done := make(chan struct{}) + r.mu.Lock() + r.device = device + r.address = address + r.adapter = adapter + r.cancel = cancel + r.done = done + r.attached = true + r.mu.Unlock() + + log.Info().Str("address", address).Msg("bluetooth simple serial reader attached, looking for device") + go r.run(ctx, central, done, iq) + return nil +} + +// run keeps the device connected for as long as the reader is attached. +// What it needs is passed in rather than read from the reader, so Close +// can clear the reader's fields without racing this goroutine. +func (r *Reader) run(ctx context.Context, central bluez.Central, done chan<- struct{}, iq chan<- readers.Scan) { + defer close(done) + + pause := initialRetryPause + reported := false + for { + dev, values, err := r.connect(ctx, central) + if err != nil { + if ctx.Err() != nil { + return + } + event := log.Debug() + if !reported { + event = log.Info() + reported = true + } + event.Err(err).Str("address", r.address).Dur("retryIn", pause). + Msg("bluetooth simple serial reader not reachable, will retry") + select { + case <-ctx.Done(): + return + case <-r.clock.After(pause): + } + pause = min(pause*2, maxRetryPause) + continue + } + + pause = initialRetryPause + reported = false + r.setLinked(true) + log.Info().Str("address", r.address).Msg("bluetooth simple serial reader connected") + + r.readLoop(ctx, iq, dev, values) + r.setLinked(false) + if ctx.Err() != nil { + r.disconnect(dev) + return + } + r.linkLost(iq) + } +} + +// connect finds and connects the device and subscribes to its TX stream. +func (r *Reader) connect(ctx context.Context, central bluez.Central) (bluez.Device, <-chan []byte, error) { + findCtx, findCancel := context.WithTimeout(ctx, r.findTimeout) + defer findCancel() + dev, err := central.Find(findCtx, r.address, []string{nusServiceUUID}) + if err != nil { + return nil, nil, fmt.Errorf("find: %w", err) + } + if connectErr := dev.Connect(findCtx); connectErr != nil { + return nil, nil, fmt.Errorf("connect: %w", connectErr) + } + + tx, err := dev.Characteristic(nusServiceUUID, nusTXUUID) + if err != nil { + r.disconnect(dev) + return nil, nil, fmt.Errorf("serial characteristic: %w", err) + } + values, err := tx.Subscribe(ctx) + if err != nil { + r.disconnect(dev) + return nil, nil, fmt.Errorf("subscribe: %w", err) + } + return dev, values, nil +} + +// disconnect drops the link, best effort. +func (*Reader) disconnect(dev bluez.Device) { + ctx, cancel := context.WithTimeout(context.Background(), bluez.DefaultCallTimeout) + defer cancel() + if err := dev.Disconnect(ctx); err != nil { + log.Debug().Err(err).Msg("bluetooth reader disconnect failed") + } +} + +func (r *Reader) setLinked(linked bool) { + r.mu.Lock() + r.linked = linked + r.mu.Unlock() +} + +// readLoop turns the notification stream into scans until the link drops +// or the reader is closed. +func (r *Reader) readLoop(ctx context.Context, iq chan<- readers.Scan, dev bluez.Device, values <-chan []byte) { + splitter := simpleproto.NewLineSplitter(0) + ticker := r.clock.NewTicker(removalPoll) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-dev.Disconnected(): + return + case data, ok := <-values: + if !ok { + return + } + for _, line := range splitter.Feed(data) { + r.handleLine(line, iq) + } + case <-ticker.Chan(): + r.checkRemoval(iq) + } + } +} + +// handleLine emits a scan for a new token. Repeats of the current token +// only refresh its presence. +func (r *Reader) handleLine(line string, iq chan<- readers.Scan) { + parsed, ok := simpleproto.ParseLine(line, r.ReaderID()) + if !ok { + return + } + if parsed.Removable != nil { + r.mu.Lock() + r.removable = *parsed.Removable + r.mu.Unlock() + } + + if !helpers.TokensEqual(parsed.Token, r.lastToken) { + iq <- readers.Scan{ + Source: tokens.SourceReader, + ReaderID: r.ReaderID(), + Token: parsed.Token, + } + } + r.lastToken = parsed.Token + r.lastSeenAt = r.clock.Now() +} + +// checkRemoval reports the token gone once the reader stops repeating it. +func (r *Reader) checkRemoval(iq chan<- readers.Scan) { + if r.lastToken == nil || r.clock.Since(r.lastSeenAt) <= removalTimeout { + return + } + iq <- readers.Scan{ + Source: tokens.SourceReader, + ReaderID: r.ReaderID(), + Token: nil, + } + r.lastToken = nil +} + +// linkLost reports an active token as a reader error, not a removal, so +// media keeps running while the device is reconnected. +func (r *Reader) linkLost(iq chan<- readers.Scan) { + log.Warn().Str("address", r.address).Msg("bluetooth simple serial reader lost its link, reconnecting") + if r.lastToken == nil { + return + } + iq <- readers.Scan{ + Source: tokens.SourceReader, + ReaderID: r.ReaderID(), + Token: nil, + ReaderError: true, + } + r.lastToken = nil +} + +// Close stops the background loop, drops the link and releases the adapter. +func (r *Reader) Close() error { + r.mu.Lock() + cancel, adapter, done := r.cancel, r.adapter, r.done + r.cancel, r.adapter, r.done = nil, nil, nil + r.attached = false + r.linked = false + r.mu.Unlock() + + if cancel != nil { + cancel() + <-done + } + if adapter != nil { + if err := adapter.Close(); err != nil { + return fmt.Errorf("close bluetooth adapter: %w", err) + } + } + return nil +} + +func (*Reader) Detect(_ []string) string { + return "" +} + +func (r *Reader) Path() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.address +} + +func (r *Reader) ReaderID() string { + return readers.GenerateReaderID(DriverID, r.Path()) +} + +// Connected reports whether the reader is attached: opened and not closed. +// The device itself may be out of range meanwhile; Info says which. +func (r *Reader) Connected() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.attached +} + +func (r *Reader) Info() string { + r.mu.RLock() + defer r.mu.RUnlock() + state := "searching" + if r.linked { + state = "connected" + } + return "BLE NUS " + r.address + " (" + state + ")" +} + +func (*Reader) Write(_ string) (*tokens.Token, error) { + return nil, errors.New("writing not supported on this reader") +} + +func (*Reader) CancelWrite() { + // no-op, writing not supported +} + +func (r *Reader) Capabilities() []readers.Capability { + r.mu.RLock() + defer r.mu.RUnlock() + if r.removable { + return []readers.Capability{readers.CapabilityRemovable} + } + return []readers.Capability{} +} + +func (*Reader) OnMediaChange(*models.ActiveMedia) error { + return nil +} diff --git a/pkg/readers/simpleserialble/simpleserialble_test.go b/pkg/readers/simpleserialble/simpleserialble_test.go new file mode 100644 index 000000000..de1627b57 --- /dev/null +++ b/pkg/readers/simpleserialble/simpleserialble_test.go @@ -0,0 +1,316 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package simpleserialble + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testAddress = "AA:BB:CC:DD:EE:01" + testTimeout = 5 * time.Second +) + +type testRig struct { + reader *Reader + adapter *mocks.FakeAdapter + device *mocks.FakeDevice + tx *mocks.FakeCharacteristic + clock *clockwork.FakeClock + scans chan readers.Scan +} + +// newTestRig builds a reader over a fake adapter. The device is only +// findable once addDevice is called. +func newTestRig(t *testing.T, roles ...bluez.Role) *testRig { + t.Helper() + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + + if len(roles) == 0 { + roles = []bluez.Role{bluez.RoleCentral} + } + adapter := mocks.NewFakeAdapter(roles...) + clock := clockwork.NewFakeClock() + reader := newReaderWith(cfg, func(context.Context) (bluez.Adapter, error) { + return adapter, nil + }, clock) + reader.findTimeout = 50 * time.Millisecond + + rig := &testRig{ + reader: reader, + adapter: adapter, + clock: clock, + scans: make(chan readers.Scan, 16), + } + t.Cleanup(func() { _ = reader.Close() }) + return rig +} + +// addDevice makes a fresh device with the test address findable. +func (r *testRig) addDevice() { + r.device = mocks.NewFakeDevice(testAddress) + r.tx = mocks.NewFakeCharacteristic() + r.device.AddCharacteristic(nusServiceUUID, nusTXUUID, r.tx) + r.adapter.Cent.AddDevice(r.device) +} + +func (r *testRig) open(t *testing.T) { + t.Helper() + require.NoError(t, r.reader.Open( + config.ReadersConnect{Driver: DriverID, Path: testAddress}, r.scans, readers.OpenOpts{}, + )) + require.True(t, r.reader.Connected()) +} + +// waitLinked drives the fake clock past retry pauses until the current +// device is connected. +func (r *testRig) waitLinked(t *testing.T) { + t.Helper() + require.Eventually(t, func() bool { + r.clock.Advance(time.Second) + return r.device.Connected() && strings.Contains(r.reader.Info(), "connected") + }, testTimeout, 10*time.Millisecond) +} + +func (r *testRig) scan(t *testing.T) readers.Scan { + t.Helper() + select { + case s := <-r.scans: + return s + case <-time.After(testTimeout): + t.Fatal("no scan emitted") + return readers.Scan{} + } +} + +func (r *testRig) noScan(t *testing.T) { + t.Helper() + select { + case s := <-r.scans: + t.Fatalf("unexpected scan: %+v", s) + case <-time.After(100 * time.Millisecond): + } +} + +// tick advances the fake clock past one removal poll once the read loop is +// waiting on its ticker. +func (r *testRig) tick(t *testing.T, d time.Duration) { + t.Helper() + require.NoError(t, r.clock.BlockUntilContext(t.Context(), 1)) + r.clock.Advance(d) +} + +func TestMetadata(t *testing.T) { + t.Parallel() + + r := NewReader(nil) + assert.Equal(t, DriverID, r.Metadata().ID) + assert.False(t, r.Metadata().DefaultAutoDetect) + assert.True(t, r.Metadata().DefaultEnabled) + assert.Equal(t, []string{DriverID}, r.IDs()) + assert.Empty(t, r.Detect(nil)) + assert.False(t, r.Connected()) + _, err := r.Write("x") + require.Error(t, err) +} + +func TestOpen_RejectsBadDriverAndAddress(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + err := rig.reader.Open(config.ReadersConnect{Driver: "pn532", Path: testAddress}, rig.scans, readers.OpenOpts{}) + require.Error(t, err) + err = rig.reader.Open(config.ReadersConnect{Driver: DriverID, Path: "/dev/ttyUSB0"}, rig.scans, readers.OpenOpts{}) + require.Error(t, err) + assert.False(t, rig.reader.Connected()) + assert.Empty(t, rig.adapter.Cent.Finds(), "nothing is scanned for an invalid address") +} + +func TestOpen_RequiresCentralRole(t *testing.T) { + t.Parallel() + + rig := newTestRig(t, bluez.RolePeripheral) + err := rig.reader.Open(config.ReadersConnect{Driver: DriverID, Path: testAddress}, rig.scans, readers.OpenOpts{}) + require.ErrorIs(t, err, bluez.ErrRoleUnsupported) + assert.True(t, rig.adapter.Closed(), "a failed open releases the adapter") + assert.False(t, rig.reader.Connected()) +} + +func TestOpen_ReturnsAtOnceAndKeepsSearching(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + started := time.Now() + rig.open(t) + assert.Less(t, time.Since(started), rig.reader.findTimeout, "Open must not wait for the device") + assert.Contains(t, rig.reader.Info(), "searching") + + // Attempts continue with growing pauses while the device is absent. + require.Eventually(t, func() bool { + rig.clock.Advance(maxRetryPause) + return len(rig.adapter.Cent.Finds()) >= 3 + }, testTimeout, 10*time.Millisecond) + for _, f := range rig.adapter.Cent.Finds() { + assert.Equal(t, testAddress, f.Address) + assert.Equal(t, []string{nusServiceUUID}, f.ServiceUUIDs) + } + rig.noScan(t) + + // Once the device shows up it is connected on the next attempt. + rig.addDevice() + rig.waitLinked(t) + assert.Equal(t, testAddress, rig.reader.Path()) + assert.Equal(t, readers.GenerateReaderID(DriverID, testAddress), rig.reader.ReaderID()) +} + +func TestScansAndRemoval(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.addDevice() + rig.open(t) + rig.waitLinked(t) + + // Lines may arrive split across notifications. + rig.tx.Push([]byte("SCAN\tuid=abc123\ttext=**launch")) + rig.tx.Push([]byte(".system:nes\n")) + scan := rig.scan(t) + require.NotNil(t, scan.Token) + assert.Equal(t, "abc123", scan.Token.UID) + assert.Equal(t, "**launch.system:nes", scan.Token.Text) + assert.Equal(t, rig.reader.ReaderID(), scan.ReaderID) + assert.False(t, scan.ReaderError) + + // The reader repeats the token while it is present: no duplicate scan. + rig.tx.Push([]byte("SCAN\tuid=abc123\ttext=**launch.system:nes\n")) + rig.noScan(t) + + // A different token is a new scan. + rig.tx.Push([]byte("SCAN\tuid=def456\n")) + scan = rig.scan(t) + require.NotNil(t, scan.Token) + assert.Equal(t, "def456", scan.Token.UID) + + // Silence for longer than the removal timeout reports it gone. + rig.tick(t, removalPoll) + rig.noScan(t) + rig.tick(t, removalTimeout) + scan = rig.scan(t) + assert.Nil(t, scan.Token) + assert.False(t, scan.ReaderError) + assert.True(t, rig.reader.Connected()) +} + +func TestRemovableFlag(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.addDevice() + rig.open(t) + rig.waitLinked(t) + assert.True(t, readers.HasCapability(rig.reader, readers.CapabilityRemovable)) + + rig.tx.Push([]byte("SCAN\tuid=1\tremovable=no\n")) + rig.scan(t) + assert.False(t, readers.HasCapability(rig.reader, readers.CapabilityRemovable)) + + rig.tx.Push([]byte("SCAN\tuid=2\tremovable=yes\n")) + rig.scan(t) + assert.True(t, readers.HasCapability(rig.reader, readers.CapabilityRemovable)) +} + +func TestLinkLossReportsReaderErrorAndReconnects(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.addDevice() + rig.open(t) + rig.waitLinked(t) + + rig.tx.Push([]byte("SCAN\tuid=abc123\n")) + require.NotNil(t, rig.scan(t).Token) + + rig.device.Drop() + scan := rig.scan(t) + assert.Nil(t, scan.Token) + assert.True(t, scan.ReaderError, "a lost link must not look like a removal") + assert.True(t, rig.reader.Connected(), "the reader stays attached and reconnects itself") + require.Eventually(t, func() bool { return strings.Contains(rig.reader.Info(), "searching") }, + testTimeout, 10*time.Millisecond) + + // The device comes back: scans resume on the new link. + rig.addDevice() + rig.waitLinked(t) + rig.tx.Push([]byte("SCAN\tuid=abc123\n")) + scan = rig.scan(t) + require.NotNil(t, scan.Token) + assert.Equal(t, "abc123", scan.Token.UID) +} + +func TestLinkLossWithoutTokenIsQuiet(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.addDevice() + rig.open(t) + rig.waitLinked(t) + + rig.device.Drop() + require.Eventually(t, func() bool { return strings.Contains(rig.reader.Info(), "searching") }, + testTimeout, 10*time.Millisecond) + rig.noScan(t) +} + +func TestCloseReleasesEverything(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.addDevice() + rig.open(t) + rig.waitLinked(t) + + require.NoError(t, rig.reader.Close()) + assert.False(t, rig.reader.Connected()) + assert.False(t, rig.device.Connected()) + assert.True(t, rig.adapter.Closed()) + require.NoError(t, rig.reader.Close(), "closing twice is harmless") +} + +func TestCloseWhileSearching(t *testing.T) { + t.Parallel() + + rig := newTestRig(t) + rig.open(t) + require.NoError(t, rig.reader.Close()) + assert.False(t, rig.reader.Connected()) + assert.True(t, rig.adapter.Closed()) +} diff --git a/pkg/service/discovery/discovery.go b/pkg/service/discovery/discovery.go index 3b831598d..0c7950025 100644 --- a/pkg/service/discovery/discovery.go +++ b/pkg/service/discovery/discovery.go @@ -299,19 +299,25 @@ func (s *Service) InstanceName() string { } // resolveInstanceName determines the instance name to advertise. -// Priority: config value > hostname > fallback. func (s *Service) resolveInstanceName() (string, error) { - if name := s.cfg.DiscoveryInstanceName(); name != "" { - return name, nil + return ResolveInstanceName(s.cfg), nil +} + +// ResolveInstanceName is the name this device presents to discovery +// clients, over mDNS and Bluetooth alike. Priority: configured discovery +// instance name, then the hostname, then a fallback built from the device ID. +func ResolveInstanceName(cfg *config.Instance) string { + if name := cfg.DiscoveryInstanceName(); name != "" { + return name } hostname, err := os.Hostname() if err != nil { log.Warn().Err(err).Msg("failed to get hostname, using fallback") - return fallbackInstanceName(s.cfg.DeviceID()), nil + return fallbackInstanceName(cfg.DeviceID()) } - return hostname, nil + return hostname } // fallbackInstanceName names the service when the machine will not say what it diff --git a/pkg/service/service.go b/pkg/service/service.go index 9beb861c9..6fda480ef 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -34,6 +34,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/mediadb" @@ -600,6 +601,11 @@ func startService( discoveryService := discovery.New(cfg) + // The Bluetooth manager only touches the adapter while the BLE + // transport is enabled; started here so the API can register for it. + bleManager := bluetooth.NewManager(cfg) + bleManager.Start() + // Set up the idle scheduler before API startup so the in-flight // counter is wired through the very first request. idleSched := idle.New() @@ -611,13 +617,14 @@ func startService( apiDone <- api.StartWithReady( pl, cfg, st, itq, cfq, db, limitsManager, profilesSvc, notifBroker, player, playbackManager, indexPauser, scrapePauser, - backupPauser, idleSched, apiReady, + backupPauser, idleSched, apiReady, api.WithBluetooth(bleManager), ) }() apiReadyStarted := time.Now() if apiErr := <-apiReady; apiErr != nil { discoveryService.Stop() + bleManager.Stop() if stopErr := pl.Stop(); stopErr != nil { log.Warn().Msgf("error stopping platform after API startup failure: %s", stopErr) } @@ -872,6 +879,7 @@ func startService( if apiErr := <-apiDone; apiErr != nil { log.Error().Err(apiErr).Msg("API service stopped with error") } + bleManager.Stop() limitsManager.Stop() dataSwap.Stop() notifBroker.Stop() diff --git a/pkg/testing/mocks/bluez.go b/pkg/testing/mocks/bluez.go new file mode 100644 index 000000000..b56905757 --- /dev/null +++ b/pkg/testing/mocks/bluez.go @@ -0,0 +1,334 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package mocks + +import ( + "context" + "strings" + "sync" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/bluetooth/bluez" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" +) + +// FakeAdapter is an in-memory bluez.Adapter. +type FakeAdapter struct { + Periph *FakePeripheral + Cent *FakeCentral + gone chan struct{} + Addr string + RoleList []bluez.Role + goneOnce sync.Once + mu syncutil.Mutex + closed bool +} + +// NewFakeAdapter returns an adapter reporting the given roles. +func NewFakeAdapter(roles ...bluez.Role) *FakeAdapter { + return &FakeAdapter{ + Periph: NewFakePeripheral(), + Cent: NewFakeCentral(), + gone: make(chan struct{}), + Addr: "AA:BB:CC:DD:EE:FF", + RoleList: roles, + } +} + +func (a *FakeAdapter) Address() string { return a.Addr } + +func (a *FakeAdapter) Roles() []bluez.Role { return append([]bluez.Role(nil), a.RoleList...) } + +func (a *FakeAdapter) Peripheral() (bluez.Peripheral, error) { + if !bluez.SupportsRole(a.RoleList, bluez.RolePeripheral) { + return nil, bluez.ErrRoleUnsupported + } + return a.Periph, nil +} + +func (a *FakeAdapter) Central() (bluez.Central, error) { + if !bluez.SupportsRole(a.RoleList, bluez.RoleCentral) { + return nil, bluez.ErrRoleUnsupported + } + return a.Cent, nil +} + +func (a *FakeAdapter) Gone() <-chan struct{} { return a.gone } + +// MarkGone simulates the adapter being unplugged. +func (a *FakeAdapter) MarkGone() { + a.goneOnce.Do(func() { close(a.gone) }) +} + +func (a *FakeAdapter) Close() error { + a.mu.Lock() + a.closed = true + a.mu.Unlock() + a.MarkGone() + return nil +} + +// Closed reports whether Close was called. +func (a *FakeAdapter) Closed() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.closed +} + +// FakeNotification is one value a FakePeripheral was asked to notify. +type FakeNotification struct { + CharUUID string + Value []byte +} + +// FakePeripheral is an in-memory bluez.Peripheral. Serve blocks until its +// context ends; tests drive the handler it was given through Handler. +type FakePeripheral struct { + handler bluez.PeripheralHandler + Notifications chan FakeNotification + app bluez.Application + adv bluez.Advertisement + disconnects []bluez.Peer + mu syncutil.Mutex +} + +// NewFakePeripheral returns a peripheral whose Notifications channel +// receives everything Notify is called with. +func NewFakePeripheral() *FakePeripheral { + return &FakePeripheral{Notifications: make(chan FakeNotification, 256)} +} + +func (p *FakePeripheral) Serve( + ctx context.Context, app bluez.Application, adv bluez.Advertisement, h bluez.PeripheralHandler, +) error { + p.mu.Lock() + p.handler = h + p.app = app + p.adv = adv + p.mu.Unlock() + <-ctx.Done() + p.mu.Lock() + p.handler = nil + p.mu.Unlock() + return nil +} + +// Handler returns the handler of the active Serve call, or nil. +func (p *FakePeripheral) Handler() bluez.PeripheralHandler { + p.mu.Lock() + defer p.mu.Unlock() + return p.handler +} + +// Application returns what the active Serve call registered. +func (p *FakePeripheral) Application() bluez.Application { + p.mu.Lock() + defer p.mu.Unlock() + return p.app +} + +// Advertisement returns what the active Serve call advertised. +func (p *FakePeripheral) Advertisement() bluez.Advertisement { + p.mu.Lock() + defer p.mu.Unlock() + return p.adv +} + +func (p *FakePeripheral) Notify(charUUID string, value []byte) error { + n := FakeNotification{CharUUID: charUUID, Value: append([]byte(nil), value...)} + select { + case p.Notifications <- n: + default: + } + return nil +} + +func (p *FakePeripheral) Disconnect(_ context.Context, peer bluez.Peer) error { + p.mu.Lock() + p.disconnects = append(p.disconnects, peer) + p.mu.Unlock() + return nil +} + +// Disconnects returns every peer Disconnect was called with. +func (p *FakePeripheral) Disconnects() []bluez.Peer { + p.mu.Lock() + defer p.mu.Unlock() + return append([]bluez.Peer(nil), p.disconnects...) +} + +// FakeFind records one Find call. +type FakeFind struct { + Address string + ServiceUUIDs []string +} + +// FakeCentral is an in-memory bluez.Central serving the devices it knows. +// Find for an unknown address blocks until the context ends. +type FakeCentral struct { + devices map[string]*FakeDevice + finds []FakeFind + mu syncutil.Mutex +} + +func NewFakeCentral() *FakeCentral { + return &FakeCentral{devices: make(map[string]*FakeDevice)} +} + +// AddDevice makes a device findable by address. +func (c *FakeCentral) AddDevice(d *FakeDevice) { + c.mu.Lock() + defer c.mu.Unlock() + c.devices[strings.ToUpper(d.Addr)] = d +} + +func (c *FakeCentral) Find(ctx context.Context, address string, serviceUUIDs []string) (bluez.Device, error) { + c.mu.Lock() + c.finds = append(c.finds, FakeFind{Address: address, ServiceUUIDs: append([]string(nil), serviceUUIDs...)}) + d := c.devices[strings.ToUpper(address)] + c.mu.Unlock() + if d != nil { + return d, nil + } + <-ctx.Done() + return nil, ctx.Err() +} + +// Finds returns every Find call. +func (c *FakeCentral) Finds() []FakeFind { + c.mu.Lock() + defer c.mu.Unlock() + return append([]FakeFind(nil), c.finds...) +} + +// FakeDevice is an in-memory bluez.Device. +type FakeDevice struct { + ConnectErr error + chars map[string]*FakeCharacteristic + disconnected chan struct{} + Addr string + dropOnce sync.Once + mu syncutil.Mutex + connected bool +} + +func NewFakeDevice(address string) *FakeDevice { + return &FakeDevice{ + chars: make(map[string]*FakeCharacteristic), + disconnected: make(chan struct{}), + Addr: address, + } +} + +// AddCharacteristic registers a characteristic under a service. +func (d *FakeDevice) AddCharacteristic(serviceUUID, charUUID string, c *FakeCharacteristic) { + d.mu.Lock() + defer d.mu.Unlock() + d.chars[charKey(serviceUUID, charUUID)] = c +} + +func charKey(serviceUUID, charUUID string) string { + return strings.ToLower(serviceUUID) + "/" + strings.ToLower(charUUID) +} + +func (d *FakeDevice) Address() string { return d.Addr } + +func (d *FakeDevice) Connect(_ context.Context) error { + if d.ConnectErr != nil { + return d.ConnectErr + } + d.mu.Lock() + d.connected = true + d.mu.Unlock() + return nil +} + +// Connected reports whether Connect succeeded and Disconnect was not called. +func (d *FakeDevice) Connected() bool { + d.mu.Lock() + defer d.mu.Unlock() + return d.connected +} + +func (d *FakeDevice) Disconnect(_ context.Context) error { + d.mu.Lock() + d.connected = false + d.mu.Unlock() + d.Drop() + return nil +} + +func (d *FakeDevice) Characteristic(serviceUUID, charUUID string) (bluez.RemoteCharacteristic, error) { + d.mu.Lock() + defer d.mu.Unlock() + c, ok := d.chars[charKey(serviceUUID, charUUID)] + if !ok { + return nil, bluez.ErrNotFound + } + return c, nil +} + +func (d *FakeDevice) Disconnected() <-chan struct{} { return d.disconnected } + +// Drop simulates the link going down. +func (d *FakeDevice) Drop() { + d.mu.Lock() + d.connected = false + d.mu.Unlock() + d.dropOnce.Do(func() { close(d.disconnected) }) +} + +// FakeCharacteristic is an in-memory bluez.RemoteCharacteristic. Push feeds +// notifications to subscribers. +type FakeCharacteristic struct { + in chan []byte +} + +func NewFakeCharacteristic() *FakeCharacteristic { + return &FakeCharacteristic{in: make(chan []byte, 64)} +} + +// Push delivers a notification value to the subscriber. +func (c *FakeCharacteristic) Push(value []byte) { + c.in <- append([]byte(nil), value...) +} + +func (c *FakeCharacteristic) Subscribe(ctx context.Context) (<-chan []byte, error) { + out := make(chan []byte, 64) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case v := <-c.in: + select { + case out <- v: + case <-ctx.Done(): + return + } + } + } + }() + return out, nil +} + +func (*FakeCharacteristic) Write(_ context.Context, _ []byte, _ bool) error { + return nil +}