first commit
This commit is contained in:
commit
0d7b62539d
92
Actions/ListZones.php
Normal file
92
Actions/ListZones.php
Normal 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
235
Actions/ManageRecords.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
Actions/TestConnection.php
Normal file
33
Actions/TestConnection.php
Normal 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
41
Plugin.php
Normal 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
81
README.md
Normal 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
165
Services/RecordMapper.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
Services/TechnitiumClient.php
Normal file
81
Services/TechnitiumClient.php
Normal 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
103
Technitium.php
Normal 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user