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"
schedule:
- cron: "0 13 * * *"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Matrix Test

4
go.mod
View File

@@ -2,7 +2,7 @@ module github.com/filipowm/terraform-provider-unifi
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-sdk/v2 => ../../hashicorp/terraform-plugin-sdk
@@ -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.6.0
github.com/filipowm/go-unifi v1.6.1
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

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.6.0 h1:0oLOrsLWcaU8sUsyMyjyGwaAWNC9Ee4YZ1ehtijXahg=
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/go.mod h1:gHJjDqhGM4WyPt639SOZs+G89Ko7QKH5R5BhnO6xJhw=
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 (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"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/resource"
@@ -42,6 +44,20 @@ type DatasourceModel interface {
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 {
ID types.String `tfsdk:"id"`
Site types.String `tfsdk:"site"`

View File

@@ -13,6 +13,7 @@ import (
"log"
"net"
"net/http"
"sync"
"time"
)
@@ -54,7 +55,7 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
return nil, err
}
c := &Client{
Client: unifiClient,
Client: NewRetryableUnifiClient(unifiClient),
Site: cfg.Site,
Version: version.Must(version.NewVersion(unifiClient.Version())),
}
@@ -64,6 +65,41 @@ func NewClient(cfg *ClientConfig) (*Client, error) {
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 {
unifi.Client
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)
}
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 {
if err == nil {
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
func TestNewFeatureValidator(t *testing.T) {
mockUnifiClient := &MockUnifiClient{
@@ -156,10 +163,7 @@ func TestNewFeatureValidator(t *testing.T) {
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)
@@ -225,10 +229,7 @@ func TestGetFeatures(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setup()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
@@ -267,10 +268,7 @@ func TestGetFeaturesConcurrent(t *testing.T) {
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
@@ -367,10 +365,7 @@ func TestRequireFeatures(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := &MockUnifiClient{}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := &featureEnabledValidator{
client: client,
@@ -465,10 +460,7 @@ func TestRequireFeaturesEnabledForPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
// Create a wrapper FeatureValidator that provides a minimal implementation
// of RequireFeaturesEnabledForPath without needing a real tfsdk.Config
@@ -538,10 +530,7 @@ func TestRequireFeaturesEnabled(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockUnifiClient := tt.setupClient()
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)
diags := validator.RequireFeaturesEnabled(context.Background(), "site1", tt.requiredFeatures...)
assert.Equal(t, tt.expectedHasErrors, diags.HasError())
@@ -605,10 +594,7 @@ func TestFeatureValidatorCache(t *testing.T) {
},
}
client := &Client{
Client: mockUnifiClient,
Site: "default",
}
client := newTestClient(mockUnifiClient)
validator := NewFeatureValidator(client)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"net/http"
@@ -266,12 +267,14 @@ func (te *TestEnvironment) newTestClient() (unifi.Client, error) {
return nil, err
}
return unifi.NewClient(&unifi.ClientConfig{
client, err := unifi.NewClient(&unifi.ClientConfig{
URL: te.Endpoint,
User: user,
Password: password,
VerifySSL: false,
RememberMe: true,
ValidationMode: unifi.DisableValidation,
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
The provider supports management of:
* Networks and VLANs
* Wireless Networks (WLANs)
* Firewall Rules and Groups
* Port Forwarding
* DNS Records
* User Management
* Device Configuration
* And more...
- Manage UniFi network resources using Infrastructure as Code
- Support for UniFi Controller version 6.x and later
- Compatible with UDM, UDM-Pro, UCG, and standard controller deployments
- Comprehensive resource management including:
- Network/WLAN configuration
- Firewall rules and groups
- Port forwarding
- DNS records
- User management
- Device management
- And more...
## Supported Platforms