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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mailbox actions: `GetMailAccountsAsync`, `AddMailAccountAsync`, `UpdateMailAccountAsync`, `DeleteMailAccountAsync`
- Mail-forward actions: `GetMailForwardsAsync`, `AddMailForwardAsync`, `DeleteMailForwardAsync`
- Account read actions: `GetAccountSettingsAsync`, `GetAccountResourcesAsync`, `GetDomainsAsync`
- Typed account read actions: `GetAccountSettingsTypedAsync`, `GetAccountResourcesTypedAsync`
- Subaccount actions: `AddAccountAsync`, `GetAccountsAsync`, `GetAccountAsync`, `UpdateAccountAsync`, `DeleteAccountAsync`
- Account settings/superuser actions: `UpdateAccountSettingsAsync`, `UpdateSuperuserSettingsAsync`, `UpdateChownAsync`
- 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
74 changes: 74 additions & 0 deletions KASserver.Client/IKasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,80 @@ Task<KasResponse> RequestAsync(
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<IReadOnlyDictionary<string, object?>> GetAccountResourcesAsync(CancellationToken cancellationToken = default);

/// <summary>Reads the account settings as a typed model (<c>get_accountsettings</c>).</summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<AccountSettings> GetAccountSettingsTypedAsync(CancellationToken cancellationToken = default);

/// <summary>Reads the account resource quotas as a typed model (<c>get_accountresources</c>).</summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<AccountResources> GetAccountResourcesTypedAsync(CancellationToken cancellationToken = default);

/// <summary>Edits your own account settings (<c>update_accountsettings</c>).</summary>
/// <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 UpdateAccountSettingsAsync(UpdateAccountSettings changes, CancellationToken cancellationToken = default);

/// <summary>
/// Creates a subaccount (<c>add_account</c>). Requires a superuser login.
/// </summary>
/// <param name="account">The subaccount to create.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The <c>account_login</c> of the created subaccount, as returned by KAS.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or did not return an account login.</exception>
Task<string> AddAccountAsync(AddAccount account, CancellationToken cancellationToken = default);

/// <summary>Lists the subaccounts (<c>get_accounts</c>). Requires a superuser login.</summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<IReadOnlyList<SubAccount>> GetAccountsAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Reads a single subaccount by its login (<c>get_accounts</c> with an <c>account_login</c> filter).
/// Requires a superuser login.
/// </summary>
/// <param name="accountLogin">The subaccount login (e.g. <c>w01abcde</c>).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The subaccount, or <c>null</c> when no account matches the login.</returns>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task<SubAccount?> GetAccountAsync(string accountLogin, CancellationToken cancellationToken = default);

/// <summary>
/// Edits a subaccount (<c>update_account</c>). Identified by its <c>account_login</c>. Requires a superuser login.
/// </summary>
/// <param name="accountLogin">The subaccount login (e.g. <c>w01abcde</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 UpdateAccountAsync(string accountLogin, UpdateAccount changes, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes a subaccount (<c>delete_account</c>). Requires a superuser login.
/// <b>Irreversible</b>: removes the subaccount including all of its resources.
/// </summary>
/// <param name="accountLogin">The subaccount login (e.g. <c>w01abcde</c>).</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task DeleteAccountAsync(string accountLogin, CancellationToken cancellationToken = default);

/// <summary>
/// Edits the superuser-controlled settings (SSH access/keys) of a subaccount
/// (<c>update_superusersettings</c>). May only be executed by the main account.
/// </summary>
/// <param name="accountLogin">The subaccount login to edit (e.g. <c>w01abcde</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 UpdateSuperuserSettingsAsync(string accountLogin, UpdateSuperuserSettings changes, CancellationToken cancellationToken = default);

/// <summary>Changes ownership of a path (<c>update_chown</c>). Requires a superuser login.</summary>
/// <param name="chown">The path, new owner and recursion flag.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Task UpdateChownAsync(UpdateChown chown, CancellationToken cancellationToken = default);

/// <summary>Lists the domains on the account (<c>get_domains</c>).</summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <exception cref="KasApiException">The KAS API returned a fault or an uninterpretable response.</exception>
Expand Down
99 changes: 99 additions & 0 deletions KASserver.Client/KasClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,105 @@ public Task<KasResponse> RequestAsync(
return response.AsMap();
}

/// <inheritdoc/>
public async Task<AccountSettings> GetAccountSettingsTypedAsync(CancellationToken cancellationToken = default)
{
var settings = await GetAccountSettingsAsync(cancellationToken).ConfigureAwait(false);
return AccountSettings.FromMap(settings);
}

/// <inheritdoc/>
public async Task<AccountResources> GetAccountResourcesTypedAsync(CancellationToken cancellationToken = default)
{
var resources = await GetAccountResourcesAsync(cancellationToken).ConfigureAwait(false);
return AccountResources.FromMap(resources);
}

/// <inheritdoc/>
public Task UpdateAccountSettingsAsync(UpdateAccountSettings changes, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changes);

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

/// <inheritdoc/>
public async Task<string> AddAccountAsync(AddAccount account, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(account);

var response = await _transport.CallAsync("add_account", account.ToParameters(), cancellationToken).ConfigureAwait(false);
return ExtractAccountLogin(response);
}

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

return !string.IsNullOrWhiteSpace(login)
? login
: throw new KasApiException("add_account did not return an account login.", action: "add_account");
}

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

/// <inheritdoc/>
public async Task<SubAccount?> GetAccountAsync(string accountLogin, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountLogin);

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

/// <inheritdoc/>
public Task UpdateAccountAsync(string accountLogin, UpdateAccount changes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountLogin);
ArgumentNullException.ThrowIfNull(changes);

return _transport.CallAsync("update_account", changes.ToParameters(accountLogin), cancellationToken);
}

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

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

/// <inheritdoc/>
public Task UpdateSuperuserSettingsAsync(string accountLogin, UpdateSuperuserSettings changes, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(accountLogin);
ArgumentNullException.ThrowIfNull(changes);

return _transport.CallAsync("update_superusersettings", changes.ToParameters(accountLogin), cancellationToken);
}

/// <inheritdoc/>
public Task UpdateChownAsync(UpdateChown chown, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(chown);

return _transport.CallAsync("update_chown", chown.ToParameters(), cancellationToken);
}

/// <inheritdoc/>
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> GetDomainsAsync(CancellationToken cancellationToken = default)
{
Expand Down
18 changes: 18 additions & 0 deletions KASserver.Client/Models/AccountAccessState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace KASserver;

/// <summary>
/// The KAS login access state of a subaccount (KAS <c>kas_access_forbidden</c>). While
/// <c>add_account</c> only distinguishes allowed/forbidden, <c>update_account</c> additionally
/// accepts the explicit <c>forbidden</c> value.
/// </summary>
public enum AccountAccessState
{
/// <summary>KAS login is allowed (<c>N</c>).</summary>
Allowed,

/// <summary>KAS login is forbidden (<c>Y</c>).</summary>
Forbidden,

/// <summary>KAS login is explicitly forbidden (<c>forbidden</c>) — only valid for <c>update_account</c>.</summary>
ForbiddenExplicit,
}
41 changes: 41 additions & 0 deletions KASserver.Client/Models/AccountEnumExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace KASserver;

/// <summary>
/// Maps the account enums to their KAS string values, kept in one place so
/// <c>add_account</c>, <c>update_account</c> and <c>update_accountsettings</c> stay consistent.
/// </summary>
internal static class AccountEnumExtensions
{
internal static string ToKasValue(this AccountHostnameKind kind) => kind switch
{
AccountHostnameKind.Domain => "domain",
AccountHostnameKind.Subdomain => "subdomain",
AccountHostnameKind.None => string.Empty,
_ => throw new ArgumentOutOfRangeException(nameof(kind)),
};

internal static string ToKasValue(this AccountLogging logging) => logging switch
{
AccountLogging.Full => "voll",
AccountLogging.Short => "kurz",
AccountLogging.WithoutIp => "ohneip",
AccountLogging.None => "keine",
_ => throw new ArgumentOutOfRangeException(nameof(logging)),
};

internal static string ToKasValue(this AccountStatistic statistic) => statistic switch
{
AccountStatistic.None => "0",
AccountStatistic.De => "de",
AccountStatistic.Ne => "ne",
_ => throw new ArgumentOutOfRangeException(nameof(statistic)),
};

internal static string ToKasValue(this AccountAccessState state) => state switch
{
AccountAccessState.Allowed => "N",
AccountAccessState.Forbidden => "Y",
AccountAccessState.ForbiddenExplicit => "forbidden",
_ => throw new ArgumentOutOfRangeException(nameof(state)),
};
}
16 changes: 16 additions & 0 deletions KASserver.Client/Models/AccountHostnameKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace KASserver;

/// <summary>
/// The kind of hostname created for a subaccount (KAS <c>hostname_art</c>).
/// </summary>
public enum AccountHostnameKind
{
/// <summary>The hostname is a domain (<c>domain</c>).</summary>
Domain,

/// <summary>The hostname is a subdomain (<c>subdomain</c>).</summary>
Subdomain,

/// <summary>No hostname is created (empty value).</summary>
None,
}
19 changes: 19 additions & 0 deletions KASserver.Client/Models/AccountLogging.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace KASserver;

/// <summary>
/// The web server log mode of an account (KAS <c>logging</c>).
/// </summary>
public enum AccountLogging
{
/// <summary>Full logging including the visitor IP (<c>voll</c>).</summary>
Full,

/// <summary>Short logging (<c>kurz</c>).</summary>
Short,

/// <summary>Logging without the visitor IP (<c>ohneip</c>).</summary>
WithoutIp,

/// <summary>No logging (<c>keine</c>).</summary>
None,
}
74 changes: 74 additions & 0 deletions KASserver.Client/Models/AccountQuota.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
namespace KASserver;

/// <summary>
/// The resource quotas of a subaccount, shared by <c>add_account</c> and <c>update_account</c>.
/// Only properties that are set (non-null) are sent. On <c>add_account</c> an unset quota means the
/// KAS default (<c>0</c> = unlimited/none depending on the resource); on <c>update_account</c> it
/// means the value stays unchanged. All values must be non-negative.
/// </summary>
public sealed class AccountQuota
{
/// <summary>Maximum number of subaccounts (<c>max_account</c>).</summary>
public int? MaxAccount { get; set; }

/// <summary>Maximum number of domains (<c>max_domain</c>).</summary>
public int? MaxDomain { get; set; }

/// <summary>Maximum number of subdomains (<c>max_subdomain</c>).</summary>
public int? MaxSubdomain { get; set; }

/// <summary>Maximum webspace in MB (<c>max_webspace</c>).</summary>
public int? MaxWebspace { get; set; }

/// <summary>Maximum number of mailboxes (<c>max_mail_account</c>).</summary>
public int? MaxMailAccount { get; set; }

/// <summary>Maximum number of mail forwards (<c>max_mail_forward</c>).</summary>
public int? MaxMailForward { get; set; }

/// <summary>Maximum number of mailing lists (<c>max_mailinglist</c>).</summary>
public int? MaxMailinglist { get; set; }

/// <summary>Maximum number of databases (<c>max_database</c>).</summary>
public int? MaxDatabase { get; set; }

/// <summary>Maximum number of FTP users (<c>max_ftpuser</c>).</summary>
public int? MaxFtpUser { get; set; }

/// <summary>Maximum number of Samba users (<c>max_sambauser</c>).</summary>
public int? MaxSambaUser { get; set; }

/// <summary>Maximum number of cron jobs (<c>max_cronjobs</c>).</summary>
public int? MaxCronjobs { get; set; }

/// <summary>Maximum number of WebFTP accounts (<c>max_wbk</c>).</summary>
public int? MaxWbk { get; set; }

/// <summary>Adds every set quota field to <paramref name="parameters"/> as a string value.</summary>
/// <param name="parameters">The parameter map to populate.</param>
/// <exception cref="ArgumentOutOfRangeException">A quota value is negative.</exception>
internal void ApplyTo(IDictionary<string, object?> parameters)
{
Add(parameters, "max_account", MaxAccount);
Add(parameters, "max_domain", MaxDomain);
Add(parameters, "max_subdomain", MaxSubdomain);
Add(parameters, "max_webspace", MaxWebspace);
Add(parameters, "max_mail_account", MaxMailAccount);
Add(parameters, "max_mail_forward", MaxMailForward);
Add(parameters, "max_mailinglist", MaxMailinglist);
Add(parameters, "max_database", MaxDatabase);
Add(parameters, "max_ftpuser", MaxFtpUser);
Add(parameters, "max_sambauser", MaxSambaUser);
Add(parameters, "max_cronjobs", MaxCronjobs);
Add(parameters, "max_wbk", MaxWbk);
}

private static void Add(IDictionary<string, object?> parameters, string key, int? value)
{
if (value is null)
return;

ArgumentOutOfRangeException.ThrowIfNegative(value.Value, key);
parameters[key] = value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
Loading