Patrick Schurig 693e01579f fix(optimiseur): un token recopié par l'optimiseur ne sort par aucun journal (brief 4 T7, correctif A)
Constat de T6 (question 2) : la trace masquait un token recopié dans reason ou planId, mais
les lignes [Optimiseur] (planId, texte, détail des refus, nom du handshake) et
GetOptimizerStatus.name le portaient en clair.

- OptimizerSecret : UNE fonction de masquage (texte brut et échappé JSON, objet JSON à toute
  profondeur, clés comprises ; ligne de trace sérialisée), partagée par le client et les traces.
- OptimizerClient masque chaque message reçu dès son analyse, avec le token envoyé sur cette
  connexion : rien en aval (journal, traces, app) ne voit le secret ; dit une fois.
- Test testAnEchoedTokenLeaksNowhere : secret avec " et \ recopié partout ; aucune ligne de
  journal (tous niveaux), aucune trace, aucune réponse RPC. 4 contre-épreuves mordent.

decisions sans endpoint : IDENTIQUE (23 792 lignes), 0 rejeu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:10:15 +02:00

320 lines
15 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#pragma once
#include <QByteArray>
#include <QElapsedTimer>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QVariantMap>
class QIODevice;
class QJsonObject;
class QTimer;
/*!
* \brief Adresse d'un optimiseur : \c unix:///chemin ou \c tcp://hôte:port (OPTIMIZER_PROTOCOL §2).
*
* \invariant Une adresse non reconnue est \c Invalid, jamais « à peu près » interprétée : un
* client construit dessus ne se connecte pas.
*/
struct OptimizerEndpoint
{
/*! \brief Famille de transport. */
enum Kind { Invalid, Unix, Tcp };
Kind kind = Invalid; //!< Famille ; \c Invalid si l'adresse n'a pas été reconnue.
QString path; //!< Chemin du socket local (\c Unix seulement).
QString host; //!< Hôte (\c Tcp seulement), sans crochets pour une IPv6.
quint16 port = 0; //!< Port (\c Tcp seulement), 1-65535.
/*!
* \brief Lit une adresse d'optimiseur.
* \param text \c unix:///run/powersync/optimizer.sock ou \c tcp://192.168.1.10:7400
* (IPv6 entre crochets : \c tcp://[fd00::1]:7400).
* \return L'adresse, ou \c kind == Invalid : schéma inconnu, chemin non absolu, hôte vide,
* port absent, nul ou hors bornes, ou tout reste après le port.
*/
static OptimizerEndpoint parse(const QString &text);
/*! \brief \return Vrai si l'adresse est utilisable. */
bool isValid() const { return kind != Invalid; }
/*! \brief \return La forme textuelle canonique, pour le journal (aucun secret n'y figure). */
QString toString() const;
};
/*!
* \brief Source de temps et réveil du client, injectable (\c tools/check-simulation-clock.py).
*
* Le client ne lit JAMAIS l'horloge murale : ses trois délais (handshake 5 s, \c GetPlan 10 s,
* backoff 1 → 60 s) sont des durées, mesurées sur cette horloge MONOTONE. Il n'arme qu'un réveil
* à la fois — l'échéance la plus proche — et réévalue toutes ses échéances quand il sonne.
*
* L'implémentation par défaut lit un \c QElapsedTimer et arme un \c QTimer à coup unique. Une
* suite de test la dérive pour avancer le temps à la main : aucun délai ne s'écoule alors tant
* que le test ne l'a pas décidé.
*
* \invariant \c nowMs() ne décroît jamais.
* \invariant Après \c armWakeUp(d), \c wakeUp() est émis au plus une fois, au plus tôt \c d ms
* plus tard ; un nouvel \c armWakeUp() ou \c disarmWakeUp() remplace le réveil en attente.
*/
class OptimizerClientClock : public QObject
{
Q_OBJECT
public:
/*! \brief Horloge réelle (monotone). \param parent Propriétaire Qt. */
explicit OptimizerClientClock(QObject *parent = nullptr);
/*! \brief \return Millisecondes monotones depuis une origine arbitraire. */
virtual qint64 nowMs() const;
/*! \brief Arme le réveil unique. \param delayMs Délai, en ms (0 = au prochain tour de boucle). */
virtual void armWakeUp(qint64 delayMs);
/*! \brief Désarme le réveil en attente, s'il y en a un. */
virtual void disarmWakeUp();
signals:
/*! \brief Le délai armé est écoulé. */
void wakeUp();
private:
QElapsedTimer m_elapsed;
QTimer *m_timer = nullptr;
};
/*!
* \brief Transport client vers un optimiseur externe (OPTIMIZER_PROTOCOL §2-§4) — brief 4 T2.
*
* Un socket \c unix:// (\c QLocalSocket) ou \c tcp:// (\c QTcpSocket), JSON-RPC 2.0, un message
* par ligne. Le client se connecte, fait le handshake §3, puis porte des \c GetPlan à la demande.
* Il ne sait rien du contenu d'un plan : il rend le \c result tel quel, la validation est à
* l'appelant (T3).
*
* **Machine d'états** :
* \code
* Stopped ──start()──► Connecting ──connecté──► Handshaking ──réponse valide──► Ready
* │ │ │
* └──── échec, 5 s ──────────┴── refus, majeure, 5 s ───┤ fermeture
* ▼ ▼
* Waiting (backoff 1, 2, 4 … 60 s) ──échéance──► Connecting
* \endcode
* Les 5 s du handshake courent depuis le DÉBUT de la tentative : une connexion TCP qui ne
* s'établit jamais (hôte filtré) est bornée par le même délai. Le backoff double à chaque échec
* et revient à 1 s après un handshake réussi.
*
* **Une requête en vol au plus.** \c requestPlan() pendant qu'une requête attend la remplace :
* l'ancienne n'est plus attendue, sa réponse sera ignorée à son arrivée (comptée dans
* \c staleResponses()), et elle ne produit ni \c planReceived() ni \c planFailed(). Les
* identifiants croissent sur toute la vie du client, à travers les reconnexions.
*
* **Erreurs de ligne** — ligne non JSON, message sans identifiant, identifiant jamais émis, ligne
* de plus de \c kMaxLineBytes : comptées (\c protocolErrors()), annoncées par
* \c protocolError(), et la connexion est CONSERVÉE. La requête en vol continue d'attendre.
*
* **Le token** est relu dans \c tokenPath à CHAQUE tentative de connexion : le remplacer prend
* effet à la suivante, sans redémarrage. Il ne figure dans aucune ligne de journal, ni en clair
* ni par sa longueur ; le message de handshake qui le porte n'est jamais journalisé.
* **Un optimiseur qui le recopie** (\c reason, \c planId, \c name…) ne le fait pas sortir : chaque
* message reçu est masqué dès son analyse (\c OptimizerSecret::masquerJson(), avec le token
* envoyé sur CETTE connexion), avant toute journalisation et avant tout signal — journal, traces
* et charges utiles de l'app ne voient que \c "[token masqué]". Le fait est dit une fois par
* client, jamais la valeur.
*
* \invariant Aucune méthode n'attend : toute E/S est asynchrone. \c requestPlan() rend la main
* immédiatement ; la réponse arrive par signal, au plus tôt au tour de boucle suivant.
* \invariant Le client n'écoute aucun port (invariant 10) : il se connecte, c'est tout.
* \invariant Sans \c start(), le client n'ouvre rien, n'arme rien et ne journalise rien.
*/
class OptimizerClient : public QObject
{
Q_OBJECT
public:
/*! \brief État de la connexion — voir la machine d'états de la classe. */
enum State {
Stopped, //!< Pas démarré, ou arrêté par \c stop().
Waiting, //!< Déconnecté, prochaine tentative au backoff.
Connecting, //!< Connexion du socket en cours.
Handshaking, //!< Connecté, \c Handshake envoyé, réponse attendue.
Ready //!< Handshake accepté : \c requestPlan() est servi.
};
Q_ENUM(State)
/*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */
static constexpr const char *kProtocolVersion = "1.1";
static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative.
static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan.
static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion.
static constexpr qint64 kBackoffMaxMs = 60000; //!< §2, plafond du backoff.
/*! \brief Plus longue ligne acceptée, octets hors \c '\\n' (1 Mio, proposition T0 ; écart E24). */
static constexpr int kMaxLineBytes = 1 << 20;
/*! \brief Nom du fichier de token, sous \c NymeaSettings::settingsPath() (décision Q2). */
static constexpr const char *kTokenFileName = "energy-optimizer.token";
/*!
* \brief Construit un client ARRÊTÉ : rien n'est ouvert avant \c start().
* \param endpoint Adresse de l'optimiseur ; invalide → \c start() refuse.
* \param tokenPath Fichier du token (\c defaultTokenPath() en production) ; vide = jamais de
* token. Un fichier absent vaut « pas de token configuré ».
* \param engineVersion Version du moteur, annoncée dans \c params.engine.
* \param clock Horloge injectable ; \c nullptr = horloge réelle, possédée par le client. Une
* horloge fournie n'est pas possédée et doit survivre au client.
* \param parent Propriétaire Qt.
*/
OptimizerClient(const OptimizerEndpoint &endpoint, const QString &tokenPath,
const QString &engineVersion, OptimizerClientClock *clock = nullptr,
QObject *parent = nullptr);
~OptimizerClient() override;
/*! \brief \return \c NymeaSettings::settingsPath() + \c "/energy-optimizer.token". */
static QString defaultTokenPath();
/*!
* \brief Pose le token d'optimiseur : écriture atomique (\c QSaveFile), droits \c 0600.
*
* Les droits sont posés sur le fichier temporaire AVANT d'y écrire le secret : il n'existe
* aucun instant où le token est sur disque avec les droits de l'umask. Un fichier préexistant
* aux droits plus larges est ainsi resserré à 0600.
*
* \param path Fichier cible (\c defaultTokenPath() en production). Jamais \c energy.conf.
* \param token Secret ; vide = retrait du fichier (plus de token configuré).
* \param[out] error Motif d'échec, en français, sans le secret.
* \return Vrai si le fichier est posé (ou retiré) avec succès.
* \note Pas de RPC dans ce lot : c'est la fonction qu'elle appellera.
*/
static bool writeToken(const QString &path, const QByteArray &token, QString *error = nullptr);
/*! \brief Démarre : première tentative immédiate. Sans effet si déjà démarré. */
void start();
/*! \brief Ferme la connexion et désarme tout. La requête en vol est abandonnée sans signal. */
void stop();
/*! \brief \return L'état courant. */
State state() const { return m_state; }
/*!
* \brief Envoie un \c GetPlan. Remplace la requête en vol s'il y en a une.
* \param params Les \c params de la requête, tels quels (\c SurplusContext sérialisé, T1).
* \return L'identifiant JSON-RPC de la requête, ou 0 si le client n'est pas \c Ready (rien
* n'est envoyé : l'appelant reste sur sa stratégie de repli).
*/
quint64 requestPlan(const QVariantMap &params);
/*! \brief \return Identifiant de la requête attendue, 0 si aucune. */
quint64 pendingRequestId() const { return m_pendingId; }
/*! \brief \return \c result.capabilities du dernier handshake accepté (vide si absent). */
QVariantMap capabilities() const { return m_capabilities; }
/*! \brief \return Vrai si le dernier handshake accepté portait \c capabilities (§3 : seul
* ce cas restreint les \c kind acceptés). */
bool hasCapabilities() const { return m_hasCapabilities; }
/*! \brief \return \c result.name du dernier handshake accepté. */
QString serverName() const { return m_serverName; }
/*! \brief \return \c result.protocolVersion du dernier handshake accepté. */
QString serverProtocolVersion() const { return m_serverProtocolVersion; }
/*! \brief \return \c result.planning du dernier handshake accepté. */
bool serverPlanning() const { return m_serverPlanning; }
/*! \brief \return Erreurs de ligne comptées depuis la construction (connexion conservée). */
int protocolErrors() const { return m_protocolErrors; }
/*! \brief \return Réponses reçues pour une requête remplacée ou expirée, ignorées. */
int staleResponses() const { return m_staleResponses; }
/*! \brief \return Code de la dernière erreur (transport, handshake, ligne ou requête). */
QString lastErrorCode() const { return m_lastErrorCode; }
/*! \brief \return Délai qui sera appliqué au prochain échec de connexion, en ms. */
qint64 nextBackoffMs() const { return m_backoffMs; }
signals:
/*! \brief L'état a changé. \param state Le nouvel état. */
void stateChanged(OptimizerClient::State state);
/*! \brief Handshake accepté : \c capabilities() & co. sont à jour. */
void ready();
/*! \brief Réponse à la requête attendue. \param id Son identifiant. \param result Le
* \c result JSON-RPC, non validé. */
void planReceived(quint64 id, const QVariantMap &result);
/*!
* \brief La requête attendue n'aura pas de réponse utilisable.
* \param id Son identifiant.
* \param code \c timeout (10 s), \c disconnected (connexion perdue), \c rpc-error (réponse
* \c error), \c invalid-response (ni \c result objet ni \c error).
* \note Une requête REMPLACÉE n'émet rien : son abandon est le fait de l'appelant.
*/
void planFailed(quint64 id, const QString &code);
/*! \brief Une ligne reçue a été écartée, la connexion est conservée.
* \param code \c line-too-long, \c invalid-json, \c missing-id, \c unknown-id. */
void protocolError(const QString &code);
/*! \brief Notification JSON-RPC (\c method sans \c id), ex. \c UpdateLearned (§8). Rien
* n'en est fait ici. \param method Son nom. \param params Ses paramètres. */
void notificationReceived(const QString &method, const QVariantMap &params);
private:
void setState(State state);
void connectNow();
void onConnected();
void onSocketLost();
void onReadyRead();
void onWakeUp();
void handleLine(const QByteArray &line);
void handleHandshakeReply(const QJsonObject &message);
void handlePlanReply(quint64 id, const QJsonObject &message);
void failConnection(const QString &code, const QString &detail);
void dropSocket();
void scheduleReconnect();
void rearm();
void countProtocolError(const QString &code, const QString &detail);
bool send(const QVariantMap &message);
QByteArray readToken();
OptimizerEndpoint m_endpoint;
QString m_tokenPath;
QString m_engineVersion;
OptimizerClientClock *m_clock = nullptr;
State m_state = Stopped;
QPointer<QIODevice> m_socket;
QByteArray m_buffer;
bool m_skippingLine = false; // reste d'une ligne trop longue à jeter jusqu'au '\n'
quint64 m_lastId = 0; // dernier identifiant émis
quint64 m_handshakeId = 0;
quint64 m_pendingId = 0;
bool m_tokenSent = false; // le handshake en cours porte un token (pour le diagnostic)
QString m_tokenConnexion; // le token envoyé sur CETTE connexion : ce qu'un écho recopierait
bool m_echoTokenSignale = false;
qint64 m_handshakeDeadline = -1; // -1 = pas d'échéance
qint64 m_requestDeadline = -1;
qint64 m_reconnectAt = -1;
qint64 m_backoffMs = kBackoffInitialMs;
QVariantMap m_capabilities;
bool m_hasCapabilities = false;
QString m_serverName;
QString m_serverProtocolVersion;
bool m_serverPlanning = false;
int m_protocolErrors = 0;
int m_staleResponses = 0;
QString m_lastErrorCode;
QString m_failureStreakCode; // échec répété : annoncé une fois, puis compté
int m_failureStreak = 0;
QStringList m_errorsLoggedThisConnection;
};