mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge pull request #11621 from appwrite/fix-installer-certificates
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
DOCKER_COMPOSE: 'docker-compose',
|
||||
ENV_VARS: 'env-vars',
|
||||
DOCKER_CONTAINERS: 'docker-containers',
|
||||
ACCOUNT_SETUP: 'account-setup'
|
||||
ACCOUNT_SETUP: 'account-setup',
|
||||
SSL_CERTIFICATE: 'ssl-certificate',
|
||||
REDIRECT: 'redirect'
|
||||
});
|
||||
|
||||
const STATUS = Object.freeze({
|
||||
@@ -75,7 +77,7 @@
|
||||
{
|
||||
id: STEP_IDS.ACCOUNT_SETUP,
|
||||
inProgress: 'Creating Appwrite account...',
|
||||
done: 'Appwrite account created (redirecting...)'
|
||||
done: 'Appwrite account created'
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
storeInstallId,
|
||||
clearInstallId
|
||||
} = window.InstallerStepsState || {};
|
||||
const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {};
|
||||
const { extractHostname, isLocalHost, isIPAddress } = window.InstallerStepsValidation || {};
|
||||
const { generateSecretKey } = window.InstallerStepsUI || {};
|
||||
const { showToast } = window.InstallerToast || {};
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const buildRedirectUrl = () => {
|
||||
const buildRedirectUrl = (protocol) => {
|
||||
const dataset = getBodyDataset?.() ?? {};
|
||||
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
|
||||
if (!rawDomain) return '';
|
||||
@@ -266,22 +266,53 @@
|
||||
} else if (normalizedHost === 'traefik') {
|
||||
host = rawDomain.replace(hostForProtocol, 'localhost');
|
||||
}
|
||||
let protocol = 'http';
|
||||
let port = httpPort;
|
||||
if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) {
|
||||
protocol = 'https';
|
||||
port = httpsPort;
|
||||
}
|
||||
if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) {
|
||||
const port = protocol === 'https' ? httpsPort : httpPort;
|
||||
const defaultPort = protocol === 'https' ? '443' : '80';
|
||||
if (!hasPort && port && port !== defaultPort) {
|
||||
host = `${host}:${port}`;
|
||||
}
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
const redirectToApp = () => {
|
||||
const url = buildRedirectUrl();
|
||||
const normalizeHostname = (rawDomain) => {
|
||||
const hostname = extractHostname?.(rawDomain)?.toLowerCase?.() ?? '';
|
||||
if (hostname === '0.0.0.0' || hostname === 'traefik') return 'localhost';
|
||||
return hostname;
|
||||
};
|
||||
|
||||
const canUseHttps = () => {
|
||||
const dataset = getBodyDataset?.() ?? {};
|
||||
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
|
||||
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
|
||||
if (!httpsPort || httpsPort === '0') return false;
|
||||
const hostname = normalizeHostname(rawDomain);
|
||||
return !isLocalHost?.(hostname) && !isIPAddress?.(hostname);
|
||||
};
|
||||
|
||||
const pollCertificate = async (domain, port, maxAttempts, intervalMs) => {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/install/certificate?domain=${encodeURIComponent(domain)}&port=${encodeURIComponent(port)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.ready) return true;
|
||||
}
|
||||
} catch {
|
||||
// Installer server may have shut down
|
||||
}
|
||||
if (i < maxAttempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const redirectToApp = (protocol) => {
|
||||
const url = buildRedirectUrl(protocol);
|
||||
if (!url) return;
|
||||
// Fire-and-forget: tell the installer server it can shut down
|
||||
fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {});
|
||||
window.location.href = url;
|
||||
};
|
||||
@@ -406,6 +437,7 @@
|
||||
|
||||
const initStep5 = (root) => {
|
||||
if (!root) return;
|
||||
let resolvedProtocol = 'http';
|
||||
|
||||
if (activeInstall?.controller) {
|
||||
activeInstall.controller.abort();
|
||||
@@ -605,9 +637,7 @@
|
||||
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
|
||||
const sessionDetails = sseSessionDetails || accountState?.details;
|
||||
finalizeInstall();
|
||||
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
|
||||
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
|
||||
});
|
||||
startSslCheck(sessionDetails);
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
@@ -646,6 +676,76 @@
|
||||
setUnloadGuard(false);
|
||||
};
|
||||
|
||||
const SSL_STEP = {
|
||||
id: STEP_IDS.SSL_CERTIFICATE,
|
||||
inProgress: 'Generating SSL certificate...',
|
||||
done: 'SSL certificate verified'
|
||||
};
|
||||
|
||||
const REDIRECT_STEP = {
|
||||
id: STEP_IDS.REDIRECT,
|
||||
inProgress: 'Redirecting to console...',
|
||||
done: 'Redirecting to console...'
|
||||
};
|
||||
|
||||
const showRedirectStep = (sessionDetails, protocol) => {
|
||||
animatePanelHeight(() => {
|
||||
progressState.set(REDIRECT_STEP.id, {
|
||||
status: STATUS.IN_PROGRESS,
|
||||
message: REDIRECT_STEP.inProgress
|
||||
});
|
||||
const row = ensureRow(REDIRECT_STEP);
|
||||
if (row) {
|
||||
updateInstallRow(row, REDIRECT_STEP, STATUS.IN_PROGRESS, REDIRECT_STEP.inProgress);
|
||||
}
|
||||
});
|
||||
startSyncedSpinnerRotation(list);
|
||||
|
||||
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
|
||||
setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0);
|
||||
});
|
||||
};
|
||||
|
||||
const startSslCheck = (sessionDetails) => {
|
||||
if (!canUseHttps()) {
|
||||
showRedirectStep(sessionDetails, 'http');
|
||||
return;
|
||||
}
|
||||
|
||||
animatePanelHeight(() => {
|
||||
progressState.set(SSL_STEP.id, {
|
||||
status: STATUS.IN_PROGRESS,
|
||||
message: SSL_STEP.inProgress
|
||||
});
|
||||
const row = ensureRow(SSL_STEP);
|
||||
if (row) {
|
||||
updateInstallRow(row, SSL_STEP, STATUS.IN_PROGRESS, SSL_STEP.inProgress);
|
||||
}
|
||||
});
|
||||
startSyncedSpinnerRotation(list);
|
||||
|
||||
const dataset = getBodyDataset?.() ?? {};
|
||||
const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
|
||||
const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '443').trim();
|
||||
const domain = normalizeHostname(rawDomain);
|
||||
pollCertificate(domain, httpsPort, 15, 2000).then((ready) => {
|
||||
stopSyncedSpinnerRotation();
|
||||
const certMessage = ready ? SSL_STEP.done : 'Certificate not ready, continuing over HTTP';
|
||||
animatePanelHeight(() => {
|
||||
progressState.set(SSL_STEP.id, {
|
||||
status: STATUS.COMPLETED,
|
||||
message: certMessage
|
||||
});
|
||||
const row = ensureRow(SSL_STEP);
|
||||
if (row) {
|
||||
updateInstallRow(row, SSL_STEP, STATUS.COMPLETED, certMessage);
|
||||
}
|
||||
});
|
||||
resolvedProtocol = ready ? 'https' : 'http';
|
||||
showRedirectStep(sessionDetails, resolvedProtocol);
|
||||
});
|
||||
};
|
||||
|
||||
const startInstallStream = async (installId, options = {}) => {
|
||||
const isValid = await validateInstallRequest();
|
||||
if (!isValid) {
|
||||
@@ -746,9 +846,7 @@
|
||||
const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
|
||||
const sessionDetails = sseSessionDetails || accountState?.details;
|
||||
finalizeInstall();
|
||||
notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
|
||||
setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
|
||||
});
|
||||
startSslCheck(sessionDetails);
|
||||
return;
|
||||
}
|
||||
if (event === SSE_EVENTS.ERROR) {
|
||||
@@ -857,7 +955,7 @@
|
||||
const retryButton = event.target.closest('[data-install-retry]');
|
||||
|
||||
if (consoleButton) {
|
||||
redirectToApp();
|
||||
redirectToApp(resolvedProtocol);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,12 +106,18 @@
|
||||
return LOCAL_HOSTS.has(normalized);
|
||||
};
|
||||
|
||||
const isIPAddress = (host) => {
|
||||
if (!host) return false;
|
||||
return isValidIPv4(host) || isValidIPv6(host);
|
||||
};
|
||||
|
||||
window.InstallerStepsValidation = {
|
||||
isValidEmail,
|
||||
isValidPort,
|
||||
isValidPassword,
|
||||
isValidHostnameInput,
|
||||
extractHostname,
|
||||
isLocalHost
|
||||
isLocalHost,
|
||||
isIPAddress
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Platform\Installer\Http\Installer\Certificate;
|
||||
|
||||
use Appwrite\Platform\Installer\Validator\AppDomain;
|
||||
use Utopia\Http\Adapter\Swoole\Response;
|
||||
use Utopia\Platform\Action;
|
||||
use Utopia\Validator\Range;
|
||||
|
||||
class Get extends Action
|
||||
{
|
||||
private const int CONNECTION_TIMEOUT_SECONDS = 5;
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'installerCertificateGet';
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this
|
||||
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
|
||||
->setHttpPath('/install/certificate')
|
||||
->desc('Check if SSL certificate is ready for a domain')
|
||||
->param('domain', '', new AppDomain(), 'Domain to check')
|
||||
->param('port', 443, new Range(1, 65535), 'HTTPS port to check', true)
|
||||
->inject('response')
|
||||
->callback($this->action(...));
|
||||
}
|
||||
|
||||
public function action(string $domain, int $port, Response $response): void
|
||||
{
|
||||
$domain = trim($domain);
|
||||
if ($domain === '') {
|
||||
$response->json(['ready' => false]);
|
||||
return;
|
||||
}
|
||||
|
||||
$ready = $this->checkHttps($domain, $port);
|
||||
$response->json(['ready' => $ready]);
|
||||
}
|
||||
|
||||
private function checkHttps(string $domain, int $port): bool
|
||||
{
|
||||
$gateway = $this->getDockerGateway();
|
||||
|
||||
$ch = curl_init();
|
||||
$options = [
|
||||
CURLOPT_URL => 'https://' . $domain . ':' . $port . '/',
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
];
|
||||
|
||||
if ($gateway !== '') {
|
||||
$options[CURLOPT_RESOLVE] = [$domain . ':' . $port . ':' . $gateway];
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $options);
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $errno === 0;
|
||||
}
|
||||
|
||||
private function getDockerGateway(): string
|
||||
{
|
||||
$route = @file_get_contents('/proc/net/route');
|
||||
if ($route === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach (explode("\n", $route) as $line) {
|
||||
$fields = preg_split('/\s+/', trim($line));
|
||||
if (isset($fields[1]) && $fields[1] === '00000000' && isset($fields[2])) {
|
||||
$hex = $fields[2];
|
||||
if (strlen($hex) !== 8) {
|
||||
continue;
|
||||
}
|
||||
$ip = long2ip((int) hexdec($hex[6] . $hex[7] . $hex[4] . $hex[5] . $hex[2] . $hex[3] . $hex[0] . $hex[1]));
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class Server
|
||||
public const string STEP_DOCKER_COMPOSE = 'docker-compose';
|
||||
public const string STEP_DOCKER_CONTAINERS = 'docker-containers';
|
||||
public const string STEP_ACCOUNT_SETUP = 'account-setup';
|
||||
public const string STEP_SSL_CERTIFICATE = 'ssl-certificate';
|
||||
|
||||
public const string STATUS_IN_PROGRESS = 'in-progress';
|
||||
public const string STATUS_COMPLETED = 'completed';
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Appwrite\Platform\Installer\Services;
|
||||
|
||||
use Appwrite\Platform\Installer\Http\Installer\Certificate\Get as CertificateGet;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Complete;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Install;
|
||||
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
|
||||
@@ -22,5 +23,6 @@ class Http extends Service
|
||||
$this->addAction(Complete::getName(), new Complete());
|
||||
$this->addAction(Shutdown::getName(), new Shutdown());
|
||||
$this->addAction(Install::getName(), new Install());
|
||||
$this->addAction(CertificateGet::getName(), new CertificateGet());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,13 +41,14 @@ class ModuleTest extends TestCase
|
||||
$service = reset($services);
|
||||
$actions = $service->getActions();
|
||||
|
||||
$this->assertCount(6, $actions);
|
||||
$this->assertCount(7, $actions);
|
||||
$this->assertArrayHasKey('installerView', $actions);
|
||||
$this->assertArrayHasKey('installerStatus', $actions);
|
||||
$this->assertArrayHasKey('installerValidate', $actions);
|
||||
$this->assertArrayHasKey('installerComplete', $actions);
|
||||
$this->assertArrayHasKey('installerShutdown', $actions);
|
||||
$this->assertArrayHasKey('installerInstall', $actions);
|
||||
$this->assertArrayHasKey('installerCertificateGet', $actions);
|
||||
}
|
||||
|
||||
public function testViewAction(): void
|
||||
|
||||
Reference in New Issue
Block a user