[Alts] Server-side search controls, IP counts, adjustable window

Plumbs the match-type chips through to the server: include_email/include_clientid/include_ip skip disabled identifier types in both matching and frontier expansion, so excluded types (e.g. throwaway domains) never enter the graph at any depth. Session lookback becomes adjustable (session_months, clamped 1-24, default 6). Each alt row carries ip_count, the distinct recent session IP count shown in a new IPs column. All proto additions are optional with old defaults, so old clients/servers interoperate.
This commit is contained in:
Lukas Brübach 2026-10-13 22:31:50 +02:00
parent 2b7ddfd968
commit b7f04bbb6e
7 changed files with 142 additions and 45 deletions

View file

@ -1987,6 +1987,8 @@ constexpr int MAX_USER_ALTS_PER_LEVEL = 200;
// Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are
// truncated: this tool is deliberately a first-pass sweep, not an audit.
constexpr int MAX_USER_ALT_IDENTIFIERS = 200;
constexpr int MIN_USER_ALT_MONTHS = 1;
constexpr int MAX_USER_ALT_MONTHS = 24;
QString altInClause(const QString &prefix, const QList<QString> &values)
{
@ -2037,7 +2039,12 @@ QList<QString> cappedIdentifiers(const QList<QString> &values)
}
} // namespace
QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth)
QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QString &userName,
int depth,
bool useEmail,
bool useClientId,
bool useIp,
int sessionMonths)
{
QList<ServerInfo_UserAlt> results;
@ -2050,6 +2057,16 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
} else if (depth > MAX_USER_ALT_DEPTH) {
depth = MAX_USER_ALT_DEPTH;
}
if (sessionMonths < MIN_USER_ALT_MONTHS) {
sessionMonths = MIN_USER_ALT_MONTHS;
} else if (sessionMonths > MAX_USER_ALT_MONTHS) {
sessionMonths = MAX_USER_ALT_MONTHS;
}
// Interpolated, not bound: placeholders are not allowed in INTERVAL
// expressions, and the clamped integer cannot inject SQL.
const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") +
QString::number(sessionMonths) + QStringLiteral(" MONTH)");
// Seed must exist; its row anchors the result at depth 0.
QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), "
@ -2122,32 +2139,46 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
QSet<QString> clientIdKeys;
QSet<QString> ipKeys;
QSqlQuery frontierQuery(sqlDatabase);
prepareDynamicQuery(frontierQuery, QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") +
frontierClause);
altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier);
if (execSqlQuery(&frontierQuery)) {
while (frontierQuery.next()) {
// Email comparison is case-insensitive in SQL, so normalise the
// keys too or the membership tests below miss real matches.
appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower());
appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString());
if (useEmail || useClientId) {
QSqlQuery frontierQuery(sqlDatabase);
prepareDynamicQuery(frontierQuery,
QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") +
frontierClause);
altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier);
if (execSqlQuery(&frontierQuery)) {
while (frontierQuery.next()) {
// Email comparison is case-insensitive in SQL, so normalise the
// keys too or the membership tests below miss real matches.
if (useEmail) {
appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower());
}
if (useClientId) {
appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString());
}
}
}
}
// Recent session identifiers: IPs plus historic client ids, most recent
// first so a capped list keeps what the account actually used lately.
QSqlQuery sessionQuery(sqlDatabase);
prepareDynamicQuery(sessionQuery, QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen "
"FROM {prefix}_sessions WHERE user_name IN ") +
frontierClause +
QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) "
"GROUP BY ip_address, clientid ORDER BY last_seen DESC"));
altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier);
if (execSqlQuery(&sessionQuery)) {
while (sessionQuery.next()) {
appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString());
appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString());
if (useIp || useClientId) {
QSqlQuery sessionQuery(sqlDatabase);
prepareDynamicQuery(sessionQuery,
QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen "
"FROM {prefix}_sessions WHERE user_name IN ") +
frontierClause + sessionWindow +
QStringLiteral(" GROUP BY ip_address, clientid "
"ORDER BY last_seen DESC"));
altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier);
if (execSqlQuery(&sessionQuery)) {
while (sessionQuery.next()) {
if (useIp) {
appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString());
}
if (useClientId) {
appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString());
}
}
}
}
@ -2179,14 +2210,13 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
if (!ipList.isEmpty()) {
conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE "
"s.ip_address IN ") +
altInClause(QStringLiteral(":alt_ip"), ipList) +
QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))");
altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")");
}
if (!clientIdList.isEmpty()) {
conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE "
"s.clientid IN ") +
altInClause(QStringLiteral(":alt_hcid"), clientIdList) +
QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))");
altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow +
QStringLiteral(")");
}
candidateString += conditions.join(QStringLiteral(" OR "));
// Already-visited accounts match their own identifiers at every level and
@ -2251,7 +2281,7 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
altInClause(QStringLiteral(":alt_ecid"), clientIdList);
}
evidenceString += evidenceConditions.join(QStringLiteral(" OR "));
evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)");
evidenceString += QStringLiteral(")") + sessionWindow;
QSqlQuery evidenceQuery(sqlDatabase);
prepareDynamicQuery(evidenceQuery, evidenceString);
@ -2346,9 +2376,8 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
prepareDynamicQuery(countQuery,
QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions "
"WHERE ip_address IN ") +
altInClause(QStringLiteral(":alt_ci"), neededIpList) +
QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) "
"GROUP BY ip_address"));
altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow +
QStringLiteral(" GROUP BY ip_address"));
altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList);
if (execSqlQuery(&countQuery)) {
while (countQuery.next()) {
@ -2385,6 +2414,31 @@ QList<ServerInfo_UserAlt> Servatrice_DatabaseInterface::getUserAlts(const QStrin
frontier = nextFrontier;
}
// Distinct recent IP counts per account: cheap group signal without
// shipping full address histories to the client.
if (!results.isEmpty()) {
QList<QString> resultNames;
resultNames.reserve(results.size());
for (const ServerInfo_UserAlt &alt : results) {
resultNames << QString::fromStdString(alt.user_name());
}
QSqlQuery *ipCountQuery = prepareQuery(
QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") +
altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow +
QStringLiteral(" GROUP BY user_name"));
altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames);
QMap<QString, int> ipCountsByUser;
if (execSqlQuery(ipCountQuery)) {
while (ipCountQuery->next()) {
ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt());
}
}
for (ServerInfo_UserAlt &alt : results) {
alt.set_ip_count(static_cast<google::protobuf::uint32>(
ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0)));
}
}
return results;
}