mirror of
https://github.com/dmcxblue/ANIMO
synced 2026-08-04 16:10:26 +00:00
The three login scripts were minting only management.azure.com tokens
regardless of the resource the operator picked in the UI dropdown -
because none of the pieces in the chain honoured it. az cli was
similarly minting only its default ARM token. Now the operator's
choice reaches both toolchains end-to-end.
- server/scripts/login_azure.ps1
- server/scripts/login_azure_devicecode.ps1
- server/scripts/login_spn_azure.ps1
Add `[string]$Resource='https://management.azure.com'` param.
Get-AzAccessToken now runs with -ResourceUrl $Resource (previously
hardcoded to https://management.azure.com or omitted, defaulting
to ARM). After `az login` succeeds, also fetch a resource-specific
token via `az account get-access-token --resource $Resource -o tsv`
so both Az PS and az cli have a warm token cache for the chosen
audience. Device-code hint now includes the exact
`az account get-access-token --resource ...` line.
- server/Server.cpp
handleNewSession reads req["resource"] (default ARM) into a local
and forwards it as `-Resource '<escaped>'` on all three script
invocations (credential/ROPC, device-code, SPN). Uses the existing
escapePsString helper. Also stops the SPN meta-write from
hardcoding management.azure.com - it now records the actual
resource. The old ternary that always returned ARM is replaced
with a plain default.
- client/CredentialLoginWindow.{h,cpp}
Dropdown expanded from 2 to 6 entries (Management, Graph, Key
Vault, Storage, SQL, Other...). currentData() carries the resource
URL - same pattern SPNLoginWindow already uses. "Other..." reveals
a free-text QLineEdit; a trailing "/.default" scope suffix is
stripped so -ResourceUrl gets a v1 audience URL.
- client/DeviceCodeLoginWindow.cpp
startFullServerSession() no longer hardcodes ARM - reads
resourceInput->text() (the free-text field that was already in the
UI but silently ignored). Empty falls back to ARM; a trailing
/.default is stripped for the same reason.
- client/SPNLoginWindow.cpp
authenticateViaPowerShell() no longer hardcodes ARM in req -
forwards pendingResource (already populated from the dropdown at
handler entry). Empty falls back to ARM defensively.
2024 lines
95 KiB
C++
Executable File
2024 lines
95 KiB
C++
Executable File
#include "Server.h"
|
|
#include "../shared/Protocol.h"
|
|
#include "../shared/OutputSanitizer.h"
|
|
#include "../shared/PowerShellManager.h"
|
|
#include "../shared/Config.h"
|
|
#include "SessionDBManager.h"
|
|
|
|
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonArray>
|
|
#include <QHostAddress>
|
|
#include <QProcess>
|
|
#include <QHash>
|
|
#include <QSet>
|
|
#include <QPointer>
|
|
#include <QStandardPaths>
|
|
#include <QFileInfo>
|
|
#include <QTimer>
|
|
#include <QUuid>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QTextStream>
|
|
#include <QUrlQuery>
|
|
#include <QMutex>
|
|
#include <QMutexLocker>
|
|
#include <QCoreApplication>
|
|
|
|
|
|
// Network for tenant id resolution
|
|
#include <QNetworkAccessManager>
|
|
#include <QNetworkRequest>
|
|
#include <QNetworkReply>
|
|
#include <QEventLoop>
|
|
#include <QRegularExpression>
|
|
|
|
// SQLite
|
|
#include <QSqlDatabase>
|
|
#include <QSqlQuery>
|
|
#include <QSqlError>
|
|
#include <QDateTime>
|
|
|
|
// ===== Thread-safe in-memory state =====
|
|
static QMutex g_stateMutex; // Protects all global state below
|
|
static QHash<QString, QProcess*> g_sessions; // sessionId -> process
|
|
static QHash<QString, QSet<QTcpSocket*>> g_subscribers; // sessionId -> sockets
|
|
static QHash<QString, QByteArray> g_stdoutBuf; // sessionId -> aggregated stdout (for marker scan)
|
|
static QHash<QString, QJsonObject> g_sessionInfo; // sessionId -> {user, tenantId, domain, resource}
|
|
static QHash<QString, QString> g_loginRid; // sessionId -> rid (to echo back on login)
|
|
static QHash<QString, QString> g_pendingToken; // sessionId -> access token (captured from PS before login OK)
|
|
static QHash<QString, QString> g_lastCommand; // sessionId -> last command (for history)
|
|
|
|
// Command execution state (one active command per session; the rest queued)
|
|
static QHash<QString, QString> g_activeCmdId; // sessionId -> currently executing cmdId
|
|
static QHash<QString, qint64> g_cmdStartTime; // sessionId -> when command started (ms since epoch)
|
|
static constexpr qint64 CMD_TIMEOUT_MS = 30000; // 30 second timeout for stale command detection
|
|
|
|
struct QueuedCommand { QString cmdId; QString cmd; QString op; };
|
|
static QHash<QString, QList<QueuedCommand>> g_cmdQueue; // sessionId -> commands waiting to run
|
|
static constexpr int MAX_CMD_QUEUE = 50; // bound the per-session queue
|
|
|
|
// Pending token reinject waiting for the current command to finish. If the
|
|
// client sends reinject_tokens while a command is running, we cannot write to
|
|
// stdin (it would interleave with the command's output stream). Instead the
|
|
// payload sits here and is flushed the moment the active command completes,
|
|
// so the terminal's Az context always ends up refreshed.
|
|
static QHash<QString, QByteArray> g_pendingReinject; // sessionId -> full PS payload
|
|
|
|
// Buffered pre-login (inAuth) stdout so late-opened terminal tabs can still
|
|
// see login-time diagnostics (e.g. the SPN login script's subscription list).
|
|
// Drained the first time get_session subscribes a socket to the session.
|
|
static QHash<QString, QByteArray> g_earlyOutput;
|
|
static constexpr int MAX_EARLY_OUTPUT = 64 * 1024; // bound the buffer
|
|
|
|
// Single-fire auth gating (prevents duplicate OK/FAIL handling)
|
|
enum class AuthState { Pending, Success, Failed };
|
|
static QHash<QString, AuthState> g_authState;
|
|
|
|
// ===== Helpers (define once) ===============================================
|
|
static inline QByteArray toBytes(const QJsonObject &o) { return Protocol::toBytes(o); }
|
|
|
|
// sessionId is used to build filesystem paths, so reject anything outside this
|
|
// charset/length (prevents path traversal like "../../foo").
|
|
static inline bool isValidSessionId(const QString &sid) {
|
|
static const QRegularExpression sessionIdRe(
|
|
QStringLiteral("^[a-zA-Z0-9_\\-]{1,64}$"));
|
|
return sessionIdRe.match(sid).hasMatch();
|
|
}
|
|
|
|
// Load PowerShell script from Qt resources
|
|
static QByteArray loadScript(const QString &resourcePath) {
|
|
QFile file(resourcePath);
|
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
qWarning() << "[Server] Failed to load script resource:" << resourcePath;
|
|
return QByteArray();
|
|
}
|
|
return file.readAll();
|
|
}
|
|
|
|
// Returns true if bytes were actually written to a ConnectedState socket.
|
|
// Distinguishes "socket dead" from "socket buffer full" so callers can prune.
|
|
static inline bool sendTo(QTcpSocket *sock, const QJsonObject &obj) {
|
|
if (!sock) return false;
|
|
if (sock->state() != QAbstractSocket::ConnectedState) return false;
|
|
const QByteArray bytes = toBytes(obj);
|
|
const qint64 wrote = sock->write(bytes);
|
|
if (wrote < 0) return false;
|
|
// Force the OS to see it now, not on next event-loop turn. Broadcasts
|
|
// are often followed by a mutex release + long computation, so buffered
|
|
// writes would sit in Qt's send buffer instead of hitting the socket.
|
|
sock->flush();
|
|
return true;
|
|
}
|
|
|
|
// Broadcast + prune. If a socket isn't ConnectedState or write() fails, it's
|
|
// removed from the target session's subscriber set - otherwise a crashed
|
|
// client's dangling socket pointer would silently swallow every subsequent
|
|
// broadcast, making the caller think "N subscribers received it" when N-1 got
|
|
// nothing. Caller must hold g_stateMutex (all existing callers do).
|
|
static void broadcastToSession(const QString &sessionId, const QJsonObject &obj);
|
|
|
|
// Fallback for callers that don't have a sessionId (e.g. session_exit hook
|
|
// broadcasting to a saved local copy). No pruning possible here.
|
|
static inline void broadcastTo(const QSet<QTcpSocket*> &socks, const QJsonObject &obj) {
|
|
for (QTcpSocket *s : socks) sendTo(s, obj);
|
|
}
|
|
|
|
static void broadcastToSession(const QString &sessionId, const QJsonObject &obj) {
|
|
auto it = g_subscribers.find(sessionId);
|
|
if (it == g_subscribers.end()) return;
|
|
QSet<QTcpSocket*> &subs = it.value();
|
|
QSet<QTcpSocket*> dead;
|
|
// Iterate a snapshot so we can mutate `subs` (remove-during-iterate is UB).
|
|
const QList<QTcpSocket*> snapshot(subs.begin(), subs.end());
|
|
for (QTcpSocket *s : snapshot) {
|
|
if (!sendTo(s, obj)) dead.insert(s);
|
|
}
|
|
if (!dead.isEmpty()) {
|
|
for (QTcpSocket *s : dead) subs.remove(s);
|
|
qInfo().noquote() << QString("[Server] pruned %1 dead subscriber(s) from sid=%2")
|
|
.arg(dead.size()).arg(sessionId.left(8));
|
|
}
|
|
}
|
|
|
|
// Write a wrapped command to the session's shell, mark it active, persist it, and
|
|
// announce it to subscribers. Caller must hold g_stateMutex.
|
|
static void dispatchCommand(const QString &sid, const QString &cmdId, const QString &cmd,
|
|
const QString &op = QStringLiteral("unknown")) {
|
|
QProcess *proc = g_sessions.value(sid);
|
|
if (!proc) {
|
|
qWarning().noquote() << QString("[Server] dispatchCommand: no process for session %1 (cmd=%2)")
|
|
.arg(sid.left(8), cmd.left(60));
|
|
return;
|
|
}
|
|
g_activeCmdId[sid] = cmdId;
|
|
g_cmdStartTime[sid] = QDateTime::currentMSecsSinceEpoch();
|
|
|
|
const QString wrapped = OutputSanitizer::wrapPwshCommandWithId(cmd, cmdId);
|
|
proc->write(wrapped.toUtf8());
|
|
proc->write("\n");
|
|
|
|
SessionDBManager::instance().insertCommandOutput(sid, cmd, QString(), cmdId, op);
|
|
broadcastToSession(sid,
|
|
QJsonObject{ {Protocol::F_ACTION, "command_started"},
|
|
{"sessionId", sid}, {Protocol::F_CMD_ID, cmdId}, {"command", cmd} });
|
|
}
|
|
|
|
static QString resolveTenantIdBlocking(const QString &domain) {
|
|
if (domain.isEmpty()) return QStringLiteral("N/A");
|
|
QNetworkAccessManager mgr;
|
|
QEventLoop loop;
|
|
QTimer timeout;
|
|
timeout.setSingleShot(true);
|
|
|
|
QNetworkReply *rep = mgr.get(QNetworkRequest(
|
|
QUrl(QString("https://login.microsoftonline.com/%1/.well-known/openid-configuration").arg(domain))));
|
|
|
|
QObject::connect(rep, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
|
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
|
|
|
timeout.start(5000); // 5 second timeout to prevent server hang
|
|
loop.exec();
|
|
|
|
QString tenant = "N/A";
|
|
if (timeout.isActive()) {
|
|
// Network completed before timeout
|
|
timeout.stop();
|
|
if (rep->error() == QNetworkReply::NoError) {
|
|
const auto doc = QJsonDocument::fromJson(rep->readAll());
|
|
const QString ae = doc.object().value("authorization_endpoint").toString();
|
|
// Expected: https://login.microsoftonline.com/<tenantId>/oauth2/...
|
|
const QStringList parts = ae.split('/', Qt::SkipEmptyParts);
|
|
for (int i = 0; i < parts.size(); ++i) {
|
|
if (parts[i].contains("login.microsoftonline.com", Qt::CaseInsensitive) && i + 1 < parts.size()) {
|
|
tenant = parts[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Timeout occurred - abort the request
|
|
qWarning() << "[Server] Tenant resolution timed out for domain:" << domain;
|
|
rep->abort();
|
|
}
|
|
rep->deleteLater();
|
|
return tenant.isEmpty() ? QStringLiteral("N/A") : tenant;
|
|
}
|
|
|
|
// ---- Helpers ---------------------------------------
|
|
// Output sanitization moved to shared/OutputSanitizer.h
|
|
// PowerShell utilities moved to shared/PowerShellManager.h
|
|
|
|
// Escape strings for safe use in PowerShell single-quoted strings.
|
|
// In PS single-quoted strings, the ONLY special character is the single quote
|
|
// itself (doubled to escape). However, we also sanitize null bytes and
|
|
// carriage returns/newlines to prevent injection via string termination.
|
|
static QString escapePsString(const QString &s) {
|
|
QString escaped;
|
|
escaped.reserve(s.size() + 16);
|
|
for (const QChar &ch : s) {
|
|
if (ch == QLatin1Char('\'')) {
|
|
escaped.append(QLatin1String("''"));
|
|
} else if (ch == QLatin1Char('\0')) {
|
|
// Strip null bytes - could terminate strings in edge cases
|
|
continue;
|
|
} else if (ch == QLatin1Char('\n') || ch == QLatin1Char('\r')) {
|
|
// Strip newlines - could break out of single-line PS context
|
|
continue;
|
|
} else {
|
|
escaped.append(ch);
|
|
}
|
|
}
|
|
return escaped;
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Database operations now handled by SessionDBManager
|
|
// See: SessionDBManager.h for all session and token database operations
|
|
// ===========================================================================
|
|
|
|
// ===========================================================================
|
|
// Ensure a PowerShell process exists for this session; start and wire output
|
|
// Now uses SessionDBManager for all DB I/O (main + per-session history)
|
|
// ===========================================================================
|
|
static QProcess* ensureProcess(QObject *parent, const QString &sessionId,
|
|
const QString &createdBy = QStringLiteral("unknown")) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
if (g_sessions.contains(sessionId)) return g_sessions.value(sessionId);
|
|
|
|
const QString program = PowerShellManager::getPwshPath();
|
|
|
|
auto *proc = new QProcess(parent);
|
|
proc->setProcessChannelMode(QProcess::SeparateChannels);
|
|
|
|
// Environment: use dumb terminal to disable escape sequences
|
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
|
env.insert("TERM", "dumb"); // Force dumb terminal - no escape sequences
|
|
env.insert("NO_COLOR", "1"); // Disable color output
|
|
env.insert("POWERSHELL_TELEMETRY_OPTOUT", "1");
|
|
|
|
// Per-session Az context isolation. Without this, every session shares
|
|
// ~/.Azure/AzureRmContext.json and stale AccessToken contexts poison each
|
|
// other's default context (breaks token minting non-deterministically).
|
|
const QString azConfigDir = QString("%1/data/sessions/%2/.azure")
|
|
.arg(QCoreApplication::applicationDirPath(), sessionId);
|
|
QDir().mkpath(azConfigDir);
|
|
env.insert("AZURE_CONFIG_DIR", azConfigDir);
|
|
|
|
proc->setProcessEnvironment(env);
|
|
|
|
// Common args
|
|
QStringList args{ "-NoLogo", "-NoProfile", "-NonInteractive" };
|
|
#ifdef Q_OS_WIN
|
|
// Only add ExecutionPolicy Bypass for legacy Windows PowerShell
|
|
if (program.contains("powershell", Qt::CaseInsensitive)) {
|
|
args << "-ExecutionPolicy" << "Bypass";
|
|
}
|
|
#endif
|
|
|
|
proc->start(program, args);
|
|
if (!proc->waitForStarted(APP_CONFIG.processStartTimeoutMs())) {
|
|
qWarning() << "[Server] Failed to start PowerShell:" << program << args;
|
|
delete proc;
|
|
return nullptr;
|
|
}
|
|
|
|
// Register session immediately, mark pending, ensure defaults
|
|
g_sessions.insert(sessionId, proc);
|
|
g_authState.insert(sessionId, AuthState::Pending);
|
|
|
|
QJsonObject meta = g_sessionInfo.value(sessionId);
|
|
if (!meta.contains("resource")) meta.insert("resource", "https://management.azure.com");
|
|
if (!meta.contains("user")) meta.insert("user", "Unknown");
|
|
if (!meta.contains("tenantId")) meta.insert("tenantId", "N/A");
|
|
if (!meta.contains("domain")) meta.insert("domain", "N/A");
|
|
g_sessionInfo.insert(sessionId, meta);
|
|
|
|
// Persist/ensure DBs via SessionDBManager
|
|
SessionDBManager::instance().initMainDB();
|
|
SessionDBManager::instance().addSessionToMainDB(
|
|
sessionId,
|
|
meta.value("user").toString("Unknown"),
|
|
meta.value("tenantId").toString("N/A"),
|
|
meta.value("domain").toString("N/A"),
|
|
meta.value("resource").toString("https://management.azure.com"),
|
|
createdBy
|
|
);
|
|
SessionDBManager::instance().createSessionDB(sessionId);
|
|
// Reflect live-process state so the sessions table isn't stuck on Alive=0.
|
|
SessionDBManager::instance().setSessionAlive(sessionId, true);
|
|
|
|
// Tell subscribers the shell is alive
|
|
broadcastToSession(sessionId,
|
|
QJsonObject{ {Protocol::F_ACTION, "session_open"},
|
|
{"sessionId", sessionId},
|
|
{"alive", true} });
|
|
|
|
// Minimal init: suppress noise silently using $null assignment to prevent echo
|
|
proc->write("$null=$ErrorActionPreference=$ProgressPreference=$WarningPreference='SilentlyContinue'\n");
|
|
proc->write("$null=Remove-Module PSReadLine -Force -EA SilentlyContinue\n");
|
|
proc->write("function global:prompt{''}\n");
|
|
// Force TLS 1.2+ for Invoke-RestMethod/WebRequest so calls to Azure/M365 endpoints
|
|
// don't fail TLS negotiation on hosts that still default to older protocols.
|
|
proc->write("try{[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13}catch{try{[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12}catch{}}\n");
|
|
|
|
// STDOUT hook: segment & sanitize AFTER login; raw during auth (so device code text shows)
|
|
QObject::connect(proc, &QProcess::readyReadStandardOutput, parent, [proc, sessionId]() {
|
|
const QByteArray chunk = proc->readAllStandardOutput();
|
|
if (chunk.isEmpty()) return;
|
|
|
|
QMutexLocker locker(&g_stateMutex);
|
|
QByteArray &buf = g_stdoutBuf[sessionId];
|
|
buf.append(chunk);
|
|
|
|
// Limit buffer size to prevent unbounded memory growth (1MB max)
|
|
static constexpr int MAX_BUFFER_SIZE = 1024 * 1024;
|
|
if (buf.size() > MAX_BUFFER_SIZE) {
|
|
// Trim from beginning, keeping the most recent data
|
|
// Use swap for efficient memory management instead of remove()
|
|
QByteArray trimmed = buf.mid(buf.size() - MAX_BUFFER_SIZE);
|
|
buf.swap(trimmed);
|
|
}
|
|
|
|
bool emittedAny = false; // NEW: track if we emitted a cleaned segment this read
|
|
|
|
// During login, pass-through raw so user sees device-code instructions etc.
|
|
const bool inAuth = (g_authState.value(sessionId, AuthState::Pending) == AuthState::Pending);
|
|
if (inAuth) {
|
|
// Denylist-then-allowlist filter for pre-login pwsh output.
|
|
//
|
|
// Denylist fails because pwsh's stdin echo arrives split across TCP
|
|
// chunks in arbitrary places ("nu" | "ll=$ErrorAction..."). A line
|
|
// filter can't catch fragments.
|
|
//
|
|
// So instead: only forward chunks whose text CONTAINS something we
|
|
// know an operator actually needs to see during login. Everything
|
|
// else during inAuth is dropped. Post-login output isn't affected
|
|
// (this whole block only runs while authState == Pending).
|
|
//
|
|
// Whitelist covers:
|
|
// - Device-code login (URL + code)
|
|
// - [Animo] diagnostic lines (SPN sub list, WARNING, etc.)
|
|
// - Login failure text
|
|
// - MFA prompts
|
|
static const QList<QByteArray> keepSubstrings = {
|
|
"microsoft.com/devicelogin", // device code URL
|
|
"https://microsoft.com/", // any MSFT login URL
|
|
"enter the code", // device code instruction
|
|
"[Animo]", // our SPN login-script diagnostics
|
|
"__ANIMO_MFA_REQUIRED__", // MFA marker (also parsed by scanner)
|
|
"WARNING", // any Az/PS warning worth surfacing
|
|
"ERROR", // any Az/PS error
|
|
"authentication failed", // MSAL fail
|
|
"invalid_", // invalid_client, invalid_grant, etc.
|
|
};
|
|
auto looksInteresting = [&](const QByteArray &data) -> bool {
|
|
for (const QByteArray &s : keepSubstrings) {
|
|
if (data.contains(s)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (looksInteresting(chunk)) {
|
|
broadcastToSession(sessionId,
|
|
QJsonObject{ {Protocol::F_ACTION, "output"},
|
|
{"sessionId", sessionId},
|
|
{"stream", "stdout"},
|
|
{"data", QString::fromUtf8(chunk)} });
|
|
QByteArray &early = g_earlyOutput[sessionId];
|
|
if (early.size() < MAX_EARLY_OUTPUT) {
|
|
const int room = MAX_EARLY_OUTPUT - early.size();
|
|
early.append(chunk.left(room));
|
|
}
|
|
}
|
|
emittedAny = true; // Prevent duplicate broadcast later
|
|
}
|
|
|
|
// Extract complete segments:
|
|
// __QZ_BEGIN__:<id> <output> [__QZ_ERR__:<id> <err>] __QZ_EXIT__:<id>:<fail>:<code> __QZ_END__:<id>
|
|
// Markers are matched ONLY at line boundaries and END is paired to its BEGIN by
|
|
// cmdId, so output that merely contains marker-looking text cannot break framing,
|
|
// and a command that never closed (no END) cannot merge into the next command.
|
|
static const QByteArray B = "__QZ_BEGIN__";
|
|
static const QByteArray E = "__QZ_END__";
|
|
|
|
auto atLineStart = [&buf](int p) { return p == 0 || (p > 0 && buf.at(p - 1) == '\n'); };
|
|
auto findMarkerLine = [&buf, &atLineStart](const QByteArray &m, int from) {
|
|
int p = from;
|
|
while ((p = buf.indexOf(m, p)) >= 0) {
|
|
if (atLineStart(p)) return p;
|
|
p += 1;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
while (true) {
|
|
int b = findMarkerLine(B, 0);
|
|
if (b < 0) break;
|
|
|
|
int beginLineEnd = buf.indexOf('\n', b);
|
|
if (beginLineEnd < 0) break; // BEGIN line not fully arrived yet
|
|
|
|
// cmdId from the BEGIN marker (format: __QZ_BEGIN__:<id>)
|
|
QByteArray beginLine = buf.mid(b, beginLineEnd - b);
|
|
QString beginCmdId;
|
|
int colonPos = beginLine.indexOf(':');
|
|
if (colonPos > 0) beginCmdId = QString::fromUtf8(beginLine.mid(colonPos + 1)).trimmed();
|
|
|
|
// The matching END carries the same cmdId.
|
|
const QByteArray endMarker = beginCmdId.isEmpty()
|
|
? E : (E + ":" + beginCmdId.toUtf8());
|
|
int e = findMarkerLine(endMarker, beginLineEnd);
|
|
if (e < 0) {
|
|
// No matching END yet. If a newer BEGIN has already arrived, this segment
|
|
// is orphaned (its command never closed) - drop it so it can't merge forward.
|
|
int nextBegin = findMarkerLine(B, beginLineEnd);
|
|
if (nextBegin >= 0) { buf.remove(0, nextBegin); continue; }
|
|
break; // otherwise wait for the rest of this command's output
|
|
}
|
|
|
|
QByteArray segment = buf.mid(beginLineEnd + 1, e - (beginLineEnd + 1));
|
|
|
|
// Consume through the END line.
|
|
int endLineEnd = buf.indexOf('\n', e);
|
|
buf.remove(0, (endLineEnd >= 0) ? endLineEnd + 1 : buf.size());
|
|
|
|
const QString cmdId = beginCmdId;
|
|
|
|
// Pull exit status / failure flag from the segment (format __QZ_EXIT__:<id>:<fail>:<code>).
|
|
bool failed = false;
|
|
int exitCode = 0;
|
|
int ei = segment.indexOf("__QZ_EXIT__:");
|
|
if (ei >= 0) {
|
|
int le = segment.indexOf('\n', ei);
|
|
const QByteArray exitLine = (le >= 0 ? segment.mid(ei, le - ei) : segment.mid(ei)).trimmed();
|
|
const QList<QByteArray> parts = exitLine.split(':');
|
|
if (parts.size() >= 4) {
|
|
failed = (parts.at(parts.size() - 2).trimmed() == "1");
|
|
exitCode = parts.at(parts.size() - 1).trimmed().toInt();
|
|
}
|
|
}
|
|
if (segment.contains("__QZ_ERR__")) failed = true;
|
|
|
|
// Sanitize and strip every QZ marker line from the visible output.
|
|
QString cleaned = OutputSanitizer::stripAnsiPrompt(QString::fromUtf8(segment));
|
|
static QRegularExpression markerRe(R"(__QZ_(BEGIN|END|EXIT|ERR)__(:[^\n]*)?)");
|
|
cleaned.remove(markerRe);
|
|
cleaned = cleaned.trimmed();
|
|
|
|
// A non-empty cmdId means the segment came from wrapPwshCommandWithId
|
|
// (i.e. a real operator command), which by construction only happens
|
|
// AFTER the login script has finished. Don't let a lingering
|
|
// inAuth==true gate silently drop that output - broadcast it either way.
|
|
// Pre-login raw broadcasts (line 302) are the ONLY case that legitimately
|
|
// should stay off the segment path, and those never have a cmdId.
|
|
const bool haveCmdId = !cmdId.isEmpty();
|
|
const bool shouldBroadcast = !cleaned.isEmpty() && (haveCmdId || !inAuth);
|
|
|
|
if (shouldBroadcast) {
|
|
QJsonObject outMsg{
|
|
{Protocol::F_ACTION, "output"},
|
|
{"sessionId", sessionId},
|
|
{"stream", "stdout"},
|
|
{"data", cleaned}
|
|
};
|
|
if (haveCmdId) outMsg.insert(Protocol::F_CMD_ID, cmdId);
|
|
broadcastToSession(sessionId, outMsg);
|
|
}
|
|
|
|
SessionDBManager::instance().insertCommandOutput(sessionId, QString(), cleaned, cmdId);
|
|
|
|
// Emit command_complete for THIS segment immediately, paired with its own
|
|
// output and cmdId. Include the same cleaned output in a fallback field
|
|
// so the client can render it even if the live output message got lost.
|
|
// Same gate as broadcast: real commands (haveCmdId) always fire.
|
|
if (haveCmdId || !inAuth) {
|
|
const QString finalCmdId = cmdId.isEmpty() ? g_activeCmdId.value(sessionId) : cmdId;
|
|
g_activeCmdId.remove(sessionId);
|
|
g_cmdStartTime.remove(sessionId);
|
|
|
|
QJsonObject completeMsg{
|
|
{Protocol::F_ACTION, "command_complete"},
|
|
{"sessionId", sessionId},
|
|
{Protocol::F_CMD_ID, finalCmdId},
|
|
{"ok", !failed},
|
|
{"exitCode", exitCode},
|
|
// Belt-and-suspenders: send the DB-captured stdout with the
|
|
// completion frame. Client uses it if its outputLines buffer
|
|
// is empty (i.e. the live output message got lost).
|
|
{"stdoutFallback", cleaned}
|
|
};
|
|
broadcastToSession(sessionId, completeMsg);
|
|
|
|
// Flush any pending token reinject that arrived while this
|
|
// command was running. Doing it here keeps the Az context
|
|
// renewed even for sessions that are almost always busy.
|
|
// NOTE: we're ALREADY inside g_stateMutex (locked at the top of
|
|
// this lambda). QMutex is non-recursive, so a nested QMutexLocker
|
|
// here would deadlock the whole main thread and every future
|
|
// run_command would silently block. Access maps directly instead.
|
|
{
|
|
QByteArray pending = g_pendingReinject.take(sessionId);
|
|
if (!pending.isEmpty()) {
|
|
if (QProcess *p = g_sessions.value(sessionId, nullptr))
|
|
p->write(pending);
|
|
}
|
|
}
|
|
|
|
// Dispatch the next queued command for this session, if any.
|
|
if (!g_cmdQueue.value(sessionId).isEmpty()) {
|
|
const QueuedCommand next = g_cmdQueue[sessionId].takeFirst();
|
|
if (g_cmdQueue[sessionId].isEmpty()) g_cmdQueue.remove(sessionId);
|
|
dispatchCommand(sessionId, next.cmdId, next.cmd, next.op);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==== Only if we're still in auth AND we didn't emit a cleaned segment, pass raw ====
|
|
if (inAuth && !emittedAny) {
|
|
broadcastToSession(sessionId,
|
|
QJsonObject{ {Protocol::F_ACTION, "output"},
|
|
{"sessionId", sessionId},
|
|
{"stream", "stdout"},
|
|
{"data", QString::fromUtf8(chunk)} });
|
|
}
|
|
|
|
// ===== Login OK/FAIL/MFA marker scan (single-fire) =====
|
|
if (g_authState.value(sessionId, AuthState::Pending) == AuthState::Pending) {
|
|
static const QByteArray OK_MARKER = "__ANIMO_LOGIN_OK__:";
|
|
static const QByteArray FAIL_MARKER = "__ANIMO_LOGIN_FAIL__";
|
|
static const QByteArray MFA_MARKER = "__ANIMO_MFA_REQUIRED__:";
|
|
|
|
// Strip ANSI escape sequences from buffer before marker detection
|
|
// Matches: ESC[...X where X is a letter, and ESC[?...h/l sequences
|
|
static const QRegularExpression escapeRe(R"(\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b[()][0-9A-Za-z])");
|
|
QString bufStr = QString::fromUtf8(buf);
|
|
bufStr.remove(escapeRe);
|
|
buf = bufStr.toUtf8();
|
|
|
|
// Capture access token emitted by SPN (or other) scripts before OK marker
|
|
static const QByteArray TOKEN_MARKER = "__ANIMO_TOKEN__:";
|
|
{
|
|
int tIdx = buf.indexOf(TOKEN_MARKER);
|
|
if (tIdx >= 0 && (tIdx == 0 || buf.at(tIdx - 1) == '\n')) {
|
|
const int tStart = tIdx + TOKEN_MARKER.size();
|
|
const int tNl = buf.indexOf('\n', tStart);
|
|
const QByteArray tokenBA = (tNl >= 0) ? buf.mid(tStart, tNl - tStart) : buf.mid(tStart);
|
|
QString token = QString::fromUtf8(tokenBA).trimmed();
|
|
if (!token.isEmpty()) {
|
|
g_pendingToken.insert(sessionId, token);
|
|
}
|
|
// consume the marker line
|
|
if (tNl >= 0) buf.remove(tIdx, tNl - tIdx + 1);
|
|
else buf.remove(tIdx, tokenBA.size() + TOKEN_MARKER.size());
|
|
}
|
|
}
|
|
|
|
// Check for MFA required marker first
|
|
const int mfaIdx = buf.indexOf(MFA_MARKER);
|
|
if (mfaIdx >= 0) {
|
|
const int nl = buf.indexOf('\n', mfaIdx);
|
|
const int start = mfaIdx + MFA_MARKER.size();
|
|
const QByteArray mfaData = (nl >= 0) ? buf.mid(start, nl - start) : buf.mid(start);
|
|
|
|
// Parse MFA code and message (format: CODE:message)
|
|
QString mfaStr = QString::fromUtf8(mfaData).trimmed();
|
|
QString mfaCode = mfaStr.section(':', 0, 0);
|
|
QString mfaMsg = mfaStr.section(':', 1);
|
|
|
|
// Broadcast MFA required notification
|
|
QJsonObject ev{
|
|
{Protocol::F_ACTION, Protocol::ACTION_MFA_REQUIRED},
|
|
{"sessionId", sessionId},
|
|
{"mfaCode", mfaCode},
|
|
{"mfaMessage", mfaMsg}
|
|
};
|
|
if (g_loginRid.contains(sessionId)) ev.insert("rid", g_loginRid.value(sessionId));
|
|
broadcastToSession(sessionId, ev);
|
|
|
|
// Consume the MFA marker line
|
|
if (nl >= 0) buf.remove(mfaIdx, nl - mfaIdx + 1);
|
|
else buf.remove(mfaIdx, mfaData.size() + MFA_MARKER.size());
|
|
}
|
|
|
|
// Find markers only at start of line (not embedded in echoed script code)
|
|
auto findMarkerAtLineStart = [&buf](const QByteArray &marker) -> int {
|
|
int pos = 0;
|
|
while ((pos = buf.indexOf(marker, pos)) >= 0) {
|
|
// Check if marker is at start of buffer or after a newline
|
|
if (pos == 0 || buf.at(pos - 1) == '\n') {
|
|
return pos;
|
|
}
|
|
pos += marker.size();
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
const int okIdx = findMarkerAtLineStart(OK_MARKER);
|
|
const int failIdx = findMarkerAtLineStart(FAIL_MARKER);
|
|
const bool hasOk = (okIdx >= 0);
|
|
const bool hasFail = (failIdx>= 0);
|
|
|
|
// Prefer whichever marker appears first
|
|
if (hasOk && (!hasFail || okIdx < failIdx)) {
|
|
const int nl = buf.indexOf('\n', okIdx);
|
|
const int start = okIdx + OK_MARKER.size();
|
|
const QByteArray upnBA = (nl >= 0) ? buf.mid(start, nl - start) : buf.mid(start);
|
|
|
|
QString upn = QString::fromUtf8(upnBA).trimmed();
|
|
if (upn.startsWith('"') && upn.endsWith('"') && upn.size() >= 2) {
|
|
upn = upn.mid(1, upn.size() - 2).trimmed();
|
|
}
|
|
|
|
const QString derivedDomain = upn.contains('@')
|
|
? upn.section('@', 1, 1)
|
|
: QStringLiteral("N/A");
|
|
|
|
// enrich + persist success. If the mode-specific branch already
|
|
// stashed real tenant/domain metadata (e.g. SPN mode stores the
|
|
// tenant GUID from the login request), keep it - the UPN-derived
|
|
// fallback only fills in when we still have "N/A".
|
|
QJsonObject meta = g_sessionInfo.value(sessionId);
|
|
const QString existingTenant = meta.value("tenantId").toString();
|
|
const QString existingDomain = meta.value("domain").toString();
|
|
const auto isPlaceholder = [](const QString &v) {
|
|
return v.isEmpty() || v == QStringLiteral("N/A");
|
|
};
|
|
const QString tenant = isPlaceholder(existingTenant) ? derivedDomain : existingTenant;
|
|
const QString domain = isPlaceholder(existingDomain) ? derivedDomain : existingDomain;
|
|
|
|
meta.insert("user", upn.isEmpty() ? QStringLiteral("Unknown") : upn);
|
|
meta.insert("domain", domain);
|
|
meta.insert("tenantId", tenant);
|
|
g_sessionInfo.insert(sessionId, meta);
|
|
|
|
SessionDBManager::instance().updateSessionUser(sessionId, meta.value("user").toString("Unknown"));
|
|
SessionDBManager::instance().updateSessionTenant(sessionId, tenant, domain);
|
|
|
|
// notify (single-fire)
|
|
QJsonObject ev{
|
|
{Protocol::F_ACTION, "session_created"},
|
|
{"sessionId", sessionId},
|
|
{"user", meta.value("user").toString("Unknown")},
|
|
{"domain", domain},
|
|
{"tenantId", tenant},
|
|
{"resource", meta.value("resource").toString("https://management.azure.com")}
|
|
};
|
|
if (g_loginRid.contains(sessionId)) ev.insert("rid", g_loginRid.take(sessionId));
|
|
if (g_pendingToken.contains(sessionId)) ev.insert("accessToken", g_pendingToken.take(sessionId));
|
|
// Forward storage token acquired via refresh exchange (MSAL sessions)
|
|
const QString stToken = meta.value("storageToken").toString();
|
|
if (!stToken.isEmpty()) ev.insert("storageToken", stToken);
|
|
|
|
qInfo() << "[Server] ✓ Session created:" << sessionId << "| User:" << upn << "| Method: Credentials";
|
|
broadcastToSession(sessionId, ev);
|
|
|
|
// consume matched line and lock state
|
|
if (nl >= 0) buf.remove(0, nl + 1);
|
|
else buf.clear();
|
|
g_authState[sessionId] = AuthState::Success;
|
|
SessionDBManager::instance().setSessionStatus(sessionId, QStringLiteral("success"));
|
|
// The early-output buffer is only useful for tabs opened during
|
|
// the auth window. After we've transitioned to Success, the
|
|
// buffer will never be replayed - drop it so it can't linger.
|
|
g_earlyOutput.remove(sessionId);
|
|
|
|
} else if (hasFail && (!hasOk || failIdx < okIdx)) {
|
|
// failure - extract error message if present
|
|
// Format: __ANIMO_LOGIN_FAIL__:error_message
|
|
QString errorMsg = "Login failed";
|
|
const int colonIdx = failIdx + FAIL_MARKER.size();
|
|
if (colonIdx < buf.size() && buf.at(colonIdx) == ':') {
|
|
const int nlIdx = buf.indexOf('\n', colonIdx);
|
|
const int endIdx = (nlIdx >= 0) ? nlIdx : buf.size();
|
|
QString extracted = QString::fromUtf8(buf.mid(colonIdx + 1, endIdx - colonIdx - 1)).trimmed();
|
|
if (!extracted.isEmpty()) {
|
|
errorMsg = QString("Login failed: %1").arg(extracted);
|
|
}
|
|
}
|
|
qWarning() << "[Server] Login failed for session:" << sessionId << "-" << errorMsg;
|
|
|
|
QJsonObject ev{ {Protocol::F_ACTION, "error"},
|
|
{"message", errorMsg} };
|
|
if (g_loginRid.contains(sessionId)) ev.insert("rid", g_loginRid.take(sessionId));
|
|
broadcastToSession(sessionId, ev);
|
|
|
|
const int nl = buf.indexOf('\n', failIdx);
|
|
if (nl >= 0) buf.remove(0, nl + 1); else buf.clear();
|
|
g_authState[sessionId] = AuthState::Failed;
|
|
SessionDBManager::instance().setSessionStatus(sessionId, QStringLiteral("failed"));
|
|
}
|
|
}
|
|
});
|
|
|
|
// STDERR hook (sanitize too)
|
|
QObject::connect(proc, &QProcess::readyReadStandardError, parent, [proc, sessionId]() {
|
|
const QByteArray chunk = proc->readAllStandardError();
|
|
if (chunk.isEmpty()) return;
|
|
|
|
QString cleaned = OutputSanitizer::stripAnsiPrompt(QString::fromUtf8(chunk));
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
broadcastToSession(sessionId,
|
|
QJsonObject{ {Protocol::F_ACTION, "output"},
|
|
{"sessionId", sessionId},
|
|
{"stream", "stderr"},
|
|
{"data", cleaned} });
|
|
}
|
|
|
|
SessionDBManager::instance().insertCommandOutput(sessionId, QString(), cleaned);
|
|
});
|
|
|
|
// Exit hook: notify subscribers and clean up session state
|
|
QObject::connect(proc, QOverload<int,QProcess::ExitStatus>::of(&QProcess::finished),
|
|
parent, [sessionId](int exitCode, QProcess::ExitStatus exitStatus){
|
|
QMutexLocker locker(&g_stateMutex);
|
|
|
|
// Guard: if session was already manually removed, skip cleanup
|
|
if (!g_sessions.contains(sessionId) && !g_subscribers.contains(sessionId)) {
|
|
return;
|
|
}
|
|
|
|
QJsonObject msg{ {Protocol::F_ACTION, "session_exited"},
|
|
{"sessionId", sessionId},
|
|
{"exitCode", exitCode},
|
|
{"crashed", exitStatus == QProcess::CrashExit} };
|
|
broadcastToSession(sessionId, msg);
|
|
|
|
// Reflect dead-process state in the DB so a table refresh doesn't lie.
|
|
SessionDBManager::instance().setSessionAlive(sessionId, false);
|
|
|
|
// Clean up subscribers - session is dead, no further messages will come
|
|
g_subscribers.remove(sessionId);
|
|
g_activeCmdId.remove(sessionId);
|
|
g_cmdStartTime.remove(sessionId);
|
|
g_stdoutBuf.remove(sessionId);
|
|
g_cmdQueue.remove(sessionId);
|
|
g_pendingReinject.remove(sessionId);
|
|
g_earlyOutput.remove(sessionId);
|
|
|
|
if (exitStatus == QProcess::CrashExit) {
|
|
qWarning() << "[Server] PowerShell process crashed for session:" << sessionId;
|
|
}
|
|
});
|
|
|
|
return proc;
|
|
}
|
|
|
|
// ================= Server =================
|
|
|
|
Server::Server(const QString &bindIp,
|
|
quint16 port,
|
|
const QString &user,
|
|
const QString &pass,
|
|
QObject *parent)
|
|
: QObject(parent),
|
|
bindIp_(bindIp),
|
|
port_(port),
|
|
allowedUser_(user),
|
|
allowedPass_(pass) {}
|
|
|
|
bool Server::start() {
|
|
if (!SessionDBManager::instance().initMainDB()) {
|
|
emit log("[-] Sessions DB init failed (continuing with volatile memory only).");
|
|
}
|
|
connect(&tcp_, &QTcpServer::newConnection, this, &Server::onNewConnection);
|
|
if (!tcp_.listen(QHostAddress(bindIp_), port_)) {
|
|
emit log(QString("[-] Failed to listen on %1:%2").arg(bindIp_).arg(port_));
|
|
return false;
|
|
}
|
|
emit log(QString("[+] Listening on %1:%2").arg(bindIp_).arg(port_));
|
|
return true;
|
|
}
|
|
|
|
void Server::setLoginCredential(const QString &user, const QString &pass) {
|
|
allowedUser_ = user;
|
|
allowedPass_ = pass;
|
|
}
|
|
|
|
void Server::onNewConnection() {
|
|
while (tcp_.hasPendingConnections()) {
|
|
QTcpSocket *s = tcp_.nextPendingConnection();
|
|
clients_.insert(s);
|
|
emit log(QString("[*] Client connected: %1:%2")
|
|
.arg(s->peerAddress().toString())
|
|
.arg(s->peerPort()));
|
|
connect(s, &QTcpSocket::readyRead, this, &Server::onClientReady);
|
|
connect(s, &QTcpSocket::disconnected, this, &Server::onClientDisconnected);
|
|
}
|
|
}
|
|
|
|
void Server::onClientReady() {
|
|
QTcpSocket *s = qobject_cast<QTcpSocket *>(sender());
|
|
if (!s) return;
|
|
while (s->canReadLine()) {
|
|
const QByteArray line = s->readLine().trimmed();
|
|
if (!handleLine(s, line)) {
|
|
s->write(Protocol::toBytes(Protocol::err("Malformed request")));
|
|
}
|
|
}
|
|
}
|
|
|
|
void Server::onClientDisconnected() {
|
|
QTcpSocket *s = qobject_cast<QTcpSocket *>(sender());
|
|
if (!s) return;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
for (auto it = g_subscribers.begin(); it != g_subscribers.end(); ++it)
|
|
it.value().remove(s);
|
|
}
|
|
clients_.remove(s);
|
|
authed_.remove(s);
|
|
const QString op = operatorBySocket_.take(s);
|
|
s->deleteLater();
|
|
emit log(op.isEmpty() ? QStringLiteral("[*] Client disconnected")
|
|
: QString("[*] Operator '%1' disconnected").arg(op));
|
|
}
|
|
|
|
bool Server::handleLine(QTcpSocket *sock, const QByteArray &line) {
|
|
QJsonParseError pe;
|
|
QJsonDocument d = QJsonDocument::fromJson(line, &pe);
|
|
if (pe.error != QJsonParseError::NoError || !d.isObject()) {
|
|
qWarning() << "[Server] JSON parse error from"
|
|
<< sock->peerAddress().toString() << ":"
|
|
<< pe.errorString() << "at offset" << pe.offset;
|
|
return false;
|
|
}
|
|
|
|
const QJsonObject obj = d.object();
|
|
const QString action = obj.value(Protocol::F_ACTION).toString();
|
|
|
|
if (action == Protocol::ACTION_LOGIN) {
|
|
return handleLogin(sock, obj);
|
|
}
|
|
|
|
if (!authed_.contains(sock)) {
|
|
sendTo(sock, Protocol::err("Not authenticated"));
|
|
return true;
|
|
}
|
|
|
|
// ── Create session (credentials or raw) ────────────────────────────────────
|
|
if (action == Protocol::ACTION_NEW_SESSION) {
|
|
const QString mode = obj.value("mode").toString();
|
|
QString resource = obj.value("resource").toString().trimmed();
|
|
if (resource.isEmpty()) resource = QStringLiteral("https://management.azure.com");
|
|
QString sid = obj.value("sessionId").toString().trimmed();
|
|
if (sid.isEmpty()) sid = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
|
const QString rid = obj.value("rid").toString();
|
|
|
|
// Validate session ID format (UUID or alphanumeric, max 64 chars)
|
|
static const QRegularExpression sessionIdRe(
|
|
QStringLiteral("^[a-zA-Z0-9_\\-]{1,64}$"));
|
|
if (!sessionIdRe.match(sid).hasMatch()) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
SessionDBManager::instance().logAudit(
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")), "new_session", sid, mode);
|
|
|
|
// Enforce session count limit
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
if (g_sessions.size() >= MAX_SESSIONS) {
|
|
sendTo(sock, Protocol::err(
|
|
QString("session limit reached (%1 max)").arg(MAX_SESSIONS)));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Ensure process and subscribe caller
|
|
QProcess *proc = ensureProcess(this, sid, operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
if (!proc) {
|
|
sendTo(sock, Protocol::err("failed to start session process"));
|
|
return true;
|
|
}
|
|
|
|
QJsonObject meta;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_subscribers[sid].insert(sock);
|
|
|
|
// In-memory meta + update resource if changed
|
|
meta = g_sessionInfo.value(sid);
|
|
meta.insert("resource", resource);
|
|
g_sessionInfo.insert(sid, meta);
|
|
}
|
|
|
|
// Persist resource via SessionDBManager (best-effort)
|
|
SessionDBManager::instance().initMainDB();
|
|
if (auto db = SessionDBManager::instance().mainDb(); db.isOpen()) {
|
|
QSqlQuery uq(db);
|
|
uq.prepare("UPDATE sessions SET Resource=? WHERE SessionID=?");
|
|
uq.addBindValue(resource);
|
|
uq.addBindValue(sid);
|
|
uq.exec();
|
|
}
|
|
|
|
// Per-session DB (idempotent)
|
|
SessionDBManager::instance().createSessionDB(sid);
|
|
|
|
// Immediately tell this subscriber the session process is alive
|
|
sendTo(sock, QJsonObject{
|
|
{Protocol::F_ACTION, "session_open"},
|
|
{"sessionId", sid},
|
|
{"alive", true}
|
|
});
|
|
|
|
// "credentials" flow
|
|
if (mode == QStringLiteral("credentials")) {
|
|
const QString user = obj.value("username").toString().trimmed();
|
|
const QString pass = obj.value("password").toString();
|
|
|
|
if (user.isEmpty() || pass.isEmpty()) {
|
|
sendTo(sock, Protocol::err("username/password required"));
|
|
return true;
|
|
}
|
|
|
|
// GRAPH - Connect-MgGraph (no module checks, emit markers)
|
|
if (resource.contains("graph.microsoft.com", Qt::CaseInsensitive)) {
|
|
// Load script from Qt resources
|
|
QByteArray script = loadScript(":/scripts/login_graph.ps1");
|
|
if (script.isEmpty()) {
|
|
sendTo(sock, Protocol::err("failed to load Graph login script"));
|
|
return true;
|
|
}
|
|
|
|
QString appDir = QCoreApplication::applicationDirPath();
|
|
QDir().mkpath(QString("%1/data/sessions/%2").arg(appDir, sid));
|
|
const QString ps1Path = QString("%1/data/sessions/%2/login.ps1").arg(appDir, sid);
|
|
|
|
QFile f(ps1Path);
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
sendTo(sock, Protocol::err("failed to create login script"));
|
|
return true;
|
|
}
|
|
f.write(script);
|
|
f.close();
|
|
|
|
if (!rid.isEmpty()) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_loginRid.insert(sid, rid);
|
|
}
|
|
|
|
// Use single quotes with proper escaping to prevent command injection
|
|
QString execCmd = QString(". \"%1\" -Username '%2' -Password '%3'\n")
|
|
.arg(ps1Path, escapePsString(user), escapePsString(pass));
|
|
proc->write(execCmd.toUtf8());
|
|
|
|
QJsonObject ack = Protocol::ok("new_session ok");
|
|
ack.insert("sessionId", sid);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// AZURE MANAGEMENT (Az) - Connect-AzAccount (no module checks)
|
|
// Load script from Qt resources
|
|
QByteArray script = loadScript(":/scripts/login_azure.ps1");
|
|
if (script.isEmpty()) {
|
|
sendTo(sock, Protocol::err("failed to load Azure login script"));
|
|
return true;
|
|
}
|
|
|
|
QString appDir = QCoreApplication::applicationDirPath();
|
|
QDir().mkpath(QString("%1/data/sessions/%2").arg(appDir, sid));
|
|
const QString ps1Path = QString("%1/data/sessions/%2/login.ps1").arg(appDir, sid);
|
|
|
|
QFile f(ps1Path);
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
sendTo(sock, Protocol::err("failed to create login script"));
|
|
return true;
|
|
}
|
|
f.write(script);
|
|
f.close();
|
|
|
|
if (!rid.isEmpty()) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_loginRid.insert(sid, rid);
|
|
}
|
|
|
|
// Use single quotes with proper escaping to prevent command injection
|
|
QString execCmd = QString(". \"%1\" -Username '%2' -Password '%3' -Resource '%4'\n")
|
|
.arg(ps1Path, escapePsString(user), escapePsString(pass), escapePsString(resource));
|
|
proc->write(execCmd.toUtf8());
|
|
|
|
QJsonObject ack = Protocol::ok("new_session ok");
|
|
ack.insert("sessionId", sid);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// --- FULL SESSION via DEVICE CODE (Connect-AzAccount -UseDeviceAuthentication) ---
|
|
// Interactive device-code login run INSIDE Az, so the context keeps a real token
|
|
// cache + refresh token. Data-plane cmdlets (Get-AzKeyVaultSecret, storage, etc.)
|
|
// then work natively - unlike -AccessToken sessions. No username/password needed.
|
|
if (mode == QStringLiteral("az_devicecode")) {
|
|
QByteArray script = loadScript(":/scripts/login_azure_devicecode.ps1");
|
|
if (script.isEmpty()) {
|
|
sendTo(sock, Protocol::err("failed to load device-code login script"));
|
|
return true;
|
|
}
|
|
|
|
QString appDir = QCoreApplication::applicationDirPath();
|
|
QDir().mkpath(QString("%1/data/sessions/%2").arg(appDir, sid));
|
|
const QString ps1Path = QString("%1/data/sessions/%2/login.ps1").arg(appDir, sid);
|
|
|
|
QFile f(ps1Path);
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
sendTo(sock, Protocol::err("failed to create login script"));
|
|
return true;
|
|
}
|
|
f.write(script);
|
|
f.close();
|
|
|
|
if (!rid.isEmpty()) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_loginRid.insert(sid, rid);
|
|
}
|
|
|
|
// No credentials to pass - the device-code prompt streams to the Session Tab.
|
|
QString execCmd = QString(". \"%1\" -Resource '%2'\n")
|
|
.arg(ps1Path, escapePsString(resource));
|
|
proc->write(execCmd.toUtf8());
|
|
|
|
QJsonObject ack = Protocol::ok("new_session ok");
|
|
ack.insert("sessionId", sid);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// --- SPN (Service Principal) FLOW ---
|
|
if (mode == QStringLiteral("spn")) {
|
|
const QString appId = obj.value("appId").toString().trimmed();
|
|
const QString clientSecret = obj.value("clientSecret").toString();
|
|
const QString tenantId = obj.value("tenantId").toString().trimmed();
|
|
|
|
if (appId.isEmpty() || clientSecret.isEmpty() || tenantId.isEmpty()) {
|
|
sendTo(sock, Protocol::err("appId, clientSecret, and tenantId required"));
|
|
return true;
|
|
}
|
|
|
|
QByteArray script = loadScript(":/scripts/login_spn_azure.ps1");
|
|
if (script.isEmpty()) {
|
|
sendTo(sock, Protocol::err("failed to load SPN login script"));
|
|
return true;
|
|
}
|
|
|
|
QString appDir = QCoreApplication::applicationDirPath();
|
|
QDir().mkpath(QString("%1/data/sessions/%2").arg(appDir, sid));
|
|
const QString ps1Path = QString("%1/data/sessions/%2/login.ps1").arg(appDir, sid);
|
|
|
|
QFile f(ps1Path);
|
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
sendTo(sock, Protocol::err("failed to create login script"));
|
|
return true;
|
|
}
|
|
f.write(script);
|
|
f.close();
|
|
|
|
// Write credentials to a separate file to avoid exposing secret in process list
|
|
const QString credPath = QString("%1/data/sessions/%2/spn_cred.json").arg(appDir, sid);
|
|
QFile credFile(credPath);
|
|
if (!credFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
sendTo(sock, Protocol::err("failed to create credentials file"));
|
|
return true;
|
|
}
|
|
QJsonObject credObj;
|
|
credObj.insert("appId", appId);
|
|
credObj.insert("clientSecret", clientSecret);
|
|
credObj.insert("tenantId", tenantId);
|
|
credFile.write(QJsonDocument(credObj).toJson(QJsonDocument::Compact));
|
|
credFile.close();
|
|
|
|
if (!rid.isEmpty()) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_loginRid.insert(sid, rid);
|
|
}
|
|
|
|
// Stash SPN identity in g_sessionInfo BEFORE the login script runs.
|
|
// The __ANIMO_LOGIN_OK__ handler is generic and would otherwise
|
|
// derive tenant/domain from the UPN's @suffix - which for SPNs is
|
|
// just the AppId with no '@' - giving "N/A". Preserving these here
|
|
// means the sessions table shows the real tenant GUID.
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
QJsonObject spnMeta = g_sessionInfo.value(sid);
|
|
spnMeta.insert("user", appId);
|
|
spnMeta.insert("tenantId", tenantId);
|
|
spnMeta.insert("domain", QStringLiteral("ServicePrincipal"));
|
|
spnMeta.insert("resource", resource);
|
|
g_sessionInfo.insert(sid, spnMeta);
|
|
}
|
|
SessionDBManager::instance().updateSessionUser(sid, appId);
|
|
SessionDBManager::instance().updateSessionTenant(sid, tenantId, QStringLiteral("ServicePrincipal"));
|
|
|
|
// Pass only the credential file path, not the secret itself
|
|
QString execCmd = QString(". \"%1\" -CredentialFile \"%2\" -Resource '%3'\n")
|
|
.arg(ps1Path, credPath, escapePsString(resource));
|
|
proc->write(execCmd.toUtf8());
|
|
|
|
// The script deletes this after reading, but wipe it here too in case
|
|
// pwsh never starts and the plaintext secret is left on disk.
|
|
QTimer::singleShot(15000, this, [credPath]() {
|
|
if (QFile::exists(credPath)) {
|
|
QFile::remove(credPath);
|
|
}
|
|
});
|
|
|
|
QJsonObject ack = Protocol::ok("new_session ok");
|
|
ack.insert("sessionId", sid);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// --- TOKENS FLOW ---
|
|
if (mode == QStringLiteral("tokens")) {
|
|
const QString access = obj.value("accessToken").toString();
|
|
const QString refresh= obj.value("refreshToken").toString();
|
|
QString user = obj.value("user").toString().trimmed();
|
|
QString tenantId = obj.value("tenantId").toString().trimmed();
|
|
QString domain = obj.value("domain").toString().trimmed();
|
|
|
|
if (access.isEmpty()) {
|
|
qWarning() << "[Server] Token login failed: no access token";
|
|
sendTo(sock, Protocol::err("accessToken required"));
|
|
return true;
|
|
}
|
|
|
|
// Best-effort fill-ins if client didn't send them
|
|
auto jwtField = [&](const QString &field)->QString {
|
|
const QStringList parts = access.split('.');
|
|
if (parts.size() < 2) return QString();
|
|
QByteArray payload = parts.at(1).toUtf8();
|
|
int pad = (4 - (payload.size() % 4)) % 4;
|
|
payload.append(QByteArray(pad, '='));
|
|
QByteArray decoded = QByteArray::fromBase64(payload, QByteArray::Base64UrlEncoding);
|
|
if (decoded.isEmpty()) return QString(); // Base64 decode failed
|
|
QJsonParseError parseError;
|
|
QJsonDocument doc = QJsonDocument::fromJson(decoded, &parseError);
|
|
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) return QString();
|
|
return doc.object().value(field).toString();
|
|
};
|
|
if (tenantId.isEmpty()) tenantId = jwtField("tid");
|
|
if (user.isEmpty()) user = jwtField("upn");
|
|
if (domain.isEmpty()) domain = (user.contains('@') ? user.section('@',1,1) : QStringLiteral("N/A"));
|
|
|
|
// Persist canonical metadata early
|
|
QJsonObject meta2;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
// CRITICAL: Register socket as subscriber so session_created reaches the client
|
|
g_subscribers[sid].insert(sock);
|
|
|
|
meta2 = g_sessionInfo.value(sid);
|
|
meta2.insert("user", user.isEmpty() ? QStringLiteral("Unknown") : user);
|
|
meta2.insert("tenantId", tenantId.isEmpty() ? QStringLiteral("N/A") : tenantId);
|
|
meta2.insert("domain", domain.isEmpty() ? QStringLiteral("N/A") : domain);
|
|
meta2.insert("resource", resource);
|
|
g_sessionInfo.insert(sid, meta2);
|
|
if (!rid.isEmpty()) g_loginRid.insert(sid, rid);
|
|
}
|
|
|
|
SessionDBManager::instance().initMainDB();
|
|
SessionDBManager::instance().addSessionToMainDB(sid,
|
|
meta2.value("user").toString("Unknown"),
|
|
meta2.value("tenantId").toString("N/A"),
|
|
meta2.value("domain").toString("N/A"),
|
|
resource,
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
|
|
// Prepare a PS fragment that will emit markers understood by stdout hook
|
|
auto emitOk = [&](const QString &upn)->QByteArray {
|
|
return QString("Write-Output ('__ANIMO_LOGIN_OK__:%1')\n")
|
|
.arg(escapePsString(upn.isEmpty() ? QStringLiteral("Unknown") : upn)).toUtf8();
|
|
};
|
|
// emitFail now includes error message: __ANIMO_LOGIN_FAIL__:error_message
|
|
auto emitFailWithMsg = [](const QString &errVar)->QByteArray {
|
|
return QString("Write-Output \"__ANIMO_LOGIN_FAIL__:$%1\"\n").arg(errVar).toUtf8();
|
|
};
|
|
auto emitFail = []()->QByteArray {
|
|
return QByteArray("Write-Output \"__ANIMO_LOGIN_FAIL__:Unknown error\"\n");
|
|
};
|
|
|
|
QByteArray ps;
|
|
|
|
if (resource.contains("graph.microsoft.com", Qt::CaseInsensitive)) {
|
|
// Graph: try SecureString (v2.x) then plain string (v1.x)
|
|
ps.append("$t=@'\n"); ps.append(access.toUtf8()); ps.append("\n'@\n");
|
|
ps.append("$e='Unknown'\n");
|
|
ps.append("try{Connect-MgGraph -NoWelcome -AccessToken(ConvertTo-SecureString $t -AsPlainText -Force)|Out-Null;");
|
|
ps.append(emitOk(user)); ps.append("return}catch{$e=$_.Exception.Message}\n");
|
|
ps.append("try{Connect-MgGraph -NoWelcome -AccessToken $t|Out-Null;");
|
|
ps.append(emitOk(user)); ps.append("return}catch{$e=$_.Exception.Message}\n");
|
|
ps.append(emitFailWithMsg("e"));
|
|
} else {
|
|
// -- Az: need both Az access and a Graph token for Connect-AzAccount
|
|
if (refresh.isEmpty()) {
|
|
// Access-token-only session (no refresh token, e.g. an SPN access
|
|
// token). We cannot mint other audiences - use the single token as-is.
|
|
if (resource.contains("management.azure.com", Qt::CaseInsensitive)) {
|
|
// Inert ARM context so Az cmdlets work with this token.
|
|
ps.append("$a=@\"\n"); ps.append(access.toUtf8()); ps.append("\n\"@\n");
|
|
ps.append("try{Connect-AzAccount -AccessToken $a ");
|
|
if (!user.isEmpty()) ps.append(QString("-AccountId '%1' ").arg(escapePsString(user)).toUtf8());
|
|
if (!tenantId.isEmpty()) ps.append(QString("-TenantId '%1' ").arg(escapePsString(tenantId)).toUtf8());
|
|
ps.append("-WA Ignore|Out-Null}catch{}\n");
|
|
ps.append("$ctx=Get-AzContext -EA SilentlyContinue\n");
|
|
ps.append("if($ctx-and$ctx.Account){"); ps.append(emitOk(user));
|
|
ps.append("}else{"); ps.append(emitFail()); ps.append("}\n");
|
|
} else {
|
|
// Key Vault / Storage / SQL are client-REST modules - no Az context
|
|
// needed, so just confirm the session (client stores the token).
|
|
ps.append(emitOk(user));
|
|
}
|
|
} else {
|
|
|
|
// Exchange refresh -> Graph token (server-side) to keep PS simple
|
|
QNetworkAccessManager nam;
|
|
QUrl turl(QString("https://login.microsoftonline.com/%1/oauth2/v2.0/token").arg(
|
|
tenantId.isEmpty() ? QStringLiteral("organizations") : tenantId));
|
|
QNetworkRequest treq(turl);
|
|
treq.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
|
QUrlQuery form;
|
|
form.addQueryItem("client_id", APP_CONFIG.defaultClientId());
|
|
form.addQueryItem("grant_type", "refresh_token");
|
|
form.addQueryItem("refresh_token", refresh);
|
|
form.addQueryItem("scope", "https://graph.microsoft.com/.default");
|
|
QEventLoop loop;
|
|
QNetworkReply *rep = nam.post(treq, form.query(QUrl::FullyEncoded).toUtf8());
|
|
QObject::connect(rep, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
|
loop.exec();
|
|
QString mgToken;
|
|
if (rep->error() == QNetworkReply::NoError) {
|
|
mgToken = QJsonDocument::fromJson(rep->readAll()).object().value("access_token").toString();
|
|
}
|
|
rep->deleteLater();
|
|
|
|
if (mgToken.isEmpty()) {
|
|
qWarning() << "[Server] Az login failed: could not obtain Graph token";
|
|
sendTo(sock, Protocol::err("failed to obtain Microsoft Graph token from refresh_token"));
|
|
return true;
|
|
}
|
|
|
|
// Also exchange refresh -> storage token so -UseConnectedAccount
|
|
// isn't needed (access-token sessions have no token cache).
|
|
QString storageToken;
|
|
{
|
|
QUrl surl(QString("https://login.microsoftonline.com/%1/oauth2/v2.0/token").arg(
|
|
tenantId.isEmpty() ? QStringLiteral("organizations") : tenantId));
|
|
QNetworkRequest sreq(surl);
|
|
sreq.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
|
QUrlQuery sform;
|
|
sform.addQueryItem("client_id", APP_CONFIG.defaultClientId());
|
|
sform.addQueryItem("grant_type", "refresh_token");
|
|
sform.addQueryItem("refresh_token", refresh);
|
|
sform.addQueryItem("scope", "https://storage.azure.com/.default");
|
|
QEventLoop sloop;
|
|
QNetworkReply *srep = nam.post(sreq, sform.query(QUrl::FullyEncoded).toUtf8());
|
|
QObject::connect(srep, &QNetworkReply::finished, &sloop, &QEventLoop::quit);
|
|
sloop.exec();
|
|
if (srep->error() == QNetworkReply::NoError)
|
|
storageToken = QJsonDocument::fromJson(srep->readAll()).object().value("access_token").toString();
|
|
srep->deleteLater();
|
|
}
|
|
|
|
// Also exchange refresh -> KeyVault token and inject it as
|
|
// -KeyVaultAccessToken so data-plane cmdlets (Get-AzKeyVaultSecret)
|
|
// work in the terminal without a device-code login / token cache.
|
|
QString keyVaultToken;
|
|
{
|
|
QUrl kurl(QString("https://login.microsoftonline.com/%1/oauth2/v2.0/token").arg(
|
|
tenantId.isEmpty() ? QStringLiteral("organizations") : tenantId));
|
|
QNetworkRequest kreq(kurl);
|
|
kreq.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
|
|
QUrlQuery kform;
|
|
kform.addQueryItem("client_id", APP_CONFIG.defaultClientId());
|
|
kform.addQueryItem("grant_type", "refresh_token");
|
|
kform.addQueryItem("refresh_token", refresh);
|
|
kform.addQueryItem("scope", "https://vault.azure.net/.default");
|
|
QEventLoop kloop;
|
|
QNetworkReply *krep = nam.post(kreq, kform.query(QUrl::FullyEncoded).toUtf8());
|
|
QObject::connect(krep, &QNetworkReply::finished, &kloop, &QEventLoop::quit);
|
|
kloop.exec();
|
|
if (krep->error() == QNetworkReply::NoError)
|
|
keyVaultToken = QJsonDocument::fromJson(krep->readAll()).object().value("access_token").toString();
|
|
krep->deleteLater();
|
|
}
|
|
|
|
// Az: Connect with both access and graph tokens
|
|
ps.append("$a=@\"\n"); ps.append(access.toUtf8()); ps.append("\n\"@\n");
|
|
ps.append("$g=@\"\n"); ps.append(mgToken.toUtf8()); ps.append("\n\"@\n");
|
|
if (!keyVaultToken.isEmpty()) {
|
|
ps.append("$kv=@\"\n"); ps.append(keyVaultToken.toUtf8()); ps.append("\n\"@\n");
|
|
}
|
|
ps.append("try{Connect-AzAccount -AccessToken $a -MicrosoftGraphAccessToken $g ");
|
|
if (!keyVaultToken.isEmpty()) ps.append("-KeyVaultAccessToken $kv ");
|
|
if (!user.isEmpty()) ps.append(QString("-AccountId '%1' ").arg(escapePsString(user)).toUtf8());
|
|
if (!tenantId.isEmpty()) ps.append(QString("-TenantId '%1' ").arg(escapePsString(tenantId)).toUtf8());
|
|
ps.append("-WA Ignore|Out-Null\n");
|
|
// Also connect Microsoft.Graph with the same Graph token so Get-Mg*
|
|
// cmdlets work in the terminal (Connect-AzAccount's -MicrosoftGraphAccessToken
|
|
// only feeds Az's internal Graph calls, not the Mg module). Best-effort.
|
|
ps.append("try{Connect-MgGraph -NoWelcome -AccessToken(ConvertTo-SecureString $g -AsPlainText -Force)|Out-Null}catch{try{Connect-MgGraph -NoWelcome -AccessToken $g|Out-Null}catch{}}\n");
|
|
// Store storage token for the session_created event
|
|
if (!storageToken.isEmpty()) {
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_sessionInfo[sid].insert("storageToken", storageToken);
|
|
}
|
|
// Tag this as an inert access-token Az session so token re-injection
|
|
// (WS5 renewal) is allowed - full/live sessions must NOT be re-injected
|
|
// (it would downgrade them to AccessToken and break silent minting).
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_sessionInfo[sid].insert("azInjected", true);
|
|
}
|
|
|
|
// WS3: Storage data-plane cannot be injected into the Az context (no
|
|
// -StorageAccessToken param), so expose REST helpers that use the
|
|
// minted storage token directly: Get-AnimoStorageContainer/Blob/Content.
|
|
if (!storageToken.isEmpty()) {
|
|
ps.append("$st=@\"\n"); ps.append(storageToken.toUtf8()); ps.append("\n\"@\n");
|
|
ps.append("$global:AnimoStorageToken=$st\n");
|
|
ps.append(
|
|
"function global:Get-AnimoStorageContainer{param([Parameter(Mandatory)][string]$Account)"
|
|
"$h=@{Authorization=\"Bearer $global:AnimoStorageToken\";'x-ms-version'='2021-08-06'};"
|
|
"(Invoke-RestMethod -Uri \"https://$Account.blob.core.windows.net/?comp=list\" -Headers $h)."
|
|
"EnumerationResults.Containers.Container|Select-Object Name}\n");
|
|
ps.append(
|
|
"function global:Get-AnimoStorageBlob{param([Parameter(Mandatory)][string]$Account,[Parameter(Mandatory)][string]$Container)"
|
|
"$h=@{Authorization=\"Bearer $global:AnimoStorageToken\";'x-ms-version'='2021-08-06'};"
|
|
"(Invoke-RestMethod -Uri \"https://$Account.blob.core.windows.net/$Container`?restype=container&comp=list\" -Headers $h)."
|
|
"EnumerationResults.Blobs.Blob|Select-Object Name,@{n='Size';e={$_.Properties.'Content-Length'}}}\n");
|
|
ps.append(
|
|
"function global:Get-AnimoBlobContent{param([Parameter(Mandatory)][string]$Account,[Parameter(Mandatory)][string]$Container,[Parameter(Mandatory)][string]$Blob)"
|
|
"$h=@{Authorization=\"Bearer $global:AnimoStorageToken\";'x-ms-version'='2021-08-06'};"
|
|
"Invoke-RestMethod -Uri \"https://$Account.blob.core.windows.net/$Container/$Blob\" -Headers $h}\n");
|
|
}
|
|
ps.append("$ctx=Get-AzContext -EA SilentlyContinue\n");
|
|
ps.append("if($ctx-and$ctx.Account){Write-Output \"__ANIMO_LOGIN_OK__:$($ctx.Account)\"}");
|
|
ps.append("else{"); ps.append(emitFail()); ps.append("}}catch{"); ps.append(emitFail()); ps.append("}\n");
|
|
} // end refresh-based Az path
|
|
}
|
|
|
|
// Write script directly to stdin (more reliable than sourcing file)
|
|
proc->write(ps);
|
|
proc->write("\n");
|
|
|
|
qInfo() << "[Server] Token-based session requested:" << sid
|
|
<< "| User:" << (user.isEmpty() ? "Unknown" : user)
|
|
<< "| Tenant:" << (tenantId.isEmpty() ? "N/A" : tenantId)
|
|
<< "| Resource:" << resource;
|
|
|
|
// Immediate ack; final result comes via markers
|
|
QJsonObject ack2 = Protocol::ok("new_session ok");
|
|
ack2.insert("sessionId", sid);
|
|
sendTo(sock, ack2);
|
|
return true;
|
|
}
|
|
|
|
// Raw session creation / attach
|
|
QJsonObject ack = Protocol::ok("new_session ok");
|
|
ack.insert("sessionId", sid);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// ── Run command ────────────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_RUN_COMMAND) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
const QString cmd = obj.value("command").toString();
|
|
QString cmdId = obj.value(Protocol::F_CMD_ID).toString().trimmed();
|
|
if (cmdId.isEmpty()) {
|
|
cmdId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
|
}
|
|
|
|
if (sid.isEmpty() || cmd.isEmpty()) {
|
|
sendTo(sock, Protocol::err("missing sessionId or command"));
|
|
return true;
|
|
}
|
|
if (!isValidSessionId(sid)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
// Validate command length to prevent memory exhaustion
|
|
if (cmd.size() > MAX_CMD_LENGTH) {
|
|
sendTo(sock, Protocol::err(
|
|
QString("command too long (%1 bytes, max %2)")
|
|
.arg(cmd.size()).arg(MAX_CMD_LENGTH)));
|
|
return true;
|
|
}
|
|
QProcess *proc = ensureProcess(this, sid, operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
if (!proc) {
|
|
sendTo(sock, Protocol::err("session not available"));
|
|
return true;
|
|
}
|
|
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_subscribers[sid].insert(sock);
|
|
|
|
if (g_authState.value(sid, AuthState::Pending) == AuthState::Pending) {
|
|
g_authState[sid] = AuthState::Success;
|
|
}
|
|
|
|
// If a command is already running, queue this one. It is dispatched when the
|
|
// active command completes (see the command_complete path in the stdout hook).
|
|
if (g_activeCmdId.contains(sid)) {
|
|
qint64 now = QDateTime::currentMSecsSinceEpoch();
|
|
qint64 started = g_cmdStartTime.value(sid, 0);
|
|
if (now - started < CMD_TIMEOUT_MS) {
|
|
if (g_cmdQueue.value(sid).size() >= MAX_CMD_QUEUE) {
|
|
sendTo(sock, Protocol::err("command queue full, please wait"));
|
|
return true;
|
|
}
|
|
g_cmdQueue[sid].append(QueuedCommand{cmdId, cmd,
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown"))});
|
|
broadcastToSession(sid,
|
|
QJsonObject{ {Protocol::F_ACTION, "command_queued"},
|
|
{"sessionId", sid}, {Protocol::F_CMD_ID, cmdId},
|
|
{"command", cmd} });
|
|
QJsonObject qack = Protocol::ok("command queued");
|
|
qack.insert(Protocol::F_CMD_ID, cmdId);
|
|
sendTo(sock, qack);
|
|
return true;
|
|
}
|
|
// Timeout - clear stale command state AND discard any orphaned output
|
|
// from the abandoned command so it can't contaminate this one.
|
|
g_activeCmdId.remove(sid);
|
|
g_cmdStartTime.remove(sid);
|
|
g_stdoutBuf.remove(sid);
|
|
}
|
|
|
|
dispatchCommand(sid, cmdId, cmd, operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
}
|
|
|
|
SessionDBManager::instance().logAudit(
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")), "run_command", sid, cmd.left(200));
|
|
|
|
QJsonObject ack = Protocol::ok("command accepted");
|
|
ack.insert(Protocol::F_CMD_ID, cmdId);
|
|
sendTo(sock, ack);
|
|
return true;
|
|
}
|
|
|
|
// ── List sessions (main DB) ────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_LIST) {
|
|
SessionDBManager::instance().initMainDB();
|
|
QSqlDatabase db = SessionDBManager::instance().mainDb();
|
|
|
|
QJsonArray arr;
|
|
if (db.isOpen()) {
|
|
QSqlQuery q(db);
|
|
q.prepare("SELECT SessionID, User, TenantID, DefaultDomain, Resource, CreatedBy "
|
|
"FROM sessions ORDER BY CreatedAt DESC");
|
|
if (q.exec()) {
|
|
while (q.next()) {
|
|
const QString sid = q.value(0).toString();
|
|
|
|
QJsonObject row;
|
|
row.insert("sessionId", sid);
|
|
row.insert("user", q.value(1).toString());
|
|
row.insert("tenantId", q.value(2).toString());
|
|
row.insert("domain", q.value(3).toString());
|
|
row.insert("resource", q.value(4).toString());
|
|
row.insert("createdBy", q.value(5).toString());
|
|
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
const bool alive = g_sessions.contains(sid) && g_sessions.value(sid) &&
|
|
g_sessions.value(sid)->state() == QProcess::Running;
|
|
row.insert("alive", alive);
|
|
|
|
const AuthState st = g_authState.value(sid, AuthState::Pending);
|
|
const QString status = (st == AuthState::Success) ? "success" :
|
|
(st == AuthState::Failed ) ? "failed" : "pending";
|
|
row.insert("status", status);
|
|
}
|
|
|
|
arr.append(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
QJsonObject resp = Protocol::ok("list ok");
|
|
resp.insert("data", arr);
|
|
resp.insert("count", arr.size());
|
|
sendTo(sock, resp);
|
|
return true;
|
|
}
|
|
|
|
// ── Re-inject fresh per-audience tokens into an inert Az session (WS5) ───────
|
|
// Keeps the terminal's Az/Mg context alive past the ~1h token expiry. Silently
|
|
// ignored for full/live sessions (would downgrade them to AccessToken).
|
|
if (action == QStringLiteral("reinject_tokens")) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
if (!isValidSessionId(sid)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
bool injected = false;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
injected = g_sessionInfo.value(sid).value("azInjected").toBool();
|
|
}
|
|
QProcess *proc = g_sessions.value(sid, nullptr);
|
|
if (!proc || !injected) {
|
|
// Not an inert Az session (or gone) - no-op, ack so the client moves on.
|
|
sendTo(sock, Protocol::ok("reinject skipped"));
|
|
return true;
|
|
}
|
|
const QString access = obj.value("accessToken").toString();
|
|
const QString graph = obj.value("graphToken").toString();
|
|
const QString keyVault= obj.value("keyVaultToken").toString();
|
|
const QString user = obj.value("user").toString();
|
|
const QString tenant = obj.value("tenantId").toString();
|
|
if (access.isEmpty()) {
|
|
sendTo(sock, Protocol::err("reinject requires accessToken"));
|
|
return true;
|
|
}
|
|
|
|
QByteArray ps;
|
|
ps.append("$a=@\"\n"); ps.append(access.toUtf8()); ps.append("\n\"@\n");
|
|
if (!graph.isEmpty()) { ps.append("$g=@\"\n"); ps.append(graph.toUtf8()); ps.append("\n\"@\n"); }
|
|
if (!keyVault.isEmpty()) { ps.append("$kv=@\"\n"); ps.append(keyVault.toUtf8()); ps.append("\n\"@\n"); }
|
|
ps.append("try{Connect-AzAccount -AccessToken $a ");
|
|
if (!graph.isEmpty()) ps.append("-MicrosoftGraphAccessToken $g ");
|
|
if (!keyVault.isEmpty()) ps.append("-KeyVaultAccessToken $kv ");
|
|
if (!user.isEmpty()) ps.append(QString("-AccountId '%1' ").arg(escapePsString(user)).toUtf8());
|
|
if (!tenant.isEmpty()) ps.append(QString("-TenantId '%1' ").arg(escapePsString(tenant)).toUtf8());
|
|
ps.append("-WA Ignore|Out-Null}catch{}\n");
|
|
if (!graph.isEmpty())
|
|
ps.append("try{Connect-MgGraph -NoWelcome -AccessToken(ConvertTo-SecureString $g -AsPlainText -Force)|Out-Null}catch{}\n");
|
|
|
|
// Never write into the terminal while a command is running - it would
|
|
// interleave with that command's output stream. Queue the payload; the
|
|
// command_complete path flushes it as soon as the command finishes so
|
|
// the terminal's Az context is refreshed with no operator involvement.
|
|
if (g_activeCmdId.contains(sid)) {
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_pendingReinject.insert(sid, ps); // overwrites older pending payload
|
|
}
|
|
sendTo(sock, Protocol::ok("reinject queued (busy - will apply on command complete)"));
|
|
return true;
|
|
}
|
|
|
|
proc->write(ps);
|
|
sendTo(sock, Protocol::ok("reinject ok"));
|
|
return true;
|
|
}
|
|
|
|
// ── Get session info (+ ensure proc, subscribe) ────────────────────────────
|
|
if (action == QStringLiteral("get_session")) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
if (sid.isEmpty()) {
|
|
sendTo(sock, Protocol::err("missing sessionId"));
|
|
return true;
|
|
}
|
|
if (!isValidSessionId(sid)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
// Ensure process exists/starts and subscribe caller
|
|
QProcess *proc = ensureProcess(this, sid, operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
if (!proc) {
|
|
sendTo(sock, Protocol::err("failed to ensure session process"));
|
|
return true;
|
|
}
|
|
QByteArray earlyToReplay;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_subscribers[sid].insert(sock);
|
|
// First subscriber to see this session -> replay any pre-login
|
|
// stdout that arrived before the tab existed. This is where the
|
|
// SPN login-script's [Animo] subscription list finally reaches
|
|
// the user's terminal.
|
|
earlyToReplay = g_earlyOutput.take(sid);
|
|
}
|
|
if (!earlyToReplay.isEmpty()) {
|
|
sendTo(sock, QJsonObject{
|
|
{Protocol::F_ACTION, "output"},
|
|
{"sessionId", sid},
|
|
{"stream", "stdout"},
|
|
{"data", QString::fromUtf8(earlyToReplay)}
|
|
});
|
|
}
|
|
|
|
// Pull canonical info from main DB
|
|
SessionDBManager::instance().initMainDB();
|
|
QSqlDatabase db = SessionDBManager::instance().mainDb();
|
|
|
|
QJsonObject info;
|
|
if (db.isOpen()) {
|
|
QSqlQuery q(db);
|
|
q.prepare("SELECT SessionID, User, TenantID, DefaultDomain, Resource "
|
|
"FROM sessions WHERE SessionID=? LIMIT 1");
|
|
q.addBindValue(sid);
|
|
if (q.exec() && q.next()) {
|
|
info.insert("sessionId", q.value(0).toString());
|
|
info.insert("user", q.value(1).toString());
|
|
info.insert("tenantId", q.value(2).toString());
|
|
info.insert("domain", q.value(3).toString());
|
|
info.insert("resource", q.value(4).toString());
|
|
} else {
|
|
sendTo(sock, Protocol::err(QString("no such session: %1").arg(sid)));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Merge live fields
|
|
info.insert("alive", proc->state() == QProcess::Running);
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
const AuthState st = g_authState.value(sid, AuthState::Pending);
|
|
const QString status = (st == AuthState::Success) ? "success" :
|
|
(st == AuthState::Failed ) ? "failed" : "pending";
|
|
info.insert("status", status);
|
|
}
|
|
|
|
// Reply
|
|
QJsonObject resp = Protocol::ok("session_info");
|
|
resp.insert("data", info);
|
|
sendTo(sock, resp);
|
|
return true;
|
|
}
|
|
|
|
// ── Remove session ─────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_REMOVE) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
if (sid.isEmpty()) {
|
|
sendTo(sock, Protocol::err("missing sessionId"));
|
|
return true;
|
|
}
|
|
|
|
// Validate session ID format to prevent path traversal in removeRecursively()
|
|
static const QRegularExpression sessionIdRe(
|
|
QStringLiteral("^[a-zA-Z0-9_\\-]{1,64}$"));
|
|
if (!sessionIdRe.match(sid).hasMatch()) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
SessionDBManager::instance().logAudit(
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")), "remove_session", sid, QString());
|
|
|
|
// Kill PS proc and clean global state
|
|
QProcess *procToKill = nullptr;
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
if (g_sessions.contains(sid)) {
|
|
procToKill = g_sessions.take(sid);
|
|
// Disconnect finished signal to prevent race with this cleanup
|
|
if (procToKill) {
|
|
procToKill->disconnect();
|
|
}
|
|
}
|
|
g_subscribers.remove(sid);
|
|
g_sessionInfo.remove(sid);
|
|
g_stdoutBuf.remove(sid);
|
|
g_loginRid.remove(sid);
|
|
g_authState.remove(sid);
|
|
g_lastCommand.remove(sid);
|
|
g_activeCmdId.remove(sid);
|
|
g_cmdStartTime.remove(sid);
|
|
g_pendingToken.remove(sid);
|
|
g_pendingReinject.remove(sid);
|
|
g_earlyOutput.remove(sid);
|
|
g_cmdQueue.remove(sid);
|
|
}
|
|
if (procToKill) {
|
|
procToKill->kill();
|
|
procToKill->deleteLater();
|
|
}
|
|
|
|
// Remove from main DB
|
|
SessionDBManager::instance().initMainDB();
|
|
if (auto db = SessionDBManager::instance().mainDb(); db.isOpen()) {
|
|
QSqlQuery q(db);
|
|
q.prepare("DELETE FROM sessions WHERE SessionID=?");
|
|
q.addBindValue(sid);
|
|
q.exec();
|
|
}
|
|
|
|
// Delete per-session directory (optional)
|
|
QString appDirDel = QCoreApplication::applicationDirPath();
|
|
QDir perSess(QString("%1/data/sessions/%2").arg(appDirDel, sid));
|
|
if (perSess.exists()) perSess.removeRecursively();
|
|
|
|
sendTo(sock, Protocol::ok("remove ok"));
|
|
return true;
|
|
}
|
|
|
|
// ── Update session metadata (heal DB rows created before real identity was persisted) ─
|
|
// Client calls this after `list_sessions` if it sees a placeholder row but
|
|
// has better data in its TokenStore (parsed from the JWT). We upsert-style
|
|
// update the DB so the placeholder never comes back on the next list.
|
|
if (action == QStringLiteral("update_session_meta")) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
const QString user = obj.value("user").toString().trimmed();
|
|
const QString ten = obj.value("tenantId").toString().trimmed();
|
|
const QString dom = obj.value("domain").toString().trimmed();
|
|
if (sid.isEmpty() || !isValidSessionId(sid)) {
|
|
sendTo(sock, Protocol::err("update_session_meta: invalid sessionId"));
|
|
return true;
|
|
}
|
|
auto isPlaceholder = [](const QString &v) {
|
|
return v.isEmpty() || v == QStringLiteral("Unknown") || v == QStringLiteral("N/A");
|
|
};
|
|
if (!isPlaceholder(user)) SessionDBManager::instance().updateSessionUser(sid, user);
|
|
if (!isPlaceholder(ten) || !isPlaceholder(dom)) {
|
|
SessionDBManager::instance().updateSessionTenant(sid,
|
|
isPlaceholder(ten) ? QStringLiteral("N/A") : ten,
|
|
isPlaceholder(dom) ? QStringLiteral("N/A") : dom);
|
|
}
|
|
// Also keep the in-memory copy in sync so subsequent list responses are correct.
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
QJsonObject m = g_sessionInfo.value(sid);
|
|
if (!isPlaceholder(user)) m.insert("user", user);
|
|
if (!isPlaceholder(ten)) m.insert("tenantId", ten);
|
|
if (!isPlaceholder(dom)) m.insert("domain", dom);
|
|
g_sessionInfo.insert(sid, m);
|
|
}
|
|
qInfo().noquote() << QString("[Server] healed session %1 metadata (user=%2)")
|
|
.arg(sid.left(8), user);
|
|
sendTo(sock, Protocol::ok("session meta updated"));
|
|
return true;
|
|
}
|
|
|
|
// ── Attach (subscribe) ─────────────────────────────────────────────────────
|
|
if (action == QStringLiteral("attach")) {
|
|
const QString sid = obj.value("sessionId").toString().trimmed();
|
|
if (sid.isEmpty()) {
|
|
sendTo(sock, Protocol::err("missing sessionId"));
|
|
return true;
|
|
}
|
|
if (!isValidSessionId(sid)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
// Ensure process exists and subscribe
|
|
QProcess *proc = ensureProcess(this, sid, operatorBySocket_.value(sock, QStringLiteral("unknown")));
|
|
if (!proc) {
|
|
sendTo(sock, Protocol::err("session not available"));
|
|
return true;
|
|
}
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
g_subscribers[sid].insert(sock);
|
|
}
|
|
|
|
// Echo back compact session_info (DB + live)
|
|
SessionDBManager::instance().initMainDB();
|
|
QSqlDatabase db = SessionDBManager::instance().mainDb();
|
|
|
|
QJsonObject info;
|
|
if (db.isOpen()) {
|
|
QSqlQuery q(db);
|
|
q.prepare("SELECT SessionID, User, TenantID, DefaultDomain, Resource "
|
|
"FROM sessions WHERE SessionID=? LIMIT 1");
|
|
q.addBindValue(sid);
|
|
if (q.exec() && q.next()) {
|
|
info.insert("sessionId", q.value(0).toString());
|
|
info.insert("user", q.value(1).toString());
|
|
info.insert("tenantId", q.value(2).toString());
|
|
info.insert("domain", q.value(3).toString());
|
|
info.insert("resource", q.value(4).toString());
|
|
}
|
|
}
|
|
|
|
info.insert("alive", proc->state() == QProcess::Running);
|
|
{
|
|
QMutexLocker locker(&g_stateMutex);
|
|
const AuthState st = g_authState.value(sid, AuthState::Pending);
|
|
const QString status = (st == AuthState::Success) ? "success" :
|
|
(st == AuthState::Failed ) ? "failed" : "pending";
|
|
info.insert("status", status);
|
|
}
|
|
|
|
QJsonObject resp = Protocol::ok("attach ok");
|
|
resp.insert("data", info);
|
|
sendTo(sock, resp);
|
|
return true;
|
|
}
|
|
|
|
// ── Log Token ──────────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_LOG_TOKEN) {
|
|
const QString sessionId = obj.value("sessionId").toString();
|
|
const QString source = obj.value("source").toString();
|
|
const QString accessToken = obj.value("accessToken").toString();
|
|
const QString refreshToken = obj.value("refreshToken").toString();
|
|
const QString idToken = obj.value("idToken").toString();
|
|
const QString user = obj.value("user").toString();
|
|
const QString tenantId = obj.value("tenantId").toString();
|
|
const QString resource = obj.value("resource").toString();
|
|
const QString scope = obj.value("scope").toString();
|
|
const int expiresIn = obj.value("expiresIn").toInt();
|
|
|
|
if (sessionId.isEmpty() || source.isEmpty() || accessToken.isEmpty()) {
|
|
sendTo(sock, Protocol::err("missing required fields for token logging"));
|
|
return true;
|
|
}
|
|
if (!isValidSessionId(sessionId)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
bool success = SessionDBManager::instance().logToken(
|
|
sessionId, source, accessToken, refreshToken, idToken,
|
|
user, tenantId, resource, scope, expiresIn,
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown"))
|
|
);
|
|
|
|
if (success) {
|
|
SessionDBManager::instance().logAudit(
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")), "capture_token", sessionId, source);
|
|
sendTo(sock, Protocol::ok("token logged"));
|
|
emit log(QString("[+] Token logged: session=%1, source=%2, user=%3")
|
|
.arg(sessionId, source, user.isEmpty() ? "N/A" : user));
|
|
} else {
|
|
sendTo(sock, Protocol::err("failed to log token"));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Get Tokens ─────────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_GET_TOKENS) {
|
|
const QString sessionId = obj.value("sessionId").toString();
|
|
|
|
QJsonArray tokens;
|
|
if (sessionId.isEmpty()) {
|
|
// Get all tokens
|
|
tokens = SessionDBManager::instance().getAllTokens();
|
|
} else if (!isValidSessionId(sessionId)) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
} else {
|
|
// Get tokens for specific session
|
|
tokens = SessionDBManager::instance().getTokensBySession(sessionId);
|
|
}
|
|
|
|
QJsonObject resp = Protocol::ok("tokens retrieved");
|
|
resp.insert("tokens", tokens);
|
|
sendTo(sock, resp);
|
|
return true;
|
|
}
|
|
|
|
// ── Delete Token ──────────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_DELETE_TOKEN) {
|
|
const qint64 tokenId = obj.value("tokenId").toVariant().toLongLong();
|
|
|
|
if (tokenId <= 0) {
|
|
sendTo(sock, Protocol::err("missing or invalid tokenId"));
|
|
return true;
|
|
}
|
|
|
|
bool success = SessionDBManager::instance().deleteToken(tokenId);
|
|
|
|
if (success) {
|
|
QJsonObject resp = Protocol::ok("token deleted");
|
|
resp.insert("tokenId", tokenId);
|
|
sendTo(sock, resp);
|
|
emit log(QString("[+] Token deleted: id=%1").arg(tokenId));
|
|
} else {
|
|
sendTo(sock, Protocol::err("failed to delete token"));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Get Report Data ───────────────────────────────────────────────────────
|
|
if (action == Protocol::ACTION_GET_REPORT_DATA) {
|
|
QJsonObject reportData = SessionDBManager::instance().getReportData();
|
|
|
|
QJsonObject resp = Protocol::ok("report data retrieved");
|
|
resp.insert("report", reportData);
|
|
sendTo(sock, resp);
|
|
emit log("[+] Report data requested");
|
|
return true;
|
|
}
|
|
|
|
// ── Operator activity audit log ───────────────────────────────────────────
|
|
if (action == QStringLiteral("get_audit")) {
|
|
const int limit = obj.value("limit").toInt(500);
|
|
QJsonObject resp = Protocol::ok("audit retrieved");
|
|
resp.insert("audit", SessionDBManager::instance().getAuditLog(limit));
|
|
sendTo(sock, resp);
|
|
return true;
|
|
}
|
|
|
|
// ── Import Session (from encrypted backup) ────────────────────────────────
|
|
if (action == Protocol::ACTION_IMPORT_SESSION) {
|
|
QString sessionId = obj.value("sessionId").toString().trimmed();
|
|
QString user = obj.value("user").toString();
|
|
QString tenantId = obj.value("tenantId").toString();
|
|
QString defaultDomain = obj.value("defaultDomain").toString();
|
|
QString resource = obj.value("resource").toString();
|
|
|
|
if (sessionId.isEmpty()) {
|
|
sendTo(sock, Protocol::err("sessionId is required for import"));
|
|
return true;
|
|
}
|
|
|
|
// Validate session ID format (same check as new_session)
|
|
static const QRegularExpression sessionIdRe(
|
|
QStringLiteral("^[a-zA-Z0-9_\\-]{1,64}$"));
|
|
if (!sessionIdRe.match(sessionId).hasMatch()) {
|
|
sendTo(sock, Protocol::err("invalid sessionId format"));
|
|
return true;
|
|
}
|
|
|
|
bool success = SessionDBManager::instance().addSessionToMainDB(
|
|
sessionId, user, tenantId, defaultDomain, resource,
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown"))
|
|
);
|
|
|
|
if (success) {
|
|
SessionDBManager::instance().logAudit(
|
|
operatorBySocket_.value(sock, QStringLiteral("unknown")), "import_session", sessionId, user);
|
|
QJsonObject resp = Protocol::ok("session imported");
|
|
resp.insert("sessionId", sessionId);
|
|
sendTo(sock, resp);
|
|
emit log(QString("[+] Session imported: %1 (%2)").arg(sessionId.left(8), user));
|
|
} else {
|
|
sendTo(sock, Protocol::err("failed to import session"));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
sendTo(sock, Protocol::err("Unknown action"));
|
|
return true;
|
|
}
|
|
|
|
|
|
bool Server::constantTimeCompare(const QString &a, const QString &b) {
|
|
const QByteArray ab = a.toUtf8();
|
|
const QByteArray bb = b.toUtf8();
|
|
if (ab.size() != bb.size()) {
|
|
// Still do a dummy loop to avoid leaking length info via timing
|
|
volatile int dummy = 0;
|
|
for (int i = 0; i < ab.size(); ++i) dummy |= ab[i];
|
|
Q_UNUSED(dummy);
|
|
return false;
|
|
}
|
|
volatile int result = 0;
|
|
for (int i = 0; i < ab.size(); ++i) {
|
|
result |= (ab[i] ^ bb[i]);
|
|
}
|
|
return result == 0;
|
|
}
|
|
|
|
bool Server::handleLogin(QTcpSocket *sock, const QJsonObject &obj) {
|
|
const QString pass = obj.value(Protocol::F_PASSWORD).toString();
|
|
const QString clientIp = sock->peerAddress().toString();
|
|
|
|
// Rate limiting check
|
|
LoginAttempt &attempt = loginAttempts_[clientIp];
|
|
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
|
|
|
if (attempt.lockoutUntilMs > now) {
|
|
const qint64 remainSec = (attempt.lockoutUntilMs - now) / 1000;
|
|
sock->write(Protocol::toBytes(Protocol::err(
|
|
QString("Too many failed attempts. Locked out for %1s").arg(remainSec))));
|
|
emit log(QString("[-] Auth blocked (rate limit): %1").arg(clientIp));
|
|
return true;
|
|
}
|
|
|
|
// Operator identity used for activity attribution (claimed, not verified).
|
|
QString op = obj.value(Protocol::F_USERNAME).toString().trimmed();
|
|
op.remove(QRegularExpression(QStringLiteral("[\\x00-\\x1F\\x7F]"))); // strip control chars
|
|
if (op.size() > 64) op = op.left(64);
|
|
if (op.isEmpty()) {
|
|
sock->write(Protocol::toBytes(Protocol::err("operator username required")));
|
|
return true;
|
|
}
|
|
|
|
if (constantTimeCompare(pass, allowedPass_)) {
|
|
authed_.insert(sock);
|
|
operatorBySocket_.insert(sock, op);
|
|
// Reset failures on success
|
|
attempt.failCount = 0;
|
|
attempt.lockoutUntilMs = 0;
|
|
sock->write(Protocol::toBytes(Protocol::ok("login ok")));
|
|
emit log(QString("[+] Auth success: operator '%1' from %2").arg(op, clientIp));
|
|
SessionDBManager::instance().logAudit(op, "login", clientIp, QString());
|
|
} else {
|
|
attempt.failCount++;
|
|
attempt.lastAttemptMs = now;
|
|
|
|
if (attempt.failCount >= MAX_LOGIN_FAILURES) {
|
|
attempt.lockoutUntilMs = now + LOGIN_LOCKOUT_MS;
|
|
emit log(QString("[-] Auth lockout triggered for %1 (%2 failures)")
|
|
.arg(clientIp).arg(attempt.failCount));
|
|
}
|
|
|
|
sock->write(Protocol::toBytes(Protocol::err("invalid credentials")));
|
|
emit log(QString("[-] Auth failed from %1 (attempt %2)")
|
|
.arg(clientIp).arg(attempt.failCount));
|
|
}
|
|
return true;
|
|
}
|