From 0d7b62539d09194f8524f59142239c97620d8638 Mon Sep 17 00:00:00 2001 From: Corentin BARNICHON Date: Thu, 16 Apr 2026 18:13:36 +0200 Subject: [PATCH] first commit --- Actions/ListZones.php | 92 +++++++++++++ Actions/ManageRecords.php | 235 ++++++++++++++++++++++++++++++++++ Actions/TestConnection.php | 33 +++++ Plugin.php | 41 ++++++ README.md | 81 ++++++++++++ Services/RecordMapper.php | 165 ++++++++++++++++++++++++ Services/TechnitiumClient.php | 81 ++++++++++++ Technitium.php | 103 +++++++++++++++ 8 files changed, 831 insertions(+) create mode 100644 Actions/ListZones.php create mode 100644 Actions/ManageRecords.php create mode 100644 Actions/TestConnection.php create mode 100644 Plugin.php create mode 100644 README.md create mode 100644 Services/RecordMapper.php create mode 100644 Services/TechnitiumClient.php create mode 100644 Technitium.php diff --git a/Actions/ListZones.php b/Actions/ListZones.php new file mode 100644 index 0000000..871d75e --- /dev/null +++ b/Actions/ListZones.php @@ -0,0 +1,92 @@ + $allowedZones + * @return array> + */ + public function list(array $allowedZones = []): array + { + try { + $zones = $this->fetchAll(); + + return collect($zones) + // Only show Primary zones (authoritative zones the user actually manages) + ->filter(fn (array $zone) => ($zone['type'] ?? '') === 'Primary') + // Exclude internal zones + ->reject(fn (array $zone) => ($zone['internal'] ?? false) === true) + // Apply optional zone filter + ->when(count($allowedZones) > 0, fn ($collection) => $collection->filter( + fn (array $zone) => in_array($zone['name'], $allowedZones) + )) + ->map(fn (array $zone) => self::mapZone($zone)) + ->values() + ->toArray(); + } catch (Throwable $e) { + Log::error('Technitium getDomains exception', ['error' => $e->getMessage()]); + + return []; + } + } + + /** + * @return array + */ + public function find(string $domainId): array + { + try { + $zones = $this->fetchAll(); + + $zone = collect($zones)->first( + fn (array $zone) => $zone['name'] === $domainId + ); + + return $zone ? self::mapZone($zone) : []; + } catch (Throwable $e) { + Log::error('Technitium getDomain exception', ['error' => $e->getMessage()]); + + return []; + } + } + + /** + * @return array> + */ + private function fetchAll(): array + { + $response = $this->client->get('zones/list'); + + if (! $this->client->isSuccessful($response)) { + Log::error('Failed to fetch Technitium zones', ['response' => $response->json()]); + + return []; + } + + return $this->client->responseData($response, 'zones') ?? []; + } + + /** + * Map a Technitium zone to the format Vito expects. + * + * @return array + */ + private static function mapZone(array $zone): array + { + return [ + 'id' => $zone['name'], + 'name' => $zone['name'], + 'status' => ($zone['disabled'] ?? false) ? 'disabled' : 'active', + 'created_on' => null, + 'modified_on' => null, + ]; + } +} diff --git a/Actions/ManageRecords.php b/Actions/ManageRecords.php new file mode 100644 index 0000000..0c4b282 --- /dev/null +++ b/Actions/ManageRecords.php @@ -0,0 +1,235 @@ +> + * + * @throws \RuntimeException + */ + public function list(string $domainId): array + { + $response = $this->client->get('zones/records/get', [ + 'domain' => $domainId, + 'listZone' => 'true', + ]); + + if (! $this->client->isSuccessful($response)) { + Log::error('Failed to fetch Technitium DNS records', [ + 'domainId' => $domainId, + 'response' => $response->json(), + ]); + + throw new \RuntimeException( + 'Failed to fetch DNS records: ' + .($response->json('errorMessage') ?? 'Unknown error') + ); + } + + $records = $this->client->responseData($response, 'records') ?? []; + + return collect($records) + // Skip SOA records — they are managed by Technitium itself + ->reject(fn (array $r) => strtoupper($r['type'] ?? '') === 'SOA') + ->map(function (array $record) { + $type = $record['type'] ?? ''; + $rData = $record['rData'] ?? []; + $domain = $record['name'] ?? ''; + + return [ + 'id' => RecordMapper::encodeRecordId($domain, $type, $rData), + 'type' => $type, + 'name' => $domain, + 'content' => RecordMapper::rDataToContent($type, $rData), + 'ttl' => $record['ttl'] ?? self::DEFAULT_TTL, + 'proxied' => false, + 'created_on' => null, + 'modified_on' => null, + ]; + }) + ->values() + ->toArray(); + } + + /** + * Create a new DNS record. + * + * @return array + * + * @throws ValidationException + */ + public function create(string $domainId, array $input): array + { + try { + $type = $input['type']; + $name = $input['name']; + $content = $input['content']; + $ttl = $input['ttl'] ?? self::DEFAULT_TTL; + $priority = $input['prio'] ?? null; + + $params = array_merge( + [ + 'domain' => $name, + 'zone' => $domainId, + 'type' => $type, + 'ttl' => $ttl, + ], + RecordMapper::contentToApiParams($type, $content, $priority), + ); + + $response = $this->client->get('zones/records/add', $params); + + $this->ensureSuccessful($response, 'create', [ + 'domainId' => $domainId, + 'input' => $input, + ]); + + // Build the rData from the content to create a valid record ID + $rData = RecordMapper::contentToApiParams($type, $content, $priority); + + return [ + 'id' => RecordMapper::encodeRecordId($name, $type, $rData), + 'type' => $type, + 'name' => $name, + 'content' => $content, + 'ttl' => $ttl, + 'proxied' => false, + ]; + } catch (ValidationException $e) { + throw $e; + } catch (Throwable $e) { + Log::error('Technitium createRecord exception', ['error' => $e->getMessage()]); + throw ValidationException::withMessages(['record' => 'Failed to create DNS record: '.$e->getMessage()]); + } + } + + /** + * Update an existing DNS record. + * + * Technitium requires specifying the OLD record values to identify the record, + * plus the NEW values. The composite record ID contains the old values. + * + * @return array + * + * @throws ValidationException + */ + public function update(string $domainId, string $recordId, array $input): array + { + try { + $old = RecordMapper::decodeRecordId($recordId); + + $type = $input['type']; + $name = $input['name']; + $content = $input['content']; + $ttl = $input['ttl'] ?? self::DEFAULT_TTL; + $priority = $input['prio'] ?? null; + + $params = array_merge( + [ + 'domain' => $old['domain'], + 'zone' => $domainId, + 'type' => $old['type'], + 'ttl' => $ttl, + // New domain name if it changed + 'newDomain' => $name, + ], + // Old record values for identification + RecordMapper::oldRecordParams($old['type'], $old['rData']), + // New record values + RecordMapper::contentToApiParams($type, $content, $priority), + ); + + $response = $this->client->get('zones/records/update', $params); + + $this->ensureSuccessful($response, 'update', [ + 'domainId' => $domainId, + 'recordId' => $recordId, + 'input' => $input, + ]); + + $newRData = RecordMapper::contentToApiParams($type, $content, $priority); + + return [ + 'id' => RecordMapper::encodeRecordId($name, $type, $newRData), + 'type' => $type, + 'name' => $name, + 'content' => $content, + 'ttl' => $ttl, + 'proxied' => false, + ]; + } catch (ValidationException $e) { + throw $e; + } catch (Throwable $e) { + Log::error('Technitium updateRecord exception', ['error' => $e->getMessage()]); + throw ValidationException::withMessages(['record' => 'Failed to update DNS record: '.$e->getMessage()]); + } + } + + /** + * Delete a DNS record. + */ + public function delete(string $domainId, string $recordId): bool + { + try { + $old = RecordMapper::decodeRecordId($recordId); + + $params = array_merge( + [ + 'domain' => $old['domain'], + 'zone' => $domainId, + 'type' => $old['type'], + ], + RecordMapper::deleteParams($old['type'], $old['rData']), + ); + + $response = $this->client->get('zones/records/delete', $params); + + if (! $this->client->isSuccessful($response)) { + Log::error('Failed to delete Technitium DNS record', [ + 'domainId' => $domainId, + 'recordId' => $recordId, + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } catch (Throwable $e) { + Log::error('Technitium deleteRecord exception', ['error' => $e->getMessage()]); + + return false; + } + } + + /** + * @throws ValidationException + */ + private function ensureSuccessful(Response $response, string $operation, array $context): void + { + if (! $this->client->isSuccessful($response)) { + Log::error("Failed to {$operation} Technitium DNS record", array_merge($context, [ + 'response' => $response->json(), + ])); + + throw ValidationException::withMessages([ + 'record' => "Failed to {$operation} DNS record: ".($response->json('errorMessage') ?? 'Unknown error'), + ]); + } + } +} diff --git a/Actions/TestConnection.php b/Actions/TestConnection.php new file mode 100644 index 0000000..b5dd849 --- /dev/null +++ b/Actions/TestConnection.php @@ -0,0 +1,33 @@ +get('zones/list'); + + if ($client->isSuccessful($response)) { + return true; + } + + Log::error('Technitium connection failed', ['response' => $response->json()]); + + return false; + } catch (Throwable $e) { + Log::error('Technitium connection exception', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/Plugin.php b/Plugin.php new file mode 100644 index 0000000..3a5f240 --- /dev/null +++ b/Plugin.php @@ -0,0 +1,41 @@ +label('Technitium') + ->handler(Technitium::class) + ->form( + DynamicForm::make([ + DynamicField::make('server_url') + ->text() + ->label('Server URL') + ->placeholder('http://dns.example.com:5380') + ->description('Base URL of your Technitium DNS Server (with port, without trailing slash)'), + DynamicField::make('api_token') + ->passwordWithToggle() + ->label('API Token') + ->description('Generate a non-expiring API token from the Technitium web console (Administration > Sessions > Create API Token)'), + DynamicField::make('zone_filter') + ->text() + ->label('Zone Filter') + ->placeholder('example.com, mysite.org') + ->description('Optional comma-separated list of zones to show. Leave empty to show all Primary zones.'), + ]) + ) + ->register(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..41c5882 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Vito Technitium DNS Plugin + +A [Vito](https://github.com/vitodeploy/vito) plugin that adds [Technitium DNS Server](https://technitium.com/dns/) as a DNS provider, enabling zone and DNS record management directly from Vito. + +## Summary + +Vito ships with Cloudflare as a built-in DNS provider. This plugin adds Technitium as an additional option using Vito's plugin system — no core files are modified. + +Once enabled, the plugin registers Technitium in the DNS provider list. You can then connect your self-hosted Technitium instance, import zones, and create, update, or delete DNS records from the Vito dashboard. + +### Key differences from Cloudflare + +- **Self-hosted:** Technitium runs on your own infrastructure. You provide the server URL and port. +- **Authentication:** Uses a single API token (generated from the Technitium web console), passed as a query parameter. +- **Zone-based:** Technitium manages authoritative zones. Only Primary zones are listed by default. +- **No proxy support:** Unlike Cloudflare, Technitium is a pure DNS server. The `proxied` flag is always `false`. +- **Record identification:** Technitium does not assign numeric IDs to records. Records are identified by their domain name, type, and data. This plugin uses base64-encoded composite IDs internally. + +### Supported operations + +- Connect and authenticate with Technitium HTTP API +- List and retrieve Primary zones +- Create, update, and delete DNS records (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA, PTR) + +### Supported record types + +| Type | Content field | Priority | +|-------|--------------------------|----------| +| A | IPv4 address | — | +| AAAA | IPv6 address | — | +| CNAME | Target hostname | — | +| MX | Mail server hostname | ✓ | +| TXT | Text value | — | +| NS | Name server hostname | — | +| SRV | Target hostname | ✓ | +| CAA | `flags tag "value"` | — | +| PTR | Pointer name | — | + +## Setup + +1. Open your Technitium DNS web console (usually `http://your-server:5380`). +2. Navigate to **Administration → Sessions → Create API Token** and generate a non-expiring token. +3. Enable the plugin in Vito (**Admin → Plugins**). +4. Add a new DNS provider, select **Technitium**, and fill in: + - **Server URL:** The full URL with port (e.g. `http://dns.example.com:5380`) + - **API Token:** The token you generated + - **Zone Filter** *(optional)*: Comma-separated list of zones to show + +## Zone Filter + +Technitium may host many zones (including internal, secondary, or forwarder zones). By default, the plugin only shows **Primary** zones and excludes internal ones. + +If you want to further restrict which zones appear in Vito, use the optional **Zone Filter** field. Enter a comma-separated list of zone names (e.g. `example.com, mysite.org`). Only matching zones will be shown. + +If the field is left empty, all Primary zones are returned. + +## Security Notes + +- The API token grants full access to your Technitium DNS server. Keep it secure. +- If your Technitium instance is not exposed to the internet, make sure the Vito server can reach it on the configured port. +- Consider using HTTPS if the Technitium web console is accessible over the network (Technitium supports TLS certificates). + +## Development + +To develop locally, place the plugin at: + +``` +app/Vito/Plugins/LiittleCookie/VitoTechnitiumDns/ +``` + +The namespace must be `App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns`. + +Then go to **Admin → Plugins → Discover** to install and enable it. + +## Community & Open Source + +This plugin is community-built and open source. It is not officially maintained by the Vito core team. + +Contributions, bug reports, and feature requests are welcome. + +Licensed under the same terms as the Vito project. diff --git a/Services/RecordMapper.php b/Services/RecordMapper.php new file mode 100644 index 0000000..65f430e --- /dev/null +++ b/Services/RecordMapper.php @@ -0,0 +1,165 @@ + $domain, + 'type' => $type, + 'rData' => $rData, + ])); + } + + /** + * Decode a virtual record ID back into its components. + * + * @return array{domain: string, type: string, rData: array} + */ + public static function decodeRecordId(string $recordId): array + { + $decoded = json_decode(base64_decode($recordId), true); + + if (! $decoded || ! isset($decoded['domain'], $decoded['type'], $decoded['rData'])) { + throw new \InvalidArgumentException('Invalid record ID format'); + } + + return $decoded; + } + + /** + * Extract the human-readable "content" from Technitium's rData object. + */ + public static function rDataToContent(string $type, array $rData): string + { + return match (strtoupper($type)) { + 'A', 'AAAA' => $rData['ipAddress'] ?? '', + 'CNAME' => $rData['cname'] ?? '', + 'MX' => $rData['exchange'] ?? '', + 'NS' => $rData['nameServer'] ?? '', + 'TXT' => $rData['text'] ?? '', + 'SRV' => $rData['target'] ?? '', + 'CAA' => sprintf('%d %s "%s"', $rData['flags'] ?? 0, $rData['tag'] ?? '', $rData['value'] ?? ''), + 'PTR' => $rData['ptrName'] ?? '', + 'SOA' => sprintf( + '%s %s', + $rData['primaryNameServer'] ?? '', + $rData['responsiblePerson'] ?? '', + ), + default => json_encode($rData), + }; + } + + /** + * Extract the priority from rData, if applicable for this record type. + */ + public static function rDataToPriority(string $type, array $rData): ?int + { + return match (strtoupper($type)) { + 'MX' => isset($rData['preference']) ? (int) $rData['preference'] : null, + 'SRV' => isset($rData['priority']) ? (int) $rData['priority'] : null, + default => null, + }; + } + + /** + * Convert Vito's flat content/type into Technitium API parameters for add/update. + */ + public static function contentToApiParams(string $type, string $content, ?int $priority = null): array + { + return match (strtoupper($type)) { + 'A', 'AAAA' => ['ipAddress' => $content], + 'CNAME' => ['cname' => $content], + 'MX' => array_filter([ + 'exchange' => $content, + 'preference' => $priority ?? 10, + ]), + 'NS' => ['nameServer' => $content], + 'TXT' => ['text' => $content], + 'SRV' => array_filter([ + 'target' => $content, + 'priority' => $priority ?? 0, + 'weight' => 0, + 'port' => 0, + ]), + 'CAA' => self::parseCaaContent($content), + 'PTR' => ['ptrName' => $content], + default => [], + }; + } + + /** + * Build the "old" record parameters needed for a Technitium update call. + * The API requires specifying the old values to identify which record to update. + */ + public static function oldRecordParams(string $type, array $rData): array + { + $prefix = 'old'; + + return match (strtoupper($type)) { + 'A', 'AAAA' => ["{$prefix}IpAddress" => $rData['ipAddress'] ?? ''], + 'CNAME' => ["{$prefix}Cname" => $rData['cname'] ?? ''], + 'MX' => [ + "{$prefix}Exchange" => $rData['exchange'] ?? '', + "{$prefix}Preference" => $rData['preference'] ?? 10, + ], + 'NS' => ["{$prefix}NameServer" => $rData['nameServer'] ?? ''], + 'TXT' => ["{$prefix}Text" => $rData['text'] ?? ''], + 'SRV' => [ + "{$prefix}Target" => $rData['target'] ?? '', + "{$prefix}Priority" => $rData['priority'] ?? 0, + "{$prefix}Weight" => $rData['weight'] ?? 0, + "{$prefix}Port" => $rData['port'] ?? 0, + ], + 'CAA' => [ + "{$prefix}Flags" => $rData['flags'] ?? 0, + "{$prefix}Tag" => $rData['tag'] ?? '', + "{$prefix}Value" => $rData['value'] ?? '', + ], + 'PTR' => ["{$prefix}PtrName" => $rData['ptrName'] ?? ''], + default => [], + }; + } + + /** + * Build the delete parameters from rData. + * Technitium needs the record data to identify which record to delete. + */ + public static function deleteParams(string $type, array $rData): array + { + return self::contentToApiParams( + $type, + self::rDataToContent($type, $rData), + self::rDataToPriority($type, $rData), + ); + } + + /** + * Parse a CAA record content string like: 0 issue "letsencrypt.org" + */ + private static function parseCaaContent(string $content): array + { + if (preg_match('/^(\d+)\s+(\S+)\s+"?([^"]*)"?$/', $content, $matches)) { + return [ + 'flags' => (int) $matches[1], + 'tag' => $matches[2], + 'value' => $matches[3], + ]; + } + + return ['flags' => 0, 'tag' => 'issue', 'value' => $content]; + } +} diff --git a/Services/TechnitiumClient.php b/Services/TechnitiumClient.php new file mode 100644 index 0000000..34d3953 --- /dev/null +++ b/Services/TechnitiumClient.php @@ -0,0 +1,81 @@ +apiToken; + + return $this->client()->get($this->url($endpoint), $params); + } + + /** + * Perform a POST request to the Technitium API. + * The token is included in the form data. + */ + public function post(string $endpoint, array $data = []): Response + { + $data['token'] = $this->apiToken; + + return $this->client() + ->asForm() + ->post($this->url($endpoint), $data); + } + + /** + * Check if the API response indicates success. + * Technitium uses {"status": "ok"} for successful responses. + */ + public function isSuccessful(Response $response): bool + { + return $response->successful() && $response->json('status') === 'ok'; + } + + /** + * Extract the response payload from a Technitium API response. + * Data lives under the "response" key. + */ + public function responseData(Response $response, ?string $key = null): mixed + { + $data = $response->json('response'); + + if ($key !== null) { + return $data[$key] ?? null; + } + + return $data; + } + + private function client(): PendingRequest + { + return Http::timeout(15)->connectTimeout(5); + } + + private function url(string $endpoint): string + { + return $this->serverUrl.'/api/'.ltrim($endpoint, '/'); + } +} diff --git a/Technitium.php b/Technitium.php new file mode 100644 index 0000000..fcff2cb --- /dev/null +++ b/Technitium.php @@ -0,0 +1,103 @@ + 'required|url', + 'api_token' => 'required|string', + 'zone_filter' => 'nullable|string', + ]; + } + + public function credentialData(array $input): array + { + return [ + 'server_url' => rtrim($input['server_url'], '/'), + 'api_token' => $input['api_token'], + 'zone_filter' => $input['zone_filter'] ?? '', + ]; + } + + public function connect(array $credentials): bool + { + return app(TestConnection::class)->test($credentials); + } + + public function getDomains(): array + { + return $this->zones()->list($this->getAllowedZones()); + } + + public function getDomain(string $domainId): array + { + return $this->zones()->find($domainId); + } + + public function getRecords(string $domainId): array + { + return $this->records()->list($domainId); + } + + public function createRecord(string $domainId, array $recordData): array + { + return $this->records()->create($domainId, $recordData); + } + + public function updateRecord(string $domainId, string $recordId, array $recordData): array + { + return $this->records()->update($domainId, $recordId, $recordData); + } + + public function deleteRecord(string $domainId, string $recordId): bool + { + return $this->records()->delete($domainId, $recordId); + } + + // ------------------------------------------------------------------------- + // Private Helpers + // ------------------------------------------------------------------------- + + private function client(): TechnitiumClient + { + return TechnitiumClient::fromCredentials($this->dnsProvider->credentials); + } + + private function zones(): ListZones + { + return new ListZones($this->client()); + } + + private function records(): ManageRecords + { + return new ManageRecords($this->client()); + } + + /** + * @return array + */ + private function getAllowedZones(): array + { + $filter = $this->dnsProvider->credentials['zone_filter'] ?? ''; + + if (! $filter) { + return []; + } + + return array_filter(array_map('trim', explode(',', $filter))); + } +}