first commit

This commit is contained in:
Corentin BARNICHON 2026-04-16 18:13:36 +02:00
commit 0d7b62539d
8 changed files with 831 additions and 0 deletions

92
Actions/ListZones.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services\TechnitiumClient;
use Illuminate\Support\Facades\Log;
use Throwable;
class ListZones
{
public function __construct(private readonly TechnitiumClient $client) {}
/**
* @param array<int, string> $allowedZones
* @return array<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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,
];
}
}

235
Actions/ManageRecords.php Normal file
View File

@ -0,0 +1,235 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services\RecordMapper;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services\TechnitiumClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Throwable;
class ManageRecords
{
private const int DEFAULT_TTL = 3600;
public function __construct(private readonly TechnitiumClient $client) {}
/**
* List all records for a zone.
*
* @return array<int, array<string, mixed>>
*
* @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<string, mixed>
*
* @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<string, mixed>
*
* @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'),
]);
}
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services\TechnitiumClient;
use Illuminate\Support\Facades\Log;
use Throwable;
class TestConnection
{
public function test(array $credentials): bool
{
try {
$client = TechnitiumClient::fromCredentials($credentials);
// Use zones/list as a connectivity test — if the token is valid,
// we get back a list of zones.
$response = $client->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;
}
}
}

41
Plugin.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns;
use App\DTOs\DynamicField;
use App\DTOs\DynamicForm;
use App\Plugins\AbstractPlugin;
use App\Plugins\RegisterDNSProvider;
class Plugin extends AbstractPlugin
{
protected string $name = 'Technitium DNS';
protected string $description = 'Technitium DNS Server provider integration';
public function boot(): void
{
RegisterDNSProvider::make(Technitium::id())
->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();
}
}

81
README.md Normal file
View File

@ -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.

165
Services/RecordMapper.php Normal file
View File

@ -0,0 +1,165 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services;
/**
* Handles the mapping between Technitium's rData objects (type-specific fields)
* and the flat content/priority format that Vito expects.
*
* Also handles encoding/decoding composite record IDs since Technitium
* doesn't have a unique record ID records are identified by domain + type + rData.
*/
class RecordMapper
{
/**
* Build a virtual record ID from the record's identifying fields.
* Technitium has no numeric record IDs, so we encode the full identity.
*/
public static function encodeRecordId(string $domain, string $type, array $rData): string
{
return base64_encode(json_encode([
'domain' => $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];
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class TechnitiumClient
{
public function __construct(
private readonly string $serverUrl,
private readonly string $apiToken,
) {}
public static function fromCredentials(array $credentials): self
{
return new self(
serverUrl: rtrim($credentials['server_url'], '/'),
apiToken: $credentials['api_token'],
);
}
/**
* Perform a GET request to the Technitium API.
* The token is always appended as a query parameter.
*/
public function get(string $endpoint, array $params = []): Response
{
$params['token'] = $this->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, '/');
}
}

103
Technitium.php Normal file
View File

@ -0,0 +1,103 @@
<?php
namespace App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns;
use App\DNSProviders\AbstractDNSProvider;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions\ListZones;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions\ManageRecords;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Actions\TestConnection;
use App\Vito\Plugins\LiittleCookie\VitoTechnitiumDns\Services\TechnitiumClient;
class Technitium extends AbstractDNSProvider
{
public static function id(): string
{
return 'technitium';
}
public function validationRules(array $input): array
{
return [
'server_url' => '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<int, string>
*/
private function getAllowedZones(): array
{
$filter = $this->dnsProvider->credentials['zone_filter'] ?? '';
if (! $filter) {
return [];
}
return array_filter(array_map('trim', explode(',', $filter)));
}
}