ns/sync.php: instant sync when adding new sync

This commit is contained in:
Miraty 2023-10-08 00:50:48 +02:00
parent 8624262482
commit e96d61703b
18 changed files with 105 additions and 84 deletions

View file

@ -25,7 +25,7 @@ function checkPasswordFormat(string $password): void {
} }
function hashUsername(string $username): string { function hashUsername(string $username): string {
return base64_encode(sodium_crypto_pwhash(32, $username, hex2bin(query('select', 'params', ['name' => 'username_salt'], 'value')[0]), 2**10, 2**14, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13)); return base64_encode(sodium_crypto_pwhash(32, $username, hex2bin(query('select', 'params', ['name' => 'username_salt'], ['value'])[0]), 2**10, 2**14, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13));
} }
function hashPassword(string $password): string { function hashPassword(string $password): string {
@ -33,15 +33,15 @@ function hashPassword(string $password): string {
} }
function usernameExists(string $username): bool { function usernameExists(string $username): bool {
return isset(query('select', 'users', ['username' => $username], 'id')[0]); return isset(query('select', 'users', ['username' => $username], ['id'])[0]);
} }
function checkPassword(string $id, string $password): bool { function checkPassword(string $id, string $password): bool {
return password_verify($password, query('select', 'users', ['id' => $id], 'password')[0]); return password_verify($password, query('select', 'users', ['id' => $id], ['password'])[0]);
} }
function outdatedPasswordHash(string $id): bool { function outdatedPasswordHash(string $id): bool {
return password_needs_rehash(query('select', 'users', ['id' => $id], 'password')[0], ALGO_PASSWORD, OPTIONS_PASSWORD); return password_needs_rehash(query('select', 'users', ['id' => $id], ['password'])[0], ALGO_PASSWORD, OPTIONS_PASSWORD);
} }
function changePassword(string $id, string $password): void { function changePassword(string $id, string $password): void {
@ -88,18 +88,18 @@ function setupDisplayUsername(string $display_username): void {
} }
function authDeleteUser(string $user_id): void { function authDeleteUser(string $user_id): void {
$user_services = explode(',', query('select', 'users', ['id' => $user_id], 'services')[0]); $user_services = explode(',', query('select', 'users', ['id' => $user_id], ['services'])[0]);
foreach (SERVICES_USER as $service) foreach (SERVICES_USER as $service)
if (in_array($service, $user_services, true) AND CONF['common']['services'][$service] !== 'enabled') 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>')); 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)) if (in_array('reg', $user_services, true))
foreach (query('select', 'registry', ['username' => $user_id], 'domain') as $domain) foreach (query('select', 'registry', ['username' => $user_id], ['domain']) as $domain)
regDeleteDomain($domain, $user_id); regDeleteDomain($domain, $user_id);
if (in_array('ns', $user_services, true)) if (in_array('ns', $user_services, true))
foreach (query('select', 'zones', ['username' => $user_id], 'zone') as $zone) foreach (query('select', 'zones', ['username' => $user_id], ['zone']) as $zone)
nsDeleteZone($zone, $user_id); nsDeleteZone($zone, $user_id);
if (in_array('ht', $user_services, true)) { if (in_array('ht', $user_services, true)) {
@ -178,8 +178,8 @@ function rateLimitAccount(int $requestedTokens): int {
function rateLimitInstance(int $requestedTokens): void { function rateLimitInstance(int $requestedTokens): void {
// Get // Get
$tokens = query('select', 'params', ['name' => 'instance_bucket_tokens'], 'value')[0]; $tokens = query('select', 'params', ['name' => 'instance_bucket_tokens'], ['value'])[0];
$bucketLastUpdate = query('select', 'params', ['name' => 'instance_bucket_last_update'], 'value')[0]; $bucketLastUpdate = query('select', 'params', ['name' => 'instance_bucket_last_update'], ['value'])[0];
// Compute // Compute
$tokens = min(86400, $tokens + (time() - $bucketLastUpdate)); $tokens = min(86400, $tokens + (time() - $bucketLastUpdate));

View file

@ -21,7 +21,6 @@ function exescape(array $args, array &$output = NULL, int &$result_code = NULL):
return $result_code; return $result_code;
} }
class KdigException extends Exception {};
function kdig(string $name, string $type, string $server = NULL): array { function kdig(string $name, string $type, string $server = NULL): array {
exescape([ exescape([
CONF['dns']['kdig_path'], CONF['dns']['kdig_path'],
@ -36,7 +35,7 @@ function kdig(string $name, string $type, string $server = NULL): array {
...(isset($server) ? ['@' . $server] : []), ...(isset($server) ? ['@' . $server] : []),
], $output, $code); ], $output, $code);
if ($code !== 0) if ($code !== 0)
throw new KdigException(); throw new KdigException($name . ' ' . $type . ' resolution failed.');
return json_decode(implode(LF, $output), true, flags: JSON_THROW_ON_ERROR); return json_decode(implode(LF, $output), true, flags: JSON_THROW_ON_ERROR);
} }
@ -63,10 +62,10 @@ function insert(string $table, array $values): void {
->execute($values); ->execute($values);
} }
function query(string $action, string $table, array $conditions = [], string $column = NULL): array { function query(string $action, string $table, array $conditions = [], array $columns = NULL): array {
$query = match ($action) { $query = match ($action) {
'select' => 'SELECT *', 'select' => 'SELECT ' . implode(',', $columns ?? ['*']),
'delete' => 'DELETE', 'delete' => 'DELETE',
}; };
@ -82,7 +81,9 @@ function query(string $action, string $table, array $conditions = [], string $co
$stmt = DB->prepare($query); $stmt = DB->prepare($query);
$stmt->execute($conditions); $stmt->execute($conditions);
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), $column); if (count($columns ?? []) === 1)
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), $columns[0]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
} }
function displayIndex(): void { ?> function displayIndex(): void { ?>
@ -138,13 +139,13 @@ function equalArrays(array $a, array $b): bool {
- the user's id - the user's id
- that a same user used a token multiple times (by using a unique salt for each token) - that a same user used a token multiple times (by using a unique salt for each token)
*/ */
if (time() - query('select', 'params', ['name' => 'secret_key_last_change'], 'value')[0] >= 86400 * 20) { if (time() - query('select', 'params', ['name' => 'secret_key_last_change'], ['value'])[0] >= 86400 * 20) {
DB->prepare("UPDATE params SET value = :secret_key WHERE name = 'secret_key';") DB->prepare("UPDATE params SET value = :secret_key WHERE name = 'secret_key';")
->execute([':secret_key' => bin2hex(random_bytes(32))]); ->execute([':secret_key' => bin2hex(random_bytes(32))]);
DB->prepare("UPDATE params SET value = :last_change WHERE name = 'secret_key_last_change';") DB->prepare("UPDATE params SET value = :last_change WHERE name = 'secret_key_last_change';")
->execute([':last_change' => time()]); ->execute([':last_change' => time()]);
} }
define('SECRET_KEY', hex2bin(query('select', 'params', ['name' => 'secret_key'], 'value')[0])); define('SECRET_KEY', hex2bin(query('select', 'params', ['name' => 'secret_key'], ['value'])[0]));
function getAuthToken(): string { function getAuthToken(): string {
$salt = bin2hex(random_bytes(4)); $salt = bin2hex(random_bytes(4));
$hash = hash_hmac('sha256', $salt . ($_SESSION['id'] ?? ''), SECRET_KEY); $hash = hash_hmac('sha256', $salt . ($_SESSION['id'] ?? ''), SECRET_KEY);

View file

@ -81,7 +81,7 @@ function dirsStatuses(string $type): array {
$dbDirs = query('select', 'sites', [ $dbDirs = query('select', 'sites', [
'username' => $_SESSION['id'], 'username' => $_SESSION['id'],
'type' => $type, 'type' => $type,
], 'site_dir'); ], ['site_dir']);
$dirs = []; $dirs = [];
foreach (listFsDirs($_SESSION['id']) as $fsDir) foreach (listFsDirs($_SESSION['id']) as $fsDir)
$dirs[$fsDir] = in_array($fsDir, $dbDirs); $dirs[$fsDir] = in_array($fsDir, $dbDirs);
@ -103,7 +103,7 @@ function htDeleteSite(string $address, string $type, string $user_id): void {
'username' => $user_id, 'username' => $user_id,
'address' => $address, 'address' => $address,
'type' => $type, 'type' => $type,
], 'site_dir')[0]; ], ['site_dir'])[0];
// Delete Tor config // Delete Tor config
if (unlink(CONF['ht']['tor_config_path'] . '/' . $user_id . '/' . $dir) !== true) if (unlink(CONF['ht']['tor_config_path'] . '/' . $user_id . '/' . $dir) !== true)

View file

@ -39,7 +39,7 @@ function nsParseCommonRequirements(): array {
function nsListUserZones(): array { function nsListUserZones(): array {
if (isset($_SESSION['id'])) if (isset($_SESSION['id']))
return query('select', 'zones', ['username' => $_SESSION['id']], 'zone'); return query('select', 'zones', ['username' => $_SESSION['id']], ['zone']);
return []; return [];
} }
@ -81,3 +81,38 @@ function nsDeleteZone(string $zone, string $user_id): void {
'username' => $user_id, 'username' => $user_id,
]); ]);
} }
function nsSync(string $source, string $destination): void {
$zone_raw = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $destination . 'zone');
if ($zone_raw === false)
output(403, 'Unable to read zone file.');
foreach (['AAAA', 'A', 'CAA'] as $type) {
// Get source/distant records
$results = kdig(name: $source, type: $type);
if ($results['AD'] !== 1)
throw new NoDnssecException($source . ' not DNSSEC-signed.');
$source_records = array_column($results['answerRRs'] ?? [], 'rdata' . $type);
// Get destination/local records
$dest_records = array_column(parseZoneFile($zone_raw, [$type], $destination, false), 3);
// Add source records that are not yet in destination
foreach (array_diff($source_records, $dest_records) as $value_to_add)
knotcZoneExec($destination, [
$destination,
NS_SYNC_TTL,
$type,
$value_to_add,
], 'add');
// Delete destination records that are not part of source anymore
foreach (array_diff($dest_records, $source_records) as $value_to_delete)
knotcZoneExec($destination, [
$destination,
$type,
$value_to_delete,
], 'delete');
}
}

View file

@ -7,7 +7,7 @@ const REG_ALLOWED_TYPES = ['NS', 'DS', 'AAAA', 'A'];
function regListUserDomains(): array { function regListUserDomains(): array {
if (isset($_SESSION['id'])) if (isset($_SESSION['id']))
return query('select', 'registry', ['username' => $_SESSION['id']], 'domain'); return query('select', 'registry', ['username' => $_SESSION['id']], ['domain']);
return []; return [];
} }
@ -38,7 +38,7 @@ function regDeleteDomain(string $domain, string $user_id): void {
insert('registry-history', [ insert('registry-history', [
'domain' => $domain, 'domain' => $domain,
'creation' => query('select', 'registry', $conditions, 'creation')[0], 'creation' => query('select', 'registry', $conditions, ['creation'])[0],
'expiration' => date('Y-m'), 'expiration' => date('Y-m'),
]); ]);

View file

@ -2,6 +2,8 @@
umask(0077); umask(0077);
const LF = "\n"; const LF = "\n";
class KdigException extends Exception {};
class NoDnssecException extends Exception {};
set_error_handler(function ($level, $message, $file = '', $line = 0) { set_error_handler(function ($level, $message, $file = '', $line = 0) {
throw new ErrorException($message, 0, $level, $file, $line); throw new ErrorException($message, 0, $level, $file, $line);
}); });

View file

@ -1,42 +1,9 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
require __DIR__ . '/../init.php'; require __DIR__ . '/../init.php';
foreach (query('select', 'ns-syncs') as $sync) { foreach (query('select', 'ns-syncs', columns: ['source', 'destination']) as ['source' => $source, 'destination' => $destination])
$zone_raw = file_get_contents(CONF['ns']['knot_zones_path'] . '/' . $sync['destination'] . 'zone');
if ($zone_raw === false)
output(403, 'Unable to read zone file.');
foreach (['AAAA', 'A', 'CAA'] as $type) {
// Get source/distant records
try { try {
$results = kdig(name: $sync['source'], type: $type); nsSync($source, $destination);
} catch (KdigException) { } catch (KdigException | NoDnssecException $e) {
fwrite(STDERR, $sync['source'] . ' resolution failed.' . LF); fwrite(STDERR, $e->getMessage() . LF);
break; };
}
if ($results['AD'] !== 1) {
fwrite(STDERR, $sync['source'] . ' skipped: not signed using DNSSEC.' . LF);
continue;
}
$source_records = array_column($results['answerRRs'] ?? [], 'rdata' . $type);
// Get destination/local records
$dest_records = array_column(parseZoneFile($zone_raw, [$type], $sync['destination'], false), 3);
// Add source records that are not yet in destination
foreach (array_diff($source_records, $dest_records) as $value_to_add)
knotcZoneExec($sync['destination'], [
$sync['destination'],
NS_SYNC_TTL,
$type,
$value_to_add,
], 'add');
// Delete destination records that are not part of source anymore
foreach (array_diff($dest_records, $source_records) as $value_to_delete)
knotcZoneExec($sync['destination'], [
$sync['destination'],
$type,
$value_to_delete,
], 'delete');
}
}

View file

@ -5,7 +5,7 @@ if ($_SESSION['type'] !== 'testing')
rateLimit(); rateLimit();
if (isset(query('select', 'approval-keys', ['key' => $_POST['key']], 'key')[0]) !== true) if (isset(query('select', 'approval-keys', ['key' => $_POST['key']], ['key'])[0]) !== true)
output(403, _('This approval key is not available. It has been mistyped, used for another account, or has expired.')); output(403, _('This approval key is not available. It has been mistyped, used for another account, or has expired.'));
query('delete', 'approval-keys', ['key' => $_POST['key']]); query('delete', 'approval-keys', ['key' => $_POST['key']]);

View file

@ -9,7 +9,7 @@ $username = hashUsername($_POST['username']);
if (usernameExists($username) !== true) if (usernameExists($username) !== true)
output(403, _('This account does not exist.')); output(403, _('This account does not exist.'));
$id = query('select', 'users', ['username' => $username], 'id')[0]; $id = query('select', 'users', ['username' => $username], ['id'])[0];
if (checkPassword($id, $_POST['password']) !== true) if (checkPassword($id, $_POST['password']) !== true)
output(403, _('Wrong password.')); output(403, _('Wrong password.'));
@ -21,7 +21,7 @@ stopSession();
startSession(); startSession();
$_SESSION['id'] = $id; $_SESSION['id'] = $id;
$_SESSION['type'] = query('select', 'users', ['id' => $id], 'type')[0]; $_SESSION['type'] = query('select', 'users', ['id' => $id], ['type'])[0];
setupDisplayUsername($_POST['username']); setupDisplayUsername($_POST['username']);

View file

@ -5,7 +5,7 @@ $_POST['domain'] = formatDomain($_POST['domain']);
if (dirsStatuses('dns')[$_POST['dir']] !== false) if (dirsStatuses('dns')[$_POST['dir']] !== false)
output(403, 'Wrong value for <code>dir</code>.'); output(403, 'Wrong value for <code>dir</code>.');
if (query('select', 'sites', ['address' => $_POST['domain']], 'address') !== []) if (query('select', 'sites', ['address' => $_POST['domain']], ['address']) !== [])
output(403, _('This domain already exists on this service. Use another one.')); output(403, _('This domain already exists on this service. Use another one.'));
$remoteAaaaRecords = dns_get_record($_POST['domain'], DNS_AAAA); $remoteAaaaRecords = dns_get_record($_POST['domain'], DNS_AAAA);

View file

@ -3,7 +3,7 @@
if (preg_match('/^(?<type>subpath|subdomain|onion|dns):(?<address>[a-z0-9._-]{1,256})$/D', $_POST['site'], $site) !== 1) if (preg_match('/^(?<type>subpath|subdomain|onion|dns):(?<address>[a-z0-9._-]{1,256})$/D', $_POST['site'], $site) !== 1)
output(403, 'Malformed value for <code>site</code>.'); output(403, 'Malformed value for <code>site</code>.');
if (isset(query('select', 'sites', ['username' => $_SESSION['id'], 'address' => $site['address'], 'type' => $site['type']], 'address')[0]) !== true) 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>.'); output(403, 'Unavailable value for <code>site</code>.');
htDeleteSite($site['address'], $site['type'], $_SESSION['id']); htDeleteSite($site['address'], $site['type'], $_SESSION['id']);

View file

@ -4,7 +4,7 @@ $el_nb = count($_POST['syncs']);
if ($el_nb < 1 OR $el_nb > 8) if ($el_nb < 1 OR $el_nb > 8)
output(403, 'Wrong elements number.'); output(403, 'Wrong elements number.');
foreach ($_POST['syncs'] as $i => $sync) { foreach ($_POST['syncs'] as $i => &$sync) {
if (($sync['source'] ?? '') === '') { if (($sync['source'] ?? '') === '') {
unset($_POST['syncs'][$i]); unset($_POST['syncs'][$i]);
continue; continue;
@ -12,24 +12,40 @@ foreach ($_POST['syncs'] as $i => $sync) {
$sync['source'] = formatAbsoluteDomain($sync['source']); $sync['source'] = formatAbsoluteDomain($sync['source']);
nsCheckZonePossession($sync['destination']); nsCheckZonePossession($sync['destination']);
} }
$syncs = array_values($_POST['syncs']); $new_syncs = array_values($_POST['syncs']);
$destinations = array_column($syncs, 'destination'); $new_destinations = array_column($new_syncs, 'destination');
if (count($destinations) !== count(array_unique($destinations))) if (count($new_destinations) !== count(array_unique($new_destinations)))
output(403, _('Multiple source domains can\'t be applied to the same target domain.')); output(403, _('Multiple source domains can\'t be applied to the same target domain.'));
rateLimit(); rateLimit();
$current_syncs = query('select', 'ns-syncs', ['username' => $_SESSION['id']], ['source', 'destination']);
try {
foreach ($new_syncs as $new_sync)
if (!in_array($new_sync, $current_syncs))
nsSync($new_sync['source'], $new_sync['destination']);
} catch (KdigException | NoDnssecException $e) {
output(403, $e->getMessage() . LF);
}
try { try {
DB->beginTransaction(); DB->beginTransaction();
query('delete', 'ns-syncs', ['username' => $_SESSION['id']]); foreach ($current_syncs as $current_sync) // Deletions
if (!in_array($current_sync, $new_syncs))
foreach ($syncs as $sync) query('delete', 'ns-syncs', [
'username' => $_SESSION['id'],
'source' => $current_sync['source'],
'destination' => $current_sync['destination'],
]);
foreach ($new_syncs as $new_sync) // Adds
if (!in_array($new_sync, $current_syncs))
insert('ns-syncs', [ insert('ns-syncs', [
'username' => $_SESSION['id'], 'username' => $_SESSION['id'],
'source' => $sync['source'], 'source' => $new_sync['source'],
'destination' => $sync['destination'], 'destination' => $new_sync['destination'],
]); ]);
DB->commit(); DB->commit();

View file

@ -2,7 +2,7 @@
$domain = formatAbsoluteDomain($_POST['domain']); $domain = formatAbsoluteDomain($_POST['domain']);
if (query('select', 'zones', ['zone' => $domain], 'zone') !== []) if (query('select', 'zones', ['zone' => $domain], ['zone']) !== [])
output(403, _('This zone already exists on the service.')); output(403, _('This zone already exists on the service.'));
$parent_domain = ltrim(strstr($domain, '.'), '.'); $parent_domain = ltrim(strstr($domain, '.'), '.');

View file

@ -8,7 +8,7 @@ if (array_key_exists($_POST['suffix'], CONF['reg']['suffixes']) !== true)
$domain = formatAbsoluteDomain($_POST['subdomain'] . '.' . $_POST['suffix']); $domain = formatAbsoluteDomain($_POST['subdomain'] . '.' . $_POST['suffix']);
if (query('select', 'registry', ['username' => $_SESSION['id'], 'domain' => $domain], 'domain') !== []) if (query('select', 'registry', ['username' => $_SESSION['id'], 'domain' => $domain], ['domain']) !== [])
output(403, _('The current account already owns this domain.')); output(403, _('The current account already owns this domain.'));
$ns_records = array_column(kdig(name: $domain, type: 'NS', server: CONF['reg']['address'])['authorityRRs'], 'rdataNS'); $ns_records = array_column(kdig(name: $domain, type: 'NS', server: CONF['reg']['address'])['authorityRRs'], 'rdataNS');

View file

@ -10,7 +10,7 @@
<?php <?php
$zones = query('select', 'zones', ['username' => $_SESSION['id'] ?? ''], 'zone'); $zones = query('select', 'zones', ['username' => $_SESSION['id'] ?? ''], ['zone']);
if ($zones === []) if ($zones === [])
echo '<p>∅<p>' . LF; echo '<p>∅<p>' . LF;
else { else {

View file

@ -25,7 +25,7 @@ foreach (array_slice(array_merge(query('select', 'ns-syncs', ['username' => $_SE
<select required="" name="syncs[<?= $i ?>][destination]" id="destination<?= $i ?>"> <select required="" name="syncs[<?= $i ?>][destination]" id="destination<?= $i ?>">
<option <?= (($sync['destination'] === '') ? 'value="" disabled=""' : 'value="' . $sync['destination'] . '"') ?> selected=""><?= $sync['destination'] ?></option> <option <?= (($sync['destination'] === '') ? 'value="" disabled=""' : 'value="' . $sync['destination'] . '"') ?> selected=""><?= $sync['destination'] ?></option>
<?php <?php
foreach (array_diff(nsListUserZones(), query('select', 'ns-syncs', ['username' => $_SESSION['id'] ?? ''], 'destination')) as $zone) foreach (array_diff(nsListUserZones(), query('select', 'ns-syncs', ['username' => $_SESSION['id'] ?? ''], ['destination'])) as $zone)
echo "<option value='" . $zone . "'>" . $zone . "</option>"; echo "<option value='" . $zone . "'>" . $zone . "</option>";
?> ?>

View file

@ -9,7 +9,7 @@
<?php <?php
$domains = query('select', 'registry', ['username' => $_SESSION['id'] ?? ''], 'domain'); $domains = query('select', 'registry', ['username' => $_SESSION['id'] ?? ''], ['domain']);
if ($domains === []) if ($domains === [])
echo '<p>∅</p>' . LF; echo '<p>∅</p>' . LF;
else { else {

View file

@ -64,7 +64,7 @@ if (isset($_COOKIE[SESSION_COOKIE_NAME]))
startSession(); // Resume session startSession(); // Resume session
if (isset($_SESSION['id'])) { if (isset($_SESSION['id'])) {
if (!isset(query('select', 'users', ['id' => $_SESSION['id']], 'id')[0])) if (!isset(query('select', 'users', ['id' => $_SESSION['id']], ['id'])[0]))
logout(); logout();
// Decrypt display username // Decrypt display username
@ -81,7 +81,7 @@ if (isset($_SESSION['id'])) {
define('DISPLAY_USERNAME', htmlspecialchars($decryption_result)); define('DISPLAY_USERNAME', htmlspecialchars($decryption_result));
// Enable not already enabled services for this user // Enable not already enabled services for this user
$user_services = array_filter(explode(',', query('select', 'users', ['id' => $_SESSION['id']], 'services')[0])); $user_services = array_filter(explode(',', query('select', 'users', ['id' => $_SESSION['id']], ['services'])[0]));
foreach (SERVICES_USER as $service) foreach (SERVICES_USER as $service)
if (!in_array($service, $user_services, true) AND CONF['common']['services'][$service] === 'enabled') { if (!in_array($service, $user_services, true) AND CONF['common']['services'][$service] === 'enabled') {
$user_services[] = $service; $user_services[] = $service;