Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Subdomain.php
2025-11-30 11:57:22 +01:00

595 lines
24 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Models\IPAddress;
use Paymenter\Extensions\Servers\Proxmox\TechnitiumDns;
class Subdomain extends Component
{
public string $newSubdomain = '';
public ?int $selectedIpId = null;
public array $serverZones = [];
public bool $showCreateModal = false;
public bool $showRecordModal = false;
public bool $showDeleteModal = false;
public ?string $subdomainToDelete = null;
public ?int $ipAddressIdToDelete = null;
public ?string $subdomainToDeleteFull = null;
public ?string $selectedZone = null;
public string $recordName = '';
public string $recordType = 'A';
public string $recordRdata = '';
public int $recordTtl = 3600;
public array $zoneRecords = [];
protected array $baseAdjectives = [
'ancient','agile','amazing','amber','ample','arcane','arctic','artful','atomic','attuned',
'balanced','bare','basic','bashful','beauteous','beloved','better','beyond','big','bitter',
'blazing','bleak','blind','blissful','blue','bold','brave','breezy','bright','brilliant',
'brisk','broad','broken','burning','buzzing','calm','capable','careful','casual','celestial',
'certain','charmed','cheerful','chief','chilly','choice','chosen','circular','classic','clean',
'clear','clever','close','cloudy','coastal','cold','colossal','cosmic','courageous','crimson',
'crisp','critical','cunning','curious','daily','daring','dark','deep','defiant','delicate',
'deserted','detailed','diligent','distant','divine','dormant','double','drifting','driven','dry',
'eager','early','earnest','earthy','easy','ebony','echoing','ecstatic','electric','elegant',
'elite','emerald','endless','eternal','even','every','evil','exact','exalted','excited',
'exotic','fabled','faint','fair','faithful','fancy','far','faster','fat','fearless',
'feathered','ferocious','fiery','final','fine','firm','first','fitting','flawless','fleeting',
'flexible','fluffy','flying','focused','fond','forever','forgotten','fortunate','fragile','free',
'fresh','friendly','frosty','frozen','full','funny','furry','fussy','galactic','gentle',
'genuine','giant','gilded','giving','glad','gleaming','glimmering','global','glorious','golden',
'good','grand','grateful','grave','gray','great','green','growing','guarded','gutsy',
'happy','hardy','harsh','hasty','haunted','hearty','heavenly','heavy','helpful','heroic',
'hidden','high','hollow','holy','honest','hopeful','horned','hot','huge','humble',
'icy','ideal','idle','illuminated','imagined','immense','immune','imperial','important','infinite',
'innocent','inner','inspired','intense','intrepid','iron','ivory','jaded','jazzy','joyful',
'juicy','jumbo','just','keen','kind','kingly','knowing','kosmic','labeled','lame',
'lanky','large','lasting','lavish','lawful','lazy','leading','leafy','lean','leaping',
'legendary','legible','light','lilac','limber','limited','lively','living','lonely','long',
'loose','loud','loving','lucid','lucky','lunar','lush','mad','magic','magnetic',
'magnificent','major','mammoth','mellow','merry','mighty','mild','mini','minute','mirrored',
'misty','modern','modest','molten','moonlit','moral','mossy','muddy','mystic','naive',
'narrow','nascent','native','natural','nautical','near','neat','nebulous','needed','nervy',
'new','next','nice','nifty','nimble','noble','noisy','normal','northern','notable',
'novel','numerous','nutty','oaken','oasis','obedient','obvious','oceanic','odd','official',
'old','olive','ominous','only','open','optimal','orange','ordinary','organic','origin',
'ornate','outer','overdue','overt','pacific','pale','parallel','passive','patient','peaceful',
'peach','pearl','perfect','perilous','petite','phobic','phoenix','pinnacle','pink','pious',
'pivotal','plain','playful','pleasant','plentiful','pliant','plucky','plump','polar','polished',
'poor','popular','positive','possible','potent','powerful','precious','pretty','prickly','primal',
'primed','proper','proud','public','puffy','pulsing','pure','purple','quaint','quick',
'quiet','radiant','ragged','random','rapid','rare','raspy','raw','ready','real',
'rebel','reckless','regal','regular','relaxed','reliable','remarkable','remote','resting','revered',
'rich','rigid','ripe','rising','roaring','robust','rocky','rosy','rough','round',
'royal','ruddy','rugged','rusted','rustic','sacred','safe','sandy','savage','scarlet',
'scary','scenic','secret','serene','sharp','shining','shiny','shivering','shocking','short',
'shy','silent','silver','simple','sincere','single','sizzling','skilled','sleepy','slender',
'slimy','slow','smart','smoky','smooth','snappy','snarling','sneaky','snug','soaring',
'solar','solemn','solid','somber','sonic','soothing','soulful','southern','spacious','sparkling',
'special','speedy','spicy','spiky','spiral','spirited','splendid','spooky','spotless','spry',
'square','squeaky','stable','starry','steadfast','steady','steel','steep','stellar','sticky',
'stout','strange','strong','sturdy','subtle','sudden','sugary','sunny','super','sure',
'surreal','swift','tall','tame','tasty','teal','telling','tense','tender','terrific',
'thick','thin','third','thorny','thoughtful','thrifty','thunderous','tidy','timber','timid',
'tiny','tired','titanic','toasty','tough','tranquil','trembling','triangular','true','trusted',
'twilight','ultimate','ultra','united','unique','untamed','upbeat','upright','upward','urban',
'usable','useful','usual','utopian','valiant','vast','velvet','verdant','vibrant','vicious',
'vigilant','vintage','violet','virtuous','vivid','vocal','volatile','wandering','warm','warped',
'watery','wealthy','weary','weathered','weird','western','whimsical','whispering','white','wicked',
'wild','willing','windy','winning','winter','wise','witty','wobbly','wooden','woolly',
'worthy','wounded','young','yummy','zealous','zesty','zigzag','zinc','zippy','zonal',
];
protected array $baseAnimals = [
'aardvark','albatross','alligator','alpaca','ant','anteater','antelope','armadillo','asp','auk',
'baboon','badger','bandicoot','barnacle','barracuda','bat','bear','beaver','bee','beetle',
'bison','boar','bobcat','buffalo','bull','bunny','butterfly','calf','camel','canary',
'caribou','cat','caterpillar','catfish','cheetah','chicken','chimp','chinchilla','chipmunk','cobra',
'condor','cougar','cow','coyote','crab','crane','cricket','crow','deer','dingo',
'dog','dolphin','donkey','dove','dragon','duck','eagle','earthworm','eel','egret',
'elephant','elk','emu','falcon','ferret','finch','fish','flamingo','fox','frog',
'gazelle','gecko','gerbil','giraffe','goat','goose','gorilla','gopher','grizzly','grouse',
'guanaco','gull','hamster','hare','hawk','hedgehog','heron','hippo','horse','hound',
'hyena','ibis','iguana','impala','jackal','jaguar','jay','jellyfish','kangaroo','kingfisher',
'koala','koi','kraken','lemming','lemur','leopard','lion','lizard','llama','lobster',
'lynx','macaw','magpie','mallard','manatee','mandrill','marten','meerkat','mink','mole',
'monkey','moose','moray','mouse','mule','narwhal','newt','nightjar','ocelot','octopus',
'opossum','orca','osprey','ostrich','otter','owl','ox','panda','panther','parrot',
'peacock','pelican','penguin','pheasant','pig','pigeon','piranha','pitbull','platypus','pony',
'porcupine','possum','puffin','puma','python','quail','quokka','rabbit','raccoon','ram',
'raven','reindeer','rhino','robin','rooster','salamander','salmon','seal','serpent','shark',
'sheep','shrew','shrimp','skink','skunk','sloth','snail','snake','sparrow','spider',
'squid','squirrel','stallion','starling','stingray','stoat','stork','swan','tapir','tiger',
'toad','toucan','trout','turkey','turtle','urial','viper','vixen','vulture','wallaby',
'walrus','warthog','weasel','whale','wolf','wolverine','wombat','woodpecker','yak','zebra',
];
public function mount(Server $server): void
{
parent::mount($server);
$this->loadServerZones();
}
/**
* Get public IP addresses for the server
*/
public function getPublicIpsProperty(): array
{
$publicIps = [];
foreach ($this->server->ipAddresses as $ip) {
if ($this->isPublicIp($ip->ip, $ip->type)) {
$publicIps[] = $ip;
}
}
return $publicIps;
}
/**
* Check if IP is public
*/
private function isPublicIp(string $ip, string $type = 'ipv4'): bool
{
if ($type === 'ipv6' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
if ($ip === '::1' || strpos($ip, '::1/') === 0) {
return false;
}
if (preg_match('/^fe[89ab][0-9a-f]:/i', $ip)) {
return false;
}
if (preg_match('/^fc[0-9a-f]{1,2}:/i', $ip) || preg_match('/^fd[0-9a-f]{1,2}:/i', $ip)) {
return false;
}
if (strpos($ip, '::ffff:') === 0 || strpos($ip, '::ffff:') !== false) {
return false;
}
return true;
} else {
$parts = explode('.', $ip);
if (count($parts) !== 4) {
return false;
}
$first = (int)$parts[0];
$second = (int)$parts[1];
if ($first === 10) {
return false;
}
if ($first === 172 && $second >= 16 && $second <= 31) {
return false;
}
if ($first === 192 && $second === 168) {
return false;
}
if ($first === 127) {
return false;
}
if ($first === 169 && $second === 254) {
return false;
}
return true;
}
}
/**
* Load zones associated with this server
*/
public function loadServerZones(): void
{
try {
$dns = new TechnitiumDns;
$this->serverZones = $dns->getServerZones($this->server->id);
} catch (\Exception $e) {
$this->serverZones = [];
$this->notify('Failed to load subdomains: ' . $e->getMessage(), 'error');
}
}
/**
* Check if subdomain is available
*/
public function checkSubdomainAvailability(): void
{
if (empty($this->newSubdomain)) {
return;
}
// Validate subdomain format
if (!preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/i', $this->newSubdomain)) {
$this->notify('Invalid subdomain format. Use only letters, numbers, and hyphens.', 'error');
return;
}
try {
$dns = new TechnitiumDns;
if ($dns->subdomainExists($this->newSubdomain)) {
$this->notify('This subdomain is already taken.', 'error');
} else {
$this->notify('Subdomain is available!', 'success');
}
} catch (\Exception $e) {
$this->notify('Failed to check subdomain availability: ' . $e->getMessage(), 'error');
}
}
/**
* Create a new subdomain
*/
public function createSubdomain(): void
{
$this->validate([
'newSubdomain' => 'required|string|regex:/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/i',
'selectedIpId' => 'required|exists:ext_proxmox_ip_addresses,id',
], [
'newSubdomain.regex' => 'Invalid subdomain format. Use only letters, numbers, and hyphens.',
'selectedIpId.required' => 'Please select an IP address.',
]);
// Check if user already has a subdomain for this IP
$selectedIp = $this->server->ipAddresses->firstWhere('id', $this->selectedIpId);
if (!$selectedIp) {
$this->notify('Selected IP address not found.', 'error');
return;
}
// Check if subdomain already exists in database
try {
// Determine the selected IP's type (IPv4 or IPv6)
$selectedIpType = filter_var($selectedIp->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6' : 'ipv4';
$recordType = $selectedIpType === 'ipv6' ? 'AAAA' : 'A';
// Check if user already has a subdomain for this specific IP address
$selectedIpSubdomainData = $this->normalizeSubdomainData($selectedIp->subdomain);
if ($selectedIpSubdomainData && !empty($selectedIpSubdomainData['subdomain'])) {
$this->notify('You already have a subdomain for this IP address.', 'error');
return;
}
// Load every subdomain entry in the database to ensure global uniqueness rules
$allSubdomainEntries = IPAddress::query()
->whereNotNull('subdomain')
->get();
$existingIpv4Count = 0;
$existingIpv6Count = 0;
foreach ($allSubdomainEntries as $existingIp) {
$subdomainData = $this->normalizeSubdomainData($existingIp->subdomain);
if (!$subdomainData) {
continue;
}
$existingSubdomainName = $subdomainData['subdomain'] ?? null;
if ($existingSubdomainName === $this->newSubdomain) {
$existingIpType = filter_var($existingIp->ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6' : 'ipv4';
if ($existingIpType === 'ipv4') {
$existingIpv4Count++;
} else {
$existingIpv6Count++;
}
}
}
// Check if we're trying to create a duplicate of the same IP type
if ($selectedIpType === 'ipv4' && $existingIpv4Count > 0) {
$this->notify('This subdomain already exists for an IPv4 address on this server. Please choose a different name.', 'error');
return;
}
if ($selectedIpType === 'ipv6' && $existingIpv6Count > 0) {
$this->notify('This subdomain already exists for an IPv6 address on this server. Please choose a different name.', 'error');
return;
}
// Check if we already have both IPv4 and IPv6 for this subdomain
if ($existingIpv4Count > 0 && $existingIpv6Count > 0) {
$this->notify('This subdomain already exists for both IPv4 and IPv6 addresses on this server. Please choose a different name.', 'error');
return;
}
// Create the subdomain via Technitium DNS API
$dns = new TechnitiumDns;
$result = $dns->createZone($this->newSubdomain, $selectedIp->ip, $this->server->id, $selectedIp->id);
if (($result['success'] ?? false) === true) {
$recordType = $result['recordType'] ?? ($selectedIpType === 'ipv6' ? 'AAAA' : 'A');
$records = [
['name' => '@', 'type' => $recordType, 'rdata' => $selectedIp->ip, 'ttl' => 3600],
];
if (($result['wildcardCreated'] ?? false) === true) {
$records[] = ['name' => '*', 'type' => $recordType, 'rdata' => $selectedIp->ip, 'ttl' => 3600];
}
$selectedIp->subdomain = [
'subdomain' => $this->newSubdomain,
'fullDomain' => $this->newSubdomain . '.dns.virtnet.host',
'ipAddress' => $selectedIp->ip,
'records' => $records,
];
$selectedIp->save();
$selectedIp->refresh();
// Refresh the server's IP addresses collection
$this->server->load('ipAddresses');
$this->notify('Subdomain created successfully!', 'success');
$this->newSubdomain = '';
$this->selectedIpId = null;
$this->showCreateModal = false;
$this->loadServerZones();
} else {
$errorMessage = $result['message'] ?? 'Failed to create subdomain.';
$this->notify($errorMessage, 'error');
}
} catch (\Exception $e) {
$this->notify('Failed to create subdomain: ' . $e->getMessage(), 'error');
}
}
/**
* Generate a random subdomain from word pool
*/
public function generateRandomSubdomain(): void
{
$adjectives = $this->adjectivePool();
$animals = $this->animalPool();
$adjective = Str::slug(Arr::random($adjectives), '');
$animal = Str::slug(Arr::random($animals), '');
$this->newSubdomain = strtolower($adjective . '-' . $animal);
}
protected function adjectivePool(): array
{
static $pool = null;
if ($pool === null) {
$pool = $this->expandPool($this->baseAdjectives);
}
return $pool;
}
protected function animalPool(): array
{
static $pool = null;
if ($pool === null) {
$pool = $this->expandPool($this->baseAnimals);
}
return $pool;
}
protected function expandPool(array $base): array
{
if (count($base) >= 1000) {
return array_slice($base, 0, 1000);
}
$pool = $base;
while (count($pool) < 1000) {
$pool = array_merge($pool, $base);
}
return array_slice($pool, 0, 1000);
}
/**
* Open delete confirmation modal
*/
public function openDeleteModal(string $subdomain, int $ipAddressId): void
{
$this->subdomainToDelete = $subdomain;
$this->ipAddressIdToDelete = $ipAddressId;
$this->subdomainToDeleteFull = $subdomain . '.dns.virtnet.host';
$this->showDeleteModal = true;
}
/**
* Close delete confirmation modal
*/
public function closeDeleteModal(): void
{
$this->showDeleteModal = false;
$this->subdomainToDelete = null;
$this->ipAddressIdToDelete = null;
$this->subdomainToDeleteFull = null;
}
/**
* Delete a subdomain
*/
public function deleteSubdomain(): void
{
if (!$this->subdomainToDelete || !$this->ipAddressIdToDelete) {
$this->notify('Invalid subdomain selection.', 'error');
return;
}
try {
$dns = new TechnitiumDns;
if ($dns->deleteZone($this->subdomainToDelete, $this->ipAddressIdToDelete)) {
$this->notify('Subdomain deleted successfully!', 'success');
$this->loadServerZones();
$this->closeDeleteModal();
} else {
$this->notify('Failed to delete subdomain.', 'error');
}
} catch (\Exception $e) {
$this->notify('Failed to delete subdomain: ' . $e->getMessage(), 'error');
}
}
/**
* Open record creation modal
*/
public function openRecordModal(string $subdomain): void
{
$this->selectedZone = $subdomain;
$this->recordName = '';
$this->recordType = 'A';
$this->recordRdata = '';
$this->recordTtl = 3600;
$this->loadZoneRecords();
$this->showRecordModal = true;
}
/**
* Load records for a zone
*/
public function loadZoneRecords(): void
{
if (!$this->selectedZone) {
return;
}
try {
$dns = new TechnitiumDns;
$records = $dns->getZoneRecords($this->selectedZone);
// Ensure we have an array and it's properly formatted
$this->zoneRecords = is_array($records) ? $records : [];
} catch (\Exception $e) {
$this->zoneRecords = [];
// Don't show error on every load, only log it
\Log::debug('Failed to load DNS records: ' . $e->getMessage());
}
}
/**
* Add a DNS record
*/
public function addRecord(): void
{
$this->validate([
'recordName' => 'required|string',
'recordType' => 'required|in:A,AAAA',
'recordRdata' => 'required|string',
'recordTtl' => 'required|integer|min:60|max:86400',
]);
if (!$this->selectedZone) {
$this->notify('No zone selected.', 'error');
return;
}
// Validate IP address format
if ($this->recordType === 'A' && !filter_var($this->recordRdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$this->notify('Invalid IPv4 address.', 'error');
return;
}
if ($this->recordType === 'AAAA' && !filter_var($this->recordRdata, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$this->notify('Invalid IPv6 address.', 'error');
return;
}
try {
$dns = new TechnitiumDns;
if ($dns->addRecord($this->selectedZone, $this->recordName, $this->recordType, $this->recordRdata, $this->recordTtl)) {
$this->notify('DNS record added successfully!', 'success');
$this->recordName = '';
$this->recordRdata = '';
// Small delay to ensure API has processed the record
usleep(500000); // 0.5 seconds
// Reload records from DNS API to get the latest data
$this->loadZoneRecords();
} else {
$this->notify('Failed to add DNS record.', 'error');
}
} catch (\Exception $e) {
$this->notify('Failed to add DNS record: ' . $e->getMessage(), 'error');
}
}
/**
* Delete a DNS record
*/
public function deleteRecord(string $name, string $type): void
{
if (!$this->selectedZone) {
$this->notify('No zone selected.', 'error');
return;
}
try {
// Find the record to get its IP address
$ipAddress = null;
foreach ($this->zoneRecords as $record) {
if (($record['name'] ?? '') === $name && ($record['type'] ?? '') === $type) {
$ipAddress = $record['rdata'] ?? $record['ipAddress'] ?? null;
break;
}
}
$dns = new TechnitiumDns;
if ($dns->deleteRecord($this->selectedZone, $name, $type, $ipAddress)) {
// Update database with new record list
$this->updateSubdomainRecords();
$this->notify('DNS record deleted successfully!', 'success');
$this->loadZoneRecords();
} else {
$this->notify('Failed to delete DNS record.', 'error');
}
} catch (\Exception $e) {
$this->notify('Failed to delete DNS record: ' . $e->getMessage(), 'error');
}
}
/**
* Get available IPs for creating subdomains
*/
public function getAvailableIpsForSubdomainProperty(): array
{
$publicIps = $this->publicIps;
// Filter out IPs that already have subdomains
return collect($publicIps)->filter(function ($ip) {
// Check if this IP already has a subdomain assigned
$subdomainData = $this->normalizeSubdomainData($ip->subdomain);
if ($subdomainData && !empty($subdomainData['subdomain'])) {
return false;
}
return true;
})->values()->all();
}
/**
* Normalize subdomain storage values to an array regardless of DB column type
*/
private function normalizeSubdomainData(mixed $value): ?array
{
if (!$value) {
return null;
}
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
public function render()
{
return view('proxmox::subdomain')->layoutData([
'sidebar' => true,
]);
}
}