Token bucket rate limiting

This commit is contained in:
Miraty 2022-09-17 00:49:07 +02:00
parent c65dedf9de
commit 77f6dfaada
10 changed files with 133 additions and 15 deletions

View file

@ -0,0 +1,29 @@
BEGIN TRANSACTION;
-- Add user-relative bucket
ALTER TABLE "users" ADD COLUMN "bucket_tokens" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "users" ADD COLUMN "bucket_last_update" INTEGER NOT NULL DEFAULT 0;
-- Remove "DEFAULT 0" from user-relative bucket
CREATE TABLE "users_temp" (
"id" INTEGER NOT NULL UNIQUE,
"username" TEXT NOT NULL UNIQUE,
"password" TEXT NOT NULL,
"registration_date" TEXT NOT NULL,
"bucket_tokens" INTEGER NOT NULL,
"bucket_last_update" INTEGER NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
);
INSERT INTO "users_temp" SELECT "id","username","password","registration_date","bucket_tokens","bucket_last_update" FROM "users";
DROP TABLE "users";
ALTER TABLE "users_temp" RENAME TO "users";
-- Add instance-wide bucket
CREATE TABLE IF NOT EXISTS "params" (
"name" TEXT NOT NULL UNIQUE,
"value" TEXT NOT NULL
);
INSERT INTO "params"("name", "value") VALUES("instance_bucket_tokens", "0");
INSERT INTO "params"("name", "value") VALUES("instance_bucket_last_update", "0");
COMMIT;

View file

@ -1,9 +1,13 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "params" (
"name" TEXT NOT NULL UNIQUE,
"value" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "registry" (
"id" INTEGER NOT NULL UNIQUE,
"domain" TEXT NOT NULL UNIQUE,
"username" TEXT NOT NULL,
"last_renewal" INTEGER,
"last_renewal" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE IF NOT EXISTS "zones" (
@ -16,7 +20,9 @@ CREATE TABLE IF NOT EXISTS "users" (
"id" INTEGER NOT NULL UNIQUE,
"username" TEXT NOT NULL UNIQUE,
"password" TEXT NOT NULL,
"registration_date" INTEGER NOT NULL,
"registration_date" TEXT NOT NULL,
"bucket_tokens" INTEGER NOT NULL,
"bucket_last_update" INTEGER NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE IF NOT EXISTS "sites" (
@ -26,7 +32,9 @@ CREATE TABLE IF NOT EXISTS "sites" (
"domain" TEXT NOT NULL UNIQUE,
"domain_type" TEXT NOT NULL,
"protocol" TEXT NOT NULL,
"creation_date" INTEGER NOT NULL,
"creation_date" TEXT NOT NULL,
PRIMARY KEY("id" AUTOINCREMENT)
);
INSERT INTO "params"("name", "value") VALUES("instance_bucket_tokens", "0");
INSERT INTO "params"("name", "value") VALUES("instance_bucket_last_update", "0");
COMMIT;

View file

@ -50,3 +50,58 @@ function changePassword($username, $password) {
$stmt->execute();
}
function rateLimit() {
if (PAGE_METADATA['tokens_account_cost'] ?? 0 > 0)
rateLimitAccount(PAGE_METADATA['tokens_account_cost']);
if (PAGE_METADATA['tokens_instance_cost'] ?? 0 > 0)
rateLimitInstance(PAGE_METADATA['tokens_instance_cost']);
}
function rateLimitAccount($requestedTokens) {
// Get
$userData = query('select', 'users', ['username' => $_SESSION['username']]);
$tokens = $userData[0]['bucket_tokens'];
$bucketLastUpdate = $userData[0]['bucket_last_update'];
// Compute
$tokens = min(86400, $tokens + (time() - $bucketLastUpdate));
if ($requestedTokens > $tokens)
output(453, 'Limite d\'actions par compte atteinte. Réessayez plus tard.');
$tokens -= $requestedTokens;
// Update
$db = new PDO('sqlite:' . DB_PATH);
$stmt = $db->prepare("UPDATE users SET bucket_tokens = :bucket_tokens, bucket_last_update = :bucket_last_update WHERE username = :username");
$stmt->bindValue(':username', $_SESSION['username']);
$stmt->bindValue(':bucket_tokens', $tokens);
$stmt->bindValue(':bucket_last_update', time());
$stmt->execute();
}
function rateLimitInstance($requestedTokens) {
// Get
$tokens = query('select', 'params', ['name' => 'instance_bucket_tokens'], 'value')[0];
$bucketLastUpdate = query('select', 'params', ['name' => 'instance_bucket_last_update'], 'value')[0];
// Compute
$tokens = min(86400, $tokens + (time() - $bucketLastUpdate));
if ($requestedTokens > $tokens)
output(453, 'Limite d\'actions globale atteinte. Réessayez plus tard.');
$tokens -= $requestedTokens;
// Update
$db = new PDO('sqlite:' . DB_PATH);
$stmt = $db->prepare("UPDATE params SET value = :bucket_tokens WHERE name = 'instance_bucket_tokens';");
$stmt->bindValue(':bucket_tokens', $tokens);
$stmt->execute();
$stmt = $db->prepare("UPDATE params SET value = :bucket_last_update WHERE name = 'instance_bucket_last_update';");
$stmt->bindValue(':bucket_last_update', time());
$stmt->execute();
}

View file

@ -16,6 +16,7 @@ define('PAGES', [
'register' => [
'title' => 'Créer un compte',
'description' => 'Créer un nouveau compte Niver',
'tokens_instance_cost' => 7200,
],
'unregister' => [
'title' => 'Supprimer son compte',
@ -38,6 +39,7 @@ define('PAGES', [
'register' => [
'title' => 'Enregistrer un nouveau domaine',
'description' => 'Prendre possession d\'un sous-domaine de <code>' . CONF['reg']['registry'] . '</code>',
'tokens_account_cost' => 3600,
],
'unregister' => [
'title' => 'Effacer un domaine',
@ -68,6 +70,7 @@ define('PAGES', [
'zone-add' => [
'title' => 'Ajouter une zone',
'description' => 'Pour qu\'elle soit gérée par le serveur de noms de Niver',
'tokens_account_cost' => 1800,
],
'zone-del' => [
'title' => 'Effacer une zone',
@ -130,10 +133,12 @@ define('PAGES', [
'add-http-onion' => [
'title' => 'Ajouter un accès HTTP par Onion',
'description' => 'Ajouter un accès HTTP par ' . linkToDocs('tor', 'service Onion') . ' sur un sous-dossier de l\'espace SFTP',
'tokens_account_cost' => 1800,
],
'add-http-dns' => [
'title' => 'Ajouter un accès HTTP par DNS+TLS',
'description' => 'Ajouter un accès HTTP par ' . linkToDocs('dns', 'DNS') . ' et ' . linkToDocs('tls', 'TLS') . ' sur un sous-dossier de l\'espace SFTP',
'tokens_account_cost' => 3600,
],
'del-http-onion' => [
'title' => 'Retirer un accès HTTP par Onion',

View file

@ -8,6 +8,16 @@ if (processForm(false)) {
if (userExist($_POST['username']) !== false)
output(403, 'Ce nom de compte est déjà utilisé.');
rateLimit();
insert('users', [
'username' => $_POST['username'],
'password' => hashPassword($_POST['password']),
'registration_date' => date("Y-m-d H:i:s"),
'bucket_tokens' => 0,
'bucket_last_update' => 0,
]);
// Setup SFTP directory
umask(0002);
if (mkdir(CONF['ht']['ht_path'] . "/" . $_POST['username'], 0775) !== true)
@ -25,12 +35,6 @@ if (processForm(false)) {
if ($code !== 0)
output(500, 'Can\'t create Tor keys directory.');
insert('users', [
'username' => $_POST['username'],
'password' => hashPassword($_POST['password']),
'registration_date' => date("Y-m-d H:i:s"),
]);
$_SESSION['username'] = $_POST['username'];
redir();

View file

@ -26,6 +26,8 @@ if (processForm()) {
if (equalArrays([CONF['ht']['ipv4_address']], array_column($remoteARecords, 'ip')) !== true)
output(403, 'Ce domaine doit avoir pour unique enregistrement A <code>' . CONF['ht']['ipv4_address'] . '</code>.');
rateLimit();
addSite($_SESSION['username'], $_POST['dir'], $_POST['domain'], "dns", "http");
exec(CONF['ht']['sudo_path'] . " " . CONF['ht']['certbot_path'] . " certonly --quiet" . (CONF['ht']['letsencrypt_use_production'] ? '' : ' --test-cert') . " --key-type rsa --rsa-key-size 3072 --webroot --webroot-path /srv/niver/acme --domain " . $_POST['domain'], $output, $returnCode);

View file

@ -9,6 +9,8 @@ if (processForm()) {
if ($dirsStatuses[$_POST['dir']] !== false)
output(403, 'Wrong value for <code>dir</code>.');
rateLimit();
// Add Tor config
$torConf = "HiddenServiceDir " . CONF['ht']['tor_keys_path'] . "/" . $_SESSION['username'] . "/" . $_POST['dir'] . "/
HiddenServicePort 80 [::1]:" . CONF['ht']['internal_onion_http_port'] . "

View file

@ -14,6 +14,8 @@ if (processForm()) {
if (equalArrays(CONF['ns']['servers'], $matches[1]) !== true)
output(403, 'Les serveurs ayant autorité dans cette zone indiqués par la zone parente ne sont pas ceux de Niver.');
rateLimit();
insert('zones', [
'zone' => $_POST['domain'],
'username' => $_SESSION['username'],

View file

@ -12,6 +12,8 @@ if (processForm()) {
if (in_array($_POST['subdomain'], explode("\n", file_get_contents(CONF['common']['root_path'] . '/pages/reg/reserved.txt'))))
output(403, 'Ce domaine n\'est pas disponible à l\'enregistrement. Il est réservé.');
rateLimit();
insert('registry', [
'domain' => $domain,
'username' => $_SESSION['username'],

View file

@ -26,19 +26,28 @@ define("PAGE_LINEAGE", explode('/', PAGE_ADDRESS));
define("SERVICE", dirname(PAGE_ADDRESS));
define("PAGE", basename(PAGE_ADDRESS, '.php'));
function getTitlesLineage($pages, $pageElements) {
if (!isset($pages['index']) OR $pageElements[0] === 'index')
return [$pages[$pageElements[0]]['title'] ?? false];
$pageMetadata = [];
function getPageInformations($pages, $pageElements) {
global $pageMetadata;
if (!isset($pages['index']) OR $pageElements[0] === 'index') {
return [
'titles_lineage' => [$pages[$pageElements[0]]['title'] ?? false],
'page_metadata' => $pages[$pageElements[0]] ?? NULL
];
define("PAGE_METADATA", $pages[$pageElements[0]] ?? []);
}
$result = $pages['index']['title'];
if (!isset($pageElements[1]))
unset($pages['index']);
else
$pages = $pages[array_shift($pageElements)] ?? false;
$results = getTitlesLineage($pages, $pageElements);
$results[] = $result;
$results = getPageInformations($pages, $pageElements);
$results['titles_lineage'][] = $result;
return $results;
}
define('TITLES_LINEAGE', array_reverse(getTitlesLineage(PAGES, PAGE_LINEAGE)));
$pageInformations = getPageInformations(PAGES, PAGE_LINEAGE);
define('TITLES_LINEAGE', array_reverse($pageInformations['titles_lineage']));
define('PAGE_METADATA', $pageInformations['page_metadata']);
if (!TITLES_LINEAGE[array_key_last(TITLES_LINEAGE)]) {
http_response_code(404);