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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`)
- Subdomain actions: `GetSubdomainsAsync`, `GetSubdomainAsync`, `AddSubdomainAsync`, `UpdateSubdomainAsync`, `DeleteSubdomainAsync`, `MoveSubdomainAsync` — with the `Subdomain` read model and the `RedirectStatus`/`WebalizerVersion`/`WebalizerLanguage` enums (`add_subdomain`/`get_subdomains`/`update_subdomain`/`delete_subdomain`/`move_subdomain`). `AddSubdomainAsync` returns the created host name. Note: KAS provisions a new subdomain asynchronously (`in_progress = TRUE`) and rejects updates until that clears
- 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
50 changes: 50 additions & 0 deletions KASserver.Client/IKasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,54 @@ 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 DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default);

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

/// <summary>
/// Reads a single subdomain by its host name (<c>get_subdomains</c> with a <c>subdomain_name</c> filter).
/// </summary>
/// <param name="subdomainName">The subdomain host name, e.g. <c>shop.example.com</c>.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The matching subdomain, or <c>null</c> when no subdomain matches the name.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<Subdomain?> GetSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a subdomain (<c>add_subdomain</c>). The new host is <c>{SubdomainName}.{DomainName}</c>.
/// </summary>
/// <remarks>
/// KAS provisions the subdomain asynchronously: it reports <c>in_progress = TRUE</c> for a short
/// while after creation, and <see cref="UpdateSubdomainAsync"/> is rejected with an
/// <c>in_progress</c> fault until that clears.
/// </remarks>
/// <param name="subdomain">The subdomain to create.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The full host name of the created subdomain (<c>{SubdomainName}.{DomainName}</c>), used to address it in subsequent update/delete/get calls.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or did not return a subdomain name.</exception>
Task<string> AddSubdomainAsync(AddSubdomain subdomain, CancellationToken cancellationToken = default);

/// <summary>Edits a subdomain (<c>update_subdomain</c>). Identified by its host name.</summary>
/// <param name="subdomainName">The subdomain host name, e.g. <c>shop.example.com</c>.</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 UpdateSubdomainAsync(string subdomainName, UpdateSubdomain changes, CancellationToken cancellationToken = default);

/// <summary>Deletes a subdomain (<c>delete_subdomain</c>). Identified by its host name.</summary>
/// <param name="subdomainName">The subdomain host name, e.g. <c>shop.example.com</c>.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task DeleteSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default);

/// <summary>Moves a subdomain between accounts (<c>move_subdomain</c>). Identified by its host name.</summary>
/// <param name="subdomainName">The subdomain host name, e.g. <c>shop.example.com</c>.</param>
/// <param name="sourceAccount">The source account the subdomain currently belongs to (<c>source_account</c>).</param>
/// <param name="targetAccount">The target account the subdomain is moved to (<c>target_account</c>).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task MoveSubdomainAsync(string subdomainName, string sourceAccount, string targetAccount, CancellationToken cancellationToken = default);
}
77 changes: 77 additions & 0 deletions KASserver.Client/KasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -353,4 +353,81 @@ public Task DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancella
var parameters = new Dictionary<string, object?> { ["dyndns_login"] = dyndnsLogin };
return _transport.CallAsync("delete_ddnsuser", parameters, cancellationToken);
}

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

/// <inheritdoc/>
public async Task<Subdomain?> GetSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName);

var parameters = new Dictionary<string, object?> { ["subdomain_name"] = subdomainName };
var response = await _transport.CallAsync("get_subdomains", parameters, cancellationToken).ConfigureAwait(false);
return response.AsList().Select(Subdomain.FromMap).FirstOrDefault();
}

/// <inheritdoc/>
public async Task<string> AddSubdomainAsync(AddSubdomain subdomain, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(subdomain);

var response = await _transport.CallAsync("add_subdomain", subdomain.ToParameters(), cancellationToken).ConfigureAwait(false);
return ExtractSubdomainName(response);
}

// add_subdomain returns the full host name (subdomain_name.domain_name) as the ReturnInfo scalar
// (verified live; the docs only state it returns "TRUE", but ReturnInfo carries the host name).
// The map branch is a defensive fallback in case KAS ever wraps it in a "subdomain_name" field.
internal static string ExtractSubdomainName(KasResponse response)
{
var name = response.ReturnInfo switch
{
string scalar => scalar,
IReadOnlyDictionary<string, object?> map => map.GetValueOrDefault("subdomain_name") as string,
_ => null,
};

return !string.IsNullOrWhiteSpace(name)
? name
: throw new KasApiException("add_subdomain did not return a subdomain name.", action: "add_subdomain");
}

/// <inheritdoc/>
public Task UpdateSubdomainAsync(string subdomainName, UpdateSubdomain changes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName);
ArgumentNullException.ThrowIfNull(changes);

return _transport.CallAsync("update_subdomain", changes.ToParameters(subdomainName), cancellationToken);
}

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

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

/// <inheritdoc/>
public Task MoveSubdomainAsync(string subdomainName, string sourceAccount, string targetAccount, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceAccount);
ArgumentException.ThrowIfNullOrWhiteSpace(targetAccount);

var parameters = new Dictionary<string, object?>
{
["subdomain_name"] = subdomainName,
["source_account"] = sourceAccount,
["target_account"] = targetAccount,
};
return _transport.CallAsync("move_subdomain", parameters, cancellationToken);
}
}
75 changes: 75 additions & 0 deletions KASserver.Client/Models/AddSubdomain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
namespace KASserver;

/// <summary>
/// Parameters for creating a subdomain (<c>add_subdomain</c>). The new host is
/// <c>{SubdomainName}.{DomainName}</c>. Only the two name parts are required; every other property is
/// optional and only sent when set, leaving the KAS defaults in place
/// (<c>redirect_status</c> = no redirect, <c>statistic_version</c> = 5, <c>statistic_language</c> = de,
/// <c>php_version</c> = 7.1).
/// </summary>
public sealed class AddSubdomain
{
/// <summary>The subdomain label (<c>subdomain_name</c>), e.g. <c>shop</c> for <c>shop.example.com</c>. Required.</summary>
public required string SubdomainName { get; set; }

/// <summary>The domain the label is added to (<c>domain_name</c>), e.g. <c>example.com</c>. Required.</summary>
public required string DomainName { get; set; }

/// <summary>
/// The host path within the account, or — when a redirect is set — a target FQDN or WBK
/// (<c>subdomain_path</c>). Examples: <c>/path/</c>, <c>http://domain.tld</c>, <c>wbk:wbk000001</c>.
/// Optional.
/// </summary>
public string? SubdomainPath { get; set; }

/// <summary>The redirect behaviour (<c>redirect_status</c>); defaults to no redirect on the server. Optional.</summary>
public RedirectStatus? Redirect { get; set; }

/// <summary>The webalizer statistics version (<c>statistic_version</c>); defaults to <c>5</c> on the server. Optional.</summary>
public WebalizerVersion? StatisticVersion { get; set; }

/// <summary>The webalizer statistics language (<c>statistic_language</c>); defaults to <c>de</c> on the server. Optional.</summary>
public WebalizerLanguage? StatisticLanguage { get; set; }

/// <summary>
/// The PHP version (<c>php_version</c>), a raw KAS version string such as <c>8.3</c>; defaults to
/// <c>7.1</c> on the server. The documented <c>5.X|7.X</c> format is outdated — current accounts
/// also accept <c>8.x</c>. Optional.
/// </summary>
public string? PhpVersion { get; set; }

internal IReadOnlyDictionary<string, object?> ToParameters()
{
ArgumentException.ThrowIfNullOrWhiteSpace(SubdomainName);
ArgumentException.ThrowIfNullOrWhiteSpace(DomainName);

var parameters = new Dictionary<string, object?>
{
["subdomain_name"] = SubdomainName,
["domain_name"] = DomainName,
};

if (SubdomainPath is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(SubdomainPath);
parameters["subdomain_path"] = SubdomainPath;
}

if (Redirect is not null)
parameters["redirect_status"] = Redirect.Value.ToKasValue();

if (StatisticVersion is not null)
parameters["statistic_version"] = StatisticVersion.Value.ToKasValue();

if (StatisticLanguage is not null)
parameters["statistic_language"] = StatisticLanguage.Value.ToKasValue();

if (PhpVersion is not null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(PhpVersion);
parameters["php_version"] = PhpVersion;
}

return parameters;
}
}
21 changes: 21 additions & 0 deletions KASserver.Client/Models/RedirectStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace KASserver;

/// <summary>
/// The redirect behaviour of a subdomain (KAS <c>redirect_status</c>). When <see cref="None"/> the
/// subdomain serves the host path; otherwise it issues an HTTP redirect to the FQDN/WBK given as the
/// subdomain path.
/// </summary>
public enum RedirectStatus
{
/// <summary>No redirect (<c>0</c>); the subdomain serves its host path.</summary>
None,

/// <summary>Permanent redirect, HTTP <c>301</c>.</summary>
MovedPermanently,

/// <summary>Temporary redirect, HTTP <c>302</c>.</summary>
Found,

/// <summary>Temporary redirect, HTTP <c>307</c>.</summary>
TemporaryRedirect,
}
84 changes: 84 additions & 0 deletions KASserver.Client/Models/Subdomain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace KASserver;

/// <summary>
/// A subdomain as returned by <c>get_subdomains</c>. Typed convenience properties cover the common
/// fields; <see cref="Raw"/> exposes the complete map for everything else.
/// </summary>
/// <remarks>
/// Field shape verified live against the KAS API (<c>get_subdomains</c> on famgmbh.de). Note that the
/// response field names differ from the request parameter names: the redirect status is returned as
/// <c>subdomain_redirect_status</c> (the request uses <c>redirect_status</c>), while
/// <c>subdomain_name</c> is the full host name, e.g. <c>shop.example.com</c>. Freshly created
/// subdomains report <c>in_progress = TRUE</c> while KAS provisions them; updates are rejected with an
/// <c>in_progress</c> fault until that clears.
/// </remarks>
public sealed class Subdomain
{
/// <summary>The complete raw field map as returned by the KAS API.</summary>
public required IReadOnlyDictionary<string, object?> Raw { get; init; }

/// <summary>The subdomain host name (KAS field <c>subdomain_name</c>), e.g. <c>shop.example.com</c>.</summary>
public string? SubdomainName => Raw.GetValueOrDefault("subdomain_name") as string;

/// <summary>The host path or redirect target (KAS field <c>subdomain_path</c>).</summary>
public string? SubdomainPath => Raw.GetValueOrDefault("subdomain_path") as string;

/// <summary>The raw redirect-status string as returned by KAS (KAS field <c>subdomain_redirect_status</c>).</summary>
public string? RawRedirectStatus => Raw.GetValueOrDefault("subdomain_redirect_status") as string;

/// <summary>
/// The redirect behaviour as a <see cref="RedirectStatus"/>, or <c>null</c> when KAS returned a
/// value not covered by the enum (the raw string is then available via <see cref="RawRedirectStatus"/>).
/// </summary>
public RedirectStatus? Redirect => SubdomainEnumExtensions.TryParseRedirectStatus(RawRedirectStatus);

/// <summary>The PHP version (KAS field <c>php_version</c>), a raw version string such as <c>8.5</c>.</summary>
public string? PhpVersion => Raw.GetValueOrDefault("php_version") as string;

/// <summary>Whether the configured PHP version is deprecated (KAS field <c>php_deprecated</c>, <c>Y</c>/<c>N</c>).</summary>
public bool PhpDeprecated => IsYes("php_deprecated");

/// <summary>The raw webalizer-version string as returned by KAS (KAS field <c>statistic_version</c>).</summary>
public string? RawStatisticVersion => Raw.GetValueOrDefault("statistic_version") as string;

/// <summary>The webalizer statistics version as a <see cref="WebalizerVersion"/>, or <c>null</c> for an unknown/missing value.</summary>
public WebalizerVersion? StatisticVersion => SubdomainEnumExtensions.TryParseWebalizerVersion(RawStatisticVersion);

/// <summary>The raw webalizer-language string as returned by KAS (KAS field <c>statistic_language</c>).</summary>
public string? RawStatisticLanguage => Raw.GetValueOrDefault("statistic_language") as string;

/// <summary>The webalizer statistics language as a <see cref="WebalizerLanguage"/>, or <c>null</c> for an unknown/missing value.</summary>
public WebalizerLanguage? StatisticLanguage => SubdomainEnumExtensions.TryParseWebalizerLanguage(RawStatisticLanguage);

/// <summary>The account the subdomain belongs to (KAS field <c>subdomain_account</c>).</summary>
public string? Account => Raw.GetValueOrDefault("subdomain_account") as string;

/// <summary>The server hosting the subdomain (KAS field <c>subdomain_server</c>).</summary>
public string? Server => Raw.GetValueOrDefault("subdomain_server") as string;

/// <summary>Whether the subdomain is active (KAS field <c>is_active</c>, <c>Y</c>/<c>N</c>).</summary>
public bool IsActive => IsYes("is_active");

/// <summary>
/// Whether KAS is still provisioning the subdomain (KAS field <c>in_progress</c>, <c>TRUE</c>/<c>FALSE</c>).
/// Updates are rejected with an <c>in_progress</c> fault while this is <c>true</c>.
/// </summary>
public bool InProgress => string.Equals(Raw.GetValueOrDefault("in_progress") as string, "TRUE", StringComparison.OrdinalIgnoreCase);

/// <summary>Whether FrontPage Server Extensions are active (KAS field <c>fpse_active</c>, <c>Y</c>/<c>N</c>).</summary>
public bool FpseActive => IsYes("fpse_active");

/// <summary>Whether the SSL proxy is enabled (KAS field <c>ssl_proxy</c>, <c>Y</c>/<c>N</c>).</summary>
public bool SslProxy => IsYes("ssl_proxy");

/// <summary>Whether an IP-based SSL certificate is present (KAS field <c>ssl_certificate_ip</c>, <c>Y</c>/<c>N</c>).</summary>
public bool SslCertificateIp => IsYes("ssl_certificate_ip");

/// <summary>Whether an SNI-based SSL certificate is present (KAS field <c>ssl_certificate_sni</c>, <c>Y</c>/<c>N</c>).</summary>
public bool SslCertificateSni => IsYes("ssl_certificate_sni");

private bool IsYes(string key) =>
string.Equals(Raw.GetValueOrDefault(key) as string, "Y", StringComparison.OrdinalIgnoreCase);

internal static Subdomain FromMap(IReadOnlyDictionary<string, object?> map) => new() { Raw = map };
}
Loading