Merge pull request 'dev' (#8) from dev into main

Reviewed-on: https://code.antopie.org/servnest/servnest/pulls/8
This commit is contained in:
Miraty 2023-06-19 03:51:58 +02:00
commit f5aee06ff5
41 changed files with 645 additions and 241 deletions

View file

@ -2,6 +2,9 @@
## Program flow
`init.php`
: Initializes common values
`router.php`
: Receives every external HTTP request from the web server, executes actions required in any case, executes matching code in `pg-act` if appropriate, and calls `view.php` either way.
@ -21,12 +24,12 @@ The `output` function is used to return success or error messages and stop proce
`fn/`
: Functions, grouped by concerned service
`jobs/`
: CLI scripts
`sftpgo-auth.php`
: When someone tries to log in over SFTP, SFTPGo sends username and password to this script, which queries the database and replies whether authentication succeeded or not.
`check.php`
: This file is not part of the normal program execution, it is meant to be run by developers to test that the current setup is working.
`DOCS/`
: Documentation (some important or standard files may be directly in the root)

View file

@ -24,7 +24,7 @@ I plan to create and maintain a public stable instance of ServNest, but I haven'
### Name server (`ns`)
* Host a zone on the server
* Zone file edition through `<textarea>`
* Plain zone file edition
* Dedicated forms to set/unset `A`, `AAAA`, `NS`, `TXT`, `CAA`, `SRV`, `MX`, `SRV`, `SSHFP`, `TLSA`, `CNAME`, `DNAME` and `LOC` records
* Display records or the full zone file

View file

@ -41,6 +41,14 @@ input[type=password] {
width: 7ch;
}
#public-key {
width: 70ch;
}
#key {
width: 65ch;
}
:disabled {
cursor: not-allowed;
}

View file

@ -36,7 +36,7 @@ h3 {
font-size: 1.1rem;
}
main > *:not(pre, form), form > *:not(textarea), footer {
main > *:not(pre, form), footer {
max-width: 40rem;
margin-left: auto;
margin-right: auto;

View file

@ -0,0 +1,11 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "ssh-keys" (
"key" TEXT NOT NULL,
"username" TEXT NOT NULL,
"directory" TEXT NOT NULL,
UNIQUE("key", "username", "directory"),
FOREIGN KEY("username") REFERENCES "users"("id")
);
COMMIT;

View file

@ -53,4 +53,11 @@ CREATE TABLE IF NOT EXISTS "sites" (
PRIMARY KEY("address", "type"),
FOREIGN KEY("username") REFERENCES "users"("id")
);
CREATE TABLE IF NOT EXISTS "ssh-keys" (
"key" TEXT NOT NULL,
"username" TEXT NOT NULL,
"directory" TEXT NOT NULL,
UNIQUE("key", "username", "directory"),
FOREIGN KEY("username") REFERENCES "users"("id")
);
COMMIT;

View file

@ -87,6 +87,57 @@ function setupDisplayUsername($display_username) {
$_SESSION['display-username-cyphertext'] = $cyphertext;
}
function authDeleteUser($user_id) {
$user_services = explode(',', query('select', 'users', ['id' => $user_id], 'services')[0]);
foreach (SERVICES_USER as $service)
if (in_array($service, $user_services, true) AND CONF['common']['services'][$service] !== 'enabled')
output(503, sprintf(_('Your account can\'t be deleted because the %s service is currently unavailable.'), '<em>' . PAGES[$service]['index']['title'] . '</em>'));
if (in_array('reg', $user_services, true))
foreach (query('select', 'registry', ['username' => $user_id], 'domain') as $domain)
regDeleteDomain($domain, $user_id);
if (in_array('ns', $user_services, true))
foreach (query('select', 'zones', ['username' => $user_id], 'zone') as $zone)
nsDeleteZone($zone, $user_id);
if (in_array('ht', $user_services, true)) {
foreach (query('select', 'sites', ['username' => $user_id]) as $site)
htDeleteSite($site['address'], $site['type'], $user_id);
exescape([
CONF['ht']['sudo_path'],
'-u',
CONF['ht']['tor_user'],
'--',
CONF['ht']['rm_path'],
'-r',
'--',
CONF['ht']['tor_keys_path'] . '/' . $user_id,
], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t remove Tor keys directory.');
removeDirectory(CONF['ht']['tor_config_path'] . '/' . $user_id);
exescape([
CONF['ht']['sudo_path'],
'-u',
CONF['ht']['sftpgo_user'],
'--',
CONF['ht']['rm_path'],
'-r',
'--',
CONF['ht']['ht_path'] . '/fs/' . $user_id
], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t remove user\'s directory.');
}
query('delete', 'users', ['id' => $user_id]);
}
function rateLimit() {
if (PAGE_METADATA['tokens_account_cost'] ?? 0 > 0)
rateLimitAccount(PAGE_METADATA['tokens_account_cost']);

View file

@ -10,7 +10,15 @@ function output($code, $msg = '', $logs = [''], $data = []) {
4 => '<p><output>' . _('<strong>User error</strong>: ') . '<em>' . $msg . '</em></output></p>' . LF,
5 => '<p><output>' . _('<strong>Server error</strong>: ') . '<em>' . $msg . '</em></output></p>' . LF,
};
displayPage(array_merge(['final_message' => $final_message], $data));
if (is_callable('displayPage'))
displayPage(array_merge(['final_message' => $final_message], $data));
echo $final_message;
exit();
}
function exescape(array $args, array &$output = NULL, int &$result_code = NULL): int {
exec('2>&1 ' . implode(' ', array_map('escapeshellarg', $args)), $output, $result_code);
return $result_code;
}
function insert($table, $values) {

View file

@ -15,42 +15,53 @@ function parseZoneFile($zone_content, $types, $filter_domain = false) {
return $parsed_zone_content;
}
function knotcConfExec($cmds) {
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 conf-begin', $output['begin'], $code['begin']);
function knotc(array $cmds, array &$output = NULL, int &$return_code = NULL): void {
exescape([
CONF['dns']['knotc_path'],
'--blocking',
'--timeout',
'3',
'--',
...$cmds,
], $output, $return_code);
}
function knotcConfExec(array $cmds): void {
knotc(['conf-begin'], $output['begin'], $code['begin']);
if ($code['begin'] !== 0)
output(500, 'knotcConfExec: <code>knotc</code> failed with exit code <samp>' . $code['begin'] . '</samp>: <samp>' . $output['begin'][0] . '</samp>.');
foreach ($cmds as $cmd) {
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 conf-' . $cmd, $output['op'], $code['op']);
knotc($cmd, $output['op'], $code['op']);
if ($code['op'] !== 0) {
exec(CONF['dns']['knotc_path'] . ' --blocking conf-abort');
knotc(['conf-abort']);
output(500, 'knotcConfExec: <code>knotc</code> failed with exit code <samp>' . $code['op'] . '</samp>: <samp>' . $output['op'][0] . '</samp>.');
}
}
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 conf-commit', $output['commit'], $code['commit']);
knotc(['conf-commit'], $output['commit'], $code['commit']);
if ($code['commit'] !== 0) {
exec(CONF['dns']['knotc_path'] . ' --blocking conf-abort');
knotc(['conf-abort']);
output(500, 'knotcConfExec: <code>knotc</code> failed with exit code <samp>' . $code['commit'] . '</samp>: <samp>' . $output['commit'][0] . '</samp>.');
}
}
function knotcZoneExec($zone, $cmd, $action = NULL) {
function knotcZoneExec(string $zone, array $cmd, string $action = NULL) {
$action = checkAction($action ?? $_POST['action']);
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-begin ' . $zone, $output['begin'], $code['begin']);
knotc(['zone-begin', $zone], $output['begin'], $code['begin']);
if ($code['begin'] !== 0)
output(500, 'knotcZoneExec: <code>knotc</code> failed with exit code <samp>' . $code['begin'] . '</samp>: <samp>' . $output['begin'][0] . '</samp>.');
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-' . $action . 'set ' . $zone . ' ' . implode(' ', $cmd), $output['op'], $code['op']);
knotc(['zone-' . $action . 'set', $zone, ...$cmd], $output['op'], $code['op']);
if ($code['op'] !== 0) {
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-abort ' . $zone);
knotc(['zone-abort', $zone]);
output(500, 'knotcZoneExec: <code>knotc</code> failed with exit code <samp>' . $code['op'] . '</samp>: <samp>' . $output['op'][0] . '</samp>.');
}
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-commit ' . $zone, $output['commit'], $code['commit']);
knotc(['zone-commit', $zone], $output['commit'], $code['commit']);
if ($code['commit'] !== 0) {
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-abort ' . $zone);
knotc(['zone-abort', $zone]);
output(500, 'knotcZoneExec: <code>knotc</code> failed with exit code <samp>' . $code['commit'] . '</samp>: <samp>' . $output['commit'][0] . '</samp>.');
}
}

View file

@ -1,12 +1,23 @@
<?php
const SUBPATH_REGEX = '^[a-z0-9-]{4,63}$';
const ED25519_PUBKEY_REGEX = '^[a-zA-Z0-9/+]{68}$';
function htSetupUserFs($id) {
// Setup SFTP directory
if (mkdir(CONF['ht']['ht_path'] . '/fs/' . $id, 0000) !== true)
output(500, 'Can\'t create user directory.');
if (chmod(CONF['ht']['ht_path'] . '/fs/' . $id, 0775) !== true)
output(500, 'Can\'t chmod user directory.');
exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['chgrp_path'] . ' ' . CONF['ht']['sftpgo_group'] . ' ' . CONF['ht']['ht_path'] . '/fs/' . $id . ' --no-dereference', result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'--',
CONF['ht']['chgrp_path'],
'--no-dereference',
'--',
CONF['ht']['sftpgo_group'],
CONF['ht']['ht_path'] . '/fs/' . $id,
], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t change user directory group.');
@ -17,7 +28,16 @@ function htSetupUserFs($id) {
output(500, 'Can\'t chmod Tor config directory.');
// Setup Tor keys directory
exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['mkdir_path'] . ' --mode=0700 ' . CONF['ht']['tor_keys_path'] . '/' . $id, result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'-u',
CONF['ht']['tor_user'],
'--',
CONF['ht']['mkdir_path'],
'--mode=0700',
'--',
CONF['ht']['tor_keys_path'] . '/' . $id,
], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t create Tor keys directory.');
}
@ -35,6 +55,8 @@ function formatDomain($domain) {
}
function listFsDirs($username) {
if ($username === '')
return [];
$absoluteDirs = glob(CONF['ht']['ht_path'] . '/fs/' . $username . '/*/', GLOB_ONLYDIR);
$dirs = [];
foreach ($absoluteDirs as $absoluteDir)
@ -74,33 +96,53 @@ function htRelativeSymlink($target, $name) {
output(500, 'Unable to create symlink.');
}
function htDeleteSite($address, $type) {
function htDeleteSite($address, $type, $user_id) {
if ($type === 'onion') {
$dir = query('select', 'sites', [
'username' => $_SESSION['id'],
'username' => $user_id,
'address' => $address,
'type' => $type,
], 'site_dir')[0];
// Delete Tor config
if (unlink(CONF['ht']['tor_config_path'] . '/' . $_SESSION['id'] . '/' . $dir) !== true)
if (unlink(CONF['ht']['tor_config_path'] . '/' . $user_id . '/' . $dir) !== true)
output(500, 'Failed to delete Tor configuration.');
// Reload Tor
exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['tor_reload_cmd'], result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'--',
...explode(' ', CONF['ht']['tor_reload_cmd']),
], result_code: $code);
if ($code !== 0)
output(500, 'Failed to reload Tor.');
// Delete Tor keys
exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['rm_path'] . ' -r ' . CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'] . '/' . $dir, result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'-u',
CONF['ht']['tor_user'],
'--',
CONF['ht']['rm_path'],
'-r',
'--',
CONF['ht']['tor_keys_path'] . '/' . $user_id . '/' . $dir,
], result_code: $code);
if ($code !== 0)
output(500, 'Failed to delete Tor keys.');
}
if ($type === 'dns') {
// Delete Let's Encrypt certificate
exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['certbot_path'] . ' delete --quiet --cert-name ' . $address, result_code: $code);
exescape([
CONF['ht']['sudo_path'],
CONF['ht']['certbot_path'],
'delete',
'--quiet',
'--cert-name',
$address,
], result_code: $code);
if ($code !== 0)
output(500, 'Certbot failed to delete the Let\'s Encrypt certificate.');
}
@ -115,7 +157,7 @@ function htDeleteSite($address, $type) {
output(500, 'Unable to delete symlink.');
query('delete', 'sites', [
'username' => $_SESSION['id'],
'username' => $user_id,
'type' => $type,
'address' => $address,
]);

View file

@ -48,22 +48,32 @@ function nsCheckZonePossession($zone) {
output(403, 'You don\'t own this zone on the name server.');
}
function nsDeleteZone($zone) {
function nsDeleteZone($zone, $user_id) {
// Remove from Knot configuration
knotcConfExec(["unset 'zone[$zone]'"]);
knotcConfExec([['conf-unset', 'zone[' . $zone . ']']]);
// Remove Knot zone file
if (unlink(CONF['ns']['knot_zones_path'] . '/' . $zone . 'zone') !== true)
output(500, 'Failed to remove Knot zone file.');
// Remove Knot related data
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 --force zone-purge ' . $zone . ' +orphan', result_code: $code);
exescape([
CONF['dns']['knotc_path'],
'--blocking',
'--timeout',
'3',
'--force',
'--',
'zone-purge',
$zone,
'+orphan',
], result_code: $code);
if ($code !== 0)
output(500, 'Failed to purge zone data.');
// Remove from database
query('delete', 'zones', [
'zone' => $zone,
'username' => $_SESSION['id'],
'username' => $user_id,
]);
}

View file

@ -1,6 +1,6 @@
<?php
const SUBDOMAIN_REGEX = '^[a-z0-9]{4,63}$';
const SUBDOMAIN_REGEX = '^(?!\-)(?!..\-\-)[a-z0-9-]{4,63}(?<!\-)$';
function regListUserDomains() {
if (isset($_SESSION['id']))
@ -13,7 +13,7 @@ function regCheckDomainPossession($domain) {
output(403, 'You don\'t own this domain on the registry.');
}
function regDeleteDomain($domain) {
function regDeleteDomain($domain, $user_id) {
// Delete domain from registry file
$path = CONF['reg']['suffixes_path'] . '/' . regParseDomain($domain)['suffix'] . 'zone';
$content = file_get_contents($path);
@ -28,7 +28,7 @@ function regDeleteDomain($domain) {
$conditions = [
'domain' => $domain,
'username' => $_SESSION['id'],
'username' => $user_id,
];
insert('registry-history', [

48
init.php Normal file
View file

@ -0,0 +1,48 @@
<?php
umask(0077);
set_error_handler(function ($level, $message, $file = '', $line = 0) {
throw new ErrorException($message, 0, $level, $file, $line);
});
set_exception_handler(function ($e) {
error_log($e);
http_response_code(500);
echo '<h1>Error</h1>An error occured.';
});
register_shutdown_function(function () { // Also catch fatal errors
if (($error = error_get_last()) !== NULL)
throw new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line']);
});
const ROOT_PATH = __DIR__;
define('CONF', parse_ini_file(ROOT_PATH . '/config.ini', true, INI_SCANNER_TYPED));
define('DB', new PDO('sqlite:' . ROOT_PATH . '/db/servnest.db'));
DB->exec('PRAGMA foreign_keys = ON;');
DB->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
date_default_timezone_set('UTC');
foreach (explode(',', preg_replace('/[A-Z0-9]|q=|;|-|\./', '', $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '')) as $client_locale)
if (in_array($client_locale, array_diff(scandir(ROOT_PATH . '/locales'), ['..', '.']), true)) {
$locale = $client_locale;
break;
}
define('LOCALE', $locale ?? 'en');
putenv('LANG=C.UTF-8');
setlocale(LC_MESSAGES, 'C.UTF-8');
bindtextdomain('messages', ROOT_PATH . '/locales/' . LOCALE);
header('Content-Language: ' . LOCALE);
const SERVICES_USER = ['reg', 'ns', 'ht'];
const LF = "\n";
const PLACEHOLDER_DOMAIN = 'example'; // From RFC2606: Reserved Top Level DNS Names > 2. TLDs for Testing, & Documentation Examples
const PLACEHOLDER_IPV6 = '2001:db8::3'; // From RFC3849: IPv6 Address Prefix Reserved for Documentation
const PLACEHOLDER_IPV4 = '203.0.113.42'; // From RFC5737: IPv4 Address Blocks Reserved for Documentation
foreach (array_diff(scandir(ROOT_PATH . '/fn'), ['..', '.']) as $file)
require ROOT_PATH . '/fn/' . $file;
require ROOT_PATH . '/pages.php';

View file

@ -1,7 +1,6 @@
<?php
umask(0077);
const ROOT_PATH = __DIR__;
define('CONF', parse_ini_file(ROOT_PATH . '/config.ini', true, INI_SCANNER_TYPED));
<?php // Test that the current setup is working
require 'init.php';
const SFTP = '/usr/bin/sftp';
const SSHPASS = '/usr/bin/sshpass';
@ -13,16 +12,10 @@ const SUFFIX = 'test.servnest.test.';
const TOR_PROXY = 'socks5h://127.0.0.1:9050';
const LF = "\n";
exec(CONF['dns']['kdig_path'] . ' torproject.org AAAA', $output, $return_code);
exescape([CONF['dns']['kdig_path'], 'torproject.org', 'AAAA'], $output, $return_code);
if (preg_match('/^;; Flags: qr rd ra ad;/Dm', implode("\n", $output)) !== 1)
exit('Unable to do a DNSSEC-validated DNS query.' . LF);
if (CONF['common']['services']['ns'] === 'rest') {
echo 'a';
}
define('COOKIE_FILE', sys_get_temp_dir() . '/cookie-' . bin2hex(random_bytes(16)) . '.txt');
function curlTest($address, $post = [], $tor = false) {
@ -108,7 +101,12 @@ function testReg() {
'domain' => $domain,
'ns' => 'ns1.servnest.invalid.',
]);
exec(CONF['dns']['kdig_path'] . ' @' . CONF['reg']['address'] . ' ' . $domain . ' NS', $output);
exescape([
CONF['dns']['kdig_path'],
'@' . CONF['reg']['address'],
$domain,
'NS',
], $output);
if (preg_match('/[ \t]+ns1\.servnest\.invalid\.$/Dm', implode(LF, $output)) !== 1)
exit('Error: /reg/ns: NS record not set' . LF);
@ -156,7 +154,12 @@ function testNs($domain) {
'tag' => 'issue',
'value' => 'letsencrypt.org',
]);
exec(CONF['dns']['kdig_path'] . ' @' . CONF['reg']['address'] . ' ' . $domain . ' CAA', $output);
exescape([
CONF['dns']['kdig_path'],
'@' . CONF['reg']['address'],
$domain,
'CAA',
], $output);
if (preg_match('/^' . preg_quote($domain, '/') . '[ \t]+7200[ \t]+IN[ \t]+CAA[ \t]+0[ \t]+issue[ \t]+"letsencrypt\.org"$/Dm', implode(LF, $output)) !== 1)
exit('Error: /ns/caa: CAA record not set' . LF);
@ -165,15 +168,18 @@ function testNs($domain) {
'zone-content' => 'aaaa.' . $domain . ' 3600 AAAA ' . CONF['ht']['ipv6_address'] . "\r\n"
. '@ 86400 NS ' . CONF['ns']['servers'][0] . "\r\n",
]);
exec(CONF['dns']['kdig_path'] . ' @' . CONF['reg']['address'] . ' aaaa.' . $domain . ' AAAA', $output);
exescape([
CONF['dns']['kdig_path'],
'@' . CONF['reg']['address'],
'aaaa.' . $domain,
'AAAA',
], $output);
if (preg_match('/[ \t]+' . preg_quote(CONF['ht']['ipv6_address'], '/') . '$/Dm', implode(LF, $output)) !== 1)
exit('Error: /ns/edit: AAAA record not set' . LF);
}
function testHt($username, $password) {
curlTest('/ht/', []);
define('TEST_CONTENT', 'test-' . random_bytes(4));
define('TEST_CONTENT', 'test-' . bin2hex(random_bytes(16)));
file_put_contents(sys_get_temp_dir() . '/index.html', TEST_CONTENT);

View file

@ -0,0 +1,10 @@
<?php
require 'init.php';
const MAX_TESTING_ACCOUNT_AGE = 86400 * 10;
foreach (query('select', 'users', ['type' => 'testing']) as $account) {
$account_age = time() - date_create_immutable_from_format('Y-m-d H:i:s', $account['registration_date'])->getTimestamp();
if ($account_age > MAX_TESTING_ACCOUNT_AGE)
authDeleteUser($account['id']);
}

View file

@ -1,7 +1,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 23:15+0200\n"
"POT-Creation-Date: 2023-06-15 01:33+0200\n"
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
@ -274,17 +274,33 @@ msgstr "Supprimer un accès"
msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
msgstr "Retirer un accès HTTP existant d'un sous-dossier de l'espace SFTP"
#: router.php:137 view.php:39
#: pages.php:197
msgid "Manage SSH keys"
msgstr "Gérer les clés SSH"
#: pages.php:198
msgid "Choose what SSH key can edit what directory"
msgstr "Choisir quelle clé SSH peut modifier quel dossier"
#: router.php:68
msgid "This account doesn't exist anymore. Log out to end this ghost session."
msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
#: router.php:106 view.php:39
msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
msgstr "Ce service est en cours de maintenance. Aucune action ne peut être effectuée avant qu'ane administrataire termine de le réparer."
#: router.php:147
#: router.php:115
msgid "You need to be logged in to do this."
msgstr "Vous devez être connecté·e à un compte pour faire cela."
#: router.php:149
msgid "This account doesn't exist anymore. Log out to end this ghost session."
msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
#: view.php:19
msgid "You are using a testing account. It may be deleted anytime."
msgstr "Vous utilisez un compte de test. Il risque d'être supprimé n'importe quand."
#: view.php:19
msgid "Read more"
msgstr "En savoir plus"
#: view.php:21
msgid "Anonymous"
@ -300,11 +316,16 @@ msgstr "Ce formulaire ne sera pas accepté car il faut %sse connecter%s d'abord.
msgid "%sSource code%s available under %s."
msgstr "%sCode source%s disponible sous %s."
#: fn/auth.php:110
#: fn/auth.php:95
#, php-format
msgid "Your account can't be deleted because the %s service is currently unavailable."
msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
#: fn/auth.php:143
msgid "Account rate limit reached, try again later."
msgstr "Limite de taux pour ce compte atteinte, réessayez plus tard."
#: fn/auth.php:135
#: fn/auth.php:168
msgid "Global rate limit reached, try again later."
msgstr "Limite de taux globale atteinte, réessayez plus tard."
@ -320,15 +341,15 @@ msgstr "<strong>Erreur de l'utilisataire</strong>&nbsp;: "
msgid "<strong>Server error</strong>: "
msgstr "<strong>Erreur du serveur</strong>&nbsp;: "
#: fn/common.php:129
#: fn/common.php:132
msgid "Wrong proof."
msgstr "Preuve incorrecte."
#: fn/dns.php:63
#: fn/dns.php:64
msgid "IP address malformed."
msgstr "Adresse IP malformée."
#: fn/dns.php:68 fn/ht.php:28
#: fn/dns.php:69 fn/ht.php:31
msgid "Domain malformed."
msgstr "Domaine malformé."
@ -388,11 +409,6 @@ msgid "Account deletion must be confirmed."
msgstr "La suppression du compte doit être confirmée."
#: pg-act/auth/unregister.php:13
#, php-format
msgid "Your account can't be deleted because the %s service is currently unavailable."
msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
#: pg-act/auth/unregister.php:42
msgid "Account deleted."
msgstr "Compte supprimé."
@ -445,6 +461,18 @@ msgstr "Ce chemin est déjà pris sur ce service. Utilisez-en un autre."
msgid "Access removed."
msgstr "Accès retiré."
#: pg-act/ht/keys.php:13
msgid "Path is not valid."
msgstr "Le chemin n'est pas valide."
#: pg-act/ht/keys.php:15
msgid "Ed25519 public key seems wrongly formatted."
msgstr "La clé public Ed25519 semble mal formattée."
#: pg-act/ht/keys.php:39
msgid "SSH keys updated."
msgstr "Clés SSH mises à jour."
#: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
#: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
#: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
@ -664,28 +692,38 @@ msgstr "Le domaine doit avoir les enregistrements suivants pendant le traitement
#: pg-view/ht/add-dns.php:29 pg-view/ns/form.ns.php:8 pg-view/ns/print.php:32
#: pg-view/ns/zone-add.php:6 pg-view/reg/ds.php:8 pg-view/reg/glue.php:8
#: pg-view/reg/glue.php:15 pg-view/reg/ns.php:8 pg-view/reg/print.php:2
#: pg-view/reg/print.php:16 pg-view/reg/register.php:7
#: pg-view/reg/print.php:16 pg-view/reg/register.php:11
#: pg-view/reg/unregister.php:6
msgid "Domain"
msgstr "Domaine"
#: pg-view/ht/add-dns.php:31 pg-view/ht/add-onion.php:2
#: pg-view/ht/add-subdomain.php:4 pg-view/ht/add-subpath.php:4
#: pg-view/ht/add-subdomain.php:8 pg-view/ht/add-subpath.php:8
msgid "Target directory"
msgstr "Dossier ciblé"
#: pg-view/ht/add-dns.php:40 pg-view/ht/add-onion.php:11
#: pg-view/ht/add-subdomain.php:13 pg-view/ht/add-subpath.php:13
#: pg-view/ht/add-subdomain.php:17 pg-view/ht/add-subpath.php:17
msgid "Setup access"
msgstr "Créer l'accès"
#: pg-view/ht/add-subdomain.php:2 pg-view/ns/form.ns.php:10
#: pg-view/reg/glue.php:10 pg-view/reg/register.php:9
#: pg-view/ht/add-subdomain.php:2 pg-view/reg/register.php:6
#, php-format
msgid "The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can't have an hyphen (%3$s) in first, last or both third and fourth position."
msgstr "Le sous-domain peut uniquement contenir %1$s, %2$s et %3$s, et doit être entre 4 et 63 caractères. Il ne peut pas avoir un tiret (%3$s) en première, dernière ou à la fois troisième et quatrième position."
#: pg-view/ht/add-subdomain.php:6 pg-view/ns/form.ns.php:10
#: pg-view/reg/glue.php:10 pg-view/reg/register.php:13
#: pg-view/reg/transfer.php:9
msgid "Subdomain"
msgstr "Sous-domaine"
#: pg-view/ht/add-subpath.php:2
#, php-format
msgid "The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters."
msgstr "Le chemin peut uniquement contenir %1$s, %2$s et %3$s, et doit être entre 4 et 63 caractères."
#: pg-view/ht/add-subpath.php:6
msgid "Path"
msgstr "Chemin"
@ -795,6 +833,30 @@ msgstr "Approuvé"
msgid "Stable Let's Encrypt certificates"
msgstr "Vrai certificat Let's Encrypt"
#: pg-view/ht/keys.php:2
msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always allowed to list any directory content."
msgstr "En plus de la clé de passe, c'est également possible d'accéder à l'espace SFTP en utilisant des clés SSH Ed25519. Une clé peut être autorisée à modifier dans tout l'espace (<code>/</code>) ou dans un quelconque sous-dossier spécifique. Une clé est toujours autorisée à lister le contenu de n'importe quel dossier."
#: pg-view/ht/keys.php:17
msgid "Add new SSH key access"
msgstr "Ajouter un nouvel accès par clé SSH"
#: pg-view/ht/keys.php:17
msgid "SSH key access"
msgstr "Accès par clé SSH"
#: pg-view/ht/keys.php:19
msgid "Public key"
msgstr "Clé publique"
#: pg-view/ht/keys.php:23
msgid "Allowed directory"
msgstr "Dossier autorisé"
#: pg-view/ht/keys.php:30
msgid "Update"
msgstr "Mettre à jour"
#: pg-view/ns/caa.php:3
msgid "Flag"
msgstr "Flag"
@ -1118,18 +1180,18 @@ msgid "Nobody can register a domain under these suffixes:"
msgstr "Personne ne peut enregistrer un domain sous ces suffixes&nbsp;:"
#: pg-view/reg/register.php:2
msgid "Register a new domain on your account. It must consist of between 4 and 63 letters and digits."
msgstr "Enregistrer un nouveau domaine sur son compte. Il doit être composé d'entre 4 et 63 lettres et chiffres."
msgid "Register a new domain on your account."
msgstr "Enregistrer un nouveau domaine sur son compte."
#: pg-view/reg/register.php:14 pg-view/reg/transfer.php:14
#: pg-view/reg/register.php:18 pg-view/reg/transfer.php:14
msgid "Suffix"
msgstr "Suffixe"
#: pg-view/reg/register.php:27
#: pg-view/reg/register.php:31
msgid "Check availability"
msgstr "Vérifier sa disponibilité"
#: pg-view/reg/register.php:29
#: pg-view/reg/register.php:33
msgid "Register"
msgstr "Enregistrer"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 23:15+0200\n"
"POT-Creation-Date: 2023-06-15 01:33+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -286,16 +286,32 @@ msgstr ""
msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
msgstr ""
#: router.php:137 view.php:39
#: pages.php:197
msgid "Manage SSH keys"
msgstr ""
#: pages.php:198
msgid "Choose what SSH key can edit what directory"
msgstr ""
#: router.php:68
msgid "This account doesn't exist anymore. Log out to end this ghost session."
msgstr ""
#: router.php:106 view.php:39
msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
msgstr ""
#: router.php:147
#: router.php:115
msgid "You need to be logged in to do this."
msgstr ""
#: router.php:149
msgid "This account doesn't exist anymore. Log out to end this ghost session."
#: view.php:19
msgid "You are using a testing account. It may be deleted anytime."
msgstr ""
#: view.php:19
msgid "Read more"
msgstr ""
#: view.php:21
@ -312,11 +328,16 @@ msgstr ""
msgid "%sSource code%s available under %s."
msgstr ""
#: fn/auth.php:110
#: fn/auth.php:95
#, php-format
msgid "Your account can't be deleted because the %s service is currently unavailable."
msgstr ""
#: fn/auth.php:143
msgid "Account rate limit reached, try again later."
msgstr ""
#: fn/auth.php:135
#: fn/auth.php:168
msgid "Global rate limit reached, try again later."
msgstr ""
@ -332,15 +353,15 @@ msgstr ""
msgid "<strong>Server error</strong>: "
msgstr ""
#: fn/common.php:129
#: fn/common.php:132
msgid "Wrong proof."
msgstr ""
#: fn/dns.php:63
#: fn/dns.php:64
msgid "IP address malformed."
msgstr ""
#: fn/dns.php:68 fn/ht.php:28
#: fn/dns.php:69 fn/ht.php:31
msgid "Domain malformed."
msgstr ""
@ -400,11 +421,6 @@ msgid "Account deletion must be confirmed."
msgstr ""
#: pg-act/auth/unregister.php:13
#, php-format
msgid "Your account can't be deleted because the %s service is currently unavailable."
msgstr ""
#: pg-act/auth/unregister.php:42
msgid "Account deleted."
msgstr ""
@ -457,6 +473,18 @@ msgstr ""
msgid "Access removed."
msgstr ""
#: pg-act/ht/keys.php:13
msgid "Path is not valid."
msgstr ""
#: pg-act/ht/keys.php:15
msgid "Ed25519 public key seems wrongly formatted."
msgstr ""
#: pg-act/ht/keys.php:39
msgid "SSH keys updated."
msgstr ""
#: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
#: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
#: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
@ -676,28 +704,38 @@ msgstr ""
#: pg-view/ht/add-dns.php:29 pg-view/ns/form.ns.php:8 pg-view/ns/print.php:32
#: pg-view/ns/zone-add.php:6 pg-view/reg/ds.php:8 pg-view/reg/glue.php:8
#: pg-view/reg/glue.php:15 pg-view/reg/ns.php:8 pg-view/reg/print.php:2
#: pg-view/reg/print.php:16 pg-view/reg/register.php:7
#: pg-view/reg/print.php:16 pg-view/reg/register.php:11
#: pg-view/reg/unregister.php:6
msgid "Domain"
msgstr ""
#: pg-view/ht/add-dns.php:31 pg-view/ht/add-onion.php:2
#: pg-view/ht/add-subdomain.php:4 pg-view/ht/add-subpath.php:4
#: pg-view/ht/add-subdomain.php:8 pg-view/ht/add-subpath.php:8
msgid "Target directory"
msgstr ""
#: pg-view/ht/add-dns.php:40 pg-view/ht/add-onion.php:11
#: pg-view/ht/add-subdomain.php:13 pg-view/ht/add-subpath.php:13
#: pg-view/ht/add-subdomain.php:17 pg-view/ht/add-subpath.php:17
msgid "Setup access"
msgstr ""
#: pg-view/ht/add-subdomain.php:2 pg-view/ns/form.ns.php:10
#: pg-view/reg/glue.php:10 pg-view/reg/register.php:9
#: pg-view/ht/add-subdomain.php:2 pg-view/reg/register.php:6
#, php-format
msgid "The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can't have an hyphen (%3$s) in first, last or both third and fourth position."
msgstr ""
#: pg-view/ht/add-subdomain.php:6 pg-view/ns/form.ns.php:10
#: pg-view/reg/glue.php:10 pg-view/reg/register.php:13
#: pg-view/reg/transfer.php:9
msgid "Subdomain"
msgstr ""
#: pg-view/ht/add-subpath.php:2
#, php-format
msgid "The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters."
msgstr ""
#: pg-view/ht/add-subpath.php:6
msgid "Path"
msgstr ""
@ -807,6 +845,30 @@ msgstr ""
msgid "Stable Let's Encrypt certificates"
msgstr ""
#: pg-view/ht/keys.php:2
msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always allowed to list any directory content."
msgstr ""
#: pg-view/ht/keys.php:17
msgid "Add new SSH key access"
msgstr ""
#: pg-view/ht/keys.php:17
msgid "SSH key access"
msgstr ""
#: pg-view/ht/keys.php:19
msgid "Public key"
msgstr ""
#: pg-view/ht/keys.php:23
msgid "Allowed directory"
msgstr ""
#: pg-view/ht/keys.php:30
msgid "Update"
msgstr ""
#: pg-view/ns/caa.php:3
msgid "Flag"
msgstr ""
@ -1130,18 +1192,18 @@ msgid "Nobody can register a domain under these suffixes:"
msgstr ""
#: pg-view/reg/register.php:2
msgid "Register a new domain on your account. It must consist of between 4 and 63 letters and digits."
msgid "Register a new domain on your account."
msgstr ""
#: pg-view/reg/register.php:14 pg-view/reg/transfer.php:14
#: pg-view/reg/register.php:18 pg-view/reg/transfer.php:14
msgid "Suffix"
msgstr ""
#: pg-view/reg/register.php:27
#: pg-view/reg/register.php:31
msgid "Check availability"
msgstr ""
#: pg-view/reg/register.php:29
#: pg-view/reg/register.php:33
msgid "Register"
msgstr ""

View file

@ -193,5 +193,10 @@ define('PAGES', [
'title' => _('Delete access'),
'description' => _('Delete an existing HTTP access from a subdirectory of the SFTP space'),
],
'keys' => [
'title' => _('Manage SSH keys'),
'description' => _('Choose what SSH key can edit what directory'),
'tokens_account_cost' => 300,
],
],
]);

View file

@ -26,4 +26,3 @@ $_SESSION['type'] = query('select', 'users', ['id' => $id], 'type')[0];
setupDisplayUsername($_POST['username']);
redir();

View file

@ -6,36 +6,7 @@ if (checkPassword($_SESSION['id'], $_POST['current-password']) !== true)
if (!isset($_POST['delete']))
output(403, _('Account deletion must be confirmed.'));
$user_services = explode(',', query('select', 'users', ['id' => $_SESSION['id']], 'services')[0]);
foreach (SERVICES_USER as $service)
if (in_array($service, $user_services, true) AND CONF['common']['services'][$service] !== 'enabled')
output(503, sprintf(_('Your account can\'t be deleted because the %s service is currently unavailable.'), '<em>' . PAGES[$service]['index']['title'] . '</em>'));
if (in_array('reg', $user_services, true))
foreach (query('select', 'registry', ['username' => $_SESSION['id']], 'domain') as $domain)
regDeleteDomain($domain);
if (in_array('ns', $user_services, true))
foreach (query('select', 'zones', ['username' => $_SESSION['id']], 'zone') as $zone)
nsDeleteZone($zone);
if (in_array('ht', $user_services, true)) {
foreach (query('select', 'sites', ['username' => $_SESSION['id']]) as $site)
htDeleteSite($site['address'], $site['type']);
exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['rm_path'] . ' -r ' . CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t remove Tor keys directory.');
removeDirectory(CONF['ht']['tor_config_path'] . '/' . $_SESSION['id']);
exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['sftpgo_user'] . ' ' . CONF['ht']['rm_path'] . ' -r ' . CONF['ht']['ht_path'] . '/fs/' . $_SESSION['id'], result_code: $code);
if ($code !== 0)
output(500, 'Can\'t remove user\'s directory.');
}
query('delete', 'users', ['id' => $_SESSION['id']]);
authDeleteUser($_SESSION['id']);
logout();

View file

@ -30,7 +30,14 @@ checkAuthToken($matches[1], $matches[2]);
rateLimit();
exec('2>&1 ' . CONF['ht']['sudo_path'] . ' ' . CONF['ht']['certbot_path'] . ' certonly' . (($_SESSION['type'] === 'approved') ? '' : ' --test-cert') . ' --domain ' . $_POST['domain'], $output, $returnCode);
exescape([
CONF['ht']['sudo_path'],
CONF['ht']['certbot_path'],
'certonly',
'--domain',
$_POST['domain'],
...(($_SESSION['type'] === 'approved') ? [] : ['--test-cert']),
], $output, $returnCode);
if ($returnCode !== 0)
output(500, 'Certbot failed to get a Let\'s Encrypt certificate.', $output);

View file

@ -15,16 +15,29 @@ if (chmod($torConfFile, 0644) !== true)
output(500, 'Failed to give correct permissions to new Tor configuration file.');
// Reload Tor
exec(CONF['ht']['sudo_path'] . ' ' . CONF['ht']['tor_reload_cmd'], result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'--',
...explode(' ', CONF['ht']['tor_reload_cmd']),
], result_code: $code);
if ($code !== 0)
output(500, 'Failed to reload Tor.');
usleep(10000);
// Get the hostname generated by Tor
$onion = exec(CONF['ht']['sudo_path'] . ' -u ' . CONF['ht']['tor_user'] . ' ' . CONF['ht']['cat_path'] . ' ' . CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'] . '/' . $_POST['dir'] . '/hostname', result_code: $code);
exescape([
CONF['ht']['sudo_path'],
'-u',
CONF['ht']['tor_user'],
'--',
CONF['ht']['cat_path'],
'--',
CONF['ht']['tor_keys_path'] . '/' . $_SESSION['id'] . '/' . $_POST['dir'] . '/hostname',
], $output, $code);
if ($code !== 0)
output(500, 'Unable to read hostname file.');
$onion = $output[0];
if (preg_match('/^[0-9a-z]{56}\.onion$/D', $onion) !== 1)
output(500, 'No onion address found.');

View file

@ -3,7 +3,7 @@
if (dirsStatuses('subdomain')[$_POST['dir']] !== false)
output(403, 'Wrong value for <code>dir</code>.');
if (preg_match('/^[a-z0-9]{1,32}$/D', $_POST['subdomain']) !== 1)
if (preg_match('/' . SUBDOMAIN_REGEX . '/D', $_POST['subdomain']) !== 1)
output(403, _('Invalid domain label.'));
if (query('select', 'sites', ['address' => $_POST['subdomain'], 'type' => 'subdomain']) !== [])

View file

@ -3,7 +3,7 @@
if (dirsStatuses('subpath')[$_POST['dir']] !== false)
output(403, 'Wrong value for <code>dir</code>.');
if (preg_match('/^[a-z0-9]{1,32}$/D', $_POST['path']) !== 1)
if (preg_match('/' . SUBPATH_REGEX . '/D', $_POST['path']) !== 1)
output(403, _('Invalid path.'));
if (query('select', 'sites', ['address' => $_POST['path'], 'type' => 'subpath']) !== [])

View file

@ -6,6 +6,6 @@ if (preg_match('/^(?<type>subpath|subdomain|onion|dns):(?<address>[a-z0-9._-]{1,
if (isset(query('select', 'sites', ['username' => $_SESSION['id'], 'address' => $site['address'], 'type' => $site['type']], 'address')[0]) !== true)
output(403, 'Unavailable value for <code>site</code>.');
htDeleteSite($site['address'], $site['type']);
htDeleteSite($site['address'], $site['type'], $_SESSION['id']);
output(200, _('Access removed.'));

39
pg-act/ht/keys.php Executable file
View file

@ -0,0 +1,39 @@
<?php
$el_nb = count($_POST['keys']);
if ($el_nb < 1 OR $el_nb > 8)
output(403, 'Wrong elements number.');
foreach ($_POST['keys'] as $i => $key) {
if (($key['public-key'] ?? '') === '') {
unset($_POST['keys'][$i]);
continue;
}
if (preg_match('#^/[/\p{L}\{M}\p{N}\p{P}\p{S}\p{Zs}]{1,254}$#Du', $key['dir'] ?? '') !== 1)
output(403, _('Path is not valid.'));
if (preg_match('#' . ED25519_PUBKEY_REGEX . '#D', $key['public-key']) !== 1)
output(403, _('Ed25519 public key seems wrongly formatted.'));
}
$keys = array_values($_POST['keys']);
rateLimit();
try {
DB->beginTransaction();
query('delete', 'ssh-keys', ['username' => $_SESSION['id']]);
foreach ($keys as $key)
insert('ssh-keys', [
'key' => $key['public-key'],
'username' => $_SESSION['id'],
'directory' => $key['dir'],
]);
DB->commit();
} catch (Exception $e) {
DB->rollback();
output(500, 'Database error.', [$e->getMessage()]);
}
output(200, _('SSH keys updated.'));

View file

@ -39,22 +39,22 @@ if (isset($_POST['zone-content'])) { // Update zone
ratelimit();
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-freeze ' . $_POST['zone'], $output, $return_code);
knotc(['zone-freeze', $_POST['zone']], $output, $return_code);
if ($return_code !== 0)
output(500, 'Failed to freeze zone file.', $output);
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-flush ' . $_POST['zone'], $output, $return_code);
knotc(['zone-flush', $_POST['zone']], $output, $return_code);
if ($return_code !== 0)
output(500, 'Failed to flush zone file.', $output);
if (file_put_contents(CONF['ns']['knot_zones_path'] . '/' . $_POST['zone'] . 'zone', $new_zone_content) === false)
output(500, 'Failed to write zone file.');
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-reload ' . $_POST['zone'], $output, $return_code);
knotc(['zone-reload', $_POST['zone']], $output, $return_code);
if ($return_code !== 0)
output(500, 'Failed to reload zone file.', $output);
exec(CONF['dns']['knotc_path'] . ' --blocking --timeout 3 zone-thaw ' . $_POST['zone'], $output, $return_code);
knotc(['zone-thaw', $_POST['zone']], $output, $return_code);
if ($return_code !== 0)
output(500, 'Failed to thaw zone file.', $output);

View file

@ -5,7 +5,13 @@ $_POST['domain'] = formatAbsoluteDomain($_POST['domain']);
if (query('select', 'zones', ['zone' => $_POST['domain']], 'zone') !== [])
output(403, _('This zone already exists on the service.'));
exec(CONF['dns']['kdig_path'] . ' ' . ltrim(strstr($_POST['domain'], '.'), '.') . ' NS +short' . (CONF['ns']['local_only_check'] ? (' @' . CONF['reg']['address']) : ''), $parentAuthoritatives, $code);
exescape([
CONF['dns']['kdig_path'],
ltrim(strstr($_POST['domain'], '.'), '.'),
'NS',
'+short',
...(CONF['ns']['local_only_check'] ? ['@' . CONF['reg']['address']] : []),
], $parentAuthoritatives, $code);
if ($code !== 0)
output(500, 'Unable to query parent name servers.');
if ($parentAuthoritatives === [])
@ -13,7 +19,13 @@ if ($parentAuthoritatives === [])
foreach ($parentAuthoritatives as $parentAuthoritative)
checkAbsoluteDomainFormat($parentAuthoritative);
exec(CONF['dns']['kdig_path'] . ' ' . $_POST['domain'] . ' NS @' . (CONF['ns']['local_only_check'] ? CONF['reg']['address'] : $parentAuthoritatives[0]) . ' +noidn', $results);
exescape([
CONF['dns']['kdig_path'],
$_POST['domain'],
'NS',
'@' . (CONF['ns']['local_only_check'] ? CONF['reg']['address'] : $parentAuthoritatives[0]),
'+noidn'
], $results);
if (preg_match('/^' . preg_quote($_POST['domain'], '/') . '[\t ]+[0-9]{1,8}[\t ]+IN[\t ]+NS[\t ]+(?<salt>[0-9a-f]{8})-(?<hash>[0-9a-f]{32})\._domain-verification\.' . preg_quote(SERVER_NAME, '/') . '\.$/Dm', implode(LF, $results), $matches) !== 1)
output(403, _('NS authentication record not found.'));
@ -47,8 +59,8 @@ if (chmod($knotZonePath, 0660) !== true)
output(500, 'Failed to chmod new zone file.');
knotcConfExec([
"set 'zone[" . $_POST['domain'] . "]'",
"set 'zone[" . $_POST['domain'] . "].template' 'servnest'",
['conf-set', 'zone[' . $_POST['domain'] . ']'],
['conf-set', 'zone[' . $_POST['domain'] . '].template', 'servnest'],
]);
output(200, _('Zone created.'));

View file

@ -2,6 +2,6 @@
nsCheckZonePossession($_POST['zone']);
nsDeleteZone($_POST['zone']);
nsDeleteZone($_POST['zone'], $_SESSION['id']);
output(200, _('Zone deleted.'));

View file

@ -1,20 +1,18 @@
<?php
if (
($_POST['algo'] !== '8')
AND ($_POST['algo'] !== '13')
AND ($_POST['algo'] !== '14')
AND ($_POST['algo'] !== '15')
AND ($_POST['algo'] !== '16')
) output(403, 'Wrong value for <code>algo</code>.');
if (!in_array($_POST['algo'], ['8', '13', '14', '15', '16'], true))
output(403, 'Wrong value for <code>algo</code>.');
$_POST['keytag'] = intval($_POST['keytag']);
if ((!preg_match('/^[0-9]{1,6}$/D', $_POST['keytag'])) OR !($_POST['keytag'] >= 1) OR !($_POST['keytag'] <= 65535))
if ((preg_match('/^[0-9]{1,6}$/D', $_POST['keytag'])) !== 1 OR !($_POST['keytag'] >= 1) OR !($_POST['keytag'] <= 65535))
output(403, 'Wrong value for <code>keytag</code>.');
if ($_POST['dt'] !== '2' AND $_POST['dt'] !== '4')
output(403, 'Wrong value for <code>dt</code>.');
if (preg_match('/^(?:[0-9a-fA-F]{64}|[0-9a-fA-F]{96})$/D', $_POST['key']) !== 1)
output(403, 'Wrong value for <code>key</code>.');
regCheckDomainPossession($_POST['zone']);
rateLimit();

View file

@ -11,7 +11,13 @@ $domain = formatAbsoluteDomain($_POST['subdomain'] . '.' . $_POST['suffix']);
if (query('select', 'registry', ['username' => $_SESSION['id'], 'domain' => $domain], 'domain') !== [])
output(403, _('The current account already owns this domain.'));
exec(CONF['dns']['kdig_path'] . ' ' . $domain . ' NS @' . CONF['reg']['address'] . ' +noidn', $results, $code);
exescape([
CONF['dns']['kdig_path'],
$domain,
'NS',
'@' . CONF['reg']['address'],
'+noidn',
], $results, $code);
if ($code !== 0)
output(500, 'Unable to query registry\'s name servers.');
if (preg_match('/^' . preg_quote($domain, '/') . '[\t ]+[0-9]{1,8}[\t ]+IN[\t ]+NS[\t ]+(?<salt>[0-9a-f]{8})-(?<hash>[0-9a-f]{32})\._transfer-verification\.' . preg_quote(SERVER_NAME, '/') . '\.$/Dm', implode(LF, $results), $matches) !== 1)

View file

@ -2,6 +2,6 @@
regCheckDomainPossession($_POST['domain']);
regDeleteDomain($_POST['domain']);
regDeleteDomain($_POST['domain'], $_SESSION['id']);
output(200, _('Domain unregistered.'));

View file

@ -1,6 +1,6 @@
<?php displayIndex(); ?>
<h2><?= _('Account type') ?></h2>
<h2 id="type"><?= _('Account type') ?></h2>
<p>
<?php

View file

@ -1,3 +1,7 @@
<p>
<?= sprintf(_('The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can\'t have an hyphen (%3$s) in first, last or both third and fourth position.'), '<abbr title="abcdefghijklmnopqrstuvwxyz"><code>a</code>-<code>z</code></abbr>', '<abbr title="0123456789"><code>0</code>-<code>9</code></abbr>', '<code>-</code>') ?>
</p>
<form method="post">
<label for="subdomain"><?= _('Subdomain') ?></label><br>
<input required="" placeholder="label" id="subdomain" name="subdomain" type="text"><code>.<?= CONF['ht']['subdomain_domain'] ?></code><br>

View file

@ -1,6 +1,10 @@
<p>
<?= sprintf(_('The path can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters.'), '<abbr title="abcdefghijklmnopqrstuvwxyz"><code>a</code>-<code>z</code></abbr>', '<abbr title="0123456789"><code>0</code>-<code>9</code></abbr>', '<code>-</code>') ?>
</p>
<form method="post">
<label for="path"><?= _('Path') ?></label><br>
<code>https://<?= CONF['ht']['subpath_domain'] ?>/</code><input required="" placeholder="path" id="path" name="path" type="text"><br>
<code>https://<?= CONF['ht']['subpath_domain'] ?>/</code><input required="" pattern="<?= SUBPATH_REGEX ?>" placeholder="path" id="path" name="path" type="text"><br>
<label for="dir"><?= _('Target directory') ?></label><br>
<select required="" name="dir" id="dir">
<option value="" disabled="" selected=""></option>

31
pg-view/ht/keys.php Executable file
View file

@ -0,0 +1,31 @@
<p>
<?= _('In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always allowed to list any directory content.') ?>
</p>
<form method="post">
<datalist id="dirs">
<option value="/"></option>
<?php
foreach (listFsDirs($_SESSION['id'] ?? '') as $dir)
echo ' <option value="/' . $dir . '"></option>' . LF;
?>
</datalist>
<?php
foreach (array_slice(array_merge(query('select', 'ssh-keys', ['username' => $_SESSION['id'] ?? '']), [['key' => '', 'username' => '', 'directory' => '/']]), 0, 8) as $i => $ssh_key) {
?>
<fieldset>
<legend><?= ($ssh_key['key'] === '') ? _('Add new SSH key access') : _('SSH key access') ?></legend>
<div>
<label for="public-key"><?= _('Public key') ?></label><br>
<code>ssh-ed15519 <input pattern="<?= ED25519_PUBKEY_REGEX ?>" placeholder="AAAAC3NzaC1lZDI1NTE5AAAAI<?= substr(base64_encode(random_bytes(32)), 0, 43) ?>" id="public-key" name="keys[<?= $i ?>][public-key]" value="<?= $ssh_key['key'] ?>" type="text"></code>
</div>
<div>
<label for="dir"><?= _('Allowed directory') ?></label><br>
<input list="dirs" placeholder="/" value="<?= htmlspecialchars($ssh_key['directory']) ?>" id="dir" name="keys[<?= $i ?>][dir]" type="text">
</div>
</fieldset>
<?php
}
?>
<input type="submit" value="<?= _('Update') ?>">
</form>

View file

@ -51,7 +51,7 @@ foreach (regListUserDomains() as $domain)
<br>
<label for="key"><?= _('Key') ?></label>
<br>
<input id="key" required="" name="key" type="text" placeholder="018F25E4A022463478C9E30136EC53771A1704A0F0B3CE5B883AC9C8A6A55D16B638B4DE70662ACA5295D3669E7CADD9">
<input id="key" required="" name="key" type="text" pattern="^([0-9a-fA-F]{64}|[0-9a-fA-F]{96})$" placeholder="<?= strtoupper(bin2hex(random_bytes(32))) ?>">
<br>
<input type="submit" value="<?= _('Apply') ?>">
</form>

View file

@ -1,5 +1,9 @@
<p>
<?= _('Register a new domain on your account. It must consist of between 4 and 63 letters and digits.') ?>
<?= _('Register a new domain on your account.') ?>
</p>
<p>
<?= sprintf(_('The subdomain can only contain %1$s, %2$s and %3$s, and must be between 4 and 63 characters. It can\'t have an hyphen (%3$s) in first, last or both third and fourth position.'), '<abbr title="abcdefghijklmnopqrstuvwxyz"><code>a</code>-<code>z</code></abbr>', '<abbr title="0123456789"><code>0</code>-<code>9</code></abbr>', '<code>-</code>') ?>
</p>
<form method="post">

View file

@ -1,40 +1,5 @@
<?php
umask(0077);
const ROOT_PATH = __DIR__;
define('CONF', parse_ini_file(ROOT_PATH . '/config.ini', true, INI_SCANNER_TYPED));
define('DB', new PDO('sqlite:' . ROOT_PATH . '/db/servnest.db'));
DB->exec('PRAGMA foreign_keys = ON;');
DB->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
date_default_timezone_set('UTC');
foreach (explode(',', preg_replace('/[A-Z0-9]|q=|;|-|\./', '', $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '')) as $client_locale)
if (in_array($client_locale, array_diff(scandir(ROOT_PATH . '/locales'), ['..', '.']), true)) {
$locale = $client_locale;
break;
}
define('LOCALE', $locale ?? 'en');
putenv('LANG=C.UTF-8');
setlocale(LC_MESSAGES, 'C.UTF-8');
bindtextdomain('messages', ROOT_PATH . '/locales/' . LOCALE);
header('Content-Language: ' . LOCALE);
const SERVICES_USER = ['reg', 'ns', 'ht'];
const LF = "\n";
const PLACEHOLDER_DOMAIN = 'example'; // From RFC2606: Reserved Top Level DNS Names > 2. TLDs for Testing, & Documentation Examples
const PLACEHOLDER_IPV6 = '2001:db8::3'; // From RFC3849: IPv6 Address Prefix Reserved for Documentation
const PLACEHOLDER_IPV4 = '203.0.113.42'; // From RFC5737: IPv4 Address Blocks Reserved for Documentation
foreach (array_diff(scandir(ROOT_PATH . '/fn'), ['..', '.']) as $file)
require ROOT_PATH . '/fn/' . $file;
require ROOT_PATH . '/pages.php';
if ($_SERVER['REQUEST_URI'] === '/sftpgo-auth.php')
return;
require 'init.php';
$pageAddress = substr($_SERVER['REQUEST_URI'], strlen(CONF['common']['prefix']) + 1);
if (strpos($pageAddress, '?') !== false) {
@ -99,30 +64,34 @@ if (isset($_COOKIE[SESSION_COOKIE_NAME]))
startSession(); // Resume session
if (isset($_SESSION['id'])) {
if (!isset(query('select', 'users', ['id' => $_SESSION['id']], 'id')[0]))
output(403, _('This account doesn\'t exist anymore. Log out to end this ghost session.'));
// Decrypt display username
if (!isset($_COOKIE['display-username-decryption-key']))
output(403, 'The display username decryption key has not been sent.');
$decryption_result = htmlspecialchars(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$decryption_result = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$_SESSION['display-username-cyphertext'],
'',
$_SESSION['display-username-nonce'],
base64_decode($_COOKIE['display-username-decryption-key'])
));
);
if ($decryption_result === false)
output(403, 'Unable to decrypt display username.');
define('DISPLAY_USERNAME', $decryption_result);
define('DISPLAY_USERNAME', htmlspecialchars($decryption_result));
// Enable not already enabled services for this user
$user_services = array_filter(explode(',', query('select', 'users', ['id' => $_SESSION['id']], 'services')[0]));
if (in_array(SERVICE, SERVICES_USER, true) AND !in_array(SERVICE, $user_services, true) AND CONF['common']['services'][SERVICE] === 'enabled') {
$user_services[] = SERVICE;
foreach (SERVICES_USER as $service)
if (!in_array($service, $user_services, true) AND CONF['common']['services'][$service] === 'enabled') {
$user_services[] = $service;
DB->prepare('UPDATE users SET services = :services WHERE id = :id')
->execute([':services' => implode(',', $user_services), ':id' => $_SESSION['id']]);
DB->prepare('UPDATE users SET services = :services WHERE id = :id')
->execute([':services' => implode(',', $user_services), ':id' => $_SESSION['id']]);
if (SERVICE === 'ht')
htSetupUserFs($_SESSION['id']);
}
if ($service === 'ht')
htSetupUserFs($_SESSION['id']);
}
}
function displayFinalMessage($data) {
@ -142,12 +111,8 @@ if ($_POST !== []) {
if (!in_array($_SERVER['HTTP_SEC_FETCH_SITE'], ['none', 'same-origin'], true))
output(403, 'The <code>Sec-Fetch-Site</code> HTTP header must be <code>same-origin</code> or <code>none</code> when submitting a POST request to prevent Cross-Site Request Forgery (<abbr>CSRF</abbr>).');
if (PAGE_METADATA['require-login'] ?? true !== false) {
if (isset($_SESSION['id']) !== true)
output(403, _('You need to be logged in to do this.'));
if (isset(query('select', 'users', ['id' => $_SESSION['id']], 'id')[0]) !== true)
output(403, _('This account doesn\'t exist anymore. Log out to end this ghost session.'));
}
if (PAGE_METADATA['require-login'] ?? true AND !isset($_SESSION['id']))
output(403, _('You need to be logged in to do this.'));
if (file_exists(ROOT_PATH . '/pg-act/' . PAGE_ADDRESS . '.php'))
require ROOT_PATH . '/pg-act/' . PAGE_ADDRESS . '.php';

View file

@ -1,47 +1,54 @@
<?php
<?php // ServNest authenticator for SFTPGo https://github.com/drakkan/sftpgo/blob/main/docs/external-auth.md
const DEBUG = false;
!DEBUG or ob_start();
require 'router.php';
require 'init.php';
function deny() {
!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents());
function deny($reason) {
!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents() . $reason . LF);
http_response_code(403);
exit();
}
if (CONF['common']['services']['ht'] !== 'enabled')
deny();
deny('Service not enabled.');
$auth_data = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
$username = hashUsername($auth_data['username']);
if (usernameExists($username) !== true)
deny();
deny('This username doesn\'t exist.');
if (!in_array('ht', explode(',', query('select', 'users', ['username' => $username], 'services')[0]), true))
deny();
$account = query('select', 'users', ['username' => $username])[0];
$id = query('select', 'users', ['username' => $username], 'id')[0];
if (!in_array('ht', explode(',', $account['services']), true))
deny('Service not enabled for this user.');
if (checkPassword($id, $auth_data['password']) !== true)
deny();
const SFTPGO_DENY_PERMS = ['/' => ['list']];
const SFTPGO_ALLOW_PERMS = ['list', 'download', 'upload', 'overwrite', 'delete_files', 'delete_dirs', 'rename_files', 'rename_dirs', 'create_dirs', 'chtimes'];
if ($auth_data['password'] !== '') {
if (checkPassword($account['id'], $auth_data['password']) !== true)
deny('Wrong password.');
$permissions['/'] = SFTPGO_ALLOW_PERMS;
} else if ($auth_data['public_key'] !== '') {
$permissions = SFTPGO_DENY_PERMS;
foreach (query('select', 'ssh-keys', ['username' => $account['id']]) as $key)
if (hash_equals('ssh-ed25519 ' . $key['key'] . LF, $auth_data['public_key']))
$permissions[$key['directory']] = SFTPGO_ALLOW_PERMS;
if ($permissions === SFTPGO_DENY_PERMS)
deny('No matching SSH key allowed.');
} else
deny('Unknown authentication method.');
echo '
{
"status": 1,
"username": ' . json_encode($auth_data['username']) . ',
"home_dir": "' . CONF['ht']['ht_path'] . '/fs/' . $id . '",
"quota_size": ' . ((query('select', 'users', ['id' => $id], 'type')[0] === 'approved') ? CONF['ht']['user_quota_approved'] : CONF['ht']['user_quota_testing']) . ',
"permissions": {
"/": [
"*"
]
}
}
';
echo json_encode([
'status' => 1,
'username' => $auth_data['username'],
'home_dir' => CONF['ht']['ht_path'] . '/fs/' . $account['id'],
'quota_size' => ($account['type'] === 'approved') ? CONF['ht']['user_quota_approved'] : CONF['ht']['user_quota_testing'],
'permissions' => $permissions,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents());
!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents() . 'accepted');
http_response_code(200);

View file

@ -14,11 +14,11 @@ foreach (glob('css/*.css') as $css_path)
</head>
<body>
<header>
<p>
<p class="auth">
<?php if (isset($_SESSION['id'])) { ?>
<span aria-hidden="true"><?= ($_SESSION['type'] === 'approved') ? '👤' : '⏳' ?> </span><strong><?= (defined('DISPLAY_USERNAME') ? DISPLAY_USERNAME : '<em>?</em>') ?></strong> <a class="auth" href="<?= CONF['common']['prefix'] ?>/auth/logout"><?= _('Log out') ?></a>
<span aria-hidden="true"><?= ($_SESSION['type'] === 'approved') ? '👤' : '⏳' ?> </span><strong><?= (defined('DISPLAY_USERNAME') ? DISPLAY_USERNAME : '<em>?</em>') ?></strong> <a href="<?= CONF['common']['prefix'] ?>/auth/logout"><?= _('Log out') ?></a><?= ($_SESSION['type'] === 'testing') ? '<br>' . _('You are using a testing account. It may be deleted anytime.') . ' <a href="' . CONF['common']['prefix'] . '/auth/#type">' . _('Read more') . '</a>' : '' ?>
<?php } else { ?>
<span aria-hidden="true">👻 </span><em><?= _('Anonymous') ?></em> <a class="auth" href="<?= redirUrl('auth/login') ?>"><?= _('Log in') ?></a>
<span aria-hidden="true">👻 </span><em><?= _('Anonymous') ?></em> <a href="<?= redirUrl('auth/login') ?>"><?= _('Log in') ?></a>
<?php } ?>
</p>
<nav>