mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Store server IPs in one form (no brackets, short lower-case IPv6) and write each address in the form its context needs: - URLs and host:port (Sentinel, database URLs, SFTP backup download, DNS servers, SMTP, webhook base URLs, links, health checks) get brackets through formatHostForUrl(). - Traefik Host() rules use the address without brackets, so IPv6 domains no longer return 404; saved labels are fixed at deploy. Caddy keeps the brackets. - SSH keeps the plain address. - Hetzner gives an IPv6 /64 network; IPv6-only servers now get the server address (prefix::1) instead of the unusable prefix::64. - sslip labels, registry image names with an IPv6 host, the API domain list, allowlist matching and duplicate checks handle IPv6. A migration normalizes stored IPv6 server addresses, repairs Hetzner servers saved as ::64 that are not reachable, and adds brackets to saved Sentinel URLs.
202 lines
6.1 KiB
PHP
202 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Exceptions\RateLimitException;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class HetznerService
|
|
{
|
|
private string $token;
|
|
|
|
private string $baseUrl = 'https://api.hetzner.cloud/v1';
|
|
|
|
public function __construct(string $token)
|
|
{
|
|
$this->token = $token;
|
|
}
|
|
|
|
private function request(string $method, string $endpoint, array $data = [])
|
|
{
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
])
|
|
->timeout(30)
|
|
->retry(3, function (int $attempt, \Exception $exception) {
|
|
// Handle rate limiting (429 Too Many Requests)
|
|
if ($exception instanceof RequestException) {
|
|
$response = $exception->response;
|
|
|
|
if ($response && $response->status() === 429) {
|
|
// Get rate limit reset timestamp from headers
|
|
$resetTime = $response->header('RateLimit-Reset');
|
|
|
|
if ($resetTime) {
|
|
// Calculate wait time until rate limit resets
|
|
$waitSeconds = max(0, $resetTime - time());
|
|
|
|
// Cap wait time at 60 seconds for safety
|
|
return min($waitSeconds, 60) * 1000;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exponential backoff for other retriable errors: 100ms, 200ms, 400ms
|
|
return $attempt * 100;
|
|
})
|
|
->{$method}($this->baseUrl.$endpoint, $data);
|
|
|
|
if (! $response->successful()) {
|
|
if ($response->status() === 429) {
|
|
$retryAfter = $response->header('Retry-After');
|
|
if ($retryAfter === null) {
|
|
$resetTime = $response->header('RateLimit-Reset');
|
|
$retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null;
|
|
}
|
|
|
|
throw new RateLimitException(
|
|
'Rate limit exceeded. Please try again later.',
|
|
$retryAfter !== null ? (int) $retryAfter : null
|
|
);
|
|
}
|
|
|
|
throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
|
|
}
|
|
|
|
return $response->json();
|
|
}
|
|
|
|
private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array
|
|
{
|
|
$allResults = [];
|
|
$page = 1;
|
|
|
|
do {
|
|
$data['page'] = $page;
|
|
$data['per_page'] = 50;
|
|
|
|
$response = $this->request($method, $endpoint, $data);
|
|
|
|
if (isset($response[$resourceKey])) {
|
|
$allResults = array_merge($allResults, $response[$resourceKey]);
|
|
}
|
|
|
|
$nextPage = $response['meta']['pagination']['next_page'] ?? null;
|
|
$page = $nextPage;
|
|
} while ($nextPage !== null);
|
|
|
|
return $allResults;
|
|
}
|
|
|
|
public function getLocations(): array
|
|
{
|
|
return $this->requestPaginated('get', '/locations', 'locations');
|
|
}
|
|
|
|
public function getImages(): array
|
|
{
|
|
return $this->requestPaginated('get', '/images', 'images', [
|
|
'type' => 'system',
|
|
]);
|
|
}
|
|
|
|
public function getServerTypes(): array
|
|
{
|
|
$types = $this->requestPaginated('get', '/server_types', 'server_types');
|
|
|
|
// Filter out entries where "deprecated" is explicitly true
|
|
$filtered = array_filter($types, function ($type) {
|
|
return ! (isset($type['deprecated']) && $type['deprecated'] === true);
|
|
});
|
|
|
|
return array_values($filtered);
|
|
}
|
|
|
|
public function getSshKeys(): array
|
|
{
|
|
return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys');
|
|
}
|
|
|
|
public function getFirewalls(): array
|
|
{
|
|
return $this->requestPaginated('get', '/firewalls', 'firewalls');
|
|
}
|
|
|
|
public function getNetworks(): array
|
|
{
|
|
return $this->requestPaginated('get', '/networks', 'networks');
|
|
}
|
|
|
|
public function uploadSshKey(string $name, string $publicKey): array
|
|
{
|
|
$response = $this->request('post', '/ssh_keys', [
|
|
'name' => $name,
|
|
'public_key' => $publicKey,
|
|
]);
|
|
|
|
return $response['ssh_key'] ?? [];
|
|
}
|
|
|
|
public function createServer(array $params): array
|
|
{
|
|
|
|
$response = $this->request('post', '/servers', $params);
|
|
|
|
return $response['server'] ?? [];
|
|
}
|
|
|
|
public function enableServerBackup(int $serverId): array
|
|
{
|
|
$response = $this->request('post', "/servers/{$serverId}/actions/enable_backup");
|
|
|
|
return $response['action'] ?? [];
|
|
}
|
|
|
|
public function getServer(int $serverId): array
|
|
{
|
|
$response = $this->request('get', "/servers/{$serverId}");
|
|
|
|
return $response['server'] ?? [];
|
|
}
|
|
|
|
public function powerOnServer(int $serverId): array
|
|
{
|
|
$response = $this->request('post', "/servers/{$serverId}/actions/poweron");
|
|
|
|
return $response['action'] ?? [];
|
|
}
|
|
|
|
public function deleteServer(int $serverId): void
|
|
{
|
|
$this->request('delete', "/servers/{$serverId}");
|
|
}
|
|
|
|
public function getServers(): array
|
|
{
|
|
return $this->requestPaginated('get', '/servers', 'servers');
|
|
}
|
|
|
|
public function findServerByIp(string $ip): ?array
|
|
{
|
|
$ip = normalizeIpAddress($ip);
|
|
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
|
|
$servers = $this->getServers();
|
|
|
|
foreach ($servers as $server) {
|
|
$ipv4 = data_get($server, 'public_net.ipv4.ip');
|
|
if (filled($ipv4) && normalizeIpAddress($ipv4) === $ip) {
|
|
return $server;
|
|
}
|
|
|
|
// Hetzner returns the IPv6 network (2a01:4f8:c016:bd23::/64), not the server address.
|
|
$ipv6Network = data_get($server, 'public_net.ipv6.ip');
|
|
if ($isIpv6 && filled($ipv6Network) && str_contains($ipv6Network, '/') && ipMatch($ip, [$ipv6Network])) {
|
|
return $server;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|