Reduce security and reliability risk across the Proxmox extension by removing password exposure from Livewire state, hardening console auth/cookie handling, and improving sanitized error paths. Improve firewall and router performance by adding safer lazy loading, bounded pagination, cross-server compatibility guards, and lower-overhead API workflows. Made-with: Cursor
455 lines
16 KiB
PHP
455 lines
16 KiB
PHP
<?php
|
|
|
|
namespace Paymenter\Extensions\Servers\Proxmox;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Http\Client\Response;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\IPAddress;
|
|
|
|
class TechnitiumDns
|
|
{
|
|
private string $baseUrl;
|
|
private string $token;
|
|
|
|
/**
|
|
* Check whether Technitium DNS credentials are configured.
|
|
*/
|
|
public static function isConfigured(): bool
|
|
{
|
|
return !empty(config('services.technitium.url'))
|
|
&& !empty(config('services.technitium.token'));
|
|
}
|
|
|
|
public function __construct()
|
|
{
|
|
$this->baseUrl = rtrim((string) config('services.technitium.url', ''), '/');
|
|
$this->token = (string) config('services.technitium.token', '');
|
|
|
|
if (empty($this->baseUrl) || empty($this->token)) {
|
|
throw new \RuntimeException('Technitium DNS is not configured. Set TECHNITIUM_DNS_URL and TECHNITIUM_DNS_TOKEN in your environment.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make an API request to Technitium DNS server
|
|
*/
|
|
private function request(string $endpoint, string $method = 'get', array $data = []): Response
|
|
{
|
|
$url = rtrim($this->baseUrl, '/') . '/api' . $endpoint;
|
|
|
|
$client = Http::timeout(10)
|
|
->connectTimeout(5)
|
|
->withHeaders([
|
|
'Authorization' => 'Bearer ' . $this->token,
|
|
'X-API-Token' => $this->token,
|
|
]);
|
|
|
|
if ($method === 'get') {
|
|
// Prefer token headers to keep secrets out of query strings and access logs.
|
|
$response = $client->get($url, $data);
|
|
if ($response->successful()) {
|
|
return $response;
|
|
}
|
|
|
|
// Compatibility fallback for deployments that only accept token query params.
|
|
return $client->get($url, array_merge(['token' => $this->token], $data));
|
|
} else {
|
|
return $client->asForm()->post($url, array_merge(['token' => $this->token], $data));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all zones
|
|
*/
|
|
public function getZones(): array
|
|
{
|
|
try {
|
|
$response = $this->request('/zones/list');
|
|
$json = $response->json();
|
|
|
|
if ($json['status'] === 'ok') {
|
|
return $json['response'] ?? [];
|
|
}
|
|
|
|
return [];
|
|
} catch (\Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get zone details
|
|
*/
|
|
public function getZone(string $zoneName): ?array
|
|
{
|
|
try {
|
|
$response = $this->request('/zones/get', 'get', ['zone' => $zoneName]);
|
|
$json = $response->json();
|
|
|
|
if ($json['status'] === 'ok') {
|
|
return $json['response'] ?? null;
|
|
}
|
|
|
|
return null;
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a subdomain exists
|
|
*/
|
|
public function subdomainExists(string $subdomain): bool
|
|
{
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
$zones = $this->getZones();
|
|
|
|
foreach ($zones as $zone) {
|
|
if (isset($zone['name']) && $zone['name'] === $fullDomain) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Create a new zone (subdomain)
|
|
*/
|
|
/**
|
|
* Create a DNS zone and primary records
|
|
*
|
|
* @return array{success: bool, recordType?: string, wildcardCreated?: bool, message?: string}
|
|
*/
|
|
public function createZone(string $subdomain, string $ipAddress, int $serverId, int $ipAddressId): array
|
|
{
|
|
try {
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
$comment = "Server ID: {$serverId}";
|
|
|
|
// Determine record type based on IP address version
|
|
$isIpv6 = filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
|
|
$recordType = $isIpv6 ? 'AAAA' : 'A';
|
|
|
|
// Create the zone
|
|
$response = $this->request('/zones/set', 'post', [
|
|
'zone' => $fullDomain,
|
|
'type' => 'Primary',
|
|
]);
|
|
|
|
$json = $response->json();
|
|
if ($json['status'] !== 'ok') {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Failed to create DNS zone.',
|
|
];
|
|
}
|
|
|
|
// Add A or AAAA record for the subdomain based on IP version
|
|
$wildcardDomain = '*.' . $fullDomain;
|
|
$mainRecordComment = $comment . " | Wildcard: {$wildcardDomain} ({$recordType})";
|
|
|
|
$recordResponse = $this->request('/zones/records/add', 'post', [
|
|
'zone' => $fullDomain,
|
|
'domain' => $fullDomain,
|
|
'type' => $recordType,
|
|
'ipAddress' => $ipAddress,
|
|
'ttl' => 3600,
|
|
'comments' => $mainRecordComment,
|
|
]);
|
|
|
|
$recordJson = $recordResponse->json();
|
|
if ($recordJson['status'] !== 'ok') {
|
|
// Clean up the zone we just created since the record failed
|
|
try {
|
|
$this->request('/zones/delete', 'post', ['zone' => $fullDomain]);
|
|
} catch (\Exception $cleanupException) {
|
|
// Ignore cleanup errors
|
|
}
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Failed to add DNS record.',
|
|
];
|
|
}
|
|
|
|
// Add wildcard record (*.subdomain.dns.virtnet.host)
|
|
$wildcardComment = $comment . " | Parent: {$fullDomain} ({$recordType})";
|
|
$wildcardResponse = $this->request('/zones/records/add', 'post', [
|
|
'zone' => $fullDomain,
|
|
'domain' => $wildcardDomain,
|
|
'type' => $recordType,
|
|
'ipAddress' => $ipAddress,
|
|
'ttl' => 3600,
|
|
'comments' => $wildcardComment,
|
|
]);
|
|
|
|
// Continue even if wildcard creation fails (non-critical)
|
|
$wildcardCreated = false;
|
|
if (isset($wildcardResponse)) {
|
|
$wildcardJson = $wildcardResponse->json();
|
|
$wildcardCreated = ($wildcardJson['status'] ?? '') === 'ok';
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'recordType' => $recordType,
|
|
'wildcardCreated' => $wildcardCreated,
|
|
];
|
|
} catch (\Exception $e) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a zone (subdomain)
|
|
*/
|
|
public function deleteZone(string $subdomain, int $ipAddressId): bool
|
|
{
|
|
try {
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
|
|
// Get the IP address to retrieve stored records
|
|
$ip = IPAddress::find($ipAddressId);
|
|
if (!$ip) {
|
|
return false;
|
|
}
|
|
|
|
// Get stored records from database
|
|
$storedRecords = $ip->subdomain['records'] ?? [];
|
|
|
|
// First, try to delete all records manually
|
|
// This handles the case where the zone doesn't exist but records do
|
|
foreach ($storedRecords as $record) {
|
|
if (isset($record['name']) && isset($record['type'])) {
|
|
try {
|
|
$ipAddress = $record['rdata'] ?? $record['ipAddress'] ?? null;
|
|
$this->deleteRecord($subdomain, $record['name'], $record['type'], $ipAddress);
|
|
} catch (\Exception $e) {
|
|
// Continue even if individual record deletion fails
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also try to get records from the API and delete them
|
|
// This handles cases where records exist in DNS but not in our database
|
|
try {
|
|
$zoneDetails = $this->getZone($fullDomain);
|
|
if ($zoneDetails && isset($zoneDetails['records'])) {
|
|
foreach ($zoneDetails['records'] as $record) {
|
|
if (in_array($record['type'] ?? '', ['A', 'AAAA'])) {
|
|
$domain = $record['domain'] ?? '';
|
|
if ($domain === $fullDomain) {
|
|
$name = '@';
|
|
} else {
|
|
$name = str_replace('.' . $fullDomain, '', $domain);
|
|
}
|
|
try {
|
|
$ipAddress = $record['rdata'] ?? $record['ipAddress'] ?? null;
|
|
$this->deleteRecord($subdomain, $name, $record['type'], $ipAddress);
|
|
} catch (\Exception $e) {
|
|
// Continue even if individual record deletion fails
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Zone might not exist, which is fine
|
|
}
|
|
|
|
// Now try to delete the zone (if it exists)
|
|
try {
|
|
$response = $this->request('/zones/delete', 'post', [
|
|
'zone' => $fullDomain,
|
|
]);
|
|
|
|
$json = $response->json();
|
|
// If zone doesn't exist, that's fine - we've already deleted the records
|
|
if ($json['status'] !== 'ok') {
|
|
$errorMessage = strtolower($json['message'] ?? '');
|
|
if (str_contains($errorMessage, 'no such zone') || str_contains($errorMessage, 'zone not found')) {
|
|
// Zone doesn't exist, but that's okay - records are deleted
|
|
} else {
|
|
// Some other error occurred, but we'll still clean up the database
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
// Zone deletion failed, but we've deleted records, so continue
|
|
}
|
|
|
|
// Remove subdomain info from database
|
|
$ip->subdomain = null;
|
|
$ip->save();
|
|
|
|
return true;
|
|
} catch (\Exception $e) {
|
|
// Even if there's an error, try to clean up the database
|
|
try {
|
|
$ip = IPAddress::find($ipAddressId);
|
|
if ($ip) {
|
|
$ip->subdomain = null;
|
|
$ip->save();
|
|
}
|
|
} catch (\Exception $dbException) {
|
|
// Ignore database cleanup errors
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get zones associated with a server (from database)
|
|
*/
|
|
public function getServerZones(int $serverId): array
|
|
{
|
|
$serverZones = [];
|
|
|
|
// Get all IP addresses for this server that have subdomains
|
|
$ipAddresses = IPAddress::where('server_id', $serverId)
|
|
->whereNotNull('subdomain')
|
|
->get();
|
|
|
|
foreach ($ipAddresses as $ip) {
|
|
$subdomainData = $ip->subdomain;
|
|
if (isset($subdomainData['subdomain'])) {
|
|
$subdomain = $subdomainData['subdomain'];
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
|
|
// Get records from DNS API
|
|
$zoneRecords = $this->getZoneRecords($subdomain);
|
|
|
|
$serverZones[] = [
|
|
'subdomain' => $subdomain,
|
|
'fullDomain' => $fullDomain,
|
|
'ipAddress' => $ip->ip,
|
|
'ipAddressId' => $ip->id,
|
|
'records' => $subdomainData['records'] ?? [],
|
|
'zoneRecords' => $zoneRecords,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $serverZones;
|
|
}
|
|
|
|
/**
|
|
* Get the IP address from zone records
|
|
*/
|
|
private function getZoneIpAddress(array $zoneDetails): ?string
|
|
{
|
|
$records = $zoneDetails['records'] ?? [];
|
|
foreach ($records as $record) {
|
|
if (isset($record['type']) && in_array($record['type'], ['A', 'AAAA'])) {
|
|
// Check both rdata and ipAddress fields
|
|
$ipAddress = $record['rdata'] ?? $record['ipAddress'] ?? null;
|
|
if ($ipAddress) {
|
|
return $ipAddress;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Add a DNS record to a zone
|
|
*/
|
|
public function addRecord(string $subdomain, string $name, string $type, string $rdata, int $ttl = 3600): bool
|
|
{
|
|
try {
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
$recordName = $name === '@' ? $fullDomain : $name . '.' . $fullDomain;
|
|
|
|
$response = $this->request('/zones/records/add', 'post', [
|
|
'zone' => $fullDomain,
|
|
'domain' => $recordName,
|
|
'type' => $type,
|
|
'ipAddress' => $rdata,
|
|
'ttl' => $ttl,
|
|
]);
|
|
|
|
$json = $response->json();
|
|
return $json['status'] === 'ok';
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a DNS record from a zone
|
|
*/
|
|
public function deleteRecord(string $subdomain, string $name, string $type, ?string $ipAddress = null): bool
|
|
{
|
|
try {
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
$recordName = $name === '@' ? $fullDomain : $name . '.' . $fullDomain;
|
|
|
|
$params = [
|
|
'zone' => $fullDomain,
|
|
'domain' => $recordName,
|
|
'type' => $type,
|
|
];
|
|
|
|
// Add ipAddress parameter if provided (required for A/AAAA records)
|
|
if ($ipAddress !== null) {
|
|
$params['ipAddress'] = $ipAddress;
|
|
}
|
|
|
|
$response = $this->request('/zones/records/delete', 'post', $params);
|
|
|
|
$json = $response->json();
|
|
return $json['status'] === 'ok';
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all records for a zone
|
|
*/
|
|
public function getZoneRecords(string $subdomain): array
|
|
{
|
|
try {
|
|
$fullDomain = $subdomain . '.dns.virtnet.host';
|
|
$zoneDetails = $this->getZone($fullDomain);
|
|
|
|
if (!$zoneDetails) {
|
|
return [];
|
|
}
|
|
|
|
$records = $zoneDetails['records'] ?? [];
|
|
$formattedRecords = [];
|
|
|
|
foreach ($records as $record) {
|
|
if (!in_array($record['type'] ?? '', ['A', 'AAAA'])) {
|
|
continue;
|
|
}
|
|
|
|
$domain = $record['domain'] ?? '';
|
|
// Extract sub-subdomain name
|
|
if ($domain === $fullDomain) {
|
|
$name = '@';
|
|
} else {
|
|
$name = str_replace('.' . $fullDomain, '', $domain);
|
|
}
|
|
|
|
// Get IP address from either 'rdata' or 'ipAddress' field
|
|
$ipAddress = $record['rdata'] ?? $record['ipAddress'] ?? '';
|
|
|
|
$formattedRecords[] = [
|
|
'name' => $name,
|
|
'type' => $record['type'] ?? '',
|
|
'rdata' => $ipAddress,
|
|
'ttl' => $record['ttl'] ?? 3600,
|
|
];
|
|
}
|
|
|
|
return $formattedRecords;
|
|
} catch (\Exception $e) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|