Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Typed account read actions: `GetAccountSettingsTypedAsync`, `GetAccountResourcesTypedAsync`
- Subaccount actions: `AddAccountAsync`, `GetAccountsAsync`, `GetAccountAsync`, `UpdateAccountAsync`, `DeleteAccountAsync`
- Account settings/superuser actions: `UpdateAccountSettingsAsync`, `UpdateSuperuserSettingsAsync`, `UpdateChownAsync`
- DNS zone-record actions: `GetDnsRecordsAsync`, `GetDnsRecordAsync`, `AddDnsRecordAsync`, `UpdateDnsRecordAsync`, `DeleteDnsRecordAsync`, `ResetDnsSettingsAsync` — with the `DnsRecord` read model, the `DnsRecordType` enum plus a raw-type escape, and automatic `zone_host` trailing-dot normalization. Note: `ResetDnsSettingsAsync` is destructive — it discards all custom records of the zone
- DynDNS user actions: `GetDynDnsUsersAsync`, `GetDynDnsUserAsync`, `AddDynDnsUserAsync`, `UpdateDynDnsUserAsync`, `DeleteDynDnsUserAsync` — with the `DynDnsUser` read model (`add_ddnsuser`/`get_ddnsusers`/`update_ddnsuser`/`delete_ddnsuser`)
- Generic escape hatch `RequestAsync(action, params)` for any KAS action not yet wrapped
- Dependency-injection integration via `AddKasServer(...)`
- Multi-target support for net8.0, net9.0, and net10.0
Expand Down
100 changes: 100 additions & 0 deletions KASserver.Client/IKasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,104 @@ Task<KasResponse> RequestAsync(
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task DeleteMailForwardAsync(string mailForward, CancellationToken cancellationToken = default);

/// <summary>
/// Lists the DNS resource records of a zone (<c>get_dns_settings</c>). A missing trailing dot on
/// <paramref name="zoneHost"/> is added automatically.
/// </summary>
/// <param name="zoneHost">The DNS zone, e.g. <c>example.com</c> (or <c>example.com.</c>).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The resource records of the zone.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<IReadOnlyList<DnsRecord>> GetDnsRecordsAsync(string zoneHost, CancellationToken cancellationToken = default);

/// <summary>
/// Reads a single DNS resource record by its id (<c>get_dns_settings</c> with a <c>record_id</c>
/// filter). A missing trailing dot on <paramref name="zoneHost"/> is added automatically.
/// </summary>
/// <param name="zoneHost">The DNS zone the record belongs to, e.g. <c>example.com</c>.</param>
/// <param name="recordId">The technical record id to look up.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The matching record, or <c>null</c> when no record matches the id.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<DnsRecord?> GetDnsRecordAsync(string zoneHost, string recordId, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a DNS resource record (<c>add_dns_settings</c>).
/// </summary>
/// <remarks>
/// Requires the account's DNS-settings permission; without it KAS faults with
/// <c>dns_settings_not_allowed</c>. The same applies to <see cref="UpdateDnsRecordAsync"/>,
/// <see cref="DeleteDnsRecordAsync"/> and <see cref="ResetDnsSettingsAsync"/>.
/// </remarks>
/// <param name="record">The record to create.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The technical <c>record_id</c> generated by KAS, required for subsequent update/delete calls.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or did not return a record id.</exception>
Task<string> AddDnsRecordAsync(AddDnsRecord record, CancellationToken cancellationToken = default);

/// <summary>
/// Edits a DNS resource record (<c>update_dns_settings</c>). Identified by its <c>record_id</c>.
/// The record type cannot be changed via this action.
/// </summary>
/// <param name="recordId">The technical record id, as returned by <see cref="AddDnsRecordAsync"/> or <see cref="GetDnsRecordsAsync"/>.</param>
/// <param name="changes">The fields to change; unset fields are left unchanged.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task UpdateDnsRecordAsync(string recordId, UpdateDnsRecord changes, CancellationToken cancellationToken = default);

/// <summary>Deletes a DNS resource record (<c>delete_dns_settings</c>). Identified by its <c>record_id</c>.</summary>
/// <param name="recordId">The technical record id, as returned by <see cref="AddDnsRecordAsync"/> or <see cref="GetDnsRecordsAsync"/>.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task DeleteDnsRecordAsync(string recordId, CancellationToken cancellationToken = default);

/// <summary>
/// Resets the DNS settings of a whole zone to the KAS defaults (<c>reset_dns_settings</c>).
/// <b>Destructive</b>: discards all custom resource records of the zone. A missing trailing dot on
/// <paramref name="zoneHost"/> is added automatically.
/// </summary>
/// <param name="zoneHost">The DNS zone to reset, e.g. <c>example.com</c>.</param>
/// <param name="nameserver">The name server to apply (<c>nameserver</c>); when <c>null</c> the KAS default <c>ns5.kasserver.com</c> is used.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task ResetDnsSettingsAsync(string zoneHost, string? nameserver = null, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a DynDNS user (<c>add_ddnsuser</c>) that can update the A/AAAA record of
/// <c>{Label}.{Zone}</c> via DynDNS.
/// </summary>
/// <param name="user">The DynDNS user to create.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The technical DynDNS login generated by KAS, required for subsequent update/delete calls.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or did not return a DynDNS login.</exception>
Task<string> AddDynDnsUserAsync(AddDynDnsUser user, CancellationToken cancellationToken = default);

/// <summary>Lists the DynDNS users on the account (<c>get_ddnsusers</c>).</summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The DynDNS users on the account.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<IReadOnlyList<DynDnsUser>> GetDynDnsUsersAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Reads a single DynDNS user by its login (<c>get_ddnsusers</c> with a <c>ddns_login</c> filter).
/// </summary>
/// <param name="dyndnsLogin">The DynDNS login.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The DynDNS user, or <c>null</c> when no user matches the login.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<DynDnsUser?> GetDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default);

/// <summary>Edits a DynDNS user (<c>update_ddnsuser</c>). Identified by its <c>dyndns_login</c>.</summary>
/// <param name="dyndnsLogin">The DynDNS login, as returned by <see cref="AddDynDnsUserAsync"/> or <see cref="GetDynDnsUsersAsync"/>.</param>
/// <param name="changes">The fields to change; unset fields are left unchanged.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task UpdateDynDnsUserAsync(string dyndnsLogin, UpdateDynDnsUser changes, CancellationToken cancellationToken = default);

/// <summary>Deletes a DynDNS user (<c>delete_ddnsuser</c>). Identified by its <c>dyndns_login</c>.</summary>
/// <param name="dyndnsLogin">The DynDNS login.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default);
}
139 changes: 139 additions & 0 deletions KASserver.Client/KasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,143 @@ public Task DeleteMailForwardAsync(string mailForward, CancellationToken cancell
var parameters = new Dictionary<string, object?> { ["mail_forward"] = mailForward };
return _transport.CallAsync("delete_mailforward", parameters, cancellationToken);
}

/// <inheritdoc/>
public async Task<IReadOnlyList<DnsRecord>> GetDnsRecordsAsync(string zoneHost, CancellationToken cancellationToken = default)
{
var parameters = new Dictionary<string, object?> { ["zone_host"] = DnsZoneHost.Normalize(zoneHost) };
var response = await _transport.CallAsync("get_dns_settings", parameters, cancellationToken).ConfigureAwait(false);
return response.AsList().Select(DnsRecord.FromMap).ToList();
}

/// <inheritdoc/>
public async Task<DnsRecord?> GetDnsRecordAsync(string zoneHost, string recordId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(recordId);

var parameters = new Dictionary<string, object?>
{
["zone_host"] = DnsZoneHost.Normalize(zoneHost),
["record_id"] = recordId,
};
var response = await _transport.CallAsync("get_dns_settings", parameters, cancellationToken).ConfigureAwait(false);
return response.AsList().Select(DnsRecord.FromMap).FirstOrDefault();
}

/// <inheritdoc/>
public async Task<string> AddDnsRecordAsync(AddDnsRecord record, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(record);

var response = await _transport.CallAsync("add_dns_settings", record.ToParameters(), cancellationToken).ConfigureAwait(false);
return ExtractRecordId(response);
}

// add_dns_settings returns the generated record_id as the ReturnInfo scalar (verified live).
// The map branch is a defensive fallback in case KAS ever wraps it in a "record_id" field.
internal static string ExtractRecordId(KasResponse response)
{
var recordId = response.ReturnInfo switch
{
string scalar => scalar,
IReadOnlyDictionary<string, object?> map => map.GetValueOrDefault("record_id") as string,
_ => null,
};

return !string.IsNullOrWhiteSpace(recordId)
? recordId
: throw new KasApiException("add_dns_settings did not return a record id.", action: "add_dns_settings");
}

/// <inheritdoc/>
public Task UpdateDnsRecordAsync(string recordId, UpdateDnsRecord changes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(recordId);
ArgumentNullException.ThrowIfNull(changes);

return _transport.CallAsync("update_dns_settings", changes.ToParameters(recordId), cancellationToken);
}

/// <inheritdoc/>
public Task DeleteDnsRecordAsync(string recordId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(recordId);

var parameters = new Dictionary<string, object?> { ["record_id"] = recordId };
return _transport.CallAsync("delete_dns_settings", parameters, cancellationToken);
}

/// <inheritdoc/>
public Task ResetDnsSettingsAsync(string zoneHost, string? nameserver = null, CancellationToken cancellationToken = default)
{
var parameters = new Dictionary<string, object?> { ["zone_host"] = DnsZoneHost.Normalize(zoneHost) };

if (!string.IsNullOrWhiteSpace(nameserver))
parameters["nameserver"] = nameserver;

return _transport.CallAsync("reset_dns_settings", parameters, cancellationToken);
}

/// <inheritdoc/>
public async Task<string> AddDynDnsUserAsync(AddDynDnsUser user, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(user);

var response = await _transport.CallAsync("add_ddnsuser", user.ToParameters(), cancellationToken).ConfigureAwait(false);
return ExtractDynDnsLogin(response);
}

// add_ddnsuser returns the generated dyndns_login as the ReturnInfo scalar (verified live; the
// docs only state it returns "true", but ReturnInfo carries the login). The map branch is a
// defensive fallback in case KAS ever wraps it in a "dyndns_login"/"ddns_login" field.
internal static string ExtractDynDnsLogin(KasResponse response)
{
var login = response.ReturnInfo switch
{
string scalar => scalar,
IReadOnlyDictionary<string, object?> map =>
(map.GetValueOrDefault("dyndns_login") ?? map.GetValueOrDefault("ddns_login")) as string,
_ => null,
};

return !string.IsNullOrWhiteSpace(login)
? login
: throw new KasApiException("add_ddnsuser did not return a DynDNS login.", action: "add_ddnsuser");
}

/// <inheritdoc/>
public async Task<IReadOnlyList<DynDnsUser>> GetDynDnsUsersAsync(CancellationToken cancellationToken = default)
{
var response = await _transport.CallAsync("get_ddnsusers", null, cancellationToken).ConfigureAwait(false);
return response.AsList().Select(DynDnsUser.FromMap).ToList();
}

/// <inheritdoc/>
public async Task<DynDnsUser?> GetDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dyndnsLogin);

// get_ddnsusers filters on ddns_login (note: not dyndns_login like update/delete).
var parameters = new Dictionary<string, object?> { ["ddns_login"] = dyndnsLogin };
var response = await _transport.CallAsync("get_ddnsusers", parameters, cancellationToken).ConfigureAwait(false);
return response.AsList().Select(DynDnsUser.FromMap).FirstOrDefault();
}

/// <inheritdoc/>
public Task UpdateDynDnsUserAsync(string dyndnsLogin, UpdateDynDnsUser changes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dyndnsLogin);
ArgumentNullException.ThrowIfNull(changes);

return _transport.CallAsync("update_ddnsuser", changes.ToParameters(dyndnsLogin), cancellationToken);
}

/// <inheritdoc/>
public Task DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dyndnsLogin);

var parameters = new Dictionary<string, object?> { ["dyndns_login"] = dyndnsLogin };
return _transport.CallAsync("delete_ddnsuser", parameters, cancellationToken);
}
}
80 changes: 80 additions & 0 deletions KASserver.Client/Models/AddDnsRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.Globalization;

namespace KASserver;

/// <summary>
/// Parameters for creating a DNS resource record (<c>add_dns_settings</c>). The record type can be
/// given either as a <see cref="DnsRecordType"/> via <see cref="Type"/> or as a raw KAS string via
/// <see cref="RawType"/> — exactly one of the two must be set.
/// </summary>
public sealed class AddDnsRecord
{
/// <summary>
/// The DNS zone the record belongs to (<c>zone_host</c>), e.g. <c>example.com</c>. A missing
/// trailing dot is added automatically. Required.
/// </summary>
public required string ZoneHost { get; set; }

/// <summary>
/// The record type (<c>record_type</c>) as a known enum value. Set either this or
/// <see cref="RawType"/>, not both.
/// </summary>
public DnsRecordType? Type { get; set; }

/// <summary>
/// The record type (<c>record_type</c>) as a raw KAS string, for types not covered by
/// <see cref="DnsRecordType"/>. Set either this or <see cref="Type"/>, not both.
/// </summary>
public string? RawType { get; set; }

/// <summary>
/// The record name (<c>record_name</c>), e.g. <c>www</c>. Use an empty string for the zone apex —
/// that is how <c>get_dns_settings</c> returns it. Required (may be empty, but not whitespace-only).
/// </summary>
public required string RecordName { get; set; }

/// <summary>The record data (<c>record_data</c>), e.g. an IP, a target host or a TXT value. Required.</summary>
public required string RecordData { get; set; }

/// <summary>
/// The AUX value (<c>record_aux</c>), used as the priority for <c>MX</c>/<c>SRV</c> records and
/// <c>0</c> otherwise. Always sent because the KAS API requires it. Default: <c>0</c>.
/// </summary>
public int Aux { get; set; }

internal IReadOnlyDictionary<string, object?> ToParameters()
{
// An empty record_name is the zone apex (as get_dns_settings returns it), so allow "" — but
// reject null and whitespace-only values.
ArgumentNullException.ThrowIfNull(RecordName);
if (RecordName.Length > 0 && string.IsNullOrWhiteSpace(RecordName))
throw new ArgumentException("RecordName must be empty (zone apex) or a non-whitespace label.", nameof(RecordName));
ArgumentException.ThrowIfNullOrWhiteSpace(RecordData);
ArgumentOutOfRangeException.ThrowIfNegative(Aux, nameof(Aux));

return new Dictionary<string, object?>
{
["zone_host"] = DnsZoneHost.Normalize(ZoneHost),
["record_type"] = ResolveRecordType(),
["record_name"] = RecordName,
["record_data"] = RecordData,
["record_aux"] = Aux.ToString(CultureInfo.InvariantCulture),
};
}

private string ResolveRecordType()
{
var hasRawType = !string.IsNullOrWhiteSpace(RawType);

if (Type is not null && hasRawType)
throw new ArgumentException($"Set either {nameof(Type)} or {nameof(RawType)} on {nameof(AddDnsRecord)}, not both.");

if (Type is not null)
return Type.Value.ToKasValue();

if (hasRawType)
return RawType!.Trim();

throw new ArgumentException($"Either {nameof(Type)} or {nameof(RawType)} must be set on {nameof(AddDnsRecord)}.");
}
}
Loading