feat: support Guest Access settings with resource_setting_guest_access (#61)

* feat: support Guest Access settings with `resource_setting_guest_access`

* feat: add support for redirect after authentication in guest access settings

* feat: add support for Facebook authentication in guest access settings

* feat: add support for Google authentication in guest access settings

* feat: add support for RADIUS authentication in guest access settings

* feat: add support for Wechat authentication in guest access settings

* feat: add support for Facebook Wifi authentication in guest access settings

* feat: add support for restricted DNS servers

* feat: add support for guest portal UI customization

* feat: add support for restricted subnet in guest portal

* feat: retry client action on HTTP 401, but first attempt relogging in

* require controllr version 7.4 for several portal customization attributes

* enable acceptance tests workflow concurrency
This commit is contained in:
Mateusz Filipowicz
2025-03-17 14:53:28 +01:00
committed by GitHub
parent 28d28f17f6
commit ca21f79083
16 changed files with 3102 additions and 42 deletions

View File

@@ -24,6 +24,11 @@ on:
- "Makefile" - "Makefile"
schedule: schedule:
- cron: "0 13 * * *" - cron: "0 13 * * *"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
test: test:
name: Matrix Test name: Matrix Test

4
go.mod
View File

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

2
go.sum
View File

@@ -284,6 +284,8 @@ github.com/filipowm/go-unifi v1.5.4 h1:kc1jWx0Rht+tiEyFsDjPAYJ6h2EV+Atq7eIoTA6fH
github.com/filipowm/go-unifi v1.5.4/go.mod h1:NQgqx3ylLVDqxVgY3uJ3ZMIecSl46fvqCCXzwHRuVDc= 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 h1:0oLOrsLWcaU8sUsyMyjyGwaAWNC9Ee4YZ1ehtijXahg=
github.com/filipowm/go-unifi v1.6.0/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U= github.com/filipowm/go-unifi v1.6.0/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/filipowm/go-unifi v1.6.1 h1:zkxLUkbUWO3d62cUGr97knIzSJd+/id9RVc32iPojqo=
github.com/filipowm/go-unifi v1.6.1/go.mod h1:hB5XyhjtnnU9GC6lYPYxuNmYq4J/SyjmElRVazCKT0U=
github.com/firefart/nonamedreturns v1.0.5 h1:tM+Me2ZaXs8tfdDw3X6DOX++wMCOqzYUho6tUTYIdRA= 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/firefart/nonamedreturns v1.0.5/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,9 @@ package base
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
@@ -42,6 +44,20 @@ type DatasourceModel interface {
Merge(context.Context, interface{}) diag.Diagnostics Merge(context.Context, interface{}) diag.Diagnostics
} }
type NestedObject interface {
AttributeTypes() map[string]attr.Type
}
func ObjectNull(obj interface{}) (basetypes.ObjectValue, diag.Diagnostics) {
diags := diag.Diagnostics{}
if nested, ok := obj.(NestedObject); ok {
obj := types.ObjectNull(nested.AttributeTypes())
return obj, diags
}
diags.AddError("Invalid object type", fmt.Sprintf("Expected NestedObject, got: %T", obj))
return types.ObjectNull(map[string]attr.Type{}), diags
}
type Model struct { type Model struct {
ID types.String `tfsdk:"id"` ID types.String `tfsdk:"id"`
Site types.String `tfsdk:"site"` Site types.String `tfsdk:"site"`

View File

@@ -13,6 +13,7 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"sync"
"time" "time"
) )
@@ -54,7 +55,7 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
return nil, err return nil, err
} }
c := &Client{ c := &Client{
Client: unifiClient, Client: NewRetryableUnifiClient(unifiClient),
Site: cfg.Site, Site: cfg.Site,
Version: version.Must(version.NewVersion(unifiClient.Version())), Version: version.Must(version.NewVersion(unifiClient.Version())),
} }
@@ -64,6 +65,41 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
return c, nil return c, nil
} }
func NewRetryableUnifiClient(client unifi.Client) unifi.Client {
return &RetryableUnifiClient{
Client: client,
loginMutex: sync.Mutex{},
}
}
type RetryableUnifiClient struct {
unifi.Client
loginMutex sync.Mutex
}
func (c *RetryableUnifiClient) relogin(err error) error {
c.loginMutex.Lock()
defer c.loginMutex.Unlock()
loginErr := c.Client.Login()
if loginErr != nil {
return fmt.Errorf("Tried relogging in after %w, but failed: %w.", err, loginErr)
} else {
return nil
}
}
func (c *RetryableUnifiClient) Do(ctx context.Context, method string, apiPath string, reqBody interface{}, respBody interface{}) error {
err := c.Client.Do(ctx, method, apiPath, reqBody, respBody)
if err != nil && IsServerErrorStatusCode(err, 401) {
err := c.relogin(err)
if err != nil {
return err
}
return c.Client.Do(ctx, method, apiPath, reqBody, respBody)
}
return err
}
type Client struct { type Client struct {
unifi.Client unifi.Client
Site string Site string

View File

@@ -14,6 +14,17 @@ func ErrorInvalidModelMergeTarget(expectedType, actualType interface{}) diag.Dia
return diag.NewErrorDiagnostic("Invalid model merge target", "Expected target type to be the same a receiver: "+e+". Was : "+a) return diag.NewErrorDiagnostic("Invalid model merge target", "Expected target type to be the same a receiver: "+e+". Was : "+a)
} }
func IsServerErrorStatusCode(err error, statusCode int) bool {
if err == nil {
return false
}
var se *unifi.ServerError
if errors.As(err, &se) {
return se.StatusCode == statusCode
}
return false
}
func IsServerErrorContains(err error, messageContains string) bool { func IsServerErrorContains(err error, messageContains string) bool {
if err == nil { if err == nil {
return false return false

View File

@@ -148,6 +148,13 @@ func TestFeaturesIsUnavailable(t *testing.T) {
} }
} }
func newTestClient(mock *MockUnifiClient) *Client {
return &Client{
Client: mock,
Site: "default",
}
}
// TestNewFeatureValidator tests the NewFeatureValidator function // TestNewFeatureValidator tests the NewFeatureValidator function
func TestNewFeatureValidator(t *testing.T) { func TestNewFeatureValidator(t *testing.T) {
mockUnifiClient := &MockUnifiClient{ mockUnifiClient := &MockUnifiClient{
@@ -156,10 +163,7 @@ func TestNewFeatureValidator(t *testing.T) {
}, },
} }
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client) validator := NewFeatureValidator(client)
@@ -225,10 +229,7 @@ func TestGetFeatures(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setup() mockUnifiClient := tt.setup()
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{ validator := &featureEnabledValidator{
client: client, client: client,
@@ -267,10 +268,7 @@ func TestGetFeaturesConcurrent(t *testing.T) {
}, },
} }
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{ validator := &featureEnabledValidator{
client: client, client: client,
@@ -367,10 +365,7 @@ func TestRequireFeatures(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := &MockUnifiClient{} mockUnifiClient := &MockUnifiClient{}
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := &featureEnabledValidator{ validator := &featureEnabledValidator{
client: client, client: client,
@@ -465,10 +460,7 @@ func TestRequireFeaturesEnabledForPath(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient() mockUnifiClient := tt.setupClient()
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
// Create a wrapper FeatureValidator that provides a minimal implementation // Create a wrapper FeatureValidator that provides a minimal implementation
// of RequireFeaturesEnabledForPath without needing a real tfsdk.Config // of RequireFeaturesEnabledForPath without needing a real tfsdk.Config
@@ -538,10 +530,7 @@ func TestRequireFeaturesEnabled(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient() mockUnifiClient := tt.setupClient()
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client) validator := NewFeatureValidator(client)
diags := validator.RequireFeaturesEnabled(context.Background(), "site1", tt.requiredFeatures...) diags := validator.RequireFeaturesEnabled(context.Background(), "site1", tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError()) assert.Equal(t, tt.expectedHasErrors, diags.HasError())
@@ -605,10 +594,7 @@ func TestFeatureValidatorCache(t *testing.T) {
}, },
} }
client := &Client{ client := newTestClient(mockUnifiClient)
Client: mockUnifiClient,
Site: "default",
}
validator := NewFeatureValidator(client) validator := NewFeatureValidator(client)

View File

@@ -179,6 +179,7 @@ func (p *unifiProvider) Resources(_ context.Context) []func() resource.Resource
settings.NewAutoSpeedtestResource, settings.NewAutoSpeedtestResource,
settings.NewCountryResource, settings.NewCountryResource,
settings.NewDpiResource, settings.NewDpiResource,
settings.NewGuestAccessResource,
settings.NewIpsResource, settings.NewIpsResource,
settings.NewLcmResource, settings.NewLcmResource,
settings.NewLocaleResource, settings.NewLocaleResource,

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/helper/resource"
"net/http" "net/http"
@@ -266,12 +267,14 @@ func (te *TestEnvironment) newTestClient() (unifi.Client, error) {
return nil, err return nil, err
} }
return unifi.NewClient(&unifi.ClientConfig{ client, err := unifi.NewClient(&unifi.ClientConfig{
URL: te.Endpoint, URL: te.Endpoint,
User: user, User: user,
Password: password, Password: password,
VerifySSL: false, VerifySSL: false,
RememberMe: true,
ValidationMode: unifi.DisableValidation, ValidationMode: unifi.DisableValidation,
Logger: unifi.NewDefaultLogger(unifi.WarnLevel), Logger: unifi.NewDefaultLogger(unifi.WarnLevel),
}) })
return base.NewRetryableUnifiClient(client), err
} }

View File

@@ -0,0 +1,48 @@
package validators
import (
"context"
"fmt"
"regexp"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
// A common regex pattern for validating email addresses
// This is a simplified version and may not catch all edge cases
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Email returns a validator which ensures that the string value is a valid email address.
func Email() validator.String {
return emailValidator{}
}
type emailValidator struct{}
func (v emailValidator) Description(_ context.Context) string {
return "must be a valid email address"
}
func (v emailValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}
func (v emailValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
value := req.ConfigValue
if !base.IsDefined(value) {
return
}
val := value.ValueString()
if !emailRegex.MatchString(val) {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid email address", val),
),
)
}
}

View File

@@ -0,0 +1,82 @@
package validators_test
import (
"context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestEmailValidator(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(),
},
"valid-simple": {
val: types.StringValue("test@example.com"),
},
"valid-with-dots": {
val: types.StringValue("john.doe@example.com"),
},
"valid-with-plus": {
val: types.StringValue("john+test@example.com"),
},
"valid-with-subdomain": {
val: types.StringValue("john@sub.example.com"),
},
"valid-with-numbers": {
val: types.StringValue("user123@example.com"),
},
"invalid-no-at": {
val: types.StringValue("testexample.com"),
expectError: true,
},
"invalid-no-domain": {
val: types.StringValue("test@"),
expectError: true,
},
"invalid-no-tld": {
val: types.StringValue("test@example"),
expectError: true,
},
"invalid-space": {
val: types.StringValue("test user@example.com"),
expectError: true,
},
"invalid-special-chars": {
val: types.StringValue("test*user@example.com"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
request := validator.StringRequest{
ConfigValue: test.val,
}
response := validator.StringResponse{}
validators.Email().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}
if response.Diagnostics.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %s", response.Diagnostics)
}
})
}
}

View File

@@ -0,0 +1,47 @@
package validators
import (
"context"
"fmt"
"regexp"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
)
var hexColorRegex = regexp.MustCompile("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
// HexColor returns a validator which ensures that the string value is a valid hex color code.
// Valid formats are: #RGB or #RRGGBB
func HexColor() validator.String {
return hexColorValidator{}
}
type hexColorValidator struct{}
func (v hexColorValidator) Description(_ context.Context) string {
return "must be a valid hex color code (e.g., #FFF or #FFFFFF)"
}
func (v hexColorValidator) MarkdownDescription(ctx context.Context) string {
return v.Description(ctx)
}
func (v hexColorValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
value := req.ConfigValue
if !base.IsDefined(value) {
return
}
val := value.ValueString()
if !hexColorRegex.MatchString(val) {
resp.Diagnostics.Append(
validatordiag.InvalidAttributeValueDiagnostic(
req.Path,
v.Description(ctx),
fmt.Sprintf("%q is not a valid hex color code", val),
),
)
}
}

View File

@@ -0,0 +1,75 @@
package validators_test
import (
"context"
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
"testing"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
func TestHexColorValidator(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(),
},
"valid-6-digits": {
val: types.StringValue("#123456"),
},
"valid-3-digits": {
val: types.StringValue("#123"),
},
"valid-uppercase": {
val: types.StringValue("#ABCDEF"),
},
"valid-mixed-case": {
val: types.StringValue("#aBcDeF"),
},
"invalid-missing-hash": {
val: types.StringValue("123456"),
expectError: true,
},
"invalid-too-short": {
val: types.StringValue("#12"),
expectError: true,
},
"invalid-too-long": {
val: types.StringValue("#1234567"),
expectError: true,
},
"invalid-wrong-chars": {
val: types.StringValue("#12345G"),
expectError: true,
},
}
for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
t.Parallel()
request := validator.StringRequest{
ConfigValue: test.val,
}
response := validator.StringResponse{}
validators.HexColor().ValidateString(context.Background(), request, &response)
if !response.Diagnostics.HasError() && test.expectError {
t.Fatal("expected error, got no error")
}
if response.Diagnostics.HasError() && !test.expectError {
t.Fatalf("got unexpected error: %s", response.Diagnostics)
}
})
}
}

View File

@@ -11,16 +11,17 @@ The UniFi provider enables infrastructure-as-code management of [Ubiquiti's UniF
## Supported Features ## Supported Features
The provider supports management of: - Manage UniFi network resources using Infrastructure as Code
- Support for UniFi Controller version 6.x and later
* Networks and VLANs - Compatible with UDM, UDM-Pro, UCG, and standard controller deployments
* Wireless Networks (WLANs) - Comprehensive resource management including:
* Firewall Rules and Groups - Network/WLAN configuration
* Port Forwarding - Firewall rules and groups
* DNS Records - Port forwarding
* User Management - DNS records
* Device Configuration - User management
* And more... - Device management
- And more...
## Supported Platforms ## Supported Platforms