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
106 changes: 106 additions & 0 deletions src/UnifiSharp.Cli/LegacyCli.cs
Original file line number Diff line number Diff line change
@@ -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<int> RunAsync(string[] args)
{
if (args.Length < 2 || args[0] is "-h" or "--help")
{
Console.Error.WriteLine(
"Usage: unifisharp legacy <list|create|delete> <portforward|firewallgroup|networkconf> [id] [--json '<body>']");
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 '<body>'.");
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 <id>.");
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<object> ListAsync(UnifiLegacyClient c, string resource) => resource switch
{
"portforward" => await c.ListPortForwardsAsync(),
"firewallgroup" => await c.ListFirewallGroupsAsync(),
_ => await c.ListNetworksAsync(),
};

private static async Task<object> CreateAsync(UnifiLegacyClient c, string resource, string json) => resource switch
{
"portforward" => await c.CreatePortForwardAsync(Parse<UnifiPortForward>(json)),
"firewallgroup" => await c.CreateFirewallGroupAsync(Parse<UnifiFirewallGroup>(json)),
_ => await c.CreateNetworkAsync(Parse<UnifiNetwork>(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<T>(string json) =>
JsonSerializer.Deserialize<T>(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));
}
16 changes: 16 additions & 0 deletions src/UnifiSharp.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <list|create|delete> <resource> [id] [--json '<body>']
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)
{
Expand Down
136 changes: 136 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace UnifiSharp.Legacy;

/// <summary>
/// Write client for the <b>legacy</b> UniFi controller REST API — port-forwards,
/// firewall groups, and networks/VLANs. The official integration API
/// (<see cref="UnifiApi"/>) has no endpoints for these yet (rolling out through
/// 2026), so this is the stopgap write path.
/// <para><b>Thin, isolated, deletion-ready</b> (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.</para>
/// </summary>
[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<IReadOnlyList<UnifiPortForward>> ListPortForwardsAsync(CancellationToken ct = default)
=> ListAsync<UnifiPortForward>("portforward", ct);

public Task<UnifiPortForward> 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<IReadOnlyList<UnifiFirewallGroup>> ListFirewallGroupsAsync(CancellationToken ct = default)
=> ListAsync<UnifiFirewallGroup>("firewallgroup", ct);

public Task<UnifiFirewallGroup> 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<IReadOnlyList<UnifiNetwork>> ListNetworksAsync(CancellationToken ct = default)
=> ListAsync<UnifiNetwork>("networkconf", ct);

public Task<UnifiNetwork> 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<IReadOnlyList<T>> ListAsync<T>(string resource, CancellationToken ct)
{
using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false);
return (await ReadEnvelopeAsync<T>(resp, resource, ct).ConfigureAwait(false)).Data;
}

private async Task<T> CreateAsync<T>(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<T>(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<JsonElement>(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<UnifiLegacyEnvelope<T>> ReadEnvelopeAsync<T>(
HttpResponseMessage resp, string resource, CancellationToken ct)
{
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
UnifiLegacyEnvelope<T>? env = null;
try
{
env = JsonSerializer.Deserialize<UnifiLegacyEnvelope<T>>(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();
}
}
}
87 changes: 87 additions & 0 deletions src/UnifiSharp/Legacy/UnifiLegacyModels.cs
Original file line number Diff line number Diff line change
@@ -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).

/// <summary>The standard legacy envelope: <c>{ "meta": { "rc": "ok" }, "data": [ … ] }</c>.</summary>
public sealed record UnifiLegacyEnvelope<T>
{
[JsonPropertyName("meta")] public UnifiLegacyMeta Meta { get; init; } = new();
[JsonPropertyName("data")] public IReadOnlyList<T> Data { get; init; } = [];
}

/// <summary>Envelope meta. <c>rc</c> is <c>"ok"</c> or <c>"error"</c>; <c>msg</c> carries the API error code.</summary>
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);
}

/// <summary>
/// A WAN→LAN port-forward (<c>rest/portforward</c>). Mirrors the homelab's
/// <c>pangolin-https</c> rule (WAN :443 → 10.10.0.13:443).
/// </summary>
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; }
/// <summary>The WAN the rule binds to (e.g. <c>wan</c>, <c>wan2</c>, <c>both</c>).</summary>
[JsonPropertyName("pfwd_interface")] public string? PfwdInterface { get; init; }
/// <summary>Permitted source — <c>any</c> or a CIDR/IP.</summary>
[JsonPropertyName("src")] public string? Src { get; init; }
/// <summary>Destination (WAN) port or range, as a string (e.g. <c>443</c>, <c>8080-8090</c>).</summary>
[JsonPropertyName("dst_port")] public string? DstPort { get; init; }
/// <summary>Forward-to LAN IP.</summary>
[JsonPropertyName("fwd")] public string? Fwd { get; init; }
/// <summary>Forward-to LAN port or range, as a string.</summary>
[JsonPropertyName("fwd_port")] public string? FwdPort { get; init; }
/// <summary><c>tcp</c>, <c>udp</c>, or <c>tcp_udp</c>.</summary>
[JsonPropertyName("proto")] public string? Proto { get; init; }
[JsonPropertyName("log")] public bool? Log { get; init; }
}

/// <summary>
/// A firewall group (<c>rest/firewallgroup</c>) — a reusable set of ports or
/// addresses referenced by firewall rules.
/// </summary>
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; }
/// <summary><c>port-group</c>, <c>address-group</c>, or <c>ipv6-address-group</c>.</summary>
[JsonPropertyName("group_type")] public string? GroupType { get; init; }
/// <summary>Members — ports (for a port-group) or addresses/CIDRs (for an address-group).</summary>
[JsonPropertyName("group_members")] public IReadOnlyList<string>? GroupMembers { get; init; }
}

/// <summary>
/// A network / VLAN (<c>rest/networkconf</c>). Only the commonly-managed fields
/// are typed; the server fills the rest with defaults on create.
/// </summary>
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; }
/// <summary>Network role — typically <c>corporate</c> (a standard L3 network) or <c>guest</c>.</summary>
[JsonPropertyName("purpose")] public string? Purpose { get; init; }
[JsonPropertyName("vlan_enabled")] public bool? VlanEnabled { get; init; }
/// <summary>VLAN id, as a string (e.g. <c>1010</c>).</summary>
[JsonPropertyName("vlan")] public string? Vlan { get; init; }
/// <summary>Gateway/CIDR, e.g. <c>10.10.0.1/16</c>.</summary>
[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; }
}
Loading
Loading