diff --git a/src/UnifiSharp.Cli/LegacyCli.cs b/src/UnifiSharp.Cli/LegacyCli.cs new file mode 100644 index 0000000..4004a58 --- /dev/null +++ b/src/UnifiSharp.Cli/LegacyCli.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using UnifiSharp.Legacy; + +// Legacy controller write commands for the CLI. Isolated here so the one +// CS0618 suppression (the legacy client is intentionally [Obsolete]) doesn't +// spread across Program.cs. +#pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003) + +internal static class LegacyCli +{ + private static readonly string[] Resources = ["portforward", "firewallgroup", "networkconf"]; + + private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; + + public static async Task RunAsync(string[] args) + { + if (args.Length < 2 || args[0] is "-h" or "--help") + { + Console.Error.WriteLine( + "Usage: unifisharp legacy [id] [--json '']"); + return args.Length == 0 ? 2 : 0; + } + + var verb = args[0].ToLowerInvariant(); + var resource = args[1].ToLowerInvariant(); + if (!Resources.Contains(resource)) + { + Console.Error.WriteLine($"Unknown resource '{resource}'. Expected: {string.Join(" | ", Resources)}"); + return 1; + } + + var options = UnifiLegacyOptions.TryFromEnvironment(); + if (options is null) + { + Console.Error.WriteLine( + "Missing legacy config. Set UNIFI_LEGACY_BASE_URL, UNIFI_USERNAME, UNIFI_PASSWORD (UNIFI_VERIFY_TLS=false for self-signed)."); + return 2; + } + + using var client = new UnifiLegacyClient(options); + + switch (verb) + { + case "list": + Dump(await ListAsync(client, resource)); + return 0; + + case "create": + var body = JsonArg(args); + if (body is null) + { + Console.Error.WriteLine("create requires --json ''."); + return 2; + } + Dump(await CreateAsync(client, resource, body)); + return 0; + + case "delete": + var id = args.ElementAtOrDefault(2); + if (string.IsNullOrEmpty(id) || id.StartsWith('-')) + { + Console.Error.WriteLine("delete requires an ."); + return 2; + } + await DeleteAsync(client, resource, id); + Console.WriteLine($"deleted {resource}/{id}"); + return 0; + + default: + Console.Error.WriteLine($"Unknown verb '{verb}'. Expected: list | create | delete"); + return 1; + } + } + + private static async Task ListAsync(UnifiLegacyClient c, string resource) => resource switch + { + "portforward" => await c.ListPortForwardsAsync(), + "firewallgroup" => await c.ListFirewallGroupsAsync(), + _ => await c.ListNetworksAsync(), + }; + + private static async Task CreateAsync(UnifiLegacyClient c, string resource, string json) => resource switch + { + "portforward" => await c.CreatePortForwardAsync(Parse(json)), + "firewallgroup" => await c.CreateFirewallGroupAsync(Parse(json)), + _ => await c.CreateNetworkAsync(Parse(json)), + }; + + private static Task DeleteAsync(UnifiLegacyClient c, string resource, string id) => resource switch + { + "portforward" => c.DeletePortForwardAsync(id), + "firewallgroup" => c.DeleteFirewallGroupAsync(id), + _ => c.DeleteNetworkAsync(id), + }; + + private static T Parse(string json) => + JsonSerializer.Deserialize(json) ?? throw new ArgumentException("--json did not parse to an object"); + + private static string? JsonArg(string[] args) + { + var i = Array.IndexOf(args, "--json"); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } + + private static void Dump(object value) => Console.WriteLine(JsonSerializer.Serialize(value, Json)); +} diff --git a/src/UnifiSharp.Cli/Program.cs b/src/UnifiSharp.Cli/Program.cs index 5670e6b..ea35ae0 100644 --- a/src/UnifiSharp.Cli/Program.cs +++ b/src/UnifiSharp.Cli/Program.cs @@ -24,12 +24,28 @@ clients List connected clients per site (name, type, ip, connectedAt) as JSON firewall List firewall zones, policies, and ACL rules per site as JSON wlans List WLANs/SSIDs per site (ssid, enabled, security) as JSON + legacy [id] [--json ''] + Legacy controller write API (port-forwards, firewall, VLANs). + resource: portforward | firewallgroup | networkconf + e.g. unifisharp legacy create portforward --json '{"name":"x","enabled":true, + "pfwd_interface":"wan","src":"any","dst_port":"443","fwd":"10.10.0.13", + "fwd_port":"443","proto":"tcp"}' + Config (env): UNIFI_BASE_URL (…/proxy/network/integration/v1), UNIFI_API_KEY, UNIFI_VERIFY_TLS (optional, 'false' for self-signed) + Legacy config (env): UNIFI_LEGACY_BASE_URL (…/proxy/network/api/s/default), + UNIFI_USERNAME, UNIFI_PASSWORD, UNIFI_VERIFY_TLS """); return 0; } +// The legacy write commands authenticate by session (user/pass), not X-API-KEY, +// and dispatch before the integration-client setup below. +if (command == "legacy") +{ + return await LegacyCli.RunAsync(args[1..]); +} + var options = UnifiClientOptions.TryFromEnvironment(); if (options is null) { diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs new file mode 100644 index 0000000..b97a1cf --- /dev/null +++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace UnifiSharp.Legacy; + +/// +/// Write client for the legacy UniFi controller REST API — port-forwards, +/// firewall groups, and networks/VLANs. The official integration API +/// () has no endpoints for these yet (rolling out through +/// 2026), so this is the stopgap write path. +/// Thin, isolated, deletion-ready (ADR-0003): the legacy API is +/// undocumented and version-brittle. When Ubiquiti ships the official write +/// endpoints, delete this whole namespace and switch callers to the generated +/// client. +/// +[Obsolete("Legacy undocumented UniFi controller API. Replace with the official integration API " + + "write endpoints once Ubiquiti ships them (rolling out through 2026); see ADR-0003. " + + "Isolated + deletion-ready by design.")] +public sealed class UnifiLegacyClient : IDisposable +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // create sends only set fields + }; + + private readonly UnifiLegacySession _session; + private readonly bool _ownsSession; + + public UnifiLegacyClient(UnifiLegacyOptions options) + : this(new UnifiLegacySession(options), ownsSession: true) { } + + public UnifiLegacyClient(UnifiLegacySession session, bool ownsSession = false) + { + ArgumentNullException.ThrowIfNull(session); + _session = session; + _ownsSession = ownsSession; + } + + // ── Port-forwards (rest/portforward) ────────────────────────────────────── + public Task> ListPortForwardsAsync(CancellationToken ct = default) + => ListAsync("portforward", ct); + + public Task CreatePortForwardAsync(UnifiPortForward spec, CancellationToken ct = default) + => CreateAsync("portforward", spec, ct); + + public Task DeletePortForwardAsync(string id, CancellationToken ct = default) + => DeleteAsync("portforward", id, ct); + + // ── Firewall groups (rest/firewallgroup) ────────────────────────────────── + public Task> ListFirewallGroupsAsync(CancellationToken ct = default) + => ListAsync("firewallgroup", ct); + + public Task CreateFirewallGroupAsync(UnifiFirewallGroup spec, CancellationToken ct = default) + => CreateAsync("firewallgroup", spec, ct); + + public Task DeleteFirewallGroupAsync(string id, CancellationToken ct = default) + => DeleteAsync("firewallgroup", id, ct); + + // ── Networks / VLANs (rest/networkconf) ─────────────────────────────────── + public Task> ListNetworksAsync(CancellationToken ct = default) + => ListAsync("networkconf", ct); + + public Task CreateNetworkAsync(UnifiNetwork spec, CancellationToken ct = default) + => CreateAsync("networkconf", spec, ct); + + public Task DeleteNetworkAsync(string id, CancellationToken ct = default) + => DeleteAsync("networkconf", id, ct); + + // ── Generic REST verbs over the {meta,data} envelope ────────────────────── + + private async Task> ListAsync(string resource, CancellationToken ct) + { + using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false); + return (await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false)).Data; + } + + private async Task CreateAsync(string resource, T spec, CancellationToken ct) + { + var json = JsonSerializer.Serialize(spec, SerializerOptions); + using var resp = await _session.SendAsync( + HttpMethod.Post, $"rest/{resource}", UnifiLegacySession.JsonBody(json), ct).ConfigureAwait(false); + var env = await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false); + return env.Data.Count > 0 + ? env.Data[0] + : throw new UnifiLegacyException($"create {resource}: ok but no object returned"); + } + + private async Task DeleteAsync(string resource, string id, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrEmpty(id); + using var resp = await _session.SendAsync( + HttpMethod.Delete, $"rest/{resource}/{id}", content: null, ct).ConfigureAwait(false); + await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false); // throws on rc != ok + } + + // Parse + validate the standard envelope. The legacy API returns HTTP 400 with + // a {meta:{rc:"error",msg:…}} body on validation failures, so surface the msg + // rather than the bare status code. + private static async Task> ReadEnvelopeAsync( + HttpResponseMessage resp, string resource, CancellationToken ct) + { + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + UnifiLegacyEnvelope? env = null; + try + { + env = JsonSerializer.Deserialize>(body, SerializerOptions); + } + catch (JsonException) + { + // fall through to the status/body error below + } + + if (env is null) + { + throw new UnifiLegacyException( + $"{resource}: unparseable response ({(int)resp.StatusCode}): {Truncate(body)}"); + } + + if (!env.Meta.IsOk) + { + throw new UnifiLegacyException($"{resource}: API error '{env.Meta.Msg ?? "unknown"}' (HTTP {(int)resp.StatusCode})"); + } + + return env; + } + + private static string Truncate(string s) => s.Length > 300 ? s[..300] : s; + + public void Dispose() + { + if (_ownsSession) + { + _session.Dispose(); + } + } +} diff --git a/src/UnifiSharp/Legacy/UnifiLegacyModels.cs b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs new file mode 100644 index 0000000..833c116 --- /dev/null +++ b/src/UnifiSharp/Legacy/UnifiLegacyModels.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Serialization; + +namespace UnifiSharp.Legacy; + +// DTOs for the legacy controller REST API. Each record doubles as the create +// request body and the response row: null properties are omitted on serialize +// (so a create sends only the fields you set), while the server echoes back +// _id / site_id on the response. Property names match the API's snake_case +// exactly — these shapes were captured from real round-trips against the +// .containers/unifi UniFi OS Server (see PR #224). + +/// The standard legacy envelope: { "meta": { "rc": "ok" }, "data": [ … ] }. +public sealed record UnifiLegacyEnvelope +{ + [JsonPropertyName("meta")] public UnifiLegacyMeta Meta { get; init; } = new(); + [JsonPropertyName("data")] public IReadOnlyList Data { get; init; } = []; +} + +/// Envelope meta. rc is "ok" or "error"; msg carries the API error code. +public sealed record UnifiLegacyMeta +{ + [JsonPropertyName("rc")] public string Rc { get; init; } = ""; + [JsonPropertyName("msg")] public string? Msg { get; init; } + public bool IsOk => string.Equals(Rc, "ok", StringComparison.OrdinalIgnoreCase); +} + +/// +/// A WAN→LAN port-forward (rest/portforward). Mirrors the homelab's +/// pangolin-https rule (WAN :443 → 10.10.0.13:443). +/// +public sealed record UnifiPortForward +{ + [JsonPropertyName("_id")] public string? Id { get; init; } + [JsonPropertyName("site_id")] public string? SiteId { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } + [JsonPropertyName("enabled")] public bool? Enabled { get; init; } + /// The WAN the rule binds to (e.g. wan, wan2, both). + [JsonPropertyName("pfwd_interface")] public string? PfwdInterface { get; init; } + /// Permitted source — any or a CIDR/IP. + [JsonPropertyName("src")] public string? Src { get; init; } + /// Destination (WAN) port or range, as a string (e.g. 443, 8080-8090). + [JsonPropertyName("dst_port")] public string? DstPort { get; init; } + /// Forward-to LAN IP. + [JsonPropertyName("fwd")] public string? Fwd { get; init; } + /// Forward-to LAN port or range, as a string. + [JsonPropertyName("fwd_port")] public string? FwdPort { get; init; } + /// tcp, udp, or tcp_udp. + [JsonPropertyName("proto")] public string? Proto { get; init; } + [JsonPropertyName("log")] public bool? Log { get; init; } +} + +/// +/// A firewall group (rest/firewallgroup) — a reusable set of ports or +/// addresses referenced by firewall rules. +/// +public sealed record UnifiFirewallGroup +{ + [JsonPropertyName("_id")] public string? Id { get; init; } + [JsonPropertyName("site_id")] public string? SiteId { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } + /// port-group, address-group, or ipv6-address-group. + [JsonPropertyName("group_type")] public string? GroupType { get; init; } + /// Members — ports (for a port-group) or addresses/CIDRs (for an address-group). + [JsonPropertyName("group_members")] public IReadOnlyList? GroupMembers { get; init; } +} + +/// +/// A network / VLAN (rest/networkconf). Only the commonly-managed fields +/// are typed; the server fills the rest with defaults on create. +/// +public sealed record UnifiNetwork +{ + [JsonPropertyName("_id")] public string? Id { get; init; } + [JsonPropertyName("site_id")] public string? SiteId { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } + /// Network role — typically corporate (a standard L3 network) or guest. + [JsonPropertyName("purpose")] public string? Purpose { get; init; } + [JsonPropertyName("vlan_enabled")] public bool? VlanEnabled { get; init; } + /// VLAN id, as a string (e.g. 1010). + [JsonPropertyName("vlan")] public string? Vlan { get; init; } + /// Gateway/CIDR, e.g. 10.10.0.1/16. + [JsonPropertyName("ip_subnet")] public string? IpSubnet { get; init; } + [JsonPropertyName("dhcpd_enabled")] public bool? DhcpdEnabled { get; init; } + [JsonPropertyName("dhcpd_start")] public string? DhcpdStart { get; init; } + [JsonPropertyName("dhcpd_stop")] public string? DhcpdStop { get; init; } + [JsonPropertyName("is_nat")] public bool? IsNat { get; init; } +} diff --git a/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs new file mode 100644 index 0000000..ab2a147 --- /dev/null +++ b/src/UnifiSharp/Legacy/UnifiLegacyOptions.cs @@ -0,0 +1,67 @@ +namespace UnifiSharp.Legacy; + +/// +/// Connection options for the legacy UniFi controller API +/// (/proxy/network/api/s/<site>/rest/…). Unlike the official +/// integration API (X-API-KEY), the legacy API uses the classic controller +/// session: POST /api/auth/login with a username + password yields +/// a TOKEN cookie + X-CSRF-Token, reused on subsequent calls. +/// This is intentionally session-based: it works against the +/// .containers/unifi UniFi OS Server test container (which exposes no +/// scriptable API-key mint) and any UniFi OS gateway. See ADR-0003. +/// +public sealed record UnifiLegacyOptions +{ + /// + /// Base URL of the legacy site API, e.g. + /// https://<host>/proxy/network/api/s/default. The controller root + /// (used for /api/auth/login) is derived from this URL's authority. + /// + public required Uri BaseUrl { get; init; } + + /// Local admin username for the session login. + public required string Username { get; init; } + + /// Local admin password for the session login. + public required string Password { get; init; } + + /// + /// Verify the console's TLS certificate. UniFi OS consoles commonly use a + /// self-signed cert on the LAN, so this can be turned off — defaults to on. + /// + public bool VerifyTls { get; init; } = true; + + /// The controller root (scheme + authority), where /api/auth/login lives. + public Uri ControllerRoot => new(BaseUrl.GetLeftPart(UriPartial.Authority)); + + /// + /// Build options from UNIFI_LEGACY_BASE_URL / UNIFI_USERNAME / + /// UNIFI_PASSWORD / UNIFI_VERIFY_TLS; null if any required value is missing. + /// Matches the env emitted by .containers/unifi/bootstrap.sh. + /// + public static UnifiLegacyOptions? TryFromEnvironment() + { + var baseUrl = Environment.GetEnvironmentVariable("UNIFI_LEGACY_BASE_URL"); + var username = Environment.GetEnvironmentVariable("UNIFI_USERNAME"); + var password = Environment.GetEnvironmentVariable("UNIFI_PASSWORD"); + if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + return null; + } + + var verifyTls = !string.Equals( + Environment.GetEnvironmentVariable("UNIFI_VERIFY_TLS"), "false", StringComparison.OrdinalIgnoreCase); + + return new UnifiLegacyOptions + { + BaseUrl = new Uri(baseUrl), + Username = username, + Password = password, + VerifyTls = verifyTls, + }; + } + + /// Redacted representation — never emits the password. + public override string ToString() => + $"UnifiLegacyOptions {{ BaseUrl = {BaseUrl}, Username = {Username}, Password = ***, VerifyTls = {VerifyTls} }}"; +} diff --git a/src/UnifiSharp/Legacy/UnifiLegacySession.cs b/src/UnifiSharp/Legacy/UnifiLegacySession.cs new file mode 100644 index 0000000..0fc9607 --- /dev/null +++ b/src/UnifiSharp/Legacy/UnifiLegacySession.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace UnifiSharp.Legacy; + +/// +/// Holds a classic UniFi controller session for the legacy API: logs in via +/// POST /api/auth/login (capturing the TOKEN cookie + CSRF token) +/// and sends authenticated requests, attaching the CSRF token to mutating calls +/// and transparently re-authenticating once on a 401. Disposable — owns its +/// . +/// +public sealed class UnifiLegacySession : IDisposable +{ + private readonly UnifiLegacyOptions _options; + private readonly HttpClient _http; + private readonly Uri _loginUrl; + private string? _csrfToken; + private bool _loggedIn; + + public UnifiLegacySession(UnifiLegacyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _loginUrl = new Uri(_options.ControllerRoot, "/api/auth/login"); + + var handler = new HttpClientHandler { CookieContainer = new CookieContainer(), UseCookies = true }; + if (!options.VerifyTls) + { + handler.ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + } + + _http = new HttpClient(handler); + } + + /// Ensure a valid session, logging in if needed. + public async Task LoginAsync(CancellationToken ct = default) + { + using var req = new HttpRequestMessage(HttpMethod.Post, _loginUrl) + { + Content = JsonContent.Create(new { username = _options.Username, password = _options.Password }), + }; + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + throw new UnifiLegacyException($"login failed ({(int)resp.StatusCode}): {Truncate(body)}"); + } + + CaptureCsrf(resp); + _loggedIn = true; + } + + /// + /// Send a request to a path relative to the legacy site base + /// (e.g. rest/portforward), returning the raw response. Attaches the + /// CSRF token; re-logs-in once on a 401. + /// + public async Task SendAsync( + HttpMethod method, string relativePath, HttpContent? content, CancellationToken ct = default) + { + if (!_loggedIn) + { + await LoginAsync(ct).ConfigureAwait(false); + } + + var resp = await SendOnceAsync(method, relativePath, content, ct).ConfigureAwait(false); + if (resp.StatusCode == HttpStatusCode.Unauthorized) + { + // Session expired — re-auth once and retry. + resp.Dispose(); + await LoginAsync(ct).ConfigureAwait(false); + resp = await SendOnceAsync(method, relativePath, content, ct).ConfigureAwait(false); + } + + return resp; + } + + private async Task SendOnceAsync( + HttpMethod method, string relativePath, HttpContent? content, CancellationToken ct) + { + // BaseUrl is the site base (…/api/s/); ensure a trailing slash so the + // relative path appends rather than replaces the last segment. + var baseWithSlash = _options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/"; + using var req = new HttpRequestMessage(method, new Uri(new Uri(baseWithSlash), relativePath)); + if (content is not null) + { + req.Content = content; + } + + if (_csrfToken is not null) + { + req.Headers.TryAddWithoutValidation("X-CSRF-Token", _csrfToken); + } + + var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + CaptureCsrf(resp); // the controller rotates the token via x-updated-csrf-token + return resp; + } + + // UniFi returns the CSRF token on login and rotates it via x-updated-csrf-token. + private void CaptureCsrf(HttpResponseMessage resp) + { + if (resp.Headers.TryGetValues("x-updated-csrf-token", out var rotated)) + { + _csrfToken = rotated.FirstOrDefault() ?? _csrfToken; + } + else if (resp.Headers.TryGetValues("x-csrf-token", out var current)) + { + _csrfToken = current.FirstOrDefault() ?? _csrfToken; + } + } + + internal static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static string Truncate(string s) => s.Length > 300 ? s[..300] : s; + + public void Dispose() => _http.Dispose(); +} + +/// Thrown when the legacy controller API returns a non-ok result. +public sealed class UnifiLegacyException : Exception +{ + public UnifiLegacyException(string message) : base(message) { } +} diff --git a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs new file mode 100644 index 0000000..fe18a3a --- /dev/null +++ b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using UnifiSharp.Legacy; + +namespace UnifiSharp.Tests; + +// Pure unit tests for the legacy write adapter — no controller required. +public class UnifiLegacyClientTests +{ + // Mirrors UnifiLegacyClient's serializer (create sends only set fields). + private static readonly JsonSerializerOptions Json = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static UnifiLegacyOptions Options(string baseUrl = "https://gw.lan:11443/proxy/network/api/s/default") => + new() { BaseUrl = new Uri(baseUrl), Username = "admin", Password = "pw" }; + + [Fact] + public void ControllerRoot_strips_path_to_authority() + { + // Login lives at the controller root, not under the site path. + Assert.Equal(new Uri("https://gw.lan:11443"), Options().ControllerRoot); + } + + [Fact] + public void VerifyTls_defaults_to_true() + { + Assert.True(Options().VerifyTls); + } + + [Fact] + public void ToString_does_not_leak_the_password() + { + var s = Options() with { Password = "super-secret" }; + Assert.DoesNotContain("super-secret", s.ToString()); + Assert.Contains("Password = ***", s.ToString()); + } + + [Fact] + public void PortForward_create_payload_uses_snake_case_and_omits_server_fields() + { + var spec = new UnifiPortForward + { + Name = "pangolin-https", + Enabled = true, + PfwdInterface = "wan", + Src = "any", + DstPort = "443", + Fwd = "10.10.0.13", + FwdPort = "443", + Proto = "tcp", + Log = false, + }; + + var json = JsonSerializer.Serialize(spec, Json); + + Assert.Contains("\"pfwd_interface\":\"wan\"", json); + Assert.Contains("\"dst_port\":\"443\"", json); + Assert.Contains("\"fwd\":\"10.10.0.13\"", json); + Assert.Contains("\"log\":false", json); // explicit false must be sent, not dropped + Assert.DoesNotContain("_id", json); // server-assigned — omitted on create + Assert.DoesNotContain("site_id", json); + } + + [Fact] + public void Envelope_deserializes_meta_and_data() + { + const string body = + """ + { "meta": { "rc": "ok" }, "data": [ + { "_id": "abc", "site_id": "s1", "name": "pf", "enabled": true, + "pfwd_interface": "wan", "dst_port": "443", "fwd": "10.10.0.13", + "fwd_port": "443", "proto": "tcp", "log": false } ] } + """; + + var env = JsonSerializer.Deserialize>(body, Json); + + Assert.NotNull(env); + Assert.True(env!.Meta.IsOk); + var pf = Assert.Single(env.Data); + Assert.Equal("abc", pf.Id); + Assert.Equal("s1", pf.SiteId); + Assert.Equal("10.10.0.13", pf.Fwd); + Assert.False(pf.Log); + } + + [Fact] + public void Envelope_error_rc_is_not_ok() + { + const string body = """{ "meta": { "rc": "error", "msg": "api.err.InvalidObject" }, "data": [] }"""; + var env = JsonSerializer.Deserialize>(body, Json); + + Assert.NotNull(env); + Assert.False(env!.Meta.IsOk); + Assert.Equal("api.err.InvalidObject", env.Meta.Msg); + } + + [Fact] + public void FirewallGroup_serializes_members_array() + { + var json = JsonSerializer.Serialize( + new UnifiFirewallGroup { Name = "g", GroupType = "port-group", GroupMembers = ["443", "80"] }, Json); + Assert.Contains("\"group_type\":\"port-group\"", json); + Assert.Contains("\"group_members\":[\"443\",\"80\"]", json); + } + + [Fact] + public void Network_create_payload_uses_snake_case() + { + var json = JsonSerializer.Serialize( + new UnifiNetwork { Name = "vlan10", Purpose = "corporate", VlanEnabled = true, Vlan = "10", IpSubnet = "10.10.0.1/24" }, Json); + Assert.Contains("\"vlan_enabled\":true", json); + Assert.Contains("\"ip_subnet\":\"10.10.0.1/24\"", json); + Assert.DoesNotContain("dhcpd_enabled", json); // unset → omitted + } +} diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs new file mode 100644 index 0000000..bc183f4 --- /dev/null +++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs @@ -0,0 +1,100 @@ +using UnifiSharp.Legacy; +using Xunit; + +namespace UnifiSharp.Tests; + +// Live write round-trips against a UniFi controller — the .containers/unifi test +// container (run ./bootstrap.sh, then `set -a && . ./credentials.env`). Runs only +// when UNIFI_LEGACY_BASE_URL / UNIFI_USERNAME / UNIFI_PASSWORD are set. Every test +// cleans up what it creates (add-only on the controller). +// +// One shared session for the whole class (IClassFixture): the controller +// rate-limits logins (HTTP 429), so we log in once and reuse the cookie. +#pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003) + +public sealed class LegacyContainerFixture : IDisposable +{ + public UnifiLegacyClient? Client { get; } = + UnifiLegacyOptions.TryFromEnvironment() is { } o ? new UnifiLegacyClient(o) : null; + + public void Dispose() => Client?.Dispose(); +} + +public class UnifiLegacyLiveTests : IClassFixture +{ + private readonly UnifiLegacyClient? _client; + public UnifiLegacyLiveTests(LegacyContainerFixture fixture) => _client = fixture.Client; + + [SkippableFact] + public async Task PortForward_create_list_delete_roundtrip() + { + var client = _client; + Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + + var created = await client!.CreatePortForwardAsync(new UnifiPortForward + { + Name = "unifisharp-test-pf", + Enabled = true, + PfwdInterface = "wan", + Src = "any", + DstPort = "65000", + Fwd = "10.10.0.13", + FwdPort = "65000", + Proto = "tcp", + Log = false, + }); + + try + { + Assert.False(string.IsNullOrEmpty(created.Id)); + Assert.Equal("10.10.0.13", created.Fwd); + + var list = await client.ListPortForwardsAsync(); + Assert.Contains(list, p => p.Id == created.Id && p.Name == "unifisharp-test-pf"); + } + finally + { + await client.DeletePortForwardAsync(created.Id!); + } + + var after = await client.ListPortForwardsAsync(); + Assert.DoesNotContain(after, p => p.Id == created.Id); + } + + [SkippableFact] + public async Task FirewallGroup_create_delete_roundtrip() + { + var client = _client; + Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + + var created = await client!.CreateFirewallGroupAsync(new UnifiFirewallGroup + { + Name = "unifisharp-test-grp", + GroupType = "port-group", + GroupMembers = ["65000", "65001"], + }); + + Assert.False(string.IsNullOrEmpty(created.Id)); + await client.DeleteFirewallGroupAsync(created.Id!); + } + + [SkippableFact] + public async Task Network_create_delete_roundtrip() + { + var client = _client; + Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + + var created = await client!.CreateNetworkAsync(new UnifiNetwork + { + Name = "unifisharp-test-vlan", + Purpose = "corporate", + VlanEnabled = true, + Vlan = "3990", // within UniFi's valid range (1–4009; 4010+ reserved) + IpSubnet = "10.231.0.1/24", + DhcpdEnabled = false, + }); + + Assert.False(string.IsNullOrEmpty(created.Id)); + await client.DeleteNetworkAsync(created.Id!); + } +}