diff --git a/README.md b/README.md index e0b89b9..8f52b5d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,34 @@ nullable: on v2 a partial is not something you can send, so the type should not Static-DNS **wildcards are supported** and match arbitrary labels (`*.lab.example.com` answers `anything.lab.example.com`). +## Firewall policies (`UnifiSharp.Firewall`) + +Zone-based firewall policies (UniFi Network 9+) go through the **official** integration API, +`{UNIFI_BASE_URL}/v1/sites/{site}/firewall/policies`, which has full CRUD. They don't use the +legacy adapter. + +```csharp +using var fw = new UnifiFirewallClient(UnifiClientOptions.TryFromEnvironment()!); +var names = await fw.GetNamesAsync(); // zone + network name → id +var spec = new FirewallPolicySpec +{ + Name = "qbittorrent-peers-v6", + Source = new() { Zone = "External" }, + Destination = new() { Zone = "Homelab", Ipv6InterfaceId = "::6342:9", Ports = ["63429"] }, + IpVersion = FirewallIpVersion.IPv6, + Protocol = "tcp_udp", +}; +await fw.CreateAsync(spec, names); +``` + +- **`FirewallPolicySpec`** is the supported subset: zones, addresses (IP, CIDR, `a-b` range), networks, an IPv6 interface identifier, ports, IP version, protocol, action, logging and enabled. +- **Equality is semantic**, so `live == desired` is the drift check. +- **`FirewallPolicyJson.FromJson`** returns a reason instead of a spec for anything outside the subset: domain/app/region filters, schedules, matching lists and the like. A caller can never mistake "can't compare" for "matches". +- **`Ipv6InterfaceId`** is written with the `/::ffff:ffff:ffff:ffff` mask, so it matches the host suffix under *any* delegated prefix. Pinning a full IPv6 address breaks the day the ISP re-delegates. +- **Updates are PUT (full replacement).** Only ever write `USER_DEFINED` policies (`LiveFirewallPolicy.IsUserDefined`). +- **Ordering is not managed.** New user policies land at index 10000, ahead of the predefined allow/block defaults. +- Generated against the 10.4.57 spec and checked live against Network 10.6.106. + ## Status Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec. diff --git a/src/UnifiSharp/Firewall/FirewallPolicyJson.cs b/src/UnifiSharp/Firewall/FirewallPolicyJson.cs new file mode 100644 index 0000000..86e45b4 --- /dev/null +++ b/src/UnifiSharp/Firewall/FirewallPolicyJson.cs @@ -0,0 +1,243 @@ +using System.Text.Json.Nodes; + +namespace UnifiSharp.Firewall; + +/// Name ↔ id lookups the mapper needs. Built from the zone and network listings. +public sealed record FirewallNames( + IReadOnlyDictionary ZoneIds, + IReadOnlyDictionary NetworkIds) +{ + internal string ZoneName(Guid id) => ZoneIds.FirstOrDefault(kv => kv.Value == id).Key ?? id.ToString(); + internal string NetworkName(Guid id) => NetworkIds.FirstOrDefault(kv => kv.Value == id).Key ?? id.ToString(); + + internal Guid ZoneId(string name) => ZoneIds.TryGetValue(name, out var id) ? id + : throw new ArgumentException($"unknown firewall zone '{name}' (known: {string.Join(", ", ZoneIds.Keys)})"); + + internal Guid NetworkId(string name) => NetworkIds.TryGetValue(name, out var id) ? id + : throw new ArgumentException($"unknown network '{name}' (known: {string.Join(", ", NetworkIds.Keys)})"); +} + +/// +/// Maps to and from the integration API's JSON +/// (/v1/sites/{site}/firewall/policies). Hand-written rather than via the generated +/// Kiota models: those fan out into a separate class per IP version × protocol style × filter +/// kind, and a round-trippable subset is far clearer as the wire shape itself. +/// +public static class FirewallPolicyJson +{ + // The interface-identifier mask: the low 64 bits are the IID. + internal const string IidMask = "::ffff:ffff:ffff:ffff"; + + public static JsonObject ToJson(FirewallPolicySpec p, FirewallNames names) + { + var action = new JsonObject { ["type"] = p.Action.ToString().ToUpperInvariant() }; + if (p.Action == FirewallAction.Allow) action["allowReturnTraffic"] = p.AllowReturnTraffic; + + var scope = new JsonObject + { + ["ipVersion"] = p.IpVersion switch + { + FirewallIpVersion.IPv4 => "IPV4", + FirewallIpVersion.IPv6 => "IPV6", + _ => "IPV4_AND_IPV6", + }, + }; + var proto = p.Protocol.Trim().ToLowerInvariant(); + if (proto == "tcp_udp") + scope["protocolFilter"] = new JsonObject { ["type"] = "PRESET", ["preset"] = new JsonObject { ["name"] = "TCP_UDP" } }; + else if (proto != "all") + scope["protocolFilter"] = new JsonObject + { + ["type"] = "NAMED_PROTOCOL", + ["protocol"] = new JsonObject { ["name"] = proto.ToUpperInvariant() }, + ["matchOpposite"] = false, + }; + + var o = new JsonObject + { + ["enabled"] = p.Enabled, + ["name"] = p.Name, + ["action"] = action, + ["source"] = Endpoint(p.Source, names), + ["destination"] = Endpoint(p.Destination, names), + ["ipProtocolScope"] = scope, + ["loggingEnabled"] = p.Logging, + }; + if (!string.IsNullOrEmpty(p.Description)) o["description"] = p.Description; + return o; + } + + private static JsonObject Endpoint(FirewallEndpoint e, FirewallNames names) + { + var o = new JsonObject { ["zoneId"] = names.ZoneId(e.Zone).ToString() }; + var narrowings = (e.Addresses.Count > 0 ? 1 : 0) + (e.Networks.Count > 0 ? 1 : 0) + (e.Ipv6InterfaceId is null ? 0 : 1); + if (narrowings > 1) + throw new ArgumentException($"zone '{e.Zone}': pick ONE of addresses, networks or ipv6InterfaceId"); + + JsonObject? f = null; + if (e.Addresses.Count > 0) + f = new JsonObject + { + ["type"] = "IP_ADDRESS", + ["ipAddressFilter"] = new JsonObject + { + ["type"] = "IP_ADDRESSES", + ["matchOpposite"] = e.MatchOpposite, + ["items"] = new JsonArray(e.Addresses.Select(IpItem).ToArray()), + }, + }; + else if (e.Networks.Count > 0) + f = new JsonObject + { + ["type"] = "NETWORK", + ["networkFilter"] = new JsonObject + { + ["networkIds"] = new JsonArray(e.Networks.Select(n => (JsonNode)names.NetworkId(n).ToString()).ToArray()), + ["matchOpposite"] = e.MatchOpposite, + }, + }; + else if (e.Ipv6InterfaceId is { } iid) + f = new JsonObject + { + ["type"] = "IPV6_IID", + ["ipv6IidFilter"] = new JsonObject { ["ipv6Iid"] = $"{iid}/{IidMask}", ["matchOpposite"] = e.MatchOpposite }, + }; + + if (e.Ports.Count > 0) + { + var ports = new JsonObject + { + ["type"] = "PORTS", + ["matchOpposite"] = e.MatchOppositePorts, + ["items"] = new JsonArray(e.Ports.Select(PortItem).ToArray()), + }; + f ??= new JsonObject { ["type"] = "PORT" }; + f["portFilter"] = ports; + } + if (f is not null) o["trafficFilter"] = f; + return o; + } + + private static JsonNode IpItem(string a) + { + a = a.Trim(); + if (a.Contains('/')) return new JsonObject { ["type"] = "SUBNET", ["value"] = a }; + var dash = a.IndexOf('-'); + if (dash > 0) return new JsonObject { ["type"] = "IP_ADDRESS_RANGE", ["start"] = a[..dash].Trim(), ["stop"] = a[(dash + 1)..].Trim() }; + return new JsonObject { ["type"] = "IP_ADDRESS", ["value"] = a }; + } + + private static JsonNode PortItem(string p) + { + p = p.Trim(); + var dash = p.IndexOf('-'); + if (dash > 0) + return new JsonObject { ["type"] = "PORT_NUMBER_RANGE", ["start"] = int.Parse(p[..dash]), ["stop"] = int.Parse(p[(dash + 1)..]) }; + return new JsonObject { ["type"] = "PORT_NUMBER", ["value"] = int.Parse(p) }; + } + + /// + /// Read a live policy back into a spec. Returns Spec = null with a reason when the + /// policy uses something outside the supported subset (domain/app/region filters, schedules, + /// connection-state or IPsec filters, protocol numbers…), so a caller never mistakes + /// "can't compare" for "matches". + /// + public static (FirewallPolicySpec? Spec, string? Unsupported) FromJson(JsonObject live, FirewallNames names) + { + foreach (var k in new[] { "schedule", "connectionStateFilter", "ipsecFilter" }) + if (live[k] is { } n && n.GetValueKind() is not System.Text.Json.JsonValueKind.Null + && !(n is JsonArray { Count: 0 }) && !(k == "schedule" && n["mode"]?.GetValue() == "ALWAYS")) + return (null, $"uses {k}"); + + var actionType = live["action"]?["type"]?.GetValue() ?? ""; + if (!Enum.TryParse(actionType, ignoreCase: true, out var action)) return (null, $"action {actionType}"); + + var scope = live["ipProtocolScope"]; + var ipVersion = scope?["ipVersion"]?.GetValue() switch + { + "IPV4" => FirewallIpVersion.IPv4, + "IPV6" => FirewallIpVersion.IPv6, + _ => FirewallIpVersion.Both, + }; + var protocol = "all"; + if (scope?["protocolFilter"] is JsonObject pf) + { + switch (pf["type"]?.GetValue()) + { + case "PRESET": protocol = (pf["preset"]?["name"]?.GetValue() ?? "").ToLowerInvariant(); break; + case "NAMED_PROTOCOL" when pf["matchOpposite"]?.GetValue() != true: + protocol = (pf["protocol"]?["name"]?.GetValue() ?? "").ToLowerInvariant(); break; + default: return (null, $"protocol filter {pf["type"]}"); + } + } + + var (src, srcWhy) = ReadEndpoint(live["source"] as JsonObject, names); + if (src is null) return (null, $"source {srcWhy}"); + var (dst, dstWhy) = ReadEndpoint(live["destination"] as JsonObject, names); + if (dst is null) return (null, $"destination {dstWhy}"); + + var desc = live["description"]?.GetValue(); + return (new FirewallPolicySpec + { + Name = live["name"]?.GetValue() ?? "", + Description = string.IsNullOrEmpty(desc) ? null : desc, + Enabled = live["enabled"]?.GetValue() ?? true, + Action = action, + AllowReturnTraffic = action != FirewallAction.Allow || (live["action"]?["allowReturnTraffic"]?.GetValue() ?? false), + Source = src, + Destination = dst, + IpVersion = ipVersion, + Protocol = protocol, + Logging = live["loggingEnabled"]?.GetValue() ?? false, + }, null); + } + + private static (FirewallEndpoint? E, string? Why) ReadEndpoint(JsonObject? o, FirewallNames names) + { + if (o?["zoneId"]?.GetValue() is not { } zid || !Guid.TryParse(zid, out var zoneId)) return (null, "has no zone"); + var e = new FirewallEndpoint { Zone = names.ZoneName(zoneId) }; + if (o["trafficFilter"] is not JsonObject f) return (e, null); + + IReadOnlyList ports = []; + var oppPorts = false; + if (f["portFilter"] is JsonObject pf) + { + if (pf["type"]?.GetValue() != "PORTS") return (null, "uses a port matching list"); + oppPorts = pf["matchOpposite"]?.GetValue() ?? false; + ports = (pf["items"] as JsonArray ?? []).Select(i => i!["type"]?.GetValue() == "PORT_NUMBER_RANGE" + ? $"{i["start"]}-{i["stop"]}" : $"{i["value"]}").ToList(); + } + if (f["macAddressFilter"] is { } mac && mac.GetValueKind() != System.Text.Json.JsonValueKind.Null) return (null, "uses a MAC filter"); + + switch (f["type"]?.GetValue()) + { + case "PORT": + return (e with { Ports = ports, MatchOppositePorts = oppPorts }, null); + case "IP_ADDRESS": + var af = f["ipAddressFilter"]; + if (af?["type"]?.GetValue() != "IP_ADDRESSES") return (null, "uses an IP matching list"); + var addrs = (af["items"] as JsonArray ?? []).Select(i => i!["type"]?.GetValue() switch + { + "IP_ADDRESS_RANGE" => $"{i["start"]}-{i["stop"]}", + _ => i["value"]?.GetValue() ?? "", + }).ToList(); + return (e with { Addresses = addrs, MatchOpposite = af["matchOpposite"]?.GetValue() ?? false, Ports = ports, MatchOppositePorts = oppPorts }, null); + case "NETWORK": + var nf = f["networkFilter"]; + var nets = (nf?["networkIds"] as JsonArray ?? []).Select(n => Guid.TryParse(n?.GetValue(), out var g) ? names.NetworkName(g) : "").ToList(); + return (e with { Networks = nets, MatchOpposite = nf?["matchOpposite"]?.GetValue() ?? false, Ports = ports, MatchOppositePorts = oppPorts }, null); + case "IPV6_IID": + var raw = f["ipv6IidFilter"]?["ipv6Iid"]?.GetValue() ?? ""; + var slash = raw.IndexOf('/'); + if (slash >= 0 && raw[(slash + 1)..] != IidMask) return (null, $"uses a non-/64 IID mask ({raw})"); + return (e with + { + Ipv6InterfaceId = slash >= 0 ? raw[..slash] : raw, + MatchOpposite = f["ipv6IidFilter"]?["matchOpposite"]?.GetValue() ?? false, + Ports = ports, MatchOppositePorts = oppPorts, + }, null); + default: + return (null, $"uses a {f["type"]} filter"); + } + } +} diff --git a/src/UnifiSharp/Firewall/FirewallPolicySpec.cs b/src/UnifiSharp/Firewall/FirewallPolicySpec.cs new file mode 100644 index 0000000..af905e0 --- /dev/null +++ b/src/UnifiSharp/Firewall/FirewallPolicySpec.cs @@ -0,0 +1,71 @@ +namespace UnifiSharp.Firewall; + +public enum FirewallAction { Allow, Block, Reject } + +public enum FirewallIpVersion { IPv4, IPv6, Both } + +/// +/// One end of a zone-based firewall policy: the zone, plus at most one way of narrowing it +/// (addresses, networks, or an IPv6 interface identifier) and optionally ports. +/// +public sealed record FirewallEndpoint +{ + /// Zone NAME as the controller shows it (e.g. "External", "Homelab"); resolved to an id on write. + public required string Zone { get; init; } + + /// IPs, CIDR subnets (contain '/'), or ranges written "first-last". + public IReadOnlyList Addresses { get; init; } = []; + + /// Network NAMES (e.g. "Consumer"); resolved to ids on write. + public IReadOnlyList Networks { get; init; } = []; + + /// + /// IPv6 interface identifier, e.g. "::6342:9". Matches that suffix under ANY prefix, so a + /// rule survives the ISP re-delegating the prefix. Written to the API with the /64 IID mask. + /// + public string? Ipv6InterfaceId { get; init; } + + /// Ports or ranges, e.g. "443", "63429", "1000-2000". Empty = all ports. + public IReadOnlyList Ports { get; init; } = []; + + /// Invert the address/network/IID match ("everything except"). + public bool MatchOpposite { get; init; } + + /// Invert the port match. + public bool MatchOppositePorts { get; init; } + + public bool Equals(FirewallEndpoint? other) => + other is not null && Zone == other.Zone && Ipv6InterfaceId == other.Ipv6InterfaceId + && MatchOpposite == other.MatchOpposite && MatchOppositePorts == other.MatchOppositePorts + && SetEq(Addresses, other.Addresses) && SetEq(Networks, other.Networks) && SetEq(Ports, other.Ports); + + public override int GetHashCode() => HashCode.Combine(Zone, Ipv6InterfaceId, Addresses.Count, Ports.Count); + + internal static bool SetEq(IReadOnlyList a, IReadOnlyList b) => + a.Count == b.Count && a.Order(StringComparer.OrdinalIgnoreCase).SequenceEqual(b.Order(StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase); +} + +/// +/// Desired state of a zone-based firewall policy (UniFi Network 9+), in the subset of the +/// integration API's model this library can round-trip. Equality is semantic (list order and +/// case don't matter), so live == desired is the drift check. +/// +public sealed record FirewallPolicySpec +{ + public required string Name { get; init; } + public string? Description { get; init; } + public bool Enabled { get; init; } = true; + public FirewallAction Action { get; init; } = FirewallAction.Allow; + + /// Allow only: also create the mirrored return-traffic policy (the UI default). + public bool AllowReturnTraffic { get; init; } = true; + + public required FirewallEndpoint Source { get; init; } + public required FirewallEndpoint Destination { get; init; } + public FirewallIpVersion IpVersion { get; init; } = FirewallIpVersion.Both; + + /// "all" (no filter), "tcp_udp" (preset), or a named protocol such as "tcp", "udp", "gre". + public string Protocol { get; init; } = "all"; + + public bool Logging { get; init; } +} diff --git a/src/UnifiSharp/Firewall/UnifiFirewallClient.cs b/src/UnifiSharp/Firewall/UnifiFirewallClient.cs new file mode 100644 index 0000000..325edf2 --- /dev/null +++ b/src/UnifiSharp/Firewall/UnifiFirewallClient.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json.Nodes; + +namespace UnifiSharp.Firewall; + +public sealed record FirewallZone(Guid Id, string Name, string Origin); + +/// A policy as the controller holds it. is USER_DEFINED, +/// SYSTEM_DEFINED or DERIVED; only USER_DEFINED policies are ever written by this library's callers. +public sealed record LiveFirewallPolicy(Guid Id, string Name, string Origin, JsonObject Raw) +{ + public bool IsUserDefined => Origin == "USER_DEFINED"; +} + +/// +/// Zone-based firewall policies through the OFFICIAL integration API +/// ({BaseUrl}/v1/sites/{site}/firewall/…, X-API-KEY). Not the legacy adapter: the +/// integration API has full CRUD for policies (UniFi Network 9+), so there's no reason to +/// go through the undocumented surface (ADR-0003). +/// +public sealed class UnifiFirewallClient : IDisposable +{ + private readonly HttpClient _http; + private readonly string _base; + private readonly string? _siteName; + private Guid? _siteId; + + /// BaseUrl is the integration root, e.g. https://gw/proxy/network/integration. + /// Site to use by name; null = the controller's first (usually only) site. + public UnifiFirewallClient(UnifiClientOptions options, string? siteName = null, HttpMessageHandler? handler = null) + { + ArgumentNullException.ThrowIfNull(options); + handler ??= options.VerifyTls ? new HttpClientHandler() + : new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }; + _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(60) }; + _http.DefaultRequestHeaders.Add("X-API-KEY", options.ApiKey); + _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _base = options.BaseUrl.AbsoluteUri.TrimEnd('/'); + _siteName = siteName; + } + + public async Task GetSiteIdAsync(CancellationToken ct = default) + { + if (_siteId is { } id) return id; + var sites = await ListAllAsync($"{_base}/v1/sites", ct).ConfigureAwait(false); + var site = _siteName is null ? sites.FirstOrDefault() + : sites.FirstOrDefault(s => string.Equals(s["name"]?.GetValue(), _siteName, StringComparison.OrdinalIgnoreCase)); + _siteId = site?["id"]?.GetValue() is { } sid ? Guid.Parse(sid) + : throw new UnifiFirewallException($"site '{_siteName ?? "(first)"}' not found"); + return _siteId.Value; + } + + public async Task> ListZonesAsync(CancellationToken ct = default) + { + var rows = await ListAllAsync($"{await SiteAsync(ct)}/firewall/zones", ct).ConfigureAwait(false); + return rows.Select(r => new FirewallZone(Guid.Parse(r["id"]!.GetValue()), r["name"]!.GetValue(), + r["metadata"]?["origin"]?.GetValue() ?? "")).ToList(); + } + + public async Task GetNamesAsync(CancellationToken ct = default) + { + var zones = await ListZonesAsync(ct).ConfigureAwait(false); + var nets = await ListAllAsync($"{await SiteAsync(ct)}/networks", ct).ConfigureAwait(false); + return new FirewallNames( + zones.ToDictionary(z => z.Name, z => z.Id, StringComparer.OrdinalIgnoreCase), + nets.Where(n => n["name"] is not null && n["id"] is not null) + .GroupBy(n => n["name"]!.GetValue(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => Guid.Parse(g.First()["id"]!.GetValue()), StringComparer.OrdinalIgnoreCase)); + } + + public async Task> ListPoliciesAsync(CancellationToken ct = default) + { + var rows = await ListAllAsync($"{await SiteAsync(ct)}/firewall/policies", ct).ConfigureAwait(false); + return rows.Select(r => new LiveFirewallPolicy(Guid.Parse(r["id"]!.GetValue()), + r["name"]?.GetValue() ?? "", r["metadata"]?["origin"]?.GetValue() ?? "", r)).ToList(); + } + + public async Task CreateAsync(FirewallPolicySpec spec, FirewallNames names, CancellationToken ct = default) + => Parse(await SendAsync(HttpMethod.Post, $"{await SiteAsync(ct)}/firewall/policies", FirewallPolicyJson.ToJson(spec, names), ct).ConfigureAwait(false)); + + /// Replace a policy wholesale (PUT): the controller keeps nothing the spec doesn't carry. + public async Task UpdateAsync(Guid id, FirewallPolicySpec spec, FirewallNames names, CancellationToken ct = default) + => Parse(await SendAsync(HttpMethod.Put, $"{await SiteAsync(ct)}/firewall/policies/{id}", FirewallPolicyJson.ToJson(spec, names), ct).ConfigureAwait(false)); + + public async Task DeleteAsync(Guid id, CancellationToken ct = default) + => await SendAsync(HttpMethod.Delete, $"{await SiteAsync(ct)}/firewall/policies/{id}", null, ct).ConfigureAwait(false); + + private static LiveFirewallPolicy Parse(JsonNode? n) => n is JsonObject o + ? new LiveFirewallPolicy(Guid.Parse(o["id"]!.GetValue()), o["name"]?.GetValue() ?? "", o["metadata"]?["origin"]?.GetValue() ?? "", o) + : throw new UnifiFirewallException("controller returned no policy object"); + + private async Task SiteAsync(CancellationToken ct) => $"{_base}/v1/sites/{await GetSiteIdAsync(ct).ConfigureAwait(false)}"; + + // The integration API pages every list: {offset, limit, count, totalCount, data}. + private async Task> ListAllAsync(string url, CancellationToken ct) + { + var all = new List(); + for (var offset = 0; ;) + { + var page = await SendAsync(HttpMethod.Get, $"{url}?offset={offset}&limit=200", null, ct).ConfigureAwait(false); + var data = page?["data"] as JsonArray ?? []; + all.AddRange(data.OfType()); + var total = page?["totalCount"]?.GetValue() ?? all.Count; + offset += data.Count; + if (data.Count == 0 || offset >= total) return all; + } + } + + private async Task SendAsync(HttpMethod method, string url, JsonObject? body, CancellationToken ct) + { + using var req = new HttpRequestMessage(method, url); + if (body is not null) req.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); + using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + var text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + throw new UnifiFirewallException($"{method} {url} → {(int)resp.StatusCode}: {text}"); + return string.IsNullOrWhiteSpace(text) ? null : JsonNode.Parse(text); + } + + public void Dispose() => _http.Dispose(); +} + +public sealed class UnifiFirewallException(string message) : Exception(message); diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj index ed1832a..a38358a 100644 --- a/src/UnifiSharp/UnifiSharp.csproj +++ b/src/UnifiSharp/UnifiSharp.csproj @@ -6,7 +6,7 @@ enable - 0.3.0 + 0.4.0