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>
381 lines
14 KiB
C++
381 lines
14 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
// Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||
|
||
#include "optimizertrace.h"
|
||
|
||
#include <QDir>
|
||
#include <QFileInfo>
|
||
#include <QJsonArray>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QLoggingCategory>
|
||
#include <QRegularExpression>
|
||
|
||
#include <nymeasettings.h>
|
||
|
||
#include "energysettings.h"
|
||
#include "optimizersecret.h"
|
||
|
||
Q_DECLARE_LOGGING_CATEGORY(dcNymeaEnergy)
|
||
|
||
namespace OptimizerTrace {
|
||
|
||
namespace {
|
||
|
||
QString iso(const QDateTime &t) { return t.toUTC().toString(Qt::ISODate); }
|
||
|
||
QString nomKind(LoadAction::Kind k)
|
||
{
|
||
switch (k) {
|
||
case LoadAction::Setpoint: return QStringLiteral("setpoint");
|
||
case LoadAction::State: return QStringLiteral("state");
|
||
case LoadAction::Constraint: return QStringLiteral("constraint");
|
||
}
|
||
return QString();
|
||
}
|
||
|
||
// Une action DÉCIDÉE, au format d'une action de plan (§6) — le motif en code, jamais en phrase.
|
||
QVariantMap actionDecidee(const LoadAction &a)
|
||
{
|
||
QVariantMap m;
|
||
m.insert(QStringLiteral("loadId"), a.loadId);
|
||
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||
m.insert(QStringLiteral("powerW"), qMax(0.0, a.estimatedPowerW));
|
||
if (a.kind == LoadAction::State)
|
||
m.insert(QStringLiteral("state"), a.state);
|
||
m.insert(QStringLiteral("reasonCode"), a.reason.code);
|
||
if (!a.reason.params.isEmpty())
|
||
m.insert(QStringLiteral("reasonParams"), a.reason.params);
|
||
return m;
|
||
}
|
||
|
||
const QString CleActive = QStringLiteral("Optimizer/trace");
|
||
const QString CleRetention = QStringLiteral("Optimizer/traceRetentionDays");
|
||
const QString ClePlafond = QStringLiteral("Optimizer/traceMaxBytesPerDay");
|
||
|
||
} // namespace
|
||
|
||
QString nomFichier(const QDate &jour)
|
||
{
|
||
return QLatin1String(PrefixeFichier) + jour.toString(QStringLiteral("yyyy-MM-dd"))
|
||
+ QLatin1String(SuffixeFichier);
|
||
}
|
||
|
||
QDate dateDuFichier(const QString &nom)
|
||
{
|
||
static const QRegularExpression forme(QLatin1String("^") + QRegularExpression::escape(QLatin1String(PrefixeFichier))
|
||
+ QStringLiteral("(\\d{4}-\\d{2}-\\d{2})")
|
||
+ QRegularExpression::escape(QLatin1String(SuffixeFichier))
|
||
+ QStringLiteral("$"));
|
||
const QRegularExpressionMatch m = forme.match(nom);
|
||
return m.hasMatch() ? QDate::fromString(m.captured(1), QStringLiteral("yyyy-MM-dd")) : QDate();
|
||
}
|
||
|
||
QString horodatage(const QDateTime &t)
|
||
{
|
||
const QDateTime local = t.toLocalTime();
|
||
return local.toOffsetFromUtc(local.offsetFromUtc()).toString(Qt::ISODate);
|
||
}
|
||
|
||
QVariantMap reponseRegles(const Plan &plan, const QDateTime &now)
|
||
{
|
||
QVariantMap r;
|
||
r.insert(QStringLiteral("strategy"), QStringLiteral("rules"));
|
||
r.insert(QStringLiteral("computedAt"), iso(now));
|
||
QVariantList creneaux;
|
||
QDateTime fin;
|
||
for (const Slot &s : plan.timeSlots) {
|
||
QVariantList actions;
|
||
for (const LoadAction &a : s.actions)
|
||
actions.append(actionDecidee(a));
|
||
creneaux.append(QVariantMap{{QStringLiteral("from"), iso(s.from.isValid() ? s.from : now)},
|
||
{QStringLiteral("to"), iso(s.to.isValid() ? s.to : now)},
|
||
{QStringLiteral("actions"), actions}});
|
||
if (s.to.isValid() && (!fin.isValid() || s.to > fin))
|
||
fin = s.to;
|
||
}
|
||
if (fin.isValid())
|
||
r.insert(QStringLiteral("validUntil"), iso(fin));
|
||
r.insert(QStringLiteral("slots"), creneaux);
|
||
return QVariantMap{{QStringLiteral("result"), r}};
|
||
}
|
||
|
||
QVariantList executees(const Slot &slot, const QHash<QString, LoadAction> &appliquees,
|
||
const QSet<QString> &delestees)
|
||
{
|
||
// Même lecture que le plan publié : ce que l'adaptateur a RENDU ; une charge délestée par L4
|
||
// ou non dispatchée n'a pas de retour, son action du créneau (fusionnée) fait foi.
|
||
QVariantList l;
|
||
for (const LoadAction &a : slot.actions) {
|
||
const auto r = appliquees.constFind(a.loadId);
|
||
const LoadAction &faite = r != appliquees.constEnd() ? *r : a;
|
||
QVariantMap m;
|
||
m.insert(QStringLiteral("loadId"), a.loadId);
|
||
m.insert(QStringLiteral("kind"), nomKind(a.kind));
|
||
m.insert(QStringLiteral("powerW"), qMax(0.0, faite.estimatedPowerW));
|
||
if (a.kind == LoadAction::State)
|
||
m.insert(QStringLiteral("state"), faite.state);
|
||
m.insert(QStringLiteral("clamped"), a.clamped);
|
||
if (delestees.contains(a.loadId))
|
||
m.insert(QStringLiteral("shed"), true);
|
||
l.append(m);
|
||
}
|
||
return l;
|
||
}
|
||
|
||
Reglage Reglage::lire()
|
||
{
|
||
EnergySettings s;
|
||
Reglage r;
|
||
r.active = s.value(CleActive, false).toBool();
|
||
bool ok = false;
|
||
const int jours = s.value(CleRetention, RetentionDefautJours).toInt(&ok);
|
||
if (ok && jours >= RetentionMinJours && jours <= RetentionMaxJours)
|
||
r.retentionJours = jours;
|
||
const qint64 plafond = s.value(ClePlafond, PlafondDefautOctets).toLongLong(&ok);
|
||
if (ok && plafond >= PlafondMinOctets && plafond <= PlafondMaxOctets)
|
||
r.plafondOctetsJour = plafond;
|
||
return r;
|
||
}
|
||
|
||
bool Reglage::operator==(const Reglage &o) const
|
||
{
|
||
return active == o.active && retentionJours == o.retentionJours
|
||
&& plafondOctetsJour == o.plafondOctetsJour && dossier == o.dossier
|
||
&& cheminToken == o.cheminToken;
|
||
}
|
||
|
||
} // namespace OptimizerTrace
|
||
|
||
// --- Écrivain : son propre fil, toutes les E/S ------------------------------------------------
|
||
|
||
OptimizerTraceWriter::OptimizerTraceWriter(std::shared_ptr<std::atomic<int>> enAttente)
|
||
: m_enAttente(std::move(enAttente))
|
||
{
|
||
}
|
||
|
||
void OptimizerTraceWriter::configurer(const OptimizerTrace::Reglage &r)
|
||
{
|
||
const bool autreDossier = r.dossier != m_reglage.dossier;
|
||
m_reglage = r;
|
||
if (autreDossier) {
|
||
m_fichier.close();
|
||
m_jour = QDate();
|
||
}
|
||
// Un plafond relevé vaut tout de suite, le jour même.
|
||
m_plafondAtteint = QDate();
|
||
if (m_jour.isValid())
|
||
purger(m_jour);
|
||
}
|
||
|
||
void OptimizerTraceWriter::ecrire(const QVariantMap &ligne, const QDate &jour)
|
||
{
|
||
// La ligne quitte la file quoi qu'il arrive ici : écrite, refusée par le plafond, ou perdue
|
||
// sur un échec.
|
||
struct Decompte {
|
||
std::atomic<int> *n;
|
||
~Decompte() { n->fetch_sub(1); }
|
||
} decompte{m_enAttente.get()};
|
||
|
||
int masques = 0;
|
||
// Seconde garde : le client masque déjà chaque message reçu (OptimizerSecret, correctif A) ;
|
||
// celle-ci couvre un token REMPLACÉ depuis la connexion, que l'écrivain relit du fichier.
|
||
const QByteArray l = OptimizerSecret::masquerLigneJson(
|
||
QJsonDocument(QJsonObject::fromVariantMap(ligne)).toJson(QJsonDocument::Compact),
|
||
token(), &masques)
|
||
+ '\n';
|
||
if (masques > 0 && !m_masqueSignale) {
|
||
// La requête ne porte jamais le token : seule la réponse BRUTE d'un optimiseur peut le
|
||
// recopier. On dit le fait, jamais la valeur.
|
||
m_masqueSignale = true;
|
||
qCWarning(dcNymeaEnergy()) << "[Trace] la réponse de l'optimiseur recopie le token — masqué dans"
|
||
" la trace (signalé une fois par session)";
|
||
}
|
||
|
||
if (jour != m_jour)
|
||
ouvrir(jour);
|
||
if (m_plafondAtteint == jour)
|
||
return;
|
||
if (!m_fichier.isOpen()) {
|
||
ouvrir(jour); // un échec précédent : on retente à chaque ligne, sans le redire
|
||
if (!m_fichier.isOpen())
|
||
return;
|
||
}
|
||
if (m_taille + l.size() > m_reglage.plafondOctetsJour) {
|
||
m_plafondAtteint = jour;
|
||
m_fichier.close();
|
||
qCWarning(dcNymeaEnergy()).noquote()
|
||
<< QStringLiteral("[Trace] plafond journalier atteint (%1 octets, réglage %2) — plus aucune"
|
||
" ligne jusqu'à demain ; décisions inchangées")
|
||
.arg(m_taille).arg(m_reglage.plafondOctetsJour);
|
||
return;
|
||
}
|
||
const qint64 n = m_fichier.write(l);
|
||
if (n != l.size() || !m_fichier.flush()) {
|
||
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||
m_fichier.close();
|
||
return;
|
||
}
|
||
m_taille += n;
|
||
if (m_echecSignale) {
|
||
m_echecSignale = false;
|
||
qCInfo(dcNymeaEnergy()).noquote() << "[Trace] écriture rétablie :" << m_fichier.fileName();
|
||
}
|
||
}
|
||
|
||
void OptimizerTraceWriter::ouvrir(const QDate &jour)
|
||
{
|
||
m_fichier.close();
|
||
if (jour != m_jour)
|
||
purger(jour);
|
||
m_jour = jour;
|
||
m_taille = 0;
|
||
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||
m_fichier.setFileName(QDir(dossier).filePath(OptimizerTrace::nomFichier(jour)));
|
||
if (!m_fichier.open(QIODevice::WriteOnly | QIODevice::Append)) {
|
||
echec(m_fichier.fileName() + QStringLiteral(" : ") + m_fichier.errorString());
|
||
return;
|
||
}
|
||
m_taille = m_fichier.size();
|
||
}
|
||
|
||
void OptimizerTraceWriter::purger(const QDate &aujourdhui)
|
||
{
|
||
const QString dossier = m_reglage.dossier.isEmpty() ? NymeaSettings::storagePath() : m_reglage.dossier;
|
||
QDir d(dossier);
|
||
// Rétention de N jours, celui du jour compris : on garde [aujourd'hui − N + 1, aujourd'hui].
|
||
const QDate limite = aujourdhui.addDays(-m_reglage.retentionJours);
|
||
int supprimes = 0;
|
||
const QStringList noms = d.entryList({QLatin1String(OptimizerTrace::PrefixeFichier) + QLatin1Char('*')
|
||
+ QLatin1String(OptimizerTrace::SuffixeFichier)},
|
||
QDir::Files);
|
||
for (const QString &nom : noms) {
|
||
const QDate date = OptimizerTrace::dateDuFichier(nom);
|
||
if (date.isValid() && date <= limite && d.remove(nom))
|
||
++supprimes;
|
||
}
|
||
if (supprimes > 0)
|
||
qCInfo(dcNymeaEnergy()) << "[Trace]" << supprimes << "fichier(s) de plus de"
|
||
<< m_reglage.retentionJours << "jour(s) supprimé(s)";
|
||
}
|
||
|
||
void OptimizerTraceWriter::echec(const QString &detail)
|
||
{
|
||
if (m_echecSignale)
|
||
return;
|
||
m_echecSignale = true;
|
||
qCWarning(dcNymeaEnergy()).noquote() << "[Trace] écriture impossible :" << detail
|
||
<< "— traces suspendues jusqu'à la prochaine écriture réussie"
|
||
" (signalé une fois) ; décisions inchangées";
|
||
}
|
||
|
||
QByteArray OptimizerTraceWriter::token()
|
||
{
|
||
if (m_reglage.cheminToken.isEmpty())
|
||
return QByteArray();
|
||
const QFileInfo fi(m_reglage.cheminToken);
|
||
if (!fi.exists()) {
|
||
m_token.clear();
|
||
m_tokenTaille = -1;
|
||
return m_token;
|
||
}
|
||
if (fi.lastModified() != m_tokenLuA || fi.size() != m_tokenTaille) {
|
||
QFile f(m_reglage.cheminToken);
|
||
m_token = f.open(QIODevice::ReadOnly) ? f.read(8192).trimmed() : QByteArray();
|
||
m_tokenLuA = fi.lastModified();
|
||
m_tokenTaille = fi.size();
|
||
}
|
||
return m_token;
|
||
}
|
||
|
||
// --- Traceur : fil de l'arbitre, jamais d'E/S ------------------------------------------------
|
||
|
||
OptimizerTracer::OptimizerTracer(const OptimizerTrace::Reglage &r, QObject *parent)
|
||
: QObject(parent)
|
||
, m_enAttente(std::make_shared<std::atomic<int>>(0))
|
||
{
|
||
qRegisterMetaType<OptimizerTrace::Reglage>();
|
||
m_ecrivain = new OptimizerTraceWriter(m_enAttente);
|
||
m_ecrivain->moveToThread(&m_fil);
|
||
connect(&m_fil, &QThread::finished, m_ecrivain, &QObject::deleteLater);
|
||
connect(this, &OptimizerTracer::versEcrivain, m_ecrivain, &OptimizerTraceWriter::ecrire);
|
||
connect(this, &OptimizerTracer::reglageVersEcrivain, m_ecrivain, &OptimizerTraceWriter::configurer);
|
||
m_fil.setObjectName(QStringLiteral("etm-powersync-trace"));
|
||
m_fil.start(QThread::LowestPriority);
|
||
configurer(r);
|
||
}
|
||
|
||
OptimizerTracer::~OptimizerTracer()
|
||
{
|
||
terminerEnAttente();
|
||
// L'arrêt passe DERRIÈRE les lignes déjà postées : elles sont écrites, puis le fil s'arrête.
|
||
// Jamais appelé depuis update() — à la désactivation (RPC) ou à l'arrêt du plugin.
|
||
QThread *fil = &m_fil;
|
||
QMetaObject::invokeMethod(m_ecrivain, [fil] { fil->quit(); }, Qt::QueuedConnection);
|
||
m_fil.wait();
|
||
}
|
||
|
||
void OptimizerTracer::configurer(const OptimizerTrace::Reglage &r)
|
||
{
|
||
m_reglage = r;
|
||
emit reglageVersEcrivain(r);
|
||
}
|
||
|
||
void OptimizerTracer::noterCycle(const QVariantMap &ligne, const QDateTime &t, quint64 idRequete)
|
||
{
|
||
terminerEnAttente();
|
||
if (idRequete == 0) {
|
||
poster(ligne, t);
|
||
return;
|
||
}
|
||
m_ligneEnAttente = ligne;
|
||
m_tEnAttente = t;
|
||
m_idEnAttente = idRequete;
|
||
}
|
||
|
||
void OptimizerTracer::noterReponse(quint64 id, const QVariantMap &reponse, qint64 latenceMs)
|
||
{
|
||
if (id == 0 || id != m_idEnAttente)
|
||
return;
|
||
QVariantMap l = m_ligneEnAttente;
|
||
l.insert(QStringLiteral("response"), reponse);
|
||
l.insert(QStringLiteral("latencyMs"), latenceMs);
|
||
m_idEnAttente = 0;
|
||
m_ligneEnAttente.clear();
|
||
poster(l, m_tEnAttente);
|
||
}
|
||
|
||
void OptimizerTracer::terminerEnAttente()
|
||
{
|
||
if (m_idEnAttente == 0)
|
||
return;
|
||
QVariantMap l = m_ligneEnAttente;
|
||
l.insert(QStringLiteral("response"),
|
||
QVariantMap{{QStringLiteral("error"),
|
||
QVariantMap{{QStringLiteral("code"), QLatin1String(OptimizerTrace::SansReponse)}}}});
|
||
m_idEnAttente = 0;
|
||
m_ligneEnAttente.clear();
|
||
poster(l, m_tEnAttente);
|
||
}
|
||
|
||
void OptimizerTracer::poster(const QVariantMap &ligne, const QDateTime &t)
|
||
{
|
||
if (m_enAttente->load() >= OptimizerTrace::FileMaxLignes) {
|
||
++m_abandonnees;
|
||
if (!m_fileSignalee) {
|
||
m_fileSignalee = true;
|
||
qCWarning(dcNymeaEnergy()) << "[Trace] l'écrivain ne suit plus (" << OptimizerTrace::FileMaxLignes
|
||
<< "lignes en attente) — lignes abandonnées jusqu'à ce qu'il rattrape ;"
|
||
" décisions inchangées";
|
||
}
|
||
return;
|
||
}
|
||
if (m_fileSignalee) {
|
||
m_fileSignalee = false;
|
||
qCInfo(dcNymeaEnergy()) << "[Trace] l'écrivain a rattrapé —" << m_abandonnees
|
||
<< "ligne(s) abandonnée(s) depuis le démarrage des traces";
|
||
}
|
||
m_enAttente->fetch_add(1);
|
||
emit versEcrivain(ligne, t.toLocalTime().date());
|
||
}
|