SensESP 3.6.1-alpha
Universal Signal K sensor toolkit ESP32
Loading...
Searching...
No Matches
signalk_ws_client.h
Go to the documentation of this file.
1#ifndef SENSESP_SRC_SENSESP_SIGNALK_SIGNALK_WS_CLIENT_H_
2#define SENSESP_SRC_SENSESP_SIGNALK_SIGNALK_WS_CLIENT_H_
3
4#include "sensesp.h"
5
6#include <algorithm>
7
8#include <ArduinoJson.h>
9#include <esp_websocket_client.h>
10#include <atomic>
11#include <functional>
12#include <list>
13#include <set>
14
21#include "sensesp_base_app.h"
22
23// Maximum number of received value/put deltas buffered for processing on the
24// main task. Oldest entries are dropped when the buffer is full.
25#ifndef SENSESP_MAX_RECEIVED_VALUE_UPDATES
26#define SENSESP_MAX_RECEIVED_VALUE_UPDATES 20
27#endif
28
29// Maximum number of received meta deltas buffered for processing on the main
30// task, budgeted independently of value deltas so a metadata burst (~one entry
31// per subscribed path at subscribe time when sendMeta=all is enabled) cannot
32// evict pending values, and vice versa. Override per board via build_flags if
33// you subscribe many paths and need the full burst delivered (more paths =>
34// more buffered meta => more RAM).
35#ifndef SENSESP_MAX_RECEIVED_META_UPDATES
36#define SENSESP_MAX_RECEIVED_META_UPDATES 20
37#endif
38
39// Signal K WebSocket buffer size in bytes, allocated by esp_websocket_client for
40// both the tx and rx buffer. send_delta drops any delta longer than this rather
41// than let esp_websocket_client split it across non-blocking writes and abort
42// the connection (see signalk_ws_delta_size.h). Override per board via
43// build_flags to hold the largest delta a device sends (e.g. a GNSS receiver's
44// full-sky satellitesInView); kept small by default for memory-constrained
45// boards.
46#ifndef SENSESP_SK_WS_BUFFER_SIZE
47#define SENSESP_SK_WS_BUFFER_SIZE 1024
48#endif
49
50namespace sensesp {
51
52static const char* NULL_AUTH_TOKEN = "";
53
54// HTTP status returned on a WebSocket upgrade when the Signal K server rejects
55// the auth token.
56static constexpr int kHttpUnauthorized = 401;
57
73bool should_clear_token_on_status(int handshake_status);
74
80 // TLS certificate pinning rejected the server certificate. Distinct from a
81 // plain disconnect so the UI can tell a cert problem from a network drop.
83};
84
90 virtual public ValueProducer<SKWSConnectionState> {
91 public:
93 // main task methods
94
95 SKWSClient(const String& config_path,
96 std::shared_ptr<SKDeltaQueue> sk_delta_queue,
97 const String& server_address, uint16_t server_port,
98 bool use_mdns = true);
99
100 const String get_server_address() const { return server_address_; }
101 uint16_t get_server_port() const { return server_port_; }
102
103 virtual bool to_json(JsonObject& root) override final;
104 virtual bool from_json(const JsonObject& config) override final;
105
113
122
123 String get_connection_status();
124
126 // SK websocket connection methods
127
128 void on_disconnected();
129 // handshake_status is the HTTP status of a failed WebSocket upgrade (0 if not
130 // applicable); a 401 means the auth token was rejected.
131 void on_error(int handshake_status);
132 void on_connected();
133 void on_receive_delta(uint8_t* payload, size_t length);
134 void on_receive_updates(JsonDocument& message);
135 void on_receive_put(JsonDocument& message);
136 void connect();
137 void loop();
138 bool is_connected();
147 void restart();
148 bool is_connect_due() const {
150 }
151 void send_delta();
152
158 void sendTXT(String& payload);
159
163 bool is_ssl_enabled() const { return ssl_enabled_; }
164
181 String get_auth_token() const { return auth_token_; }
182
189 void set_ssl_enabled(bool enabled) {
190 ssl_enabled_ = enabled;
191 save();
192 }
193
208 void set_send_meta_enabled(bool enabled) {
209 send_meta_enabled_ = enabled;
210 save();
211 }
212
216 bool is_tofu_enabled() const { return tofu_enabled_; }
217
225 void set_tofu_enabled(bool enabled) {
226 tofu_enabled_ = enabled;
227 save();
228 }
229
231 bool has_tofu_anchor() const {
232 return !tofu_ca_pem_.isEmpty() || !tofu_fingerprint_.isEmpty();
233 }
234
236 bool has_tofu_ca() const { return !tofu_ca_pem_.isEmpty(); }
237 const String& get_tofu_ca() const { return tofu_ca_pem_; }
238
240 bool has_tofu_fingerprint() const { return !tofu_fingerprint_.isEmpty(); }
241 const String& get_tofu_fingerprint() const { return tofu_fingerprint_; }
242
244 const String& get_tofu_pin_cn() const { return tofu_pin_cn_; }
245 bool is_tofu_pin_ca() const { return tofu_pin_is_ca_; }
246
251 const String& get_tofu_san() const { return tofu_san_; }
252
260 void reset_tofu() {
261 tofu_ca_pem_ = "";
263 tofu_pin_cn_ = "";
264 tofu_pin_is_ca_ = false;
265 tofu_san_ = "";
266 save();
267 }
268
272 void stash_pending_ca(const String& ca_pem, const String& cn) {
273 pending_ca_pem_ = ca_pem;
275 pending_cn_ = cn;
276 pending_is_ca_ = true;
277 pending_valid_ = true;
278 }
279 void stash_pending_leaf(const String& fingerprint, const String& cn) {
280 pending_ca_pem_ = "";
281 pending_fingerprint_ = fingerprint;
282 pending_cn_ = cn;
283 pending_is_ca_ = false;
284 pending_valid_ = true;
285 }
288 void set_pending_san(const String& san) { pending_san_ = san; }
290 pending_ca_pem_ = "";
292 pending_cn_ = "";
293 pending_san_ = "";
294 pending_is_ca_ = false;
295 pending_valid_ = false;
296 }
298 bool has_pending_ca() const { return pending_valid_ && pending_is_ca_; }
302 if (!pending_valid_) {
303 return;
304 }
307 if (pending_is_ca_) {
310 tofu_san_ = pending_san_; // bind the leaf identity in CA-anchor mode
311 } else {
313 tofu_ca_pem_ = "";
314 tofu_san_ = ""; // leaf mode: the fingerprint binds identity
315 }
317 save();
318 }
319
322 void flag_cert_error() { cert_error_.store(true); }
323
327 uint32_t client_generation() const { return client_generation_.load(); }
328
329 protected:
330 // these are the actually used values
331 String server_address_ = "";
332 uint16_t server_port_ = 80;
333 // these are the hardcoded and/or conf file values
335 uint16_t conf_server_port_ = 0;
336 bool use_mdns_ = true;
337
338 String client_id_ = "";
339 String polling_href_ = "";
340 String auth_token_ = NULL_AUTH_TOKEN;
341
342 unsigned long last_attempt_ms_ = 0;
343 unsigned long attempt_wait_ms_ = 0;
344 unsigned long connect_interval_ms_ = 2000;
345
346 // SSL/TLS configuration
347 bool ssl_enabled_ = false;
348 bool tofu_enabled_ = true; // TOFU enabled by default
349
350 // Subscribe with ?sendMeta=all so metadata (zones, units, etc) is
351 // pushed in-stream rather than requiring REST /meta polls.
353
354 // TOFU trust anchor. At most one of these is non-empty once captured:
355 // tofu_ca_pem_ — PEM of the pinned issuing CA (CA-anchor mode)
356 // tofu_fingerprint_ — SHA256 hex of the pinned leaf (leaf-fingerprint mode,
357 // and the legacy on-disk format the migration reads)
358 String tofu_fingerprint_ = ""; // SHA256 fingerprint in hex (64 chars)
359 String tofu_ca_pem_ = ""; // PEM of pinned CA (CA-anchor mode)
360 // TOFU'd leaf identity (normalized DNS SAN set) bound in CA-anchor mode, so a
361 // reconnecting leaf must present the same identity AND chain to the pinned CA.
362 // This is what makes CA pinning safe against a public CA (e.g. Let's Encrypt):
363 // a valid leaf for a different name from the same CA fails the identity check.
364 // Empty in leaf-fingerprint mode.
365 String tofu_san_ = "";
366 // Display-only identity of the pinned cert, captured at pin time (X.509
367 // parsing is only available during the handshake). The CN is attacker-
368 // controlled at capture and is bounded + sanitized before storage.
369 String tofu_pin_cn_ = "";
370 bool tofu_pin_is_ca_ = false;
371
372 // Candidate anchor seen during the current connection attempt, persisted only
373 // after the connection succeeds (commit_pending_tofu) so an unauthenticated
374 // MITM handshake cannot plant a trust anchor.
375 String pending_ca_pem_ = "";
377 String pending_cn_ = "";
378 String pending_san_ = "";
379 bool pending_is_ca_ = false;
380 bool pending_valid_ = false;
381
382 // Set by the verify callback (transport task) on certificate rejection; read
383 // by set_connection_state to surface kSKWSCertificateError. Atomic because the
384 // callback and state changes may run on different cores.
385 std::atomic<bool> cert_error_{false};
386
389
394
395 // Atomic for pointer-atomicity: the connect worker builds/stores it; the
396 // SK/event-loop context loads it to send and reaps it via exchange(nullptr)
397 // (single check-and-null, so no double-free). Writers are serialized
398 // single-owner-at-a-time by auth_job_running_ / teardown_in_progress_.
399 // NOTE: the atomic confers pointer-atomicity, not lifetime safety. send_delta
400 // and sendTXT run on the event loop, serialized with detach_teardown (same
401 // context), so they cannot hold a handle across its reap. Transport-task
402 // senders (on_connected -> subscribe_listeners, on_receive_put's response)
403 // are made safe by esp_websocket_client_stop() joining the transport task
404 // before destroy() frees the struct. See #1033.
405 std::atomic<esp_websocket_client_handle_t> client_{nullptr};
406 // Bumped on every teardown so the (singleton) event handler can drop late
407 // callbacks from a client that has been handed off for destruction. The
408 // handler is registered with the generation current at build time; an event
409 // whose generation != client_generation_ comes from a torn-down client.
410 std::atomic<uint32_t> client_generation_{0};
411 // True while a detached task is stopping+destroying a previous client.
412 // Bring-up is deferred until it clears, so at most one client ever exists.
413 std::atomic<bool> teardown_in_progress_{false};
414 // True while a one-shot worker is running a (blocking) connect attempt
415 // (mDNS / SSL-detect / access-request / poll legs). The dispatcher skips
416 // while it is set, so at most one attempt runs at a time.
417 std::atomic<bool> auth_job_running_{false};
418 // Holds a handle whose detached reaper task failed to spawn (OOM); the event
419 // loop retries the spawn each tick rather than reaping it synchronously (which
420 // would block the loop). At most one at a time (teardown_in_progress_ defers
421 // bring-up until it is reaped).
422 std::atomic<esp_websocket_client_handle_t> pending_teardown_{nullptr};
423 std::shared_ptr<SKDeltaQueue> sk_delta_queue_;
428
433
441 bool is_meta = false;
442 JsonDocument doc;
443 };
444
446 SemaphoreHandle_t received_updates_semaphore_ =
447 xSemaphoreCreateRecursiveMutexStatic(&received_updates_semaphore_buffer_);
448 std::list<ReceivedUpdate> received_updates_{};
449
451 // methods for all tasks
452
453 bool take_received_updates_semaphore(unsigned long int timeout_ms = 0) {
454 if (timeout_ms == 0) {
455 return xSemaphoreTakeRecursive(received_updates_semaphore_,
456 portMAX_DELAY) == pdTRUE;
457 } else {
458 return xSemaphoreTakeRecursive(received_updates_semaphore_,
459 timeout_ms) == pdTRUE;
460 }
461 }
463 xSemaphoreGiveRecursive(received_updates_semaphore_);
464 }
465
470 void enqueue_received_update(ReceivedUpdate&& update);
471
473 // main task methods
474
476
478 // SK websocket connection methods
479
480#ifndef SENSESP_SSL_SUPPORT
481 // Validate the auth token over plain HTTP before opening the WebSocket
482 // stream. Only used on non-SSL builds; SSL builds validate it on the upgrade
483 // itself (see connect()) to avoid a second TLS handshake that fragments the
484 // heap.
485 void test_token(const String host, const uint16_t port);
486#endif
487 void send_access_request(const String host, const uint16_t port);
488 void poll_access_request(const String host, const uint16_t port,
489 const String href);
490 void connect_ws(const String& host, const uint16_t port);
497 void detach_teardown();
501 void reap_async(esp_websocket_client_handle_t old);
503 static void teardown_task(void* arg);
507 void run_connect_attempt();
508 static void connect_worker(void* arg);
509 void subscribe_listeners();
510 bool get_mdns_service(String& server_address, uint16_t& server_port);
511 bool detect_ssl();
512
514 // A certificate rejection during this attempt (flagged by the verify
515 // callback) is surfaced distinctly instead of as a generic disconnect.
516 // This is the single chokepoint, so it covers all four TLS paths — the
517 // three esp_http_client requests and the websocket — without touching each
518 // call site. A fresh attempt (authorizing/connecting) or success clears the
519 // flag.
522 } else if (state != SKWSConnectionState::kSKWSDisconnected &&
524 cert_error_.store(false);
525 }
527 connection_state_.set(state);
528 }
530
532 last_attempt_ms_ = millis();
534 connect_interval_ms_ + (esp_random() % (connect_interval_ms_ / 4 + 1));
536 std::min(connect_interval_ms_ * 2, (unsigned long)60000);
537 }
538
542};
543
544inline const String ConfigSchema(const SKWSClient& obj) {
545 return "{\"type\":\"object\",\"properties\":{"
546 "\"ssl_enabled\":{\"title\":\"SSL/TLS Enabled\",\"type\":\"boolean\"},"
547 "\"tofu_enabled\":{\"title\":\"TOFU Verification\",\"type\":\"boolean\"},"
548 "\"tofu_pin_cn\":{\"title\":\"Pinned Certificate\",\"type\":\"string\",\"readOnly\":true},"
549 "\"tofu_pin_is_ca\":{\"title\":\"Pinned as CA\",\"type\":\"boolean\",\"readOnly\":true},"
550 "\"send_meta_enabled\":{\"title\":\"Subscribe with sendMeta=all\","
551 "\"description\":\"Request metadata deltas (units, zones, displayName, displayUnits) over the WS stream. Disable only for constrained clients that ignore them.\","
552 "\"type\":\"boolean\"}"
553 "}}";
554}
555
556inline bool ConfigRequiresRestart(const SKWSClient& obj) { return true; }
557
558} // namespace sensesp
559
560#endif
virtual bool save() override
Save the object to a persistent storage.
Definition saveable.cpp:40
Integrator integrates (accumulates) the incoming values.
Definition integrator.h:19
The websocket connection to the Signal K server.
void commit_pending_tofu()
Persist a stashed anchor after a successful (authenticated) connection. Called from on_connected().
void poll_access_request(const String host, const uint16_t port, const String href)
void on_receive_delta(uint8_t *payload, size_t length)
Called when the websocket receives a delta.
unsigned long last_attempt_ms_
SemaphoreHandle_t received_updates_semaphore_
unsigned long connect_interval_ms_
void process_received_updates()
Loop through the received updates and process them.
void set_ssl_enabled(bool enabled)
Enable or disable SSL/TLS manually.
const String & get_tofu_ca() const
std::atomic< esp_websocket_client_handle_t > pending_teardown_
std::atomic< bool > auth_job_running_
void connect_ws(const String &host, const uint16_t port)
void stash_pending_leaf(const String &fingerprint, const String &cn)
unsigned long attempt_wait_ms_
Integrator< int, int > delta_tx_count_producer_
TaskQueueProducer< SKWSConnectionState > connection_state_
const String & get_tofu_pin_cn() const
Identity (CN) of whatever is pinned, and whether it is a CA.
SKWSConnectionState get_connection_state()
std::atomic< bool > teardown_in_progress_
void on_receive_put(JsonDocument &message)
Called when a PUT event is received.
uint32_t client_generation() const
Generation tag of the currently-valid client. The event handler compares the generation it was regist...
void release_received_updates_semaphore()
void run_connect_attempt()
The (blocking) connect attempt — mDNS resolve, SSL detect, and the access-request / poll / connect_ws...
SKWSConnectionState task_connection_state_
void send_access_request(const String host, const uint16_t port)
std::atomic< uint32_t > client_generation_
void on_error(int handshake_status)
Called when the websocket connection encounters an error.
Integrator< int, int > delta_rx_count_producer_
std::list< ReceivedUpdate > received_updates_
bool is_send_meta_enabled() const
Whether the WS subscribes with sendMeta=all.
virtual bool to_json(JsonObject &root) override final
uint16_t get_server_port() const
void detach_teardown()
Hand client_ to a detached one-shot task that stops+destroys it, so the blocking teardown never runs ...
void enqueue_received_update(ReceivedUpdate &&update)
Push a received delta entry onto the queue, enforcing a per-kind budget so a metadata burst (one meta...
void sendTXT(String &payload)
Send some processed data to the websocket.
uint32_t last_oversize_log_ms_
millis() timestamp of the last oversize-delta-drop warning, used to rate-limit it when a device keeps...
bool get_mdns_service(String &server_address, uint16_t &server_port)
std::atomic< esp_websocket_client_handle_t > client_
const String & get_tofu_san() const
TOFU'd leaf identity (normalized DNS SAN set) bound in CA-anchor mode. A reconnecting leaf must chain...
void on_disconnected()
Called when the websocket connection is disconnected.
ValueProducer< int > & get_delta_tx_count_producer()
void reap_async(esp_websocket_client_handle_t old)
Spawn the detached reaper for old; on spawn failure (OOM) stash it in pending_teardown_ for a later r...
void subscribe_listeners()
Subscribes the SK delta paths to the websocket.
void on_receive_updates(JsonDocument &message)
Called when a delta update is received.
void test_token(const String host, const uint16_t port)
bool has_tofu_ca() const
True if a pinned CA certificate is stored (CA-anchor mode).
bool has_tofu_fingerprint() const
True if a pinned leaf fingerprint is stored (leaf-fingerprint mode).
void set_connection_state(SKWSConnectionState state)
ValueProducer< int > & get_delta_rx_count_producer()
Get the delta rx count producer object.
void stash_pending_ca(const String &ca_pem, const String &cn)
Stash a candidate anchor seen during a handshake. Persisted only after the connection succeeds (commi...
bool take_received_updates_semaphore(unsigned long int timeout_ms=0)
StaticSemaphore_t received_updates_semaphore_buffer_
virtual bool from_json(const JsonObject &config) override final
void set_tofu_enabled(bool enabled)
Enable or disable TOFU certificate verification.
std::atomic< bool > cert_error_
void set_pending_san(const String &san)
Record the leaf's identity (SAN set) for the pending CA anchor, captured at depth 0 and committed alo...
String get_connection_status()
Get a String representation of the current connection state.
static void connect_worker(void *arg)
void set_send_meta_enabled(bool enabled)
const String get_server_address() const
TaskQueueProducer< int > delta_tx_tick_producer_
Emits the number of deltas sent since last report.
void reset_tofu()
Reset the stored trust anchor (CA or leaf fingerprint).
static void teardown_task(void *arg)
Body of the detached teardown task (stop+destroy+self-delete).
bool has_tofu_anchor() const
True if any trust anchor (pinned CA or leaf fingerprint) is stored.
const String & get_tofu_fingerprint() const
bool is_ssl_enabled() const
Check if SSL/TLS is enabled.
std::shared_ptr< SKDeltaQueue > sk_delta_queue_
void restart()
Drop the current connection (detached, non-blocking teardown) and let the reconnect path rebuild it.
void on_connected()
Called when the websocket connection is established.
bool is_tofu_enabled() const
Check if TOFU certificate verification is enabled.
String get_auth_token() const
Current Signal K access token, or an empty string if none.
bool has_pending_ca() const
True if a candidate CA has already been stashed this handshake.
void flag_cert_error()
Flag a certificate rejection from the verify callback so the next disconnect surfaces as kSKWSCertifi...
Producer class that works across task boundaries.
virtual void set(const T &value) override
A base class for any sensor or piece of code that outputs a value for consumption elsewhere.
const String ConfigSchema(const SmartSwitchController &obj)
bool sk_connect_due(uint32_t now_ms, uint32_t last_attempt_ms, uint32_t wait_ms)
True if the next Signal K connection attempt is due.
std::shared_ptr< reactesp::EventLoop > event_loop()
Definition sensesp.cpp:9
bool should_clear_token_on_status(int handshake_status)
Decide whether a failed WebSocket upgrade should clear the auth token.
bool ConfigRequiresRestart(const HTTPServer &obj)
A single received delta entry awaiting dispatch on the main task.