From a5955a63589636b22e9cc1359ba6ef8170f94c00 Mon Sep 17 00:00:00 2001 From: Mateusz Filipowicz Date: Sun, 2 Mar 2025 22:22:18 +0100 Subject: [PATCH] feat: support checking supported and enabled controller features (#41) * feat: support checking supported and enabled controller features * linting --- codegen/customizations.yml | 40 +++++++++++ codegen/v2/DescribedFeature.json | 4 ++ docs/getting_started.md | 33 ++++++++- unifi/client.generated.go | 9 +++ unifi/described_feature.generated.go | 100 +++++++++++++++++++++++++++ unifi/described_feature.go | 32 +++++++++ unifi/features/const.go | 74 ++++++++++++++++++++ 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 codegen/v2/DescribedFeature.json create mode 100644 unifi/described_feature.generated.go create mode 100644 unifi/described_feature.go create mode 100644 unifi/features/const.go diff --git a/codegen/customizations.yml b/codegen/customizations.yml index c493307..a5c7991 100644 --- a/codegen/customizations.yml +++ b/codegen/customizations.yml @@ -2,6 +2,7 @@ customizations: client: excludeResources: + - "DescribedFeature" - "Dpi*" - "FirewallZoneMatrix" functions: @@ -296,6 +297,43 @@ customizations: returns: - "[]FirewallZoneMatrix" - "error" + - name: "ListFeatures" + resourceName: "DescribedFeature" + comment: "ListFeatures returns all features of the UniFi controller." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + returns: + - "[]DescribedFeature" + - "error" + - name: "GetFeature" + resourceName: "DescribedFeature" + comment: "GetFeature returns a specific feature by it's name. Name is case-insensitive." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "name" + type: "string" + returns: + - "*DescribedFeature" + - "error" + - name: "IsFeatureEnabled" + resourceName: "DescribedFeature" + comment: "IsFeatureEnabled returns if a specific feature is enabled by it's name. Name is case-insensitive." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "name" + type: "string" + returns: + - "bool" + - "error" resources: Account: fields: @@ -318,6 +356,8 @@ customizations: customUnmarshalType: "numberOrString" DNSRecord: resourcePath: "static-dns" + DescribedFeature: + resourcePath: "described-features?includeSystemFeatures=true" # TODO hack to get all features, because query params in requests are not yet supported Device: fields: _all: diff --git a/codegen/v2/DescribedFeature.json b/codegen/v2/DescribedFeature.json new file mode 100644 index 0000000..eb85096 --- /dev/null +++ b/codegen/v2/DescribedFeature.json @@ -0,0 +1,4 @@ +{ + "feature_exists": "true|false", + "name": "" +} \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index 9ae9124..abd237f 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -113,4 +113,35 @@ if err != nil { for _, network := range networks { fmt.Printf("Network: %s\n", network.Name) } -``` \ No newline at end of file +``` + +## Checking if features are supported and enabled + +The UniFi Go SDK provides a way to check if a feature is supported and enabled/disabled on the UniFi Controller. +This can be useful when you want to check if a feature is available before using it. Passed feature names are case-insensitive. + +**Example:** + +```go +if c.IsFeatureEnabled(ctx, "default", "feature-name") { + // Feature is enabled +} else { + // Feature is disabled +} +``` + +Library comes with a set of predefined feature names, which can be found in `github.com/filipowm/go-unifi/unifi/features` module. You can also use custom feature names. + +For example, you can check if the `features.ZoneBasedFirewallMigration` is available on the controller (no `unifi.ErrNotFound` raised) and enabled: +```go +f, err := c.GetFeature(ctx, "default", features.ZoneBasedFirewallMigration) +if err != nil { + if errors.Is(err, unifi.ErrNotFound) { + log.Printf("Feature %s unavailable (not found)", features.ZoneBasedFirewallMigration) + } else { + log.Fatalf("Error getting feature: %v", err) + } + return false +} +return f.FeatureExists // `FeatureExists` is a boolean indicating if the feature is enabled +``` diff --git a/unifi/client.generated.go b/unifi/client.generated.go index 981699c..255d3f7 100644 --- a/unifi/client.generated.go +++ b/unifi/client.generated.go @@ -170,6 +170,15 @@ type Client interface { // ==== end of client methods for Dashboard resource ==== + // GetFeature returns a specific feature by it's name. Name is case-insensitive. + GetFeature(ctx context.Context, site string, name string) (*DescribedFeature, error) + + // IsFeatureEnabled returns if a specific feature is enabled by it's name. Name is case-insensitive. + IsFeatureEnabled(ctx context.Context, site string, name string) (bool, error) + + // ListFeatures returns all features of the UniFi controller. + ListFeatures(ctx context.Context, site string) ([]DescribedFeature, error) + // ==== client methods for Device resource ==== // AdoptDevice adopts a device by MAC address. diff --git a/unifi/described_feature.generated.go b/unifi/described_feature.generated.go new file mode 100644 index 0000000..a6cab46 --- /dev/null +++ b/unifi/described_feature.generated.go @@ -0,0 +1,100 @@ +// Code generated from ace.jar fields *.json files +// DO NOT EDIT. + +package unifi + +import ( + "context" + "encoding/json" + "fmt" +) + +// just to fix compile issues with the import +var ( + _ context.Context + _ fmt.Formatter + _ json.Marshaler +) + +type DescribedFeature struct { + ID string `json:"_id,omitempty"` + SiteID string `json:"site_id,omitempty"` + + Hidden bool `json:"attr_hidden,omitempty"` + HiddenID string `json:"attr_hidden_id,omitempty"` + NoDelete bool `json:"attr_no_delete,omitempty"` + NoEdit bool `json:"attr_no_edit,omitempty"` + + FeatureExists bool `json:"feature_exists"` + Name string `json:"name,omitempty"` +} + +func (dst *DescribedFeature) UnmarshalJSON(b []byte) error { + type Alias DescribedFeature + aux := &struct { + *Alias + }{ + Alias: (*Alias)(dst), + } + + err := json.Unmarshal(b, &aux) + if err != nil { + return fmt.Errorf("unable to unmarshal alias: %w", err) + } + + return nil +} + +func (c *client) listDescribedFeature(ctx context.Context, site string) ([]DescribedFeature, error) { + var respBody []DescribedFeature + + err := c.Get(ctx, fmt.Sprintf("%s/site/%s/described-features?includeSystemFeatures=true", c.apiPaths.ApiV2Path, site), nil, &respBody) + if err != nil { + return nil, err + } + + return respBody, nil +} + +func (c *client) getDescribedFeature(ctx context.Context, site, id string) (*DescribedFeature, error) { + var respBody DescribedFeature + + err := c.Get(ctx, fmt.Sprintf("%s/site/%s/described-features?includeSystemFeatures=true/%s", c.apiPaths.ApiV2Path, site, id), nil, &respBody) + + if err != nil { + return nil, err + } + if respBody.ID == "" { + return nil, ErrNotFound + } + return &respBody, nil +} + +func (c *client) deleteDescribedFeature(ctx context.Context, site, id string) error { + err := c.Delete(ctx, fmt.Sprintf("%s/site/%s/described-features?includeSystemFeatures=true/%s", c.apiPaths.ApiV2Path, site, id), struct{}{}, nil) + if err != nil { + return err + } + return nil +} + +func (c *client) createDescribedFeature(ctx context.Context, site string, d *DescribedFeature) (*DescribedFeature, error) { + var respBody DescribedFeature + + err := c.Post(ctx, fmt.Sprintf("%s/site/%s/described-features?includeSystemFeatures=true", c.apiPaths.ApiV2Path, site), d, &respBody) + if err != nil { + return nil, err + } + + return &respBody, nil +} + +func (c *client) updateDescribedFeature(ctx context.Context, site string, d *DescribedFeature) (*DescribedFeature, error) { + var respBody DescribedFeature + + err := c.Put(ctx, fmt.Sprintf("%s/site/%s/described-features?includeSystemFeatures=true/%s", c.apiPaths.ApiV2Path, site, d.ID), d, &respBody) + if err != nil { + return nil, err + } + return &respBody, nil +} diff --git a/unifi/described_feature.go b/unifi/described_feature.go new file mode 100644 index 0000000..9dc7f53 --- /dev/null +++ b/unifi/described_feature.go @@ -0,0 +1,32 @@ +package unifi + +import ( + "context" + "strings" +) + +func (c *client) ListFeatures(ctx context.Context, site string) ([]DescribedFeature, error) { + return c.listDescribedFeature(ctx, site) +} + +func (c *client) GetFeature(ctx context.Context, site string, name string) (*DescribedFeature, error) { + features, err := c.ListFeatures(ctx, site) + if err != nil { + return nil, err + } + lowerName := strings.ToLower(name) + for _, f := range features { + if strings.ToLower(f.Name) == lowerName { + return &f, nil + } + } + return nil, ErrNotFound +} + +func (c *client) IsFeatureEnabled(ctx context.Context, site string, name string) (bool, error) { + f, err := c.GetFeature(ctx, site, name) + if err != nil { + return false, err + } + return f.FeatureExists, nil +} diff --git a/unifi/features/const.go b/unifi/features/const.go new file mode 100644 index 0000000..92e2f23 --- /dev/null +++ b/unifi/features/const.go @@ -0,0 +1,74 @@ +package features + +const ( + AdBlocking = "AD_BLOCKING" + AllUnifiDevicesPage = "ALL_UNIFI_DEVICES_PAGE" + CustomDohServers = "CUSTOM_DOH_SERVERS" + Hotspot2Passpoint = "HOTSPOT2_PASSPOINT" + IgmpProxy = "IGMP_PROXY" + IpExclusionFromLeases = "IP_EXCLUSION_FROM_LEASES" + Ips = "IPS" + IpsEtPro = "IPS_ET_PRO" + IpsSignatureReport = "IPS_SIGNATURE_REPORT" + IpsecFqdn = "IPSEC_FQDN" + Ipv4ActiveLeaseReporting = "IPV4_ACTIVE_LEASE_REPORTING" + LegacyUiSupported = "LEGACY_UI_SUPPORTED" + LimitIpsCategories = "LIMIT_IPS_CATEGORIES" + LiveDeviceUpdates = "LIVE_DEVICE_UPDATES" + LockAp = "LOCK_AP" + NatPool = "NAT_POOL" + Netflow = "NETFLOW" + OspfDefaultRouteAnnouncement = "OSPF_DEFAULT_ROUTE_ANNOUNCEMENT" + OspfRouting = "OSPF_ROUTING" + OpenVpnClient = "OPENVPN_CLIENT" + OpenVpnClientTrafficRoutes = "OPENVPN_CLIENT_TRAFFIC_ROUTES" + OpenVpnEncryptionCiphers = "OPENVPN_ENCRYPTION_CIPHERS" + OpenVpnRemoteDisconnect = "OPENVPN_REMOTE_DISCONNECT" + OpenVpnServer = "OPENVPN_SERVER" + RadiusBatchUsers = "RADIUS_BATCH_USERS" + RadiusProfiles = "RADIUS_PROFILES" + RadiusServer = "RADIUS_SERVER" + ScorePage = "SCORE_PAGE" + SdwanHubSpoke = "SDWAN_HUB_SPOKE" + SdwanMesh = "SDWAN_MESH" + SpeedTest = "SPEED_TEST" + StaticDns = "STATIC_DNS" + SwitchBgpRouting = "SWITCH_BGP_ROUTING" + SwitchCustomAclRules = "SWITCH_CUSTOM_ACL_RULES" + SwitchGlobalAclRules = "SWITCH_GLOBAL_ACL_RULES" + Teleport = "TELEPORT" + TrafficMap = "TRAFFIC_MAP" + TrafficRouteKillSwitch = "TRAFFIC_ROUTE_KILL_SWITCH" + TrafficRoutes = "TRAFFIC_ROUTES" + TrafficRoutesIpsecS2sVpn = "TRAFFIC_ROUTES_IPSEC_S2S_VPN" + TrafficRoutesOpenVpnS2sVpn = "TRAFFIC_ROUTES_OPENVPN_S2S_VPN" + TrafficRuleAndRouteRegions = "TRAFFIC_RULE_AND_ROUTE_REGIONS" + TrafficRuleRateLimiting = "TRAFFIC_RULE_RATE_LIMITING" + TrafficRuleSchedules = "TRAFFIC_RULE_SCHEDULES" + UdapiGetBlocks = "UDAPI_GET_BLOCKS" + UcoreAutolinkDeviceUpdates = "UCORE_AUTOLINK_DEVICE_UPDATES" + UcorePartialDeviceUpdates = "UCORE_PARTIAL_DEVICE_UPDATES" + UidRadius = "UID_RADIUS" + UidRadiusGroupPolicy = "UID_RADIUS_GROUP_POLICY" + UidVpn = "UID_VPN" + UidVpnAllowWanLocal = "UID_VPN_ALLOW_WAN_LOCAL" + UidVpnOverrideDns = "UID_VPN_OVERRIDE_DNS" + UidVpnStrictClientCommonName = "UID_VPN_STRICT_CLIENT_COMMON_NAME" + UidVpnSupportUdp = "UID_VPN_SUPPORT_UDP" + UidWifi = "UID_WIFI" + UidWifiIot = "UID_WIFI_IOT" + UidWifiRadiusGroupPolicy = "UID_WIFI_RADIUS_GROUP_POLICY" + UnboundWanMonitor = "UNBOUND_WAN_MONITOR" + UserDefinedNatRules = "USER_DEFINED_NAT_RULES" + VisualProgramming = "VISUAL_PROGRAMMING" + WanDhcpRequestCos = "WAN_DHCP_REQUEST_COS" + WanDhcpv6Stateless = "WAN_DHCPV6_STATELESS" + WanDsLite = "WAN_DS_LITE" + WanLoadBalancingDistributedMode = "WAN_LOAD_BALANCING_DISTRIBUTED_MODE" + WifiConfigCreated = "WIFI_CONFIG_CREATED" + WifiMimo = "WIFI_MIMO" + WireguardVpnClient = "WIREGUARD_VPN_CLIENT" + WireguardVpnServer = "WIREGUARD_VPN_SERVER" + ZoneBasedFirewall = "ZONE_BASED_FIREWALL" + ZoneBasedFirewallMigration = "ZONE_BASED_FIREWALL_MIGRATION" +)