Explorar o código

Token bucket rate limiting

Miraty %!s(int64=2) %!d(string=hai) anos
pai
achega
77f6dfaada

+ 29 - 0
db/migrations/002-add-token-bucket.sql

@@ -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;

+ 11 - 3
db/schema.sql

@@ -1,9 +1,13 @@
 BEGIN TRANSACTION;
 BEGIN TRANSACTION;
+CREATE TABLE IF NOT EXISTS "params" (
+	"name"	TEXT NOT NULL UNIQUE,
+	"value"	TEXT NOT NULL
+);
 CREATE TABLE IF NOT EXISTS "registry" (
 CREATE TABLE IF NOT EXISTS "registry" (
 	"id"	INTEGER NOT NULL UNIQUE,
 	"id"	INTEGER NOT NULL UNIQUE,
 	"domain"	TEXT NOT NULL UNIQUE,
 	"domain"	TEXT NOT NULL UNIQUE,
 	"username"	TEXT NOT NULL,
 	"username"	TEXT NOT NULL,
-	"last_renewal"	INTEGER,
+	"last_renewal"	TEXT NOT NULL,
 	PRIMARY KEY("id" AUTOINCREMENT)
 	PRIMARY KEY("id" AUTOINCREMENT)
 );
 );
 CREATE TABLE IF NOT EXISTS "zones" (
 CREATE TABLE IF NOT EXISTS "zones" (
@@ -16,7 +20,9 @@ CREATE TABLE IF NOT EXISTS "users" (
 	"id"	INTEGER NOT NULL UNIQUE,
 	"id"	INTEGER NOT NULL UNIQUE,
 	"username"	TEXT NOT NULL UNIQUE,
 	"username"	TEXT NOT NULL UNIQUE,
 	"password"	TEXT NOT NULL,
 	"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)
 	PRIMARY KEY("id" AUTOINCREMENT)
 );
 );
 CREATE TABLE IF NOT EXISTS "sites" (
 CREATE TABLE IF NOT EXISTS "sites" (
@@ -26,7 +32,9 @@ CREATE TABLE IF NOT EXISTS "sites" (
 	"domain"	TEXT NOT NULL UNIQUE,
 	"domain"	TEXT NOT NULL UNIQUE,
 	"domain_type"	TEXT NOT NULL,
 	"domain_type"	TEXT NOT NULL,
 	"protocol"	TEXT NOT NULL,
 	"protocol"	TEXT NOT NULL,
-	"creation_date"	INTEGER NOT NULL,
+	"creation_date"	TEXT NOT NULL,
 	PRIMARY KEY("id" AUTOINCREMENT)
 	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;
 COMMIT;

+ 55 - 0
fn/auth.php

@@ -50,3 +50,58 @@ function changePassword($username, $password) {
 
 
 	$stmt->execute();
 	$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();
+}

+ 5 - 0
pages.php

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

+ 10 - 6
pages/auth/register.php

@@ -8,6 +8,16 @@ if (processForm(false)) {
 	if (userExist($_POST['username']) !== false)
 	if (userExist($_POST['username']) !== false)
 		output(403, 'Ce nom de compte est déjà utilisé.');
 		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
 	// Setup SFTP directory
 	umask(0002);
 	umask(0002);
 	if (mkdir(CONF['ht']['ht_path'] . "/" . $_POST['username'], 0775) !== true)
 	if (mkdir(CONF['ht']['ht_path'] . "/" . $_POST['username'], 0775) !== true)
@@ -25,12 +35,6 @@ if (processForm(false)) {
 	if ($code !== 0)
 	if ($code !== 0)
 		output(500, 'Can\'t create Tor keys directory.');
 		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'];
 	$_SESSION['username'] = $_POST['username'];
 
 
 	redir();
 	redir();

+ 2 - 0
pages/ht/add-http-dns.php

@@ -26,6 +26,8 @@ if (processForm()) {
 	if (equalArrays([CONF['ht']['ipv4_address']], array_column($remoteARecords, 'ip')) !== true)
 	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>.');
 		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");
 	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);
 	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);

+ 2 - 0
pages/ht/add-http-onion.php

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

+ 2 - 0
pages/ns/zone-add.php

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

+ 2 - 0
pages/reg/register.php

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

+ 15 - 6
router.php

@@ -26,19 +26,28 @@ define("PAGE_LINEAGE", explode('/', PAGE_ADDRESS));
 define("SERVICE", dirname(PAGE_ADDRESS));
 define("SERVICE", dirname(PAGE_ADDRESS));
 define("PAGE", basename(PAGE_ADDRESS, '.php'));
 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'];
 	$result = $pages['index']['title'];
 	if (!isset($pageElements[1]))
 	if (!isset($pageElements[1]))
 		unset($pages['index']);
 		unset($pages['index']);
 	else
 	else
 		$pages = $pages[array_shift($pageElements)] ?? false;
 		$pages = $pages[array_shift($pageElements)] ?? false;
-	$results = getTitlesLineage($pages, $pageElements);
-	$results[] = $result;
+	$results = getPageInformations($pages, $pageElements);
+	$results['titles_lineage'][] = $result;
 	return $results;
 	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)]) {
 if (!TITLES_LINEAGE[array_key_last(TITLES_LINEAGE)]) {
 	http_response_code(404);
 	http_response_code(404);