feat: add support for Intrution Prevention System (IPS) settings with unifi_setting_ips resource (#56)

* feat: add support for Intrution Prevention System (IPS) settings with `unifi_setting_ips` resource

* require IPS features enabled on controller

* require version 7.4

* require version 7.5 for advanced_filtering_preference

* feat: use Remember Me to prolong session for user/pass authentication

* run some setting mgmt tests on 7.0+ due to auto_upgrade_hour not working until device is adopted and auto upgrade logic is different and not supported
This commit is contained in:
Mateusz Filipowicz
2025-03-16 12:53:46 +01:00
committed by GitHub
parent 8b6ff55a18
commit e9600c6e06
19 changed files with 2596 additions and 14 deletions

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/biter777/countries v1.7.5
github.com/deckarep/golang-set/v2 v2.7.0
github.com/filipowm/go-unifi v1.5.3
github.com/filipowm/go-unifi v1.6.0
github.com/golangci/golangci-lint v1.64.7
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/terraform-plugin-docs v0.21.0

4
go.sum
View File

@@ -280,6 +280,10 @@ github.com/filipowm/go-unifi v1.5.2 h1:S974Fi3BWt7qm0P8G/SwUVOjzgWTSjNJGV0jW5yD6
github.com/filipowm/go-unifi v1.5.2/go.mod h1:ldtL5szykvR9fPWB9GlGZqaJGxKd8IWLQY5M8O1PDQ8=
github.com/filipowm/go-unifi v1.5.3 h1:9e+V5xzgDtQ6ZmQvIiz8sCuwcDyM06nzbhBQxzvPBbo=
github.com/filipowm/go-unifi v1.5.3/go.mod h1:NQgqx3ylLVDqxVgY3uJ3ZMIecSl46fvqCCXzwHRuVDc=
github.com/filipowm/go-unifi v1.5.4 h1:kc1jWx0Rht+tiEyFsDjPAYJ6h2EV+Atq7eIoTA6fH0Y=
github.com/filipowm/go-unifi v1.5.4/go.mod h1:NQgqx3ylLVDqxVgY3uJ3ZMIecSl46fvqCCXzwHRuVDc=
github.com/filipowm/go-unifi v1.6.0 h1:0oLOrsLWcaU8sUsyMyjyGwaAWNC9Ee4YZ1ehtijXahg=
github.com/filipowm/go-unifi v1.6.0/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA=
github.com/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=

View File

@@ -0,0 +1,704 @@
package acctest
import (
"fmt"
pt "github.com/filipowm/terraform-provider-unifi/internal/provider/testing"
"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"sync"
"testing"
)
// Using dedicated lock for IPS settings to avoid interference with other tests
var settingIpsLock = &sync.Mutex{}
func TestAccSettingIps_basic(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_basic(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ips_mode", "ips"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_networks.#", "1"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_networks.*", "LAN"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ips.test", plancheck.ResourceActionCreate),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_updated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ips_mode", "ids"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_networks.#", "1"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_networks.*", "LAN"),
),
ConfigPlanChecks: pt.CheckResourceActions("unifi_setting_ips.test", plancheck.ResourceActionUpdate),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_enabledCategories(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_enabledCategories(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_categories.#", "3"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_categories.*", "emerging-dos"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_categories.*", "emerging-exploit"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_categories.*", "emerging-malware"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_enabledCategoriesUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_categories.#", "2"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_categories.*", "emerging-scan"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "enabled_categories.*", "emerging-worm"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_adBlocking(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_adBlocking(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ad_blocked_networks.#", "2"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "ad_blocked_networks.*", "network1"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "ad_blocked_networks.*", "network2"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_adBlockingUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ad_blocked_networks.#", "1"),
resource.TestCheckTypeSetElemAttr("unifi_setting_ips.test", "ad_blocked_networks.*", "network3"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_honeypot(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_honeypot(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.0.ip_address", "192.168.1.10"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.0.network_id", "network1"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_honeypotUpdated(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.0.ip_address", "192.168.2.20"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.0.network_id", "network2"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_honeypotDisabled(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.#", "0"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_dnsFilters(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_dnsFilters(t),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.name", "Test Filter"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.filter", "work"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.description", "Test description"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.allowed_sites.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.blocked_sites.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.blocked_tld.#", "1"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_dnsFiltersUpdated(t),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.name", "Test Filter Updated"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.0.filter", "family"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.1.name", "Second Filter"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.1.filter", "none"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_suppression(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_suppression(),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.category", "emerging-dos"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.signature", "Test Signature"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.type", "all"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.0.direction", "src"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.0.mode", "ip"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.0.value", "192.168.1.100"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_suppressionUpdated(t),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.type", "track"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.tracking.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.tracking.0.direction", "dest"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.tracking.0.mode", "subnet"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.0.tracking.0.value", "192.168.0.0/24"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.#", "2"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_comprehensive(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_comprehensive(t),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ips_mode", "ids"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "restrict_torrents", "true"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "advanced_filtering_preference", "manual"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_categories.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_networks.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ad_blocked_networks.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.#", "1"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_comprehensiveBefore8(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: "< 8.0",
MinVersion: version.Must(version.NewVersion("7.4")),
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_comprehensiveBefore8(t),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ips_mode", "ids"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "restrict_torrents", "true"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "enabled_categories.#", "2"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "ad_blocked_networks.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "honeypots.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "dns_filters.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.alerts.#", "1"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "suppression.whitelist.#", "1"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_restrictTorrents(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 8.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_restrictTorrents(true),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "restrict_torrents", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_restrictTorrents(false),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "restrict_torrents", "false"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func TestAccSettingIps_memoryOptimized(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 9.0",
Lock: settingIpsLock,
Steps: []resource.TestStep{
{
Config: testAccSettingIpsConfig_memoryOptimized(true),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "memory_optimized", "true"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
{
Config: testAccSettingIpsConfig_memoryOptimized(false),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet("unifi_setting_ips.test", "id"),
resource.TestCheckResourceAttr("unifi_setting_ips.test", "memory_optimized", "false"),
),
},
pt.ImportStepWithSite("unifi_setting_ips.test"),
},
})
}
func testAccSettingIpsConfig_basic() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ips"
enabled_networks = ["LAN"]
}
`
}
func testAccSettingIpsConfig_updated() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["LAN"]
}
`
}
func testAccSettingIpsConfig_enabledCategories() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
enabled_categories = [
"emerging-dos",
"emerging-exploit",
"emerging-malware"
]
}
`
}
func testAccSettingIpsConfig_enabledCategoriesUpdated() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
enabled_categories = [
"emerging-scan",
"emerging-worm",
]
}
`
}
func testAccSettingIpsConfig_adBlocking() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
ad_blocked_networks = [
"network1",
"network2"
]
}
`
}
func testAccSettingIpsConfig_adBlockingUpdated() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
ad_blocked_networks = [
"network3"
]
}
`
}
func testAccSettingIpsConfig_honeypot() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
honeypots = [{
ip_address = "192.168.1.10"
network_id = "network1"
}]
}
`
}
func testAccSettingIpsConfig_honeypotUpdated() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
honeypots = [{
ip_address = "192.168.2.20"
network_id = "network2"
}]
}
`
}
func testAccSettingIpsConfig_honeypotDisabled() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
honeypots = []
}
`
}
func testAccSettingIpsConfig_dnsFilters(t *testing.T) string {
subnet, vlanId := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
dns_filters = [{
name = "Test Filter"
filter = "work"
description = "Test description"
network_id = unifi_network.test.id
allowed_sites = [
"example.com",
"allowed.org"
]
blocked_sites = [
"blocked1.com",
"blocked2.com"
]
blocked_tld = [
"xyz"
]
}]
}
`, subnet.String(), vlanId)
}
func testAccSettingIpsConfig_dnsFiltersUpdated(t *testing.T) string {
subnet, vlanId := pt.GetTestVLAN(t)
subnet2, vlanId2 := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_network" "test2" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
dns_filters = [
{
name = "Test Filter Updated"
filter = "family"
description = "Updated description"
network_id = unifi_network.test.id
allowed_sites = [
"example.com",
"allowed.org",
"new-allowed.com"
]
blocked_sites = [
"blocked1.com"
]
},
{
name = "Second Filter"
filter = "none"
network_id = unifi_network.test2.id
}
]
}
`, subnet.String(), vlanId, subnet2.String(), vlanId2)
}
func testAccSettingIpsConfig_suppression() string {
return `
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
suppression = {
alerts = [{
category = "emerging-dos"
signature = "Test Signature"
type = "all"
}]
whitelist = [{
direction = "src"
mode = "ip"
value = "192.168.1.100"
}]
}
}
`
}
func testAccSettingIpsConfig_suppressionUpdated(t *testing.T) string {
subnet, vlanId := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
suppression = {
alerts = [
{
category = "emerging-dos"
signature = "Test Signature"
type = "track"
tracking = [{
direction = "dest"
mode = "subnet"
value = "192.168.0.0/24"
}]
},
{
category = "emerging-exploit"
signature = "Another Signature"
type = "track"
}
]
whitelist = [
{
direction = "src"
mode = "subnet"
value = "192.168.1.0/24"
},
{
direction = "both"
mode = "network"
value = unifi_network.test.id
}
]
}
}
`, subnet.String(), vlanId)
}
func testAccSettingIpsConfig_comprehensive(t *testing.T) string {
subnet, vlanId := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
restrict_torrents = true
advanced_filtering_preference = "manual"
enabled_categories = [
"emerging-dos",
"emerging-exploit"
]
enabled_networks = [
"network1",
"network2"
]
ad_blocked_networks = [
"network1"
]
honeypots = [{
ip_address = "192.168.1.10"
network_id = "network1"
}]
dns_filters = [{
name = "Comprehensive Filter"
filter = "work"
description = "Comprehensive test filter"
network_id = unifi_network.test.id
allowed_sites = ["allowed.com"]
blocked_sites = ["blocked.com"]
}]
suppression = {
alerts = [{
category = "emerging-dos"
signature = "Test Signature"
type = "all"
}]
whitelist = [{
direction = "src"
mode = "ip"
value = "192.168.1.100"
}]
}
}
`, subnet.String(), vlanId)
}
func testAccSettingIpsConfig_comprehensiveBefore8(t *testing.T) string {
subnet, vlanId := pt.GetTestVLAN(t)
return fmt.Sprintf(`
resource "unifi_network" "test" {
name = "Test"
purpose = "corporate"
subnet = %q
vlan_id = %d
}
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
restrict_torrents = true
enabled_categories = [
"emerging-dos",
"emerging-exploit"
]
ad_blocked_networks = [
"network1"
]
honeypots = [{
ip_address = "192.168.1.10"
network_id = "network1"
}]
dns_filters = [{
name = "Comprehensive Filter"
filter = "work"
description = "Comprehensive test filter"
network_id = unifi_network.test.id
allowed_sites = ["allowed.com"]
blocked_sites = ["blocked.com"]
}]
suppression = {
alerts = [{
category = "emerging-dos"
signature = "Test Signature"
type = "all"
}]
whitelist = [{
direction = "src"
mode = "ip"
value = "192.168.1.100"
}]
}
}
`, subnet.String(), vlanId)
}
func testAccSettingIpsConfig_restrictTorrents(enabled bool) string {
return fmt.Sprintf(`
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
restrict_torrents = %t
}
`, enabled)
}
func testAccSettingIpsConfig_memoryOptimized(enabled bool) string {
return fmt.Sprintf(`
resource "unifi_setting_ips" "test" {
ips_mode = "ids"
enabled_networks = ["network1"]
memory_optimized = %t
}
`, enabled)
}

View File

@@ -107,6 +107,7 @@ func TestAccSettingMgmt_fullConfig(t *testing.T) {
func TestAccSettingMgmt_update(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7.0",
Lock: &settingMgmtLock,
Steps: []resource.TestStep{
{
@@ -155,6 +156,7 @@ func TestAccSettingMgmt_sshCredentials(t *testing.T) {
func TestAccSettingMgmt_cornerCases(t *testing.T) {
AcceptanceTest(t, AcceptanceTestCase{
VersionConstraint: ">= 7.0",
Lock: &settingMgmtLock,
Steps: []resource.TestStep{
{

View File

@@ -25,6 +25,7 @@ type Identifiable interface {
type Resource interface {
SetClient(client *Client)
SetVersionValidator(validator ControllerVersionValidator)
SetFeatureValidator(validator FeatureValidator)
}
// ResourceModel defines the interface that all setting models must implement
@@ -35,6 +36,12 @@ type ResourceModel interface {
AsUnifiModel(context.Context) (interface{}, diag.Diagnostics)
}
// ResourceModel defines the interface that all setting models must implement
type DatasourceModel interface {
SiteAware
Merge(context.Context, interface{}) diag.Diagnostics
}
type Model struct {
ID types.String `tfsdk:"id"`
Site types.String `tfsdk:"site"`
@@ -80,6 +87,7 @@ func ConfigureDatasource(base Resource, req datasource.ConfigureRequest, resp *d
}
base.SetClient(cfg)
base.SetVersionValidator(NewControllerVersionValidator(cfg))
base.SetFeatureValidator(NewFeatureValidator(cfg))
}
func ConfigureResource(base Resource, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
@@ -98,4 +106,5 @@ func ConfigureResource(base Resource, req resource.ConfigureRequest, resp *resou
}
base.SetClient(cfg)
base.SetVersionValidator(NewControllerVersionValidator(cfg))
base.SetFeatureValidator(NewFeatureValidator(cfg))
}

View File

@@ -1,11 +1,15 @@
package base
import (
"context"
"crypto/tls"
"fmt"
"github.com/filipowm/go-unifi/unifi"
"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"log"
"net"
"net/http"
@@ -23,7 +27,7 @@ type ClientConfig struct {
}
func NewClient(cfg *ClientConfig) (*Client, error) {
unifiClient, err := unifi.NewClient(&unifi.ClientConfig{
config := &unifi.ClientConfig{
URL: cfg.Url,
User: cfg.Username,
Password: cfg.Password,
@@ -31,7 +35,15 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
HttpRoundTripperProvider: cfg.HttpConfigurer,
ValidationMode: unifi.DisableValidation,
Logger: unifi.NewDefaultLogger(unifi.WarnLevel),
})
}
if cfg.Username != "" && cfg.Password != "" {
config.User = cfg.Username
config.Password = cfg.Password
config.RememberMe = true
} else {
config.APIKey = cfg.ApiKey
}
unifiClient, err := unifi.NewClient(config)
if err != nil {
return nil, err
@@ -65,6 +77,18 @@ func (c *Client) ResolveSite(res SiteAware) string {
return res.GetSite()
}
func (c *Client) ResolveSiteFromConfig(ctx context.Context, config tfsdk.Config) (string, diag.Diagnostics) {
var site types.String
diags := config.GetAttribute(ctx, path.Root("site"), &site)
if diags.HasError() {
return "", diags
}
if IsEmptyString(site) {
return c.Site, diags
}
return site.ValueString(), diags
}
func CreateHttpTransport(insecure bool) http.RoundTripper {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,

View File

@@ -19,6 +19,7 @@ func AsVersion(versionString string) *version.Version {
var (
ControllerV6 = AsVersion("6.0.0")
ControllerV7 = AsVersion("7.0.0")
ControllerV8 = AsVersion("8.0.0")
ControllerV9 = AsVersion("9.0.0")
ControllerVersionApiKeyAuth = AsVersion("9.0.108")
// https://community.ui.com/releases/UniFi-Network-Application-8-2-93/fce86dc6-897a-4944-9c53-1eec7e37e738

View File

@@ -0,0 +1,127 @@
package base
import (
"context"
"fmt"
"strings"
"sync"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
)
const (
featureEnabled featureStatus = iota
featureDisabled
)
type featureStatus int
type FeatureValidator interface {
RequireFeaturesEnabled(ctx context.Context, site string, features ...string) diag.Diagnostics
RequireFeaturesEnabledForPath(ctx context.Context, site string, attrPath path.Path, config tfsdk.Config, features ...string) diag.Diagnostics
}
type Features map[string]featureStatus
func (v Features) IsEnabled(feature string) bool {
return !v.IsUnavailable(feature) && v[feature] == featureEnabled
}
func (v Features) IsDisabled(feature string) bool {
return !v.IsUnavailable(feature) && v[feature] == featureDisabled
}
func (v Features) IsUnavailable(feature string) bool {
if _, ok := v[feature]; ok {
return false
}
return true
}
type featureEnabledValidator struct {
client *Client
cache map[string]Features
lock sync.Mutex
}
func NewFeatureValidator(client *Client) FeatureValidator {
return &featureEnabledValidator{client: client, cache: make(map[string]Features), lock: sync.Mutex{}}
}
func (v *featureEnabledValidator) getFeatures(ctx context.Context, site string) Features {
if v.cache[site] != nil {
return v.cache[site]
}
v.lock.Lock()
defer v.lock.Unlock()
if v.cache[site] != nil {
return v.cache[site]
}
cache := make(map[string]featureStatus)
features, err := v.client.ListFeatures(ctx, site)
if err != nil {
// Return an empty Features map instead of nil to avoid potential nil pointer dereference
return Features{}
}
for _, feature := range features {
if feature.FeatureExists {
cache[feature.Name] = featureEnabled
} else {
cache[feature.Name] = featureDisabled
}
}
v.cache[site] = cache
return v.cache[site]
}
func (v *featureEnabledValidator) requireFeatures(ctx context.Context, site string, attrPath *path.Path, features ...string) diag.Diagnostics {
diags := diag.Diagnostics{}
if len(features) == 0 {
return diags
}
f := v.getFeatures(ctx, site)
var unavailableFeatures, disabledFeatures []string
for _, feature := range features {
if f.IsUnavailable(feature) {
unavailableFeatures = append(unavailableFeatures, feature)
}
if f.IsDisabled(feature) {
disabledFeatures = append(disabledFeatures, feature)
}
}
pathInfo := ""
if attrPath != nil {
pathInfo = fmt.Sprintf("%s is not supported. ", attrPath.String())
}
if len(unavailableFeatures) > 0 {
diags.AddError("Controller features not available", fmt.Sprintf("%sFeatures %s must be available on controller, but %s are not", pathInfo, strings.Join(features, ", "), strings.Join(unavailableFeatures, ", ")))
}
if len(disabledFeatures) > 0 {
diags.AddError("Controller features not disabled", fmt.Sprintf("%sFeatures %s must be enabled on controller, but %s are disabled", pathInfo, strings.Join(features, ", "), strings.Join(disabledFeatures, ", ")))
}
return diags
}
func (v *featureEnabledValidator) RequireFeaturesEnabled(ctx context.Context, site string, features ...string) diag.Diagnostics {
return v.requireFeatures(ctx, site, nil, features...)
}
func (v *featureEnabledValidator) RequireFeaturesEnabledForPath(ctx context.Context, site string, attrPath path.Path, config tfsdk.Config, features ...string) diag.Diagnostics {
diags := diag.Diagnostics{}
var val attr.Value
diags.Append(config.GetAttribute(context.Background(), attrPath, &val)...)
if diags.HasError() {
return diags
}
if !IsDefined(val) {
return diags
}
diags.Append(v.requireFeatures(ctx, site, &attrPath, features...)...)
return diags
}

View File

@@ -0,0 +1,670 @@
package base
import (
"context"
"errors"
"sync"
"testing"
"github.com/filipowm/go-unifi/unifi"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stretchr/testify/assert"
)
// MockUnifiClient provides a minimal implementation of unifi.Client for testing
type MockUnifiClient struct {
unifi.Client // Embed the interface to satisfy all methods (they'll panic if called)
featuresFunc func(ctx context.Context, site string) ([]unifi.DescribedFeature, error)
}
// ListFeatures implements the only unifi.Client method we care about for testing
func (m *MockUnifiClient) ListFeatures(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
if m.featuresFunc != nil {
return m.featuresFunc(ctx, site)
}
return nil, errors.New("ListFeatures not implemented")
}
// TestFeaturesIsEnabled tests the IsEnabled method of Features
func TestFeaturesIsEnabled(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature1",
expected: true,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: false,
},
{
name: "feature does not exist",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: false,
},
{
name: "empty features map",
features: Features{},
feature: "feature1",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsEnabled(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
// TestFeaturesIsDisabled tests the IsDisabled method of Features
func TestFeaturesIsDisabled(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature1",
expected: false,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: true,
},
{
name: "feature does not exist",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: false,
},
{
name: "empty features map",
features: Features{},
feature: "feature1",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsDisabled(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
// TestFeaturesIsUnavailable tests the IsUnavailable method of Features
func TestFeaturesIsUnavailable(t *testing.T) {
tests := []struct {
name string
features Features
feature string
expected bool
}{
{
name: "feature is enabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
feature: "feature2",
expected: false,
},
{
name: "feature is disabled",
features: Features{"feature1": featureEnabled},
feature: "feature2",
expected: true,
},
{
name: "feature does not exist",
features: Features{},
feature: "feature2",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.features.IsUnavailable(tt.feature)
assert.Equal(t, tt.expected, result)
})
}
}
// TestNewFeatureValidator tests the NewFeatureValidator function
func TestNewFeatureValidator(t *testing.T) {
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{}, nil
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client)
assert.NotNil(t, validator, "Validator should not be nil")
featureValidator, ok := validator.(*featureEnabledValidator)
assert.True(t, ok, "Validator should be of type *featureEnabledValidator")
assert.Equal(t, client, featureValidator.client, "Client should be set correctly")
assert.NotNil(t, featureValidator.cache, "Cache should be initialized")
}
// TestGetFeatures tests the getFeatures method of featureEnabledValidator
func TestGetFeatures(t *testing.T) {
tests := []struct {
name string
setup func() *MockUnifiClient
site string
expected Features
}{
{
name: "successfully get features",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
},
site: "site1",
expected: Features{
"feature1": featureEnabled,
"feature2": featureDisabled,
},
},
{
name: "error getting features",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("error listing features")
},
}
},
site: "site2",
expected: Features{}, // Now returns empty Features instead of nil
},
{
name: "no features returned",
setup: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{}, nil
},
}
},
site: "site3",
expected: Features{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setup()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{
client: client,
cache: make(map[string]Features),
lock: sync.Mutex{},
}
result := validator.getFeatures(context.Background(), tt.site)
assert.Equal(t, tt.expected, result)
// Test caching - if we call again, we should get the cached result
if tt.expected != nil {
// Replace the test client with one that fails
client.Client = &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("should not be called")
},
}
result = validator.getFeatures(context.Background(), tt.site)
assert.Equal(t, tt.expected, result)
}
})
}
}
// TestGetFeaturesConcurrent tests the concurrency safety of getFeatures
func TestGetFeaturesConcurrent(t *testing.T) {
callCount := 0
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
callCount++
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{
client: client,
cache: make(map[string]Features),
lock: sync.Mutex{},
}
var wg sync.WaitGroup
// Launch 10 concurrent goroutines to call getFeatures
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
features := validator.getFeatures(context.Background(), "site1")
assert.NotNil(t, features)
assert.True(t, features.IsEnabled("feature1"))
assert.False(t, features.IsEnabled("feature2"))
}()
}
wg.Wait()
// Verify ListFeatures was called exactly once
assert.Equal(t, 1, callCount, "ListFeatures should be called exactly once")
}
// TestRequireFeatures tests the requireFeatures method of featureEnabledValidator
func TestRequireFeatures(t *testing.T) {
tests := []struct {
name string
features Features
site string
attrPath *path.Path
requiredFeatures []string
expectedHasErrors bool
}{
{
name: "all features enabled",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: false,
},
{
name: "one feature disabled",
features: Features{"feature1": featureEnabled, "feature2": featureDisabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: true,
},
{
name: "all features disabled",
features: Features{"feature1": featureDisabled, "feature2": featureDisabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature1", "feature2"},
expectedHasErrors: true,
},
{
name: "empty required features",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{},
expectedHasErrors: false,
},
{
name: "nil required features",
features: Features{"feature1": featureEnabled, "feature2": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: nil,
expectedHasErrors: false,
},
{
name: "with attribute path",
features: Features{"feature1": featureDisabled},
site: "site1",
attrPath: &path.Path{},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
{
name: "feature not in map",
features: Features{"feature1": featureEnabled},
site: "site1",
attrPath: nil,
requiredFeatures: []string{"feature2"},
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := &MockUnifiClient{}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{
client: client,
cache: map[string]Features{tt.site: tt.features},
lock: sync.Mutex{},
}
diags := validator.requireFeatures(context.Background(), tt.site, tt.attrPath, tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
if tt.expectedHasErrors {
// Verify error message contains appropriate information
assert.Contains(t, diags[0].Detail(), "Features", "Error detail should mention 'Features'")
if tt.attrPath != nil {
assert.Contains(t, diags[0].Detail(), "is not supported", "Error should mention path is not supported")
}
}
})
}
}
// TestRequireFeaturesEnabledForPath tests the RequireFeaturesEnabledForPath method
func TestRequireFeaturesEnabledForPath(t *testing.T) {
tests := []struct {
name string
setupClient func() *MockUnifiClient
attrValue attr.Value
attrPath path.Path
requiredFeatures []string
configError bool
expectedHasErrors bool
}{
{
name: "attribute not set",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{}
},
attrValue: types.StringNull(),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: false,
},
{
name: "error getting attribute",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{}
},
attrValue: types.StringNull(),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: true,
expectedHasErrors: true,
},
{
name: "attribute set, feature enabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
}, nil
},
}
},
attrValue: types.StringValue("test"),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: false,
},
{
name: "attribute set, feature disabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: false},
}, nil
},
}
},
attrValue: types.StringValue("test"),
attrPath: path.Root("test"),
requiredFeatures: []string{"feature1"},
configError: false,
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
// Create a wrapper FeatureValidator that provides a minimal implementation
// of RequireFeaturesEnabledForPath without needing a real tfsdk.Config
validator := &testFeatureValidator{
base: NewFeatureValidator(client),
attrValue: tt.attrValue,
configErr: tt.configError,
}
diags := validator.TestRequireFeaturesEnabledForPath(context.Background(), "site1", tt.attrPath, tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
})
}
}
// TestRequireFeaturesEnabled tests the RequireFeaturesEnabled method
func TestRequireFeaturesEnabled(t *testing.T) {
tests := []struct {
name string
setupClient func() *MockUnifiClient
requiredFeatures []string
expectedHasErrors bool
}{
{
name: "feature enabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
}, nil
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: false,
},
{
name: "feature disabled",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: false},
}, nil
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
{
name: "feature error",
setupClient: func() *MockUnifiClient {
return &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
return nil, errors.New("error listing features")
},
}
},
requiredFeatures: []string{"feature1"},
expectedHasErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client)
diags := validator.RequireFeaturesEnabled(context.Background(), "site1", tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
})
}
}
// TestIsDefined is used in RequireFeaturesEnabledForPath to check if a value is defined
func TestIsDefined(t *testing.T) {
tests := []struct {
name string
value attr.Value
expected bool
}{
{
name: "null",
value: types.StringNull(),
expected: false,
},
{
name: "unknown",
value: types.StringUnknown(),
expected: false,
},
{
name: "null list",
value: types.ListNull(types.StringType),
expected: false,
},
{
name: "empty list",
value: types.ListValueMust(types.StringType, []attr.Value{}),
expected: true,
},
{
name: "defined value",
value: types.StringValue("test"),
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, IsDefined(tt.value))
})
}
}
// TestFeatureValidatorCache specifically tests the caching behavior of the FeatureValidator
// It verifies that multiple calls with the same site only result in one API call
func TestFeatureValidatorCache(t *testing.T) {
// Create a mock client with a counter for API calls
callCount := 0
mockUnifiClient := &MockUnifiClient{
featuresFunc: func(ctx context.Context, site string) ([]unifi.DescribedFeature, error) {
callCount++
return []unifi.DescribedFeature{
{Name: "feature1", FeatureExists: true},
{Name: "feature2", FeatureExists: false},
}, nil
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client)
// First call to check features should trigger an API call
diags1 := validator.RequireFeaturesEnabled(context.Background(), "site1", "feature1")
assert.Equal(t, 1, callCount, "First call should trigger an API call")
assert.False(t, diags1.HasError(), "Feature1 should be enabled")
// Second call with the same site should use the cache
diags2 := validator.RequireFeaturesEnabled(context.Background(), "site1", "feature2")
assert.Equal(t, 1, callCount, "Second call should use cached data")
assert.True(t, diags2.HasError(), "Feature2 should be disabled")
// Call with a different site should trigger another API call
diags3 := validator.RequireFeaturesEnabled(context.Background(), "site2", "feature1")
assert.Equal(t, 2, callCount, "Call with different site should trigger an API call")
assert.False(t, diags3.HasError(), "Feature1 should be enabled")
// Multiple calls using the same site should still use the cache
for i := 0; i < 5; i++ {
validator.RequireFeaturesEnabled(context.Background(), "site1", "feature1")
}
assert.Equal(t, 2, callCount, "Multiple calls with same site should use cached data")
}
// testFeatureValidator wraps a real FeatureValidator but has a special method for testing
// that doesn't require a real tfsdk.Config
type testFeatureValidator struct {
base FeatureValidator
attrValue attr.Value
configErr bool
}
// TestRequireFeaturesEnabledForPath is a test-specific version that doesn't need a real tfsdk.Config
func (v *testFeatureValidator) TestRequireFeaturesEnabledForPath(ctx context.Context, site string,
attrPath path.Path, features ...string) diag.Diagnostics {
diags := diag.Diagnostics{}
// This simulates what happens in RequireFeaturesEnabledForPath without needing a real Config
if v.configErr {
diags.AddError("Error", "Error getting attribute")
return diags
}
if !IsDefined(v.attrValue) {
return diags
}
// Call the underlying validator's RequireFeaturesEnabled
fv, ok := v.base.(*featureEnabledValidator)
if !ok {
diags.AddError("Error", "Invalid validator type")
return diags
}
diags.Append(fv.requireFeatures(ctx, site, &attrPath, features...)...)
return diags
}

View File

@@ -19,6 +19,7 @@ type ResourceFunctions struct {
// GenericResource provides common functionality for all resources
type GenericResource[T ResourceModel] struct {
ControllerVersionValidator
FeatureValidator
client *Client
typeName string
modelFactory func() T
@@ -52,6 +53,10 @@ func (b *GenericResource[T]) SetVersionValidator(validator ControllerVersionVali
b.ControllerVersionValidator = validator
}
func (b *GenericResource[T]) SetFeatureValidator(validator FeatureValidator) {
b.FeatureValidator = validator
}
func (b *GenericResource[T]) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
ConfigureResource(b, req, resp)
}

View File

@@ -24,9 +24,14 @@ var (
type dnsRecordDatasource struct {
base.ControllerVersionValidator
base.FeatureValidator
client *base.Client
}
func (d *dnsRecordDatasource) SetFeatureValidator(validator base.FeatureValidator) {
d.FeatureValidator = validator
}
func NewDnsRecordDatasource() datasource.DataSource {
return &dnsRecordDatasource{}
}

View File

@@ -17,6 +17,7 @@ var (
type dnsRecordsDatasource struct {
base.ControllerVersionValidator
base.FeatureValidator
client *base.Client
}
@@ -32,6 +33,10 @@ func (d *dnsRecordsDatasource) SetVersionValidator(validator base.ControllerVers
d.ControllerVersionValidator = validator
}
func (d *dnsRecordsDatasource) SetFeatureValidator(validator base.FeatureValidator) {
d.FeatureValidator = validator
}
func (d *dnsRecordsDatasource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
base.ConfigureDatasource(d, req, resp)
}

View File

@@ -174,10 +174,12 @@ func (p *unifiProvider) Configure(ctx context.Context, req provider.ConfigureReq
func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
dns.NewDnsRecordResource,
//firewall.NewFirewallZoneResource,
//firewall.NewFirewallZonePolicyResource,
settings.NewAutoSpeedtestResource,
settings.NewCountryResource,
settings.NewDpiResource,
//settings.NewIpsResource,
settings.NewIpsResource,
settings.NewLcmResource,
settings.NewLocaleResource,
settings.NewMagicSiteToSiteVpnResource,

View File

@@ -0,0 +1,865 @@
package settings
import (
"context"
"github.com/filipowm/go-unifi/unifi"
"github.com/filipowm/go-unifi/unifi/features"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
// DNS Filter model
type DNSFilterModel struct {
AllowedSites types.List `tfsdk:"allowed_sites"`
BlockedSites types.List `tfsdk:"blocked_sites"`
BlockedTld types.List `tfsdk:"blocked_tld"`
Description types.String `tfsdk:"description"`
Filter types.String `tfsdk:"filter"`
Name types.String `tfsdk:"name"`
NetworkID types.String `tfsdk:"network_id"`
}
func (m *DNSFilterModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"allowed_sites": types.ListType{
ElemType: types.StringType,
},
"blocked_sites": types.ListType{
ElemType: types.StringType,
},
"blocked_tld": types.ListType{
ElemType: types.StringType,
},
"description": types.StringType,
"filter": types.StringType,
"name": types.StringType,
"network_id": types.StringType,
}
}
// Honeypots model
type HoneypotModel struct {
IPAddress types.String `tfsdk:"ip_address"`
NetworkID types.String `tfsdk:"network_id"`
}
func (m *HoneypotModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"ip_address": types.StringType,
"network_id": types.StringType,
}
}
// Tracking model
type TrackingModel struct {
Direction types.String `tfsdk:"direction"`
Mode types.String `tfsdk:"mode"`
Value types.String `tfsdk:"value"`
}
func (m *TrackingModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"direction": types.StringType,
"mode": types.StringType,
"value": types.StringType,
}
}
// Alerts model
type AlertsModel struct {
Category types.String `tfsdk:"category"`
Signature types.String `tfsdk:"signature"`
Tracking types.List `tfsdk:"tracking"`
Type types.String `tfsdk:"type"`
}
func (m *AlertsModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"category": types.StringType,
"signature": types.StringType,
"tracking": types.ListType{
ElemType: types.ObjectType{
AttrTypes: (&TrackingModel{}).AttributeTypes(),
},
},
"type": types.StringType,
}
}
// Whitelist model
type WhitelistModel struct {
Direction types.String `tfsdk:"direction"`
Mode types.String `tfsdk:"mode"`
Value types.String `tfsdk:"value"`
}
func (m *WhitelistModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"direction": types.StringType,
"mode": types.StringType,
"value": types.StringType,
}
}
// Suppression model
type SuppressionModel struct {
Alerts types.List `tfsdk:"alerts"`
Whitelist types.List `tfsdk:"whitelist"`
}
func (m *SuppressionModel) AttributeTypes() map[string]attr.Type {
return map[string]attr.Type{
"alerts": types.ListType{
ElemType: types.ObjectType{
AttrTypes: (&AlertsModel{}).AttributeTypes(),
},
},
"whitelist": types.ListType{
ElemType: types.ObjectType{
AttrTypes: (&WhitelistModel{}).AttributeTypes(),
},
},
}
}
// Main IPS model
type ipsModel struct {
base.Model
AdBlockedNetworks types.List `tfsdk:"ad_blocked_networks"`
AdvancedFilteringPreference types.String `tfsdk:"advanced_filtering_preference"`
DNSFilters types.List `tfsdk:"dns_filters"`
EnabledCategories types.List `tfsdk:"enabled_categories"`
EnabledNetworks types.List `tfsdk:"enabled_networks"`
Honeypots types.List `tfsdk:"honeypots"`
Mode types.String `tfsdk:"ips_mode"`
MemoryOptimized types.Bool `tfsdk:"memory_optimized"`
RestrictTorrents types.Bool `tfsdk:"restrict_torrents"`
Suppression types.Object `tfsdk:"suppression"`
}
func (d *ipsModel) AsUnifiModel(ctx context.Context) (interface{}, diag.Diagnostics) {
diags := diag.Diagnostics{}
model := &unifi.SettingIps{
AdvancedFilteringPreference: d.AdvancedFilteringPreference.ValueString(),
IPsMode: d.Mode.ValueString(),
MemoryOptimized: d.MemoryOptimized.ValueBool(),
RestrictTorrents: d.RestrictTorrents.ValueBool(),
// Initialize empty slices for arrays to avoid null values in JSON
AdBlockingConfigurations: []unifi.SettingIpsAdBlockingConfigurations{},
DNSFilters: []unifi.SettingIpsDNSFilters{},
EnabledCategories: []string{},
EnabledNetworks: []string{},
Honeypot: []unifi.SettingIpsHoneypot{},
// Initialize suppression with empty arrays
Suppression: unifi.SettingIpsSuppression{
Alerts: []unifi.SettingIpsAlerts{},
Whitelist: []unifi.SettingIpsWhitelist{},
},
}
if model.AdvancedFilteringPreference == "" {
if model.IPsMode != "disabled" {
model.AdvancedFilteringPreference = "manual"
} else {
model.AdvancedFilteringPreference = "disabled"
}
}
var enabledCategories []string
diags.Append(utils.ListElementsAs(d.EnabledCategories, &enabledCategories)...)
if diags.HasError() {
return nil, diags
}
model.EnabledCategories = enabledCategories
var enabledNetworks []string
diags.Append(utils.ListElementsAs(d.EnabledNetworks, &enabledNetworks)...)
if diags.HasError() {
return nil, diags
}
model.EnabledNetworks = enabledNetworks
// Handle AdBlockedNetworks - if any networks are configured, set AdBlockingEnabled to true
if base.IsDefined(d.AdBlockedNetworks) {
var adBlockedNetworks []string
diags.Append(utils.ListElementsAs(d.AdBlockedNetworks, &adBlockedNetworks)...)
if diags.HasError() {
return nil, diags
}
if len(adBlockedNetworks) > 0 {
model.AdBlockingEnabled = true
model.AdBlockingConfigurations = make([]unifi.SettingIpsAdBlockingConfigurations, 0, len(adBlockedNetworks))
for _, networkID := range adBlockedNetworks {
model.AdBlockingConfigurations = append(model.AdBlockingConfigurations, unifi.SettingIpsAdBlockingConfigurations{
NetworkID: networkID,
})
}
} else {
model.AdBlockingEnabled = false
model.AdBlockingConfigurations = []unifi.SettingIpsAdBlockingConfigurations{}
}
}
// Handle DNSFilters - if any filters are configured, set DNSFiltering to true
if base.IsDefined(d.DNSFilters) {
var dnsFiltersObjects []DNSFilterModel
diags.Append(utils.ListElementsAs(d.DNSFilters, &dnsFiltersObjects)...)
if diags.HasError() {
return nil, diags
}
if len(dnsFiltersObjects) > 0 {
model.DNSFiltering = true
model.DNSFilters = make([]unifi.SettingIpsDNSFilters, 0, len(dnsFiltersObjects))
for _, filterObj := range dnsFiltersObjects {
version := "v4"
if utils.IsIPv6(filterObj.NetworkID.ValueString()) {
version = "v6"
}
filter := unifi.SettingIpsDNSFilters{
Description: filterObj.Description.ValueString(),
Filter: filterObj.Filter.ValueString(),
Name: filterObj.Name.ValueString(),
NetworkID: filterObj.NetworkID.ValueString(),
Version: version,
}
// Handle allowed sites
var allowedSites, blockedSites, blockedTlds []string
diags.Append(utils.ListElementsAs(filterObj.AllowedSites, &allowedSites)...)
diags.Append(utils.ListElementsAs(filterObj.BlockedSites, &blockedSites)...)
diags.Append(utils.ListElementsAs(filterObj.BlockedTld, &blockedTlds)...)
if diags.HasError() {
return nil, diags
}
filter.AllowedSites = allowedSites
filter.BlockedSites = blockedSites
filter.BlockedTld = blockedTlds
model.DNSFilters = append(model.DNSFilters, filter)
}
} else {
model.DNSFiltering = false
model.DNSFilters = []unifi.SettingIpsDNSFilters{}
}
}
// Handle honeypot
if base.IsDefined(d.Honeypots) {
var honeypotObjects []HoneypotModel
diags.Append(utils.ListElementsAs(d.Honeypots, &honeypotObjects)...)
if diags.HasError() {
return nil, diags
}
model.Honeypot = make([]unifi.SettingIpsHoneypot, 0)
for _, honeypotObj := range honeypotObjects {
version := "v4"
if utils.IsIPv6(honeypotObj.IPAddress.ValueString()) {
version = "v6"
}
model.Honeypot = append(model.Honeypot, unifi.SettingIpsHoneypot{
IPAddress: honeypotObj.IPAddress.ValueString(),
NetworkID: honeypotObj.NetworkID.ValueString(),
Version: version,
})
}
}
if len(model.Honeypot) > 0 {
model.HoneypotEnabled = true
} else {
model.HoneypotEnabled = false
}
// Handle suppression
if base.IsDefined(d.Suppression) {
var suppressionObj SuppressionModel
diags.Append(d.Suppression.As(ctx, &suppressionObj, basetypes.ObjectAsOptions{})...)
if diags.HasError() {
return nil, diags
}
var alerts []AlertsModel
diags.Append(utils.ListElementsAs(suppressionObj.Alerts, &alerts)...)
if diags.HasError() {
return nil, diags
}
model.Suppression.Alerts = make([]unifi.SettingIpsAlerts, 0)
for idx, alertObj := range alerts {
alert := unifi.SettingIpsAlerts{
Category: alertObj.Category.ValueString(),
Signature: alertObj.Signature.ValueString(),
Type: alertObj.Type.ValueString(),
ID: 100 + idx,
Gid: 200 + idx,
}
// Handle tracking
var trackings []TrackingModel
diags.Append(utils.ListElementsAs(alertObj.Tracking, &trackings)...)
if diags.HasError() {
return nil, diags
}
alert.Tracking = make([]unifi.SettingIpsTracking, 0)
for _, trackingObj := range trackings {
if base.IsEmptyString(trackingObj.Direction) || base.IsEmptyString(trackingObj.Mode) || base.IsEmptyString(trackingObj.Value) {
continue
}
alert.Tracking = append(alert.Tracking, unifi.SettingIpsTracking{
Direction: trackingObj.Direction.ValueString(),
Mode: trackingObj.Mode.ValueString(),
Value: trackingObj.Value.ValueString()})
}
model.Suppression.Alerts = append(model.Suppression.Alerts, alert)
}
var whitelists []WhitelistModel
diags.Append(utils.ListElementsAs(suppressionObj.Whitelist, &whitelists)...)
if diags.HasError() {
return nil, diags
}
model.Suppression.Whitelist = make([]unifi.SettingIpsWhitelist, 0, len(whitelists))
for _, whitelistObj := range whitelists {
model.Suppression.Whitelist = append(model.Suppression.Whitelist, unifi.SettingIpsWhitelist{
Direction: whitelistObj.Direction.ValueString(),
Mode: whitelistObj.Mode.ValueString(),
Value: whitelistObj.Value.ValueString(),
})
}
}
return model, diags
}
func (d *ipsModel) Merge(ctx context.Context, other interface{}) diag.Diagnostics {
diags := diag.Diagnostics{}
model, ok := other.(*unifi.SettingIps)
if !ok {
diags.AddError("Invalid model type", "Expected *unifi.SettingIps")
return diags
}
d.ID = types.StringValue(model.ID)
// Only set values for fields that were explicitly set in the configuration
// or returned by the API with non-default values
// Set basic fields if they were defined in the plan
d.AdvancedFilteringPreference = types.StringValue(model.AdvancedFilteringPreference)
d.Mode = types.StringValue(model.IPsMode)
d.MemoryOptimized = types.BoolValue(model.MemoryOptimized)
d.RestrictTorrents = types.BoolValue(model.RestrictTorrents)
// Handle enabled categories
enabledCategoriesList, diags := types.ListValueFrom(ctx, types.StringType, model.EnabledCategories)
if diags.HasError() {
return diags
}
if base.IsDefined(enabledCategoriesList) {
d.EnabledCategories = enabledCategoriesList
} else {
d.EnabledCategories = utils.EmptyList(types.StringType)
}
// Handle enabled networks
enabledNetworksList, diags := types.ListValueFrom(ctx, types.StringType, model.EnabledNetworks)
if diags.HasError() {
return diags
}
if base.IsDefined(enabledNetworksList) {
d.EnabledNetworks = enabledNetworksList
} else {
d.EnabledNetworks = utils.EmptyList(types.StringType)
}
//Handle AdBlockedNetworks - extract network IDs from AdBlockingConfigurations
adBlockedNetworks := make([]string, 0, len(model.AdBlockingConfigurations))
for _, config := range model.AdBlockingConfigurations {
adBlockedNetworks = append(adBlockedNetworks, config.NetworkID)
}
adBlockedNetworksList, diags := types.ListValueFrom(ctx, types.StringType, adBlockedNetworks)
if diags.HasError() {
return diags
}
d.AdBlockedNetworks = adBlockedNetworksList
// Handle DNSFilters
dnsFilters := make([]DNSFilterModel, 0)
for _, filter := range model.DNSFilters {
dnsFilter := DNSFilterModel{
Description: types.StringValue(filter.Description),
Filter: types.StringValue(filter.Filter),
Name: types.StringValue(filter.Name),
NetworkID: types.StringValue(filter.NetworkID),
}
allowedSites, diags := types.ListValueFrom(ctx, types.StringType, filter.AllowedSites)
if diags.HasError() {
return diags
}
dnsFilter.AllowedSites = allowedSites
blockedSites, diags := types.ListValueFrom(ctx, types.StringType, filter.BlockedSites)
if diags.HasError() {
return diags
}
dnsFilter.BlockedSites = blockedSites
blockedTlds, diags := types.ListValueFrom(ctx, types.StringType, filter.BlockedTld)
if diags.HasError() {
return diags
}
dnsFilter.BlockedTld = blockedTlds
dnsFilters = append(dnsFilters, dnsFilter)
}
dnsFiltersList, diags := types.ListValueFrom(ctx, types.ObjectType{
AttrTypes: (&DNSFilterModel{}).AttributeTypes(),
}, dnsFilters)
if diags.HasError() {
return diags
}
d.DNSFilters = dnsFiltersList
// Handle honeypot
honeypotModels := make([]HoneypotModel, 0, len(model.Honeypot))
for _, honeypot := range model.Honeypot {
honeypotModels = append(honeypotModels, HoneypotModel{
IPAddress: types.StringValue(honeypot.IPAddress),
NetworkID: types.StringValue(honeypot.NetworkID),
})
}
honeypotList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: (&HoneypotModel{}).AttributeTypes()}, honeypotModels)
if diags.HasError() {
return diags
}
d.Honeypots = honeypotList
// Handle suppression
suppression := SuppressionModel{}
// Handle alerts
alertModels := make([]AlertsModel, 0)
for _, alert := range model.Suppression.Alerts {
// Skip alerts with ID 0, because they may come as default values from the API
if alert.ID == 0 && alert.Category == "" && alert.Signature == "" && alert.Type == "" {
continue
}
alertModel := AlertsModel{
Category: types.StringValue(alert.Category),
Signature: types.StringValue(alert.Signature),
Type: types.StringValue(alert.Type),
}
// Handle tracking
trackingModels := make([]TrackingModel, 0)
for _, tracking := range alert.Tracking {
trackingModels = append(trackingModels, TrackingModel{
Direction: types.StringValue(tracking.Direction),
Mode: types.StringValue(tracking.Mode),
Value: types.StringValue(tracking.Value),
})
}
trackings, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: (&TrackingModel{}).AttributeTypes()}, trackingModels)
if diags.HasError() {
return diags
}
alertModel.Tracking = trackings
alertModels = append(alertModels, alertModel)
}
alerts, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: (&AlertsModel{}).AttributeTypes()}, alertModels)
if diags.HasError() {
return diags
}
suppression.Alerts = alerts
// Handle whitelist
whitelistModels := make([]WhitelistModel, 0)
for _, whitelist := range model.Suppression.Whitelist {
whitelistModels = append(whitelistModels, WhitelistModel{
Direction: types.StringValue(whitelist.Direction),
Mode: types.StringValue(whitelist.Mode),
Value: types.StringValue(whitelist.Value),
})
}
whitelist, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: (&WhitelistModel{}).AttributeTypes()}, whitelistModels)
if diags.HasError() {
return diags
}
suppression.Whitelist = whitelist
suppressionObj, diags := types.ObjectValueFrom(ctx, (&SuppressionModel{}).AttributeTypes(), suppression)
if diags.HasError() {
return diags
}
d.Suppression = suppressionObj
return diags
}
type ipsResource struct {
*base.GenericResource[*ipsModel]
}
func requiredTogetherIfString(ctx context.Context, config tfsdk.Config, attr, value, reqAttribute string) diag.Diagnostics {
v := validators.RequiredTogetherIf(path.MatchRoot(attr), types.StringValue(value), path.MatchRoot(reqAttribute))
return v.Validate(ctx, config)
}
func (r *ipsResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
resp.Diagnostics.Append(r.RequireMinVersion("7.4")...)
resp.Diagnostics.Append(r.RequireMinVersionForPath("7.5", path.Root("advanced_filtering_preference"), req.Config)...)
resp.Diagnostics.Append(r.RequireMinVersionForPath("8.0", path.Root("enabled_networks"), req.Config)...)
resp.Diagnostics.Append(r.RequireMinVersionForPath("9.0", path.Root("memory_optimized"), req.Config)...)
site, diags := r.GetClient().ResolveSiteFromConfig(ctx, req.Config)
if diags.HasError() {
resp.Diagnostics.Append(diags...)
return
}
resp.Diagnostics.Append(r.RequireFeaturesEnabled(ctx, site, features.Ips)...)
if r.GetClient().Version.GreaterThan(base.ControllerV8) {
diags.Append(requiredTogetherIfString(ctx, req.Config, "ips_mode", "ips", "enabled_networks")...)
diags.Append(requiredTogetherIfString(ctx, req.Config, "ips_mode", "ids", "enabled_networks")...)
diags.Append(requiredTogetherIfString(ctx, req.Config, "ips_mode", "ipsInline", "enabled_networks")...)
}
}
func (r *ipsResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{}
}
func (r *ipsResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "The `unifi_setting_ips` resource allows you to configure the Intrusion Prevention System (IPS) settings for your UniFi network. IPS provides network threat protection by monitoring, detecting, and preventing malicious traffic based on configured rules and policies. Requires controller version 7.4 or later",
Attributes: map[string]schema.Attribute{
"id": base.ID(),
"site": base.SiteAttribute(),
"ad_blocked_networks": schema.ListAttribute{
MarkdownDescription: "List of network IDs to enable ad blocking for. If any networks are configured, ad blocking will be automatically enabled. Each entry should be a valid network ID from your UniFi configuration. Leave empty to disable ad blocking.",
ElementType: types.StringType,
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"advanced_filtering_preference": schema.StringAttribute{
MarkdownDescription: "The advanced filtering preference for IPS. Valid values are:\n" +
" * `disabled` - Advanced filtering is disabled\n" +
" * `manual` - Advanced filtering is enabled and manually configured",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.OneOf("disabled", "manual"),
},
},
"dns_filters": schema.ListNestedAttribute{
MarkdownDescription: "DNS filters configuration. If any filters are configured, DNS filtering will be automatically enabled. Each filter can be applied to a specific network and provides content filtering capabilities.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"allowed_sites": schema.ListAttribute{
MarkdownDescription: "List of allowed sites for this DNS filter. These domains will always be accessible regardless of other filtering rules. Each entry should be a valid domain name (e.g., `example.com`).",
ElementType: types.StringType,
Optional: true,
},
"blocked_sites": schema.ListAttribute{
MarkdownDescription: "List of blocked sites for this DNS filter. These domains will be blocked regardless of other filtering rules. Each entry should be a valid domain name (e.g., `example.com`).",
ElementType: types.StringType,
Optional: true,
},
"blocked_tld": schema.ListAttribute{
MarkdownDescription: "List of blocked top-level domains (TLDs) for this DNS filter. All domains with these TLDs will be blocked. Each entry should be a valid TLD without the dot prefix (e.g., `xyz`, `info`).",
ElementType: types.StringType,
Optional: true,
},
"description": schema.StringAttribute{
MarkdownDescription: "Description of the DNS filter. This is used for documentation purposes only and does not affect functionality.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"filter": schema.StringAttribute{
MarkdownDescription: "Filter type that determines the predefined filtering level. Valid values are:\n" +
" * `none` - No predefined filtering\n" +
" * `work` - Work-appropriate filtering that blocks adult content\n" +
" * `family` - Family-friendly filtering that blocks adult content and other inappropriate sites",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("none", "work", "family"),
},
},
"name": schema.StringAttribute{
MarkdownDescription: "Name of the DNS filter. This is used to identify the filter in the UniFi interface.",
Required: true,
},
"network_id": schema.StringAttribute{
MarkdownDescription: "Network ID this filter applies to. This should be a valid network ID from your UniFi configuration.",
Required: true,
},
},
},
},
"enabled_categories": schema.ListAttribute{
MarkdownDescription: "List of enabled IPS threat categories. Each entry enables detection and prevention for a specific type of threat. The list of valid categories includes common threats like malware, exploits, scanning, and policy violations. See the validator for the complete list of available categories.",
ElementType: types.StringType,
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
//Default: utils.DefaultEmptyList(types.StringType),
Validators: []validator.List{
listvalidator.ValueStringsAre(stringvalidator.OneOf("emerging-activex", "emerging-attackresponse", "botcc", "emerging-chat", "ciarmy", "compromised", "emerging-dns", "emerging-dos", "dshield", "emerging-exploit", "emerging-ftp", "emerging-games", "emerging-icmp", "emerging-icmpinfo", "emerging-imap", "emerging-inappropriate", "emerging-info", "emerging-malware", "emerging-misc", "emerging-mobile", "emerging-netbios", "emerging-p2p", "emerging-policy", "emerging-pop3", "emerging-rpc", "emerging-scada", "emerging-scan", "emerging-shellcode", "emerging-smtp", "emerging-snmp", "emerging-sql", "emerging-telnet", "emerging-tftp", "tor", "emerging-useragent", "emerging-voip", "emerging-webapps", "emerging-webclient", "emerging-webserver", "emerging-worm", "exploit-kit", "adware-pup", "botcc-portgrouped", "phishing", "threatview-cs-c2", "3coresec", "chat", "coinminer", "current-events", "drop", "hunting", "icmp-info", "inappropriate", "info", "ja3", "policy", "scada", "dark-web-blocker-list", "malicious-hosts")),
},
},
"enabled_networks": schema.ListAttribute{
MarkdownDescription: "List of network IDs to enable IPS protection for. Each entry should be a valid network ID from your UniFi configuration. IPS will only monitor and protect traffic on these networks.",
ElementType: types.StringType,
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
},
"honeypots": schema.ListNestedAttribute{
MarkdownDescription: "Honeypots configuration. Honeypots are decoy systems designed to detect, deflect, or study hacking attempts. They appear as legitimate parts of the network but are isolated and monitored.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"ip_address": schema.StringAttribute{
MarkdownDescription: "IP address for the honeypot. This should be an unused IPv4 address within your network range that will be used as a decoy system.",
Required: true,
Validators: []validator.String{
stringvalidator.Any(validators.IPv4(), validators.IPv6()),
},
},
"network_id": schema.StringAttribute{
MarkdownDescription: "Network ID for the honeypot. This should be a valid network ID from your UniFi configuration where the honeypot will be deployed.",
Required: true,
},
},
},
},
"ips_mode": schema.StringAttribute{
MarkdownDescription: "The IPS operation mode. Valid values are:\n" +
" * `ids` - Intrusion Detection System mode (detect and log threats only)\n" +
" * `ips` - Intrusion Prevention System mode (detect and block threats)\n" +
" * `ipsInline` - Inline Intrusion Prevention System mode (more aggressive blocking)\n" +
" * `disabled` - IPS functionality is completely disabled",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.OneOf("ids", "ips", "ipsInline", "disabled"),
},
},
"memory_optimized": schema.BoolAttribute{
MarkdownDescription: "Whether memory optimization is enabled for IPS. When set to `true`, the system will use less memory at the cost of potentially reduced detection capabilities. Useful for devices with limited resources. Defaults to `false`. Requires controller version 9.0 or later.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"restrict_torrents": schema.BoolAttribute{
MarkdownDescription: "Whether to restrict BitTorrent and other peer-to-peer file sharing traffic. When set to `true`, the system will block P2P traffic across the network. Defaults to `false`.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.UseStateForUnknown(),
},
},
"suppression": schema.SingleNestedAttribute{
MarkdownDescription: "Suppression configuration for IPS. This allows you to customize which alerts are suppressed or tracked, and define whitelisted traffic that should never trigger IPS alerts.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"alerts": schema.ListNestedAttribute{
MarkdownDescription: "Alert suppressions. Each entry defines a specific IPS alert that should be suppressed or tracked differently from the default behavior.",
Optional: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"category": schema.StringAttribute{
MarkdownDescription: "Category of the alert to suppress. This should match one of the categories from the enabled_categories list.",
Required: true,
},
//"gid": schema.Int64Attribute{
// MarkdownDescription: "Group ID of the alert to suppress. This is a numeric identifier for the alert group in the IPS ruleset.",
// Required: true,
//},
//"id": schema.Int64Attribute{
// MarkdownDescription: "ID of the alert to suppress. This is a numeric identifier for the specific alert in the IPS ruleset.",
// Required: true,
//},
"signature": schema.StringAttribute{
MarkdownDescription: "Signature name of the alert to suppress. This is a human-readable identifier for the alert in the IPS ruleset.",
Required: true,
},
"tracking": schema.ListNestedAttribute{
MarkdownDescription: "Tracking configuration for the alert. This defines how the system should track occurrences of this alert based on source/destination addresses.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"direction": schema.StringAttribute{
MarkdownDescription: "Direction for tracking. Valid values are:\n" +
" * `src` - Track by source address\n" +
" * `dest` - Track by destination address\n" +
" * `both` - Track by both source and destination addresses",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("src", "dest", "both"),
},
},
"mode": schema.StringAttribute{
MarkdownDescription: "Mode for tracking. Valid values are:\n" +
" * `ip` - Track by individual IP address\n" +
" * `subnet` - Track by subnet\n" +
" * `network` - Track by network ID",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("ip", "subnet", "network"),
},
},
"value": schema.StringAttribute{
MarkdownDescription: "Value for tracking. The meaning depends on the mode:\n" +
" * For `ip` mode: An IP address (e.g., `192.168.1.100`)\n" +
" * For `subnet` mode: A CIDR notation subnet (e.g., `192.168.1.0/24`)\n" +
" * For `network` mode: A network ID from your UniFi configuration",
Required: true,
},
},
},
},
"type": schema.StringAttribute{
MarkdownDescription: "Type of suppression. Valid values are:\n" +
" * `all` - Suppress all occurrences of this alert\n" +
" * `track` - Only track this alert according to the tracking configuration",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("all", "track"),
},
},
},
},
},
"whitelist": schema.ListNestedAttribute{
MarkdownDescription: "Whitelist configuration. Each entry defines traffic that should never trigger IPS alerts, regardless of other rules.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.List{
listplanmodifier.UseStateForUnknown(),
},
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"direction": schema.StringAttribute{
MarkdownDescription: "Direction for whitelist. Valid values are:\n" +
" * `src` - Whitelist by source address\n" +
" * `dst` - Whitelist by destination address\n" +
" * `both` - Whitelist by both source and destination addresses",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("src", "dst", "both"),
},
},
"mode": schema.StringAttribute{
MarkdownDescription: "Mode for whitelist. Valid values are:\n" +
" * `ip` - Whitelist by individual IP address\n" +
" * `subnet` - Whitelist by subnet\n" +
" * `network` - Whitelist by network ID",
Required: true,
Validators: []validator.String{
stringvalidator.OneOf("ip", "subnet", "network"),
},
},
"value": schema.StringAttribute{
MarkdownDescription: "Value for whitelist. The meaning depends on the mode:\n" +
" * For `ip` mode: An IP address (e.g., `192.168.1.100`)\n" +
" * For `subnet` mode: A CIDR notation subnet (e.g., `192.168.1.0/24`)\n" +
" * For `network` mode: A network ID from your UniFi configuration",
Required: true,
},
},
},
},
},
},
},
}
}
func NewIpsResource() resource.Resource {
r := &ipsResource{}
r.GenericResource = NewSettingResource(
"unifi_setting_ips",
func() *ipsModel { return &ipsModel{} },
func(ctx context.Context, client *base.Client, site string) (interface{}, error) {
return client.GetSettingIps(ctx, site)
},
func(ctx context.Context, client *base.Client, site string, body interface{}) (interface{}, error) {
return client.UpdateSettingIps(ctx, site, body.(*unifi.SettingIps))
},
)
return r
}
var (
_ base.ResourceModel = &ipsModel{}
_ resource.Resource = &ipsResource{}
_ resource.ResourceWithConfigure = &ipsResource{}
_ resource.ResourceWithConfigValidators = &ipsResource{}
_ resource.ResourceWithModifyPlan = &ipsResource{}
)

View File

@@ -195,6 +195,7 @@ type mgmtResource struct {
}
func (r *mgmtResource) ModifyPlan(_ context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
resp.Diagnostics.Append(r.RequireMinVersionForPath("7.0", path.Root("auto_upgrade_hour"), req.Config)...)
resp.Diagnostics.Append(r.RequireMinVersionForPath("7.3", path.Root("debug_tools_enabled"), req.Config)...)
}

View File

@@ -3,8 +3,7 @@ package validators
import (
"context"
"fmt"
"net"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
@@ -17,15 +16,15 @@ var _ validator.String = ipv4Validator{}
type ipv4Validator struct{}
func (v ipv4Validator) Description(ctx context.Context) string {
func (v ipv4Validator) Description(_ context.Context) string {
return "value must be a valid IPv4 address"
}
func (v ipv4Validator) MarkdownDescription(ctx context.Context) string {
func (v ipv4Validator) MarkdownDescription(_ context.Context) string {
return "value must be a valid IPv4 address"
}
func (v ipv4Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
func (v ipv4Validator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}
@@ -35,8 +34,7 @@ func (v ipv4Validator) ValidateString(ctx context.Context, req validator.StringR
return
}
ip := net.ParseIP(value)
if ip == nil || ip.To4() == nil {
if !utils.IsIPv4(value) {
resp.Diagnostics.AddAttributeError(
req.Path,
"Invalid IPv4 Address",

View File

@@ -0,0 +1,45 @@
package validators
import (
"context"
"fmt"
"github.com/filipowm/terraform-provider-unifi/internal/utils"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
// IPv6 returns a validator which ensures that a string value is a valid IPv6 address.
func IPv6() validator.String {
return ipv6Validator{}
}
var _ validator.String = ipv6Validator{}
type ipv6Validator struct{}
func (v ipv6Validator) Description(_ context.Context) string {
return "value must be a valid IPv6 address"
}
func (v ipv6Validator) MarkdownDescription(_ context.Context) string {
return "value must be a valid IPv6 address"
}
func (v ipv6Validator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) {
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
return
}
value := req.ConfigValue.ValueString()
if value == "" {
return
}
if !utils.IsIPv6(value) {
resp.Diagnostics.AddAttributeError(
req.Path,
"Invalid IPv6 Address",
fmt.Sprintf("Value %q is not a valid IPv6 address", value),
)
return
}
}

View File

@@ -0,0 +1,88 @@
package validators_test
import (
"context"
"testing"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestIPv6Validator(t *testing.T) {
t.Parallel()
type testCase struct {
val types.String
expectError bool
}
tests := map[string]testCase{
"unknown": {
val: types.StringUnknown(),
},
"null": {
val: types.StringNull(),
},
"empty": {
val: types.StringValue(""),
},
"valid ipv6 full": {
val: types.StringValue("2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
},
"valid ipv6 compressed": {
val: types.StringValue("2001:db8:85a3::8a2e:370:7334"),
},
"valid ipv6 loopback": {
val: types.StringValue("::1"),
},
"valid ipv6 unspecified": {
val: types.StringValue("::"),
},
"valid ipv6 with zone": {
val: types.StringValue("fe80::1ff:fe23:4567:890a%eth0"),
},
"valid ipv6 ipv4-mapped": {
val: types.StringValue("::ffff:192.0.2.128"),
},
"invalid ipv6 - too many segments": {
val: types.StringValue("2001:0db8:85a3:0000:0000:8a2e:0370:7334:1111"),
expectError: true,
},
"invalid ipv6 - out of range": {
val: types.StringValue("2001:0db8:85a3:0000:0000:8a2e:0370:GGGG"),
expectError: true,
},
"invalid ipv6 - incomplete": {
val: types.StringValue("2001:0db8:85a3"),
expectError: true,
},
"invalid ipv6 - ipv4": {
val: types.StringValue("192.168.1.1"),
expectError: true,
},
"invalid ipv6 - characters": {
val: types.StringValue("not-an-ip"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
req := validator.StringRequest{
ConfigValue: test.val,
}
resp := validator.StringResponse{}
validators.IPv6().ValidateString(context.Background(), req, &resp)
if !test.expectError && resp.Diagnostics.HasError() {
t.Fatalf("got unexpected error: %s", resp.Diagnostics.Errors()[0].Detail())
}
if test.expectError && !resp.Diagnostics.HasError() {
t.Fatalf("expected error but got none")
}
})
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"net"
"strings"
)
func CidrValidate(raw interface{}, key string) ([]string, []error) {
@@ -53,3 +54,29 @@ func CidrDiffSuppress(k, old, new string, d *schema.ResourceData) bool {
return oldNet.String() == newNet.String()
}
// IsIPv4 checks if the provided address is a valid IPv4 address.
// It returns true if the address is a valid IPv4 address, false otherwise.
func IsIPv4(address string) bool {
ip := net.ParseIP(address)
return ip != nil && ip.To4() != nil
}
// IsIPv6 checks if the provided address is a valid IPv6 address.
// It returns true if the address is a valid IPv6 address, false otherwise.
func IsIPv6(address string) bool {
// Handle zone index if present
if idx := strings.Index(address, "%"); idx != -1 {
address = address[:idx]
}
// Handle IPv4-mapped addresses
isIPv4Mapped := strings.Contains(address, "::ffff:") && strings.Count(address, ".") == 3
ip := net.ParseIP(address)
if ip == nil || (!isIPv4Mapped && ip.To4() != nil) {
return false
}
return true
}