mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
* [Server] Gate room channels on privilege levels * [Server] Filter channel catalog per user, split read/write gating, add banners - Server_Room::getInfo() now takes the requesting user and only lists channels that user may read. Previously every room member received the full catalog (e.g. the moderators channel) while message delivery was already gated, so restricted channels were visible but silent. - Split channel access into mayReadChannel/mayWriteChannel: history replay, catalog listing, and event fan-out use the read level (access_level); Command_RoomChannelSay uses the write level (write_access_level, falling back to the read level when unset). - Plumb write_access_level and banner_message through both room sources (SQL rooms_channels table and config-file channels via writeaccess and banner keys). - Schema 38 -> 39: add write_access_level (NULL means inherit the read level) and banner_message columns; also widen access_level to the full six-value privilege enum to match the 37 -> 38 migration. * Fixup: evaluate channel access per user flag instead of ladder User flags are independent bits: IsDeveloper does not imply IsModerator/IsAdmin, so developer-only users must not reach moderator/admin channels. Replace the userPrivilegeLevel ladder with hasChannelPrivilege: moderator channels need IsModerator (admins hold it alongside IsAdmin), admin channels need IsAdmin, developer channels need IsDeveloper or IsAdmin, judge channels admit IsJudge/IsModerator. Covers catalog filtering, history replay, write gating and fan-out, and corrects the ini example and proto docs. * [Server] Make channel privilege helpers static per review * [Server] Fail closed on unknown channel access levels per review - roomChannelAccessFromKey returns nullopt for unknown keywords instead of silently falling back to Public - skip channels with unknown read/write levels with qWarning, empty read defaults to Public, empty write means no override - mayWriteChannel now also requires read access so write-below-read configs cannot post into invisible channels * [Server] Remove stray conflict marker in channel replay comment * [Server] Fix servatrice.cpp formatting per clang-format-18 --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de> Co-authored-by: BruebachL <bruebachl@cockatrice.example>
1560 lines
59 KiB
C++
1560 lines
59 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2008 by Max-Wilhelm Bruker *
|
|
* brukie@laptop *
|
|
* *
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* This program is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program; if not, write to the *
|
|
* Free Software Foundation, Inc., *
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
|
***************************************************************************/
|
|
#include "servatrice.h"
|
|
|
|
#include "email_parser.h"
|
|
#include "event_loop_watchdog.h"
|
|
#include "isl_interface.h"
|
|
#include "libcockatrice/protocol/pb/response_report_stats.pb.h"
|
|
#include "libcockatrice/protocol/pb/session_event.pb.h"
|
|
#include "main.h"
|
|
#include "servatrice_connection_pool.h"
|
|
#include "servatrice_database_interface.h"
|
|
#include "server.h"
|
|
#include "server_logger.h"
|
|
#include "server_protocolhandler.h"
|
|
#include "serversocketinterface.h"
|
|
#include "settingscache.h"
|
|
#include "smtpclient.h"
|
|
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QFile>
|
|
#include <QIODevice>
|
|
#include <QLoggingCategory>
|
|
#include <QMapIterator>
|
|
#include <QMetaObject>
|
|
#include <QMutableListIterator>
|
|
#include <QObject>
|
|
#include <QProcessEnvironment>
|
|
#include <QReadLocker>
|
|
#include <QSet>
|
|
#include <QSharedPointer>
|
|
#include <QSqlDatabase>
|
|
#include <QSqlQuery>
|
|
#include <QSsl>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QStringLiteral>
|
|
#include <QThread>
|
|
#include <QTimer>
|
|
#include <QTypeInfo>
|
|
#include <QUrl>
|
|
#include <QVariant>
|
|
#include <QtGlobal>
|
|
#include <compare>
|
|
#include <game/server_game.h>
|
|
#include <google/protobuf/stubs/port.h>
|
|
#include <libcockatrice/protocol/featureset.h>
|
|
#include <libcockatrice/protocol/pb/event_connection_closed.pb.h>
|
|
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
|
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
|
|
#include <libcockatrice/protocol/pb/serverinfo_room_channel.pb.h>
|
|
#include <new>
|
|
#include <optional>
|
|
#include <qlogging.h>
|
|
#include <qnamespace.h>
|
|
#include <server_room.h>
|
|
#include <string>
|
|
|
|
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
|
|
|
|
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
|
int _numberPools,
|
|
const QSqlDatabase &_sqlDatabase,
|
|
QObject *parent)
|
|
: QTcpServer(parent), server(_server)
|
|
{
|
|
for (int i = 0; i < _numberPools; ++i) {
|
|
auto newDatabaseInterface = new Servatrice_DatabaseInterface(i, server);
|
|
auto newPool = new Servatrice_ConnectionPool(newDatabaseInterface);
|
|
|
|
auto newThread = new QThread;
|
|
newThread->setObjectName("pool_" + QString::number(i));
|
|
newPool->moveToThread(newThread);
|
|
newDatabaseInterface->moveToThread(newThread);
|
|
server->addDatabaseInterface(newThread, newDatabaseInterface);
|
|
|
|
newThread->start();
|
|
server->watchWorkerThread(newThread);
|
|
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
|
|
Q_ARG(QSqlDatabase, _sqlDatabase));
|
|
|
|
connectionPools.append(newPool);
|
|
}
|
|
}
|
|
|
|
Servatrice_GameServer::~Servatrice_GameServer()
|
|
{
|
|
for (int i = 0; i < connectionPools.size(); ++i) {
|
|
logger->logMessage(QString("Closing pool %1...").arg(i));
|
|
QThread *poolThread = connectionPools[i]->thread();
|
|
connectionPools[i]->deleteLater(); // pool destructor calls thread()->quit()
|
|
poolThread->wait();
|
|
poolThread->deleteLater();
|
|
}
|
|
}
|
|
|
|
void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor)
|
|
{
|
|
Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool();
|
|
|
|
auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface());
|
|
ssi->moveToThread(pool->thread());
|
|
pool->addClient();
|
|
connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient()));
|
|
|
|
QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(int, socketDescriptor));
|
|
}
|
|
|
|
Servatrice_ConnectionPool *Servatrice_GameServer::findLeastUsedConnectionPool()
|
|
{
|
|
int minClientCount = -1;
|
|
int poolIndex = -1;
|
|
QStringList debugStr;
|
|
for (int i = 0; i < connectionPools.size(); ++i) {
|
|
const int clientCount = connectionPools[i]->getClientCount();
|
|
if ((poolIndex == -1) || (clientCount < minClientCount)) {
|
|
minClientCount = clientCount;
|
|
poolIndex = i;
|
|
}
|
|
debugStr.append(QString::number(clientCount));
|
|
}
|
|
qDebug().noquote() << "Pool utilisation:" << debugStr.join(", ");
|
|
return connectionPools[poolIndex];
|
|
}
|
|
|
|
#define WEBSOCKET_POOL_NUMBER 999
|
|
|
|
Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_server,
|
|
int _numberPools,
|
|
const QSqlDatabase &_sqlDatabase,
|
|
QObject *parent)
|
|
: QWebSocketServer("Servatrice", QWebSocketServer::NonSecureMode, parent), server(_server)
|
|
{
|
|
for (int i = 0; i < _numberPools; ++i) {
|
|
int poolNumber = WEBSOCKET_POOL_NUMBER + i;
|
|
auto newDatabaseInterface = new Servatrice_DatabaseInterface(poolNumber, server);
|
|
auto newPool = new Servatrice_ConnectionPool(newDatabaseInterface);
|
|
|
|
auto newThread = new QThread;
|
|
newThread->setObjectName("pool_" + QString::number(poolNumber));
|
|
newPool->moveToThread(newThread);
|
|
newDatabaseInterface->moveToThread(newThread);
|
|
server->addDatabaseInterface(newThread, newDatabaseInterface);
|
|
|
|
newThread->start();
|
|
server->watchWorkerThread(newThread);
|
|
QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection,
|
|
Q_ARG(QSqlDatabase, _sqlDatabase));
|
|
|
|
connectionPools.append(newPool);
|
|
|
|
connect(this, SIGNAL(newConnection()), this, SLOT(onNewConnection()));
|
|
}
|
|
}
|
|
|
|
Servatrice_WebsocketGameServer::~Servatrice_WebsocketGameServer()
|
|
{
|
|
for (int i = 0; i < connectionPools.size(); ++i) {
|
|
logger->logMessage(QString("Closing websocket pool %1...").arg(i));
|
|
QThread *poolThread = connectionPools[i]->thread();
|
|
connectionPools[i]->deleteLater(); // pool destructor calls thread()->quit()
|
|
poolThread->wait();
|
|
poolThread->deleteLater();
|
|
}
|
|
}
|
|
|
|
void Servatrice_WebsocketGameServer::onNewConnection()
|
|
{
|
|
Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool();
|
|
|
|
auto ssi = new WebsocketServerSocketInterface(server, pool->getDatabaseInterface());
|
|
/*
|
|
* Due to a Qt limitation, websockets can't be moved to another thread.
|
|
* This will hopefully change in Qt6 if QtWebSocket will be integrated in QtNetwork
|
|
*/
|
|
// ssi->moveToThread(pool->thread());
|
|
pool->addClient();
|
|
connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient()));
|
|
|
|
QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(void *, nextPendingConnection()));
|
|
}
|
|
|
|
Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnectionPool()
|
|
{
|
|
int minClientCount = -1;
|
|
int poolIndex = -1;
|
|
QStringList debugStr;
|
|
for (int i = 0; i < connectionPools.size(); ++i) {
|
|
const int clientCount = connectionPools[i]->getClientCount();
|
|
if ((poolIndex == -1) || (clientCount < minClientCount)) {
|
|
minClientCount = clientCount;
|
|
poolIndex = i;
|
|
}
|
|
debugStr.append(QString::number(clientCount));
|
|
}
|
|
qDebug().noquote() << "Pool utilisation:" << debugStr.join(", ");
|
|
return connectionPools[poolIndex];
|
|
}
|
|
|
|
void Servatrice_IslServer::incomingConnection(qintptr socketDescriptor)
|
|
{
|
|
auto thread = new QThread;
|
|
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
|
|
|
|
auto interface = new IslInterface(static_cast<int>(socketDescriptor), cert, privateKey, server);
|
|
interface->moveToThread(thread);
|
|
connect(interface, SIGNAL(destroyed()), thread, SLOT(quit()));
|
|
|
|
thread->start();
|
|
QMetaObject::invokeMethod(interface, "initServer", Qt::QueuedConnection);
|
|
}
|
|
|
|
Servatrice::Servatrice(QObject *parent)
|
|
: Server(parent), authenticationMethod(AuthenticationNone), uptime(0), txBytes(0), rxBytes(0),
|
|
shutdownTimer(nullptr)
|
|
{
|
|
qRegisterMetaType<QSqlDatabase>("QSqlDatabase");
|
|
}
|
|
|
|
Servatrice::~Servatrice()
|
|
{
|
|
gameServer->close();
|
|
|
|
// we are destroying the clients outside their thread!
|
|
for (auto *client : clients) {
|
|
client->prepareDestroy();
|
|
}
|
|
|
|
if (shutdownTimer) {
|
|
shutdownTimer->deleteLater();
|
|
}
|
|
|
|
servatriceDatabaseInterface->deleteLater();
|
|
prepareDestroy();
|
|
}
|
|
|
|
/**
|
|
* @brief Maps a configured channel access level (database column or ini value)
|
|
* onto the ServerInfo_RoomChannel privilege ladder.
|
|
*
|
|
* Returns nullopt for unknown keywords so callers can fail closed instead of
|
|
* silently downgrading a restricted channel to Public.
|
|
*/
|
|
static std::optional<ServerInfo_RoomChannel::AccessLevel> roomChannelAccessFromKey(const QString &key)
|
|
{
|
|
const QString level = key.trimmed().toLower();
|
|
if (level == "developer") {
|
|
return ServerInfo_RoomChannel::Developer;
|
|
}
|
|
if (level == "admin") {
|
|
return ServerInfo_RoomChannel::Admin;
|
|
}
|
|
if (level == "moderator") {
|
|
return ServerInfo_RoomChannel::Moderator;
|
|
}
|
|
if (level == "judge") {
|
|
return ServerInfo_RoomChannel::Judge;
|
|
}
|
|
if (level == "registered") {
|
|
return ServerInfo_RoomChannel::Registered;
|
|
}
|
|
if (level == "public") {
|
|
return ServerInfo_RoomChannel::Public;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
bool Servatrice::initServer()
|
|
{
|
|
|
|
serverId = getServerID();
|
|
|
|
// METRICS (always active. Slow-command logging and stall watchdogs are
|
|
// controlled by their respective thresholds below). Read up front so the
|
|
// values are available before any pool thread is started and watchdogged.
|
|
metricsSlowCommandMs = settingsCache->value("metrics/slow_command_ms", 500).toInt();
|
|
metricsStallWarnMs = qMax(0, settingsCache->value("metrics/stall_warn_ms", 2000).toInt());
|
|
|
|
if (getAuthenticationMethodString() == "sql") {
|
|
qDebug() << "Authenticating method: sql";
|
|
authenticationMethod = AuthenticationSql;
|
|
} else if (getAuthenticationMethodString() == "password") {
|
|
qDebug() << "Authenticating method: password";
|
|
authenticationMethod = AuthenticationPassword;
|
|
} else {
|
|
if (getRegOnlyServerEnabled()) {
|
|
qDebug() << "Registration only server enabled but no authentication method defined: Error.";
|
|
return false;
|
|
}
|
|
qDebug() << "Authenticating method: none";
|
|
authenticationMethod = AuthenticationNone;
|
|
}
|
|
|
|
qDebug() << "Store Replays:" << getStoreReplaysEnabled();
|
|
qDebug() << "Client ID Required:" << getClientIDRequiredEnabled();
|
|
qDebug() << "Maximum user limit enabled:" << getMaxUserLimitEnabled();
|
|
|
|
if (getMaxUserLimitEnabled()) {
|
|
qDebug() << "Maximum total user limit:" << getMaxUserTotal();
|
|
qDebug() << "Maximum tcp user limit:" << getMaxTcpUserLimit();
|
|
qDebug() << "Maximum websocket user limit:" << getMaxWebSocketUserLimit();
|
|
}
|
|
|
|
qDebug() << "Accept registered users only:" << getRegOnlyServerEnabled();
|
|
qDebug() << "Registration enabled:" << getRegistrationEnabled();
|
|
if (getRegistrationEnabled()) {
|
|
QStringList emailBlackListFilters = getEmailBlackList().split(",", Qt::SkipEmptyParts);
|
|
QStringList emailWhiteListFilters = getEmailWhiteList().split(",", Qt::SkipEmptyParts);
|
|
qDebug() << "Email blacklist:" << emailBlackListFilters;
|
|
qDebug() << "Email whitelist:" << emailWhiteListFilters;
|
|
qDebug() << "Require email address to register:" << getRequireEmailForRegistrationEnabled();
|
|
qDebug() << "Require email activation via token:" << getRequireEmailActivationEnabled();
|
|
if (getMaxAccountsPerEmail()) {
|
|
qDebug() << "Maximum number of accounts per email:" << getMaxAccountsPerEmail();
|
|
} else {
|
|
qDebug() << "Maximum number of accounts per email: unlimited";
|
|
}
|
|
qDebug() << "Enable Internal SMTP Client:" << getEnableInternalSMTPClient();
|
|
if (!getEnableInternalSMTPClient()) {
|
|
qDebug() << "WARNING: Registrations are enabled but internal SMTP client is disabled. Users activation "
|
|
"emails will not be automatically mailed to users!";
|
|
}
|
|
}
|
|
|
|
qDebug() << "Reset password enabled:" << getEnableForgotPassword();
|
|
if (getEnableForgotPassword()) {
|
|
qDebug() << "Reset password token life (in minutes):" << getForgotPasswordTokenLife();
|
|
qDebug() << "Reset password challenge on:" << getEnableForgotPasswordChallenge();
|
|
}
|
|
|
|
qDebug() << "Auditing enabled:" << getEnableAudit();
|
|
if (getEnableAudit()) {
|
|
qDebug() << "Audit registration attempts enabled:" << getEnableRegistrationAudit();
|
|
qDebug() << "Audit reset password attepts enabled:" << getEnableForgotPasswordAudit();
|
|
}
|
|
|
|
if (getDBTypeString() == "mysql") {
|
|
databaseType = DatabaseMySql;
|
|
} else {
|
|
databaseType = DatabaseNone;
|
|
}
|
|
servatriceDatabaseInterface = new Servatrice_DatabaseInterface(-1, this);
|
|
setDatabaseInterface(servatriceDatabaseInterface);
|
|
|
|
if (databaseType != DatabaseNone) {
|
|
dbPrefix = getDBPrefixString();
|
|
bool dbOpened = servatriceDatabaseInterface->initDatabase(
|
|
"QMYSQL", getDBHostNameString(), getDBDatabaseNameString(), getDBUserNameString(), getDBPasswordString());
|
|
if (!dbOpened) {
|
|
qDebug() << "Failed to open database";
|
|
return false;
|
|
}
|
|
updateServerList();
|
|
qDebug() << "Clearing previous sessions...";
|
|
servatriceDatabaseInterface->clearSessionTables();
|
|
}
|
|
|
|
// Connect the SMTP client's delivery-confirmation signals to the database cleanup handlers.
|
|
// Emails are removed from the queue tables only after delivery is confirmed (see statusUpdate()).
|
|
connect(smtpClient, &SmtpClient::mailDelivered, this, &Servatrice::onEmailDelivered);
|
|
connect(smtpClient, &SmtpClient::mailPermanentlyFailed, this, &Servatrice::onEmailPermanentlyFailed);
|
|
|
|
if (getRoomsMethodString() == "sql") {
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from "
|
|
"{prefix}_rooms where id_server = :id_server order by id asc");
|
|
query->bindValue(":id_server", serverId);
|
|
servatriceDatabaseInterface->execSqlQuery(query);
|
|
while (query->next()) {
|
|
QSqlQuery *query2 = servatriceDatabaseInterface->prepareQuery(
|
|
"select name from {prefix}_rooms_gametypes where id_room = :id_room AND id_server = :id_server");
|
|
query2->bindValue(":id_server", serverId);
|
|
query2->bindValue(":id_room", query->value(0).toInt());
|
|
servatriceDatabaseInterface->execSqlQuery(query2);
|
|
QStringList gameTypes;
|
|
while (query2->next()) {
|
|
gameTypes.append(query2->value(0).toString());
|
|
}
|
|
|
|
QSqlQuery *query3 = servatriceDatabaseInterface->prepareQuery(
|
|
"select channel_id, display_name, access_level, write_access_level, banner_message from "
|
|
"{prefix}_rooms_channels where id_room = :id_room "
|
|
"AND id_server = :id_server order by channel_id asc");
|
|
query3->bindValue(":id_server", serverId);
|
|
query3->bindValue(":id_room", query->value(0).toInt());
|
|
servatriceDatabaseInterface->execSqlQuery(query3);
|
|
QList<ServerInfo_RoomChannel> channels;
|
|
QSet<QString> seenChannelIds;
|
|
while (query3->next()) {
|
|
const QString channelId = query3->value(0).toString();
|
|
if (channelId.isEmpty()) {
|
|
qWarning() << "Skipping room channel with empty id";
|
|
continue;
|
|
}
|
|
if (Server_Room::isMainChannel(channelId)) {
|
|
qWarning() << "Skipping room channel with reserved id" << channelId;
|
|
continue;
|
|
}
|
|
if (seenChannelIds.contains(channelId)) {
|
|
qWarning() << "Skipping duplicate room channel id" << channelId;
|
|
continue;
|
|
}
|
|
seenChannelIds.insert(channelId);
|
|
const QString accessKey = query3->value(2).toString();
|
|
auto accessLevel =
|
|
accessKey.trimmed().isEmpty()
|
|
? std::optional<ServerInfo_RoomChannel::AccessLevel>(ServerInfo_RoomChannel::Public)
|
|
: roomChannelAccessFromKey(accessKey);
|
|
if (!accessLevel.has_value()) {
|
|
qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id"
|
|
<< channelId;
|
|
continue;
|
|
}
|
|
ServerInfo_RoomChannel channel;
|
|
channel.set_id(channelId.toStdString());
|
|
channel.set_display_name(query3->value(1).toString().toStdString());
|
|
channel.set_access_level(accessLevel.value());
|
|
if (!query3->isNull(3)) {
|
|
const QString writeKey = query3->value(3).toString();
|
|
if (!writeKey.trimmed().isEmpty()) {
|
|
auto writeLevel = roomChannelAccessFromKey(writeKey);
|
|
if (!writeLevel.has_value()) {
|
|
qWarning() << "Skipping room channel with unknown write access level" << writeKey
|
|
<< "for id" << channelId;
|
|
continue;
|
|
}
|
|
channel.set_write_access_level(writeLevel.value());
|
|
}
|
|
}
|
|
channel.set_banner_message(query3->value(4).toString().toStdString());
|
|
channels.append(channel);
|
|
}
|
|
|
|
addRoom(new Server_Room(query->value(0).toInt(), query->value(7).toInt(), query->value(1).toString(),
|
|
query->value(2).toString(), query->value(3).toString().toLower(),
|
|
query->value(4).toString().toLower(), static_cast<bool>(query->value(5).toInt()),
|
|
query->value(6).toString(), gameTypes, this, channels));
|
|
}
|
|
} else {
|
|
int size = settingsCache->beginReadArray("rooms/roomlist");
|
|
for (int i = 0; i < size; ++i) {
|
|
settingsCache->setArrayIndex(i);
|
|
QStringList gameTypes;
|
|
int size2 = settingsCache->beginReadArray("game_types");
|
|
for (int j = 0; j < size2; ++j) {
|
|
settingsCache->setArrayIndex(j);
|
|
gameTypes.append(settingsCache->value("name").toString());
|
|
}
|
|
settingsCache->endArray();
|
|
QList<ServerInfo_RoomChannel> channels;
|
|
QSet<QString> seenChannelIds;
|
|
int size3 = settingsCache->beginReadArray("channels");
|
|
for (int k = 0; k < size3; ++k) {
|
|
settingsCache->setArrayIndex(k);
|
|
const QString channelId = settingsCache->value("id").toString();
|
|
if (channelId.isEmpty()) {
|
|
qWarning() << "Skipping room channel with empty id";
|
|
continue;
|
|
}
|
|
if (Server_Room::isMainChannel(channelId)) {
|
|
qWarning() << "Skipping room channel with reserved id" << channelId;
|
|
continue;
|
|
}
|
|
if (seenChannelIds.contains(channelId)) {
|
|
qWarning() << "Skipping duplicate room channel id" << channelId;
|
|
continue;
|
|
}
|
|
seenChannelIds.insert(channelId);
|
|
const QString accessKey = settingsCache->value("access").toString();
|
|
auto accessLevel =
|
|
accessKey.trimmed().isEmpty()
|
|
? std::optional<ServerInfo_RoomChannel::AccessLevel>(ServerInfo_RoomChannel::Public)
|
|
: roomChannelAccessFromKey(accessKey);
|
|
if (!accessLevel.has_value()) {
|
|
qWarning() << "Skipping room channel with unknown access level" << accessKey << "for id"
|
|
<< channelId;
|
|
continue;
|
|
}
|
|
ServerInfo_RoomChannel channel;
|
|
channel.set_id(channelId.toStdString());
|
|
channel.set_display_name(settingsCache->value("displayname").toString().toStdString());
|
|
channel.set_access_level(accessLevel.value());
|
|
if (settingsCache->contains("writeaccess")) {
|
|
const QString writeKey = settingsCache->value("writeaccess").toString();
|
|
if (!writeKey.trimmed().isEmpty()) {
|
|
auto writeLevel = roomChannelAccessFromKey(writeKey);
|
|
if (!writeLevel.has_value()) {
|
|
qWarning() << "Skipping room channel with unknown write access level" << writeKey
|
|
<< "for id" << channelId;
|
|
continue;
|
|
}
|
|
channel.set_write_access_level(writeLevel.value());
|
|
}
|
|
}
|
|
channel.set_banner_message(settingsCache->value("banner").toString().toStdString());
|
|
channels.append(channel);
|
|
}
|
|
settingsCache->endArray();
|
|
Server_Room *newRoom = new Server_Room(
|
|
i, settingsCache->value("chathistorysize").toInt(), settingsCache->value("name").toString(),
|
|
settingsCache->value("description").toString(),
|
|
settingsCache->value("permissionlevel").toString().toLower(),
|
|
settingsCache->value("privilegelevel").toString().toLower(), settingsCache->value("autojoin").toBool(),
|
|
settingsCache->value("joinmessage").toString(), gameTypes, this, channels);
|
|
addRoom(newRoom);
|
|
}
|
|
|
|
if (size == 0) {
|
|
// no room defined in config, add a dummy one
|
|
Server_Room *newRoom = new Server_Room(0, 100, "General room", "Play anything here.", "none", "none", true,
|
|
"", QStringList("Standard"), this);
|
|
addRoom(newRoom);
|
|
}
|
|
|
|
settingsCache->endArray();
|
|
}
|
|
|
|
updateLoginMessage();
|
|
|
|
try {
|
|
if (getISLNetworkEnabled()) {
|
|
qDebug() << "Connecting to ISL network.";
|
|
qDebug() << "Loading certificate...";
|
|
QFile certFile(getISLNetworkSSLCertFile());
|
|
if (!certFile.open(QIODevice::ReadOnly)) {
|
|
throw QString("Error opening certificate file: %1").arg(getISLNetworkSSLCertFile());
|
|
}
|
|
QSslCertificate cert(&certFile);
|
|
|
|
const QDateTime currentTime = QDateTime::currentDateTime();
|
|
if (currentTime < cert.effectiveDate() || currentTime > cert.expiryDate() || cert.isBlacklisted()) {
|
|
throw QString("Invalid certificate.");
|
|
}
|
|
|
|
qDebug() << "Loading private key...";
|
|
QFile keyFile(getISLNetworkSSLKeyFile());
|
|
if (!keyFile.open(QIODevice::ReadOnly)) {
|
|
throw QString("Error opening private key file: %1").arg(getISLNetworkSSLKeyFile());
|
|
}
|
|
QSslKey key(&keyFile, QSsl::Rsa, QSsl::Pem, QSsl::PrivateKey);
|
|
if (key.isNull()) {
|
|
throw QString("Invalid private key.");
|
|
}
|
|
|
|
QMutableListIterator<ServerProperties> serverIterator(serverList);
|
|
while (serverIterator.hasNext()) {
|
|
const ServerProperties &prop = serverIterator.next();
|
|
if (prop.cert == cert) {
|
|
serverIterator.remove();
|
|
continue;
|
|
}
|
|
|
|
auto *thread = new QThread;
|
|
thread->setObjectName("isl_" + QString::number(prop.id));
|
|
connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater()));
|
|
|
|
IslInterface *interface = new IslInterface(prop.id, prop.hostname, prop.address.toString(),
|
|
prop.controlPort, prop.cert, cert, key, this);
|
|
interface->moveToThread(thread);
|
|
connect(interface, SIGNAL(destroyed()), thread, SLOT(quit()));
|
|
|
|
thread->start();
|
|
QMetaObject::invokeMethod(interface, "initClient", Qt::BlockingQueuedConnection);
|
|
}
|
|
|
|
qDebug() << "Starting ISL server on port" << getISLNetworkPort();
|
|
islServer = new Servatrice_IslServer(this, cert, key, this);
|
|
if (islServer->listen(QHostAddress::Any, static_cast<quint16>(getISLNetworkPort()))) {
|
|
qDebug() << "ISL server listening.";
|
|
} else {
|
|
throw QString("islServer->listen()");
|
|
}
|
|
}
|
|
} catch (QString &error) {
|
|
qDebug() << "ERROR --" << error;
|
|
return false;
|
|
}
|
|
|
|
pingClock = new QTimer(this);
|
|
connect(pingClock, SIGNAL(timeout()), this, SIGNAL(pingClockTimeout()));
|
|
pingClock->start(getClientKeepAlive() * 1000);
|
|
|
|
statusUpdateClock = new QTimer(this);
|
|
connect(statusUpdateClock, SIGNAL(timeout()), this, SLOT(statusUpdate()));
|
|
if (getServerStatusUpdateTime() != 0) {
|
|
qDebug() << "Starting status update clock, interval" << getServerStatusUpdateTime() << "ms";
|
|
statusUpdateClock->start(getServerStatusUpdateTime());
|
|
}
|
|
|
|
deckShareCleanupClock = new QTimer(this);
|
|
connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares()));
|
|
const int deckShareCleanupInterval = getDeckShareCleanupInterval();
|
|
if (deckShareCleanupInterval > 0) {
|
|
qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms";
|
|
deckShareCleanupClock->start(deckShareCleanupInterval);
|
|
}
|
|
|
|
// SOCKET SERVER
|
|
if (getNumberOfTCPPools() > 0) {
|
|
gameServer =
|
|
new Servatrice_GameServer(this, getNumberOfTCPPools(), servatriceDatabaseInterface->getDatabase(), this);
|
|
gameServer->setMaxPendingConnections(1000);
|
|
QHostAddress tcpHost = getServerTCPHost();
|
|
qDebug() << "Starting server on host" << tcpHost.toString() << "port" << getServerTCPPort();
|
|
if (gameServer->listen(tcpHost, static_cast<quint16>(getServerTCPPort()))) {
|
|
qDebug() << "Server listening.";
|
|
} else {
|
|
qDebug() << "gameServer->listen(): Error:" << gameServer->errorString();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// WEBSOCKET SERVER
|
|
if (getNumberOfWebSocketPools() > 0) {
|
|
websocketGameServer = new Servatrice_WebsocketGameServer(this, getNumberOfWebSocketPools(),
|
|
servatriceDatabaseInterface->getDatabase(), this);
|
|
websocketGameServer->setMaxPendingConnections(1000);
|
|
QHostAddress webSocketHost = getServerWebSocketHost();
|
|
qDebug() << "Starting websocket server on host" << webSocketHost.toString() << "port"
|
|
<< getServerWebSocketPort();
|
|
if (websocketGameServer->listen(webSocketHost, static_cast<quint16>(getServerWebSocketPort()))) {
|
|
qDebug() << "Websocket server listening.";
|
|
} else {
|
|
qDebug() << "websocketGameServer->listen(): Error:" << websocketGameServer->errorString();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (getIdleClientTimeout() > 0) {
|
|
qDebug() << "Idle client timeout value:" << getIdleClientTimeout();
|
|
if (getIdleClientTimeout() < 300) {
|
|
qDebug() << "WARNING: It is not recommended to set the IdleClientTimeout value very low. Doing so will "
|
|
"cause clients to very quickly be disconnected. Many players when connected may be searching "
|
|
"for card details outside the client in the middle of matches or possibly drafting outside the "
|
|
"client and short time out values will remove these players.";
|
|
}
|
|
}
|
|
|
|
setRequiredFeatures(getRequiredFeatures());
|
|
|
|
return true;
|
|
}
|
|
|
|
void Servatrice::observeGameStartDurationMs(qint64 elapsedMs)
|
|
{
|
|
metricsRegistry.observeGameStartDurationMs(elapsedMs);
|
|
}
|
|
|
|
void Servatrice::observeEventLoopStall(const QString &threadName, qint64 overshootMs)
|
|
{
|
|
eventLoopStallsTotal.fetch_add(1, std::memory_order_relaxed);
|
|
eventLoopLastStallMs.store(overshootMs, std::memory_order_relaxed);
|
|
qint64 prevMax = eventLoopMaxStallMs.load(std::memory_order_relaxed);
|
|
while (overshootMs > prevMax &&
|
|
!eventLoopMaxStallMs.compare_exchange_weak(prevMax, overshootMs, std::memory_order_relaxed)) {
|
|
// retry until the max is at least as high as the new sample
|
|
}
|
|
|
|
qWarning() << "Event loop stall in" << threadName << "- heartbeat overshot by" << overshootMs << "ms";
|
|
}
|
|
|
|
void Servatrice::watchWorkerThread(QThread *thread)
|
|
{
|
|
if (metricsStallWarnMs <= 0) {
|
|
return; // watchdogs disabled via metrics/stall_warn_ms = 0
|
|
}
|
|
|
|
auto *watchdog = new EventLoopWatchdog(this, thread->objectName());
|
|
connect(thread, &QThread::finished, watchdog, &QObject::deleteLater);
|
|
watchdog->moveToThread(thread);
|
|
QMetaObject::invokeMethod(watchdog, &EventLoopWatchdog::start, Qt::QueuedConnection);
|
|
}
|
|
|
|
qint64 Servatrice::getCardsInGamesTotal() const
|
|
{
|
|
qint64 total = 0;
|
|
QReadLocker roomsLocker(&roomsLock); // locking order: roomsLock before gamesLock/gameMutex
|
|
QMapIterator<int, Server_Room *> roomIterator(rooms);
|
|
while (roomIterator.hasNext()) {
|
|
Server_Room *room = roomIterator.next().value();
|
|
QReadLocker gamesLocker(&room->gamesLock);
|
|
for (auto *game : room->getGames()) {
|
|
total += game->getCardsInGame();
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
void Servatrice::addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface)
|
|
{
|
|
databaseInterfaces.insert(thread, databaseInterface);
|
|
}
|
|
|
|
void Servatrice::updateServerList()
|
|
{
|
|
qDebug() << "Updating server list...";
|
|
|
|
serverListMutex.lock();
|
|
serverList.clear();
|
|
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"select id, ssl_cert, hostname, address, game_port, control_port from {prefix}_servers order by id asc");
|
|
servatriceDatabaseInterface->execSqlQuery(query);
|
|
while (query->next()) {
|
|
ServerProperties prop(query->value(0).toInt(), QSslCertificate(query->value(1).toString().toUtf8()),
|
|
query->value(2).toString(), QHostAddress(query->value(3).toString()),
|
|
query->value(4).toInt(), query->value(5).toInt());
|
|
serverList.append(prop);
|
|
qDebug() << QString("#%1 CERT=%2 NAME=%3 IP=%4:%5 CPORT=%6")
|
|
.arg(prop.id)
|
|
.arg(QString(prop.cert.digest().toHex()))
|
|
.arg(prop.hostname)
|
|
.arg(prop.address.toString())
|
|
.arg(prop.gamePort)
|
|
.arg(prop.controlPort);
|
|
}
|
|
|
|
serverListMutex.unlock();
|
|
}
|
|
|
|
QList<ServerProperties> Servatrice::getServerList() const
|
|
{
|
|
serverListMutex.lock();
|
|
QList<ServerProperties> result = serverList;
|
|
serverListMutex.unlock();
|
|
|
|
return result;
|
|
}
|
|
|
|
std::shared_ptr<const Response_ReportStats> Servatrice::getCachedReportStats() const
|
|
{
|
|
QMutexLocker locker(&reportStatsMutex);
|
|
if (!reportStatsTimestamp.isValid() ||
|
|
reportStatsTimestamp.secsTo(QDateTime::currentDateTime()) >= reportStatsCacheTtlSeconds) {
|
|
return nullptr;
|
|
}
|
|
return reportStatsCache;
|
|
}
|
|
|
|
void Servatrice::cacheReportStats(const Response_ReportStats &stats)
|
|
{
|
|
QMutexLocker locker(&reportStatsMutex);
|
|
reportStatsCache = std::make_shared<const Response_ReportStats>(stats);
|
|
reportStatsTimestamp = QDateTime::currentDateTime();
|
|
}
|
|
|
|
int Servatrice::getUsersWithAddress(const QHostAddress &address) const
|
|
{
|
|
int result = 0;
|
|
QReadLocker locker(&clientsLock);
|
|
for (auto client : clients) {
|
|
if (static_cast<AbstractServerSocketInterface *>(client)->getPeerAddress() == address) {
|
|
++result;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
QList<AbstractServerSocketInterface *> Servatrice::getUsersWithAddressAsList(const QHostAddress &address) const
|
|
{
|
|
QList<AbstractServerSocketInterface *> result;
|
|
QReadLocker locker(&clientsLock);
|
|
for (auto client : clients) {
|
|
if (static_cast<AbstractServerSocketInterface *>(client)->getPeerAddress() == address) {
|
|
result.append(static_cast<AbstractServerSocketInterface *>(client));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void Servatrice::updateLoginMessage()
|
|
{
|
|
if (!servatriceDatabaseInterface->checkSql()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"select message from {prefix}_servermessages where id_server = :id_server order by timest desc limit 1");
|
|
query->bindValue(":id_server", serverId);
|
|
if (servatriceDatabaseInterface->execSqlQuery(query)) {
|
|
if (query->next()) {
|
|
const QString newLoginMessage = query->value(0).toString();
|
|
|
|
loginMessageMutex.lock();
|
|
loginMessage = newLoginMessage;
|
|
loginMessageMutex.unlock();
|
|
|
|
Event_ServerMessage event;
|
|
event.set_message(newLoginMessage.toStdString());
|
|
SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event);
|
|
QMapIterator<QString, Server_ProtocolHandler *> usersIterator(users);
|
|
while (usersIterator.hasNext()) {
|
|
usersIterator.next().value()->sendProtocolItem(*se);
|
|
}
|
|
delete se;
|
|
}
|
|
}
|
|
}
|
|
|
|
void Servatrice::setRequiredFeatures(const QString &featureList)
|
|
{
|
|
FeatureSet features;
|
|
serverRequiredFeatureList.clear();
|
|
features.initalizeFeatureList(serverRequiredFeatureList);
|
|
QStringList listReqFeatures = featureList.split(",", Qt::SkipEmptyParts);
|
|
if (!listReqFeatures.isEmpty()) {
|
|
for (const QString &reqFeature : listReqFeatures) {
|
|
features.enableRequiredFeature(serverRequiredFeatureList, reqFeature);
|
|
}
|
|
}
|
|
|
|
qDebug() << "Set required client features to:" << serverRequiredFeatureList;
|
|
}
|
|
|
|
void Servatrice::cleanupExpiredDeckShares()
|
|
{
|
|
servatriceDatabaseInterface->cleanupExpiredDeckShares();
|
|
}
|
|
|
|
void Servatrice::statusUpdate()
|
|
{
|
|
if (!servatriceDatabaseInterface->checkSql()) {
|
|
return;
|
|
}
|
|
|
|
const int uc = getUsersCount(); // for correct mutex locking order
|
|
|
|
const QStringList mods_info = getOnlineModeratorList();
|
|
const int mc = mods_info.size();
|
|
const QString ml = mods_info.join(", ");
|
|
|
|
const int gc = getGamesCount();
|
|
|
|
uptime += statusUpdateClock->interval() / 1000;
|
|
|
|
txBytesMutex.lock();
|
|
quint64 tx = txBytes;
|
|
txBytes = 0;
|
|
txBytesMutex.unlock();
|
|
rxBytesMutex.lock();
|
|
quint64 rx = rxBytes;
|
|
rxBytes = 0;
|
|
rxBytesMutex.unlock();
|
|
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"insert into {prefix}_uptime (id_server, timest, uptime, users_count, mods_count, mods_list, games_count, "
|
|
"tx_bytes, rx_bytes) values(:id, NOW(), :uptime, :users_count, :mods_count, :mods_list, :games_count, :tx, "
|
|
":rx)");
|
|
query->bindValue(":id", serverId);
|
|
query->bindValue(":uptime", uptime);
|
|
query->bindValue(":users_count", uc);
|
|
query->bindValue(":mods_count", mc);
|
|
query->bindValue(":mods_list", ml);
|
|
query->bindValue(":games_count", gc);
|
|
query->bindValue(":tx", tx);
|
|
query->bindValue(":rx", rx);
|
|
servatriceDatabaseInterface->execSqlQuery(query);
|
|
|
|
if (getRegistrationEnabled() && getEnableInternalSMTPClient()) {
|
|
if (getRequireEmailActivationEnabled()) {
|
|
auto servDbSelQuery = servatriceDatabaseInterface->prepareQuery("select a.name, b.email, b.token from "
|
|
"{prefix}_activation_emails a left join "
|
|
"{prefix}_users b on a.name = b.name");
|
|
if (!servatriceDatabaseInterface->execSqlQuery(servDbSelQuery)) {
|
|
return;
|
|
}
|
|
|
|
// Rows are intentionally NOT deleted here: the durable source of truth for a pending
|
|
// activation e-mail is the database row, which is only removed once delivery has been
|
|
// confirmed by the SMTP client (see onEmailDelivered). This makes sending resilient to
|
|
// transient SMTP failures and server restarts.
|
|
while (servDbSelQuery->next()) {
|
|
const QString userName = servDbSelQuery->value(0).toString();
|
|
const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString());
|
|
const QString token = servDbSelQuery->value(2).toString();
|
|
|
|
smtpClient->enqueueActivationTokenMail(userName, emailAddress, token);
|
|
}
|
|
}
|
|
|
|
if (getEnableForgotPassword()) {
|
|
auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery(
|
|
"select a.id, a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on "
|
|
"a.name = b.name where a.emailed = 0");
|
|
if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) {
|
|
return;
|
|
}
|
|
|
|
// Rows are intentionally left with emailed = 0 here; they are marked emailed only once
|
|
// delivery is confirmed by the SMTP client (see onEmailDelivered).
|
|
while (forgotPwQuery->next()) {
|
|
const qint64 rowId = forgotPwQuery->value(0).toLongLong();
|
|
const QString userName = forgotPwQuery->value(1).toString();
|
|
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(2).toString());
|
|
const QString token = forgotPwQuery->value(3).toString();
|
|
|
|
smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token, rowId);
|
|
}
|
|
}
|
|
|
|
smtpClient->sendAllEmails();
|
|
}
|
|
}
|
|
|
|
void Servatrice::onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId)
|
|
{
|
|
if (cleanupDatabaseForEmail(userName, type, token, rowId)) {
|
|
qCDebug(ServatriceLog) << "E-mail delivered to" << userName;
|
|
} else {
|
|
qCWarning(ServatriceLog) << "Failed to clean up database row for delivered e-mail to" << userName
|
|
<< "- row may be re-enqueued on next status update";
|
|
}
|
|
}
|
|
|
|
void Servatrice::onEmailPermanentlyFailed(const QString &userName,
|
|
EmailType type,
|
|
FailureReason reason,
|
|
const QString &token,
|
|
qint64 rowId)
|
|
{
|
|
switch (reason) {
|
|
case FailureReason::RetryExhausted:
|
|
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed after max retries";
|
|
break;
|
|
case FailureReason::RecipientRejected:
|
|
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: recipient rejected";
|
|
break;
|
|
case FailureReason::SenderRejected:
|
|
qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: sender rejected";
|
|
break;
|
|
}
|
|
|
|
if (!cleanupDatabaseForEmail(userName, type, token, rowId)) {
|
|
qCWarning(ServatriceLog) << "Failed to clean up database row for permanently failed e-mail to" << userName
|
|
<< "- row may be re-enqueued on next status update";
|
|
}
|
|
}
|
|
|
|
bool Servatrice::cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId)
|
|
{
|
|
Q_UNUSED(token);
|
|
switch (type) {
|
|
case EmailType::Activation: {
|
|
QSqlQuery *query =
|
|
servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name");
|
|
query->bindValue(":name", userName);
|
|
return servatriceDatabaseInterface->execSqlQuery(query);
|
|
}
|
|
case EmailType::ForgotPassword: {
|
|
// Scope the update to the row that was actually sent: the table
|
|
// holds one row per request, and marking by name would also mark
|
|
// rows for newer tokens that were never delivered.
|
|
if (rowId >= 0) {
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"update {prefix}_forgot_password set emailed = 1 where id = :id");
|
|
query->bindValue(":id", rowId);
|
|
return servatriceDatabaseInterface->execSqlQuery(query);
|
|
}
|
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
|
"update {prefix}_forgot_password set emailed = 1 where name = :name");
|
|
query->bindValue(":name", userName);
|
|
return servatriceDatabaseInterface->execSqlQuery(query);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
SessionEvent *Servatrice::makeShutdownEvent() const
|
|
{
|
|
Event_ServerShutdown event;
|
|
event.set_reason(shutdownReason.toStdString());
|
|
event.set_minutes(static_cast<google::protobuf::uint32>(shutdownMinutes));
|
|
return Server_ProtocolHandler::prepareSessionEvent(event);
|
|
}
|
|
|
|
SessionEvent *Servatrice::getLoginSessionEvent() const
|
|
{
|
|
// Notify newly logged-in users of a pending server shutdown
|
|
QMutexLocker locker(&shutdownStateMutex);
|
|
if (shutdownTimer && shutdownMinutes > 0) {
|
|
return makeShutdownEvent();
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void Servatrice::scheduleShutdown(const QString &reason, int minutes)
|
|
{
|
|
shutdownStateMutex.lock();
|
|
shutdownReason = reason;
|
|
shutdownMinutes = minutes;
|
|
nextShutdownMessageMinutes = shutdownMinutes;
|
|
if (minutes > 0) {
|
|
shutdownTimer = new QTimer;
|
|
connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout()));
|
|
shutdownTimer->start(60000);
|
|
}
|
|
shutdownStateMutex.unlock();
|
|
shutdownTimeout();
|
|
}
|
|
|
|
void Servatrice::incTxBytes(quint64 num)
|
|
{
|
|
txBytesMutex.lock();
|
|
txBytes += num;
|
|
txBytesMutex.unlock();
|
|
}
|
|
|
|
void Servatrice::incRxBytes(quint64 num)
|
|
{
|
|
rxBytesMutex.lock();
|
|
rxBytes += num;
|
|
rxBytesMutex.unlock();
|
|
}
|
|
|
|
void Servatrice::shutdownTimeout()
|
|
{
|
|
QMutexLocker locker(&shutdownStateMutex);
|
|
// Show every time counter cut in half & every minute for last 5 minutes
|
|
if (shutdownMinutes <= 5 || shutdownMinutes == nextShutdownMessageMinutes) {
|
|
if (shutdownMinutes == nextShutdownMessageMinutes) {
|
|
nextShutdownMessageMinutes = shutdownMinutes / 2;
|
|
}
|
|
|
|
SessionEvent *se;
|
|
if (shutdownMinutes) {
|
|
se = makeShutdownEvent();
|
|
} else {
|
|
Event_ConnectionClosed event;
|
|
event.set_reason(Event_ConnectionClosed::SERVER_SHUTDOWN);
|
|
se = Server_ProtocolHandler::prepareSessionEvent(event);
|
|
}
|
|
|
|
clientsLock.lockForRead();
|
|
for (auto &client : clients) {
|
|
client->sendProtocolItem(*se);
|
|
}
|
|
clientsLock.unlock();
|
|
delete se;
|
|
|
|
if (!shutdownMinutes) {
|
|
deleteLater();
|
|
}
|
|
}
|
|
shutdownMinutes--;
|
|
}
|
|
|
|
bool Servatrice::islConnectionExists(int _serverId) const
|
|
{
|
|
// Only call with islLock locked at least for reading
|
|
return islInterfaces.contains(_serverId);
|
|
}
|
|
|
|
void Servatrice::addIslInterface(int _serverId, IslInterface *interface)
|
|
{
|
|
// Only call with islLock locked for writing
|
|
islInterfaces.insert(_serverId, interface);
|
|
connect(interface, SIGNAL(externalUserJoined(ServerInfo_User)), this, SLOT(externalUserJoined(ServerInfo_User)));
|
|
connect(interface, SIGNAL(externalUserLeft(QString)), this, SLOT(externalUserLeft(QString)));
|
|
connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this,
|
|
SLOT(externalRoomUserJoined(int, ServerInfo_User)));
|
|
connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString)));
|
|
connect(interface, SIGNAL(externalRoomSay(int, QString, QString)), this,
|
|
SLOT(externalRoomSay(int, QString, QString)));
|
|
connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this,
|
|
SLOT(externalRoomRemoveMessages(int, QString, int)));
|
|
connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this,
|
|
SLOT(externalRoomGameListChanged(int, ServerInfo_Game)));
|
|
connect(interface, SIGNAL(joinGameCommandReceived(Command_JoinGame, int, int, int, qint64)), this,
|
|
SLOT(externalJoinGameCommandReceived(Command_JoinGame, int, int, int, qint64)));
|
|
connect(interface, SIGNAL(gameCommandContainerReceived(CommandContainer, int, int, qint64)), this,
|
|
SLOT(externalGameCommandContainerReceived(CommandContainer, int, int, qint64)));
|
|
connect(interface, SIGNAL(responseReceived(Response, qint64)), this,
|
|
SLOT(externalResponseReceived(Response, qint64)));
|
|
connect(interface, SIGNAL(gameEventContainerReceived(GameEventContainer, qint64)), this,
|
|
SLOT(externalGameEventContainerReceived(GameEventContainer, qint64)));
|
|
}
|
|
|
|
void Servatrice::removeIslInterface(int _serverId)
|
|
{
|
|
// Only call with islLock locked for writing
|
|
// XXX we probably need to delete everything that belonged to it... <-- THIS SHOULD BE FIXED FOR ISL FUNCTIONALITY
|
|
// TO WORK COMPLETLY!
|
|
islInterfaces.remove(_serverId);
|
|
}
|
|
|
|
void Servatrice::doSendIslMessage(const IslMessage &msg, int _serverId)
|
|
{
|
|
QReadLocker locker(&islLock);
|
|
|
|
if (_serverId == -1) {
|
|
QMapIterator<int, IslInterface *> islIterator(islInterfaces);
|
|
while (islIterator.hasNext()) {
|
|
islIterator.next().value()->transmitMessage(msg);
|
|
}
|
|
} else {
|
|
IslInterface *interface = islInterfaces.value(_serverId);
|
|
if (interface) {
|
|
interface->transmitMessage(msg);
|
|
}
|
|
}
|
|
}
|
|
|
|
// start helper functions
|
|
|
|
int Servatrice::getMaxUserTotal() const
|
|
{
|
|
return settingsCache->value("security/max_users_total", 500).toInt();
|
|
}
|
|
|
|
bool Servatrice::getMaxUserLimitEnabled() const
|
|
{
|
|
return settingsCache->value("security/enable_max_user_limit", false).toBool();
|
|
}
|
|
|
|
QString Servatrice::getServerName() const
|
|
{
|
|
return settingsCache->value("server/name", "My Cockatrice server").toString();
|
|
}
|
|
|
|
int Servatrice::getServerID() const
|
|
{
|
|
return settingsCache->value("server/id", 0).toInt();
|
|
}
|
|
|
|
bool Servatrice::getClientIDRequiredEnabled() const
|
|
{
|
|
return settingsCache->value("server/requireclientid", 0).toBool();
|
|
}
|
|
|
|
bool Servatrice::getRegOnlyServerEnabled() const
|
|
{
|
|
return settingsCache->value("authentication/regonly", 0).toBool();
|
|
}
|
|
|
|
QString Servatrice::getAuthenticationMethodString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return {"sql"};
|
|
}
|
|
return settingsCache->value("authentication/method").toString();
|
|
}
|
|
|
|
bool Servatrice::getStoreReplaysEnabled() const
|
|
{
|
|
return settingsCache->value("game/store_replays", true).toBool();
|
|
}
|
|
|
|
int Servatrice::getMaxTcpUserLimit() const
|
|
{
|
|
return settingsCache->value("security/max_users_tcp", 500).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxWebSocketUserLimit() const
|
|
{
|
|
return settingsCache->value("security/max_users_websocket", 500).toInt();
|
|
}
|
|
|
|
bool Servatrice::getRegistrationEnabled() const
|
|
{
|
|
return settingsCache->value("registration/enabled", false).toBool();
|
|
}
|
|
|
|
bool Servatrice::getRequireEmailForRegistrationEnabled() const
|
|
{
|
|
return settingsCache->value("registration/requireemail", true).toBool();
|
|
}
|
|
|
|
bool Servatrice::getRequireEmailActivationEnabled() const
|
|
{
|
|
return settingsCache->value("registration/requireemailactivation", true).toBool();
|
|
}
|
|
|
|
QString Servatrice::getRequiredFeatures() const
|
|
{
|
|
return settingsCache->value("server/requiredfeatures", "").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBTypeString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return {"mysql"};
|
|
}
|
|
return settingsCache->value("database/type").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBPrefixString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return {"cockatrice"};
|
|
}
|
|
return settingsCache->value("database/prefix").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBHostNameString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return QUrl(QProcessEnvironment::systemEnvironment().value("DATABASE_URL")).host();
|
|
}
|
|
return settingsCache->value("database/hostname").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBDatabaseNameString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
QString path = QUrl(QProcessEnvironment::systemEnvironment().value("DATABASE_URL")).path();
|
|
return path.right(path.length() - 1);
|
|
}
|
|
return settingsCache->value("database/database").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBUserNameString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return QUrl(QProcessEnvironment::systemEnvironment().value("DATABASE_URL")).userName();
|
|
}
|
|
return settingsCache->value("database/user").toString();
|
|
}
|
|
|
|
QString Servatrice::getDBPasswordString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return QUrl(QProcessEnvironment::systemEnvironment().value("DATABASE_URL")).password();
|
|
}
|
|
return settingsCache->value("database/password").toString();
|
|
}
|
|
|
|
QString Servatrice::getRoomsMethodString() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("DATABASE_URL")) {
|
|
return {"sql"};
|
|
}
|
|
return settingsCache->value("rooms/method").toString();
|
|
}
|
|
|
|
int Servatrice::getMaxGameInactivityTime() const
|
|
{
|
|
return settingsCache->value("game/max_game_inactivity_time", 120).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxPlayerInactivityTime() const
|
|
{
|
|
return settingsCache->value("server/max_player_inactivity_time", 15).toInt();
|
|
}
|
|
|
|
int Servatrice::getClientKeepAlive() const
|
|
{
|
|
return settingsCache->value("server/clientkeepalive", 1).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxUsersPerAddress() const
|
|
{
|
|
return settingsCache->value("security/max_users_per_address", 4).toInt();
|
|
}
|
|
|
|
int Servatrice::getMessageCountingInterval() const
|
|
{
|
|
return settingsCache->value("security/message_counting_interval", 10).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxMessageCountPerInterval() const
|
|
{
|
|
return settingsCache->value("security/max_message_count_per_interval", 15).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxMessageSizePerInterval() const
|
|
{
|
|
return settingsCache->value("security/max_message_size_per_interval", 1000).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxGamesPerUser() const
|
|
{
|
|
return settingsCache->value("security/max_games_per_user", 5).toInt();
|
|
}
|
|
|
|
int Servatrice::getCommandCountingInterval() const
|
|
{
|
|
return settingsCache->value("security/command_counting_interval", 10).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxCommandCountPerInterval() const
|
|
{
|
|
return settingsCache->value("security/max_command_count_per_interval", 20).toInt();
|
|
}
|
|
|
|
int Servatrice::getServerStatusUpdateTime() const
|
|
{
|
|
return settingsCache->value("server/statusupdate", 15000).toInt();
|
|
}
|
|
|
|
int Servatrice::getDeckShareExpiryDays() const
|
|
{
|
|
return qMax(1, settingsCache->value("deck_share/expiry_days", 7).toInt());
|
|
}
|
|
|
|
int Servatrice::getDeckShareCleanupInterval() const
|
|
{
|
|
// default: every 60 minutes
|
|
return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000;
|
|
}
|
|
|
|
int Servatrice::getDeckShareMaxDecksPerShare() const
|
|
{
|
|
return settingsCache->value("deck_share/max_decks_per_share", 50).toInt();
|
|
}
|
|
|
|
int Servatrice::getDeckShareMaxSharesPerDay() const
|
|
{
|
|
return settingsCache->value("deck_share/max_shares_per_day", 50).toInt();
|
|
}
|
|
|
|
int Servatrice::getNumberOfTCPPools() const
|
|
{
|
|
return settingsCache->value("server/number_pools", 1).toInt();
|
|
}
|
|
|
|
bool Servatrice::permitCreateGameAsJudge() const
|
|
{
|
|
return settingsCache->value("game/allow_create_as_judge", false).toBool();
|
|
}
|
|
|
|
QHostAddress Servatrice::getServerTCPHost() const
|
|
{
|
|
QString host = settingsCache->value("server/host", "any").toString();
|
|
if (host == "any") {
|
|
return QHostAddress::Any;
|
|
} else {
|
|
return QHostAddress(host);
|
|
}
|
|
}
|
|
|
|
int Servatrice::getServerTCPPort() const
|
|
{
|
|
return settingsCache->value("server/port", 4747).toInt();
|
|
}
|
|
|
|
int Servatrice::getNumberOfWebSocketPools() const
|
|
{
|
|
return settingsCache->value("server/websocket_number_pools", 1).toInt();
|
|
}
|
|
|
|
QHostAddress Servatrice::getServerWebSocketHost() const
|
|
{
|
|
QString host = settingsCache->value("server/websocket_host", "any").toString();
|
|
if (host == "any") {
|
|
return QHostAddress::Any;
|
|
} else {
|
|
return QHostAddress(host);
|
|
}
|
|
}
|
|
|
|
int Servatrice::getServerWebSocketPort() const
|
|
{
|
|
if (QProcessEnvironment::systemEnvironment().contains("PORT")) {
|
|
return QProcessEnvironment::systemEnvironment().value("PORT").toInt();
|
|
}
|
|
return settingsCache->value("server/websocket_port", 4748).toInt();
|
|
}
|
|
|
|
bool Servatrice::getISLNetworkEnabled() const
|
|
{
|
|
return settingsCache->value("servernetwork/active", false).toBool();
|
|
}
|
|
|
|
QString Servatrice::getISLNetworkSSLCertFile() const
|
|
{
|
|
return settingsCache->value("servernetwork/ssl_cert").toString();
|
|
}
|
|
|
|
QString Servatrice::getISLNetworkSSLKeyFile() const
|
|
{
|
|
return settingsCache->value("servernetwork/ssl_key").toString();
|
|
}
|
|
|
|
int Servatrice::getISLNetworkPort() const
|
|
{
|
|
return settingsCache->value("servernetwork/port", 14747).toInt();
|
|
}
|
|
|
|
int Servatrice::getIdleClientTimeout() const
|
|
{
|
|
return settingsCache->value("server/idleclienttimeout", 3600).toInt();
|
|
}
|
|
|
|
bool Servatrice::getEnableLogQuery() const
|
|
{
|
|
return settingsCache->value("logging/enablelogquery", false).toBool();
|
|
}
|
|
|
|
int Servatrice::getMaxAccountsPerEmail() const
|
|
{
|
|
return settingsCache->value("registration/maxaccountsperemail", 0).toInt();
|
|
}
|
|
|
|
bool Servatrice::getEnableInternalSMTPClient() const
|
|
{
|
|
return settingsCache->value("smtp/enableinternalsmtpclient", true).toBool();
|
|
}
|
|
|
|
bool Servatrice::getEnableForgotPassword() const
|
|
{
|
|
return settingsCache->value("forgotpassword/enable", false).toBool();
|
|
}
|
|
|
|
int Servatrice::getForgotPasswordTokenLife() const
|
|
{
|
|
return settingsCache->value("forgotpassword/tokenlife", 60).toInt();
|
|
}
|
|
|
|
/**
|
|
* @brief Rate limiter key for one login target.
|
|
*
|
|
* Keyed by (address, username) so a success only clears that pair's history:
|
|
* a guest login or a login as the attacker's own account from the same
|
|
* address must not wipe out another user's failure count. Lowercased so
|
|
* case variations of the same name share one bucket.
|
|
*/
|
|
static QString loginLimitKey(const QString &ipAddress, const QString &userName)
|
|
{
|
|
return QStringLiteral("login:") + ipAddress + QStringLiteral(":") + userName.toLower();
|
|
}
|
|
|
|
bool Servatrice::recordFailedLogin(const QString &ipAddress, const QString &userName)
|
|
{
|
|
if (isRateLimitExempt(ipAddress)) {
|
|
return false;
|
|
}
|
|
return rateLimiter.recordAttempt(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
|
getLoginAttemptWindowSeconds());
|
|
}
|
|
|
|
bool Servatrice::isLoginRateLimited(const QString &ipAddress, const QString &userName)
|
|
{
|
|
if (isRateLimitExempt(ipAddress)) {
|
|
return false;
|
|
}
|
|
return rateLimiter.isBlocked(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
|
getLoginAttemptWindowSeconds());
|
|
}
|
|
|
|
int Servatrice::loginRateLimitRemainingSeconds(const QString &ipAddress, const QString &userName)
|
|
{
|
|
return rateLimiter.retryAfterSeconds(loginLimitKey(ipAddress, userName), getMaxLoginAttemptsPerIp(),
|
|
getLoginAttemptWindowSeconds());
|
|
}
|
|
|
|
bool Servatrice::recordRegistrationAttempt(const QString &ipAddress)
|
|
{
|
|
if (isRateLimitExempt(ipAddress)) {
|
|
return false;
|
|
}
|
|
return rateLimiter.recordAttempt("register:" + ipAddress, getMaxRegistrationsPerIp(),
|
|
getRegistrationWindowSeconds());
|
|
}
|
|
|
|
bool Servatrice::recordForgotPasswordAttempt(const QString &ipAddress)
|
|
{
|
|
if (isRateLimitExempt(ipAddress)) {
|
|
return false;
|
|
}
|
|
return rateLimiter.recordAttempt("forgot:" + ipAddress, getMaxForgotPasswordRequestsPerIp(),
|
|
getForgotPasswordWindowSeconds());
|
|
}
|
|
|
|
bool Servatrice::isRateLimitExempt(const QString &ipAddress) const
|
|
{
|
|
// Mirrors the max_users_per_address bypass in initSession: trusted sources
|
|
// (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket
|
|
// client shares one address) are not subject to per-address limits.
|
|
const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
|
return trustedSources.contains(ipAddress, Qt::CaseInsensitive);
|
|
}
|
|
|
|
void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName)
|
|
{
|
|
rateLimiter.clearAttempts(loginLimitKey(ipAddress, userName));
|
|
}
|
|
|
|
int Servatrice::getMaxLoginAttemptsPerIp() const
|
|
{
|
|
return settingsCache->value("security/max_login_attempts_per_ip", 5).toInt();
|
|
}
|
|
|
|
int Servatrice::getLoginAttemptWindowSeconds() const
|
|
{
|
|
return settingsCache->value("security/login_attempt_window_seconds", 900).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxRegistrationsPerIp() const
|
|
{
|
|
return settingsCache->value("security/max_registrations_per_ip", 2).toInt();
|
|
}
|
|
|
|
int Servatrice::getRegistrationWindowSeconds() const
|
|
{
|
|
return settingsCache->value("security/registration_window_seconds", 3600).toInt();
|
|
}
|
|
|
|
int Servatrice::getMaxForgotPasswordRequestsPerIp() const
|
|
{
|
|
return settingsCache->value("security/max_forgot_password_requests_per_ip", 3).toInt();
|
|
}
|
|
|
|
int Servatrice::getForgotPasswordWindowSeconds() const
|
|
{
|
|
return settingsCache->value("security/forgot_password_window_seconds", 3600).toInt();
|
|
}
|
|
|
|
bool Servatrice::getEnableForgotPasswordChallenge() const
|
|
{
|
|
return settingsCache->value("forgotpassword/enablechallenge", false).toBool();
|
|
}
|
|
|
|
QString Servatrice::getEmailBlackList() const
|
|
{
|
|
return settingsCache->value("registration/emailproviderblacklist").toString();
|
|
}
|
|
|
|
QString Servatrice::getEmailWhiteList() const
|
|
{
|
|
return settingsCache->value("registration/emailproviderwhitelist").toString();
|
|
}
|
|
|
|
bool Servatrice::getEnableAudit() const
|
|
{
|
|
return settingsCache->value("audit/enable_audit", true).toBool();
|
|
}
|
|
|
|
bool Servatrice::getEnableRegistrationAudit() const
|
|
{
|
|
return settingsCache->value("audit/enable_registration_audit", true).toBool();
|
|
}
|
|
|
|
bool Servatrice::getEnableForgotPasswordAudit() const
|
|
{
|
|
return settingsCache->value("audit/enable_forgotpassword_audit", true).toBool();
|
|
}
|
|
|
|
int Servatrice::getMinPasswordLength() const
|
|
{
|
|
return settingsCache->value("users/minpasswordlength", 6).toInt();
|
|
}
|