- Add NetworkIDs and MatchOppositeNetworks to destination model - Add schema attributes for destination.network_ids - Handle NETWORK matching target in AsUnifiModel and mergeDestination - Fix Port type conversion (int32 to string) for API compatibility - Update go.mod to use local go-unifi with destination network support Fixes: destination.network_ids silently ignored on update
978 lines
38 KiB
Go
978 lines
38 KiB
Go
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"github.com/filipowm/go-unifi/unifi"
|
|
"github.com/filipowm/go-unifi/unifi/features"
|
|
"github.com/filipowm/terraform-provider-unifi/internal/provider/base"
|
|
ut "github.com/filipowm/terraform-provider-unifi/internal/provider/types"
|
|
"github.com/filipowm/terraform-provider-unifi/internal/provider/utils"
|
|
"github.com/filipowm/terraform-provider-unifi/internal/provider/validators"
|
|
"github.com/hashicorp/terraform-plugin-framework-validators/int32validator"
|
|
"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/objectdefault"
|
|
"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/stringdefault"
|
|
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
|
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
|
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
|
|
"maps"
|
|
)
|
|
|
|
var (
|
|
_ resource.Resource = &firewallZonePolicyResource{}
|
|
_ resource.ResourceWithConfigure = &firewallZonePolicyResource{}
|
|
_ resource.ResourceWithConfigValidators = &firewallZonePolicyResource{}
|
|
_ resource.ResourceWithImportState = &firewallZonePolicyResource{}
|
|
_ resource.ResourceWithModifyPlan = &firewallZonePolicyResource{}
|
|
_ base.Resource = &firewallZonePolicyResource{}
|
|
)
|
|
|
|
func mergedTargetAttributes(additional map[string]schema.Attribute) map[string]schema.Attribute {
|
|
attrs := map[string]schema.Attribute{
|
|
"ip_group_id": schema.StringAttribute{
|
|
MarkdownDescription: "ID of the source IP group.",
|
|
Optional: true,
|
|
},
|
|
"ips": schema.ListAttribute{
|
|
MarkdownDescription: "List of source IPs.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.ValueStringsAre(
|
|
validators.IPv4(),
|
|
),
|
|
},
|
|
},
|
|
"match_opposite_ips": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether to match opposite IPs.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"match_opposite_ports": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether to match opposite ports.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"port": schema.Int32Attribute{
|
|
MarkdownDescription: "Source port.",
|
|
Optional: true,
|
|
Validators: []validator.Int32{
|
|
int32validator.Between(1, 65535),
|
|
},
|
|
},
|
|
"port_group_id": schema.StringAttribute{
|
|
MarkdownDescription: "ID of the source port group.",
|
|
Optional: true,
|
|
},
|
|
"zone_id": schema.StringAttribute{
|
|
MarkdownDescription: "ID of the firewall zone.",
|
|
Required: true,
|
|
},
|
|
}
|
|
maps.Copy(attrs, additional)
|
|
return attrs
|
|
}
|
|
|
|
type FirewallPolicyTargetModel struct {
|
|
IPGroupID types.String `tfsdk:"ip_group_id"`
|
|
IPs types.List `tfsdk:"ips"`
|
|
MatchOppositeIPs types.Bool `tfsdk:"match_opposite_ips"`
|
|
MatchOppositePorts types.Bool `tfsdk:"match_opposite_ports"`
|
|
Port types.Int32 `tfsdk:"port"`
|
|
PortGroupID types.String `tfsdk:"port_group_id"`
|
|
ZoneID types.String `tfsdk:"zone_id"`
|
|
}
|
|
|
|
func (m *FirewallPolicyTargetModel) AttributeTypes() map[string]attr.Type {
|
|
return map[string]attr.Type{
|
|
"ip_group_id": types.StringType,
|
|
"ips": types.ListType{ElemType: types.StringType},
|
|
"match_opposite_ips": types.BoolType,
|
|
"match_opposite_ports": types.BoolType,
|
|
"port": types.Int32Type,
|
|
"port_group_id": types.StringType,
|
|
"zone_id": types.StringType,
|
|
}
|
|
}
|
|
|
|
func NewFirewallPolicyTargetModel(ipGroupId string, ips []string, matchOppositeIps, matchOppositePorts bool, port string, portGroupId, zoneId string) *FirewallPolicyTargetModel {
|
|
diags := diag.Diagnostics{}
|
|
m := &FirewallPolicyTargetModel{
|
|
IPGroupID: ut.StringOrNull(ipGroupId),
|
|
IPs: types.ListNull(types.StringType),
|
|
MatchOppositeIPs: types.BoolValue(matchOppositeIps),
|
|
MatchOppositePorts: types.BoolValue(matchOppositePorts),
|
|
Port: types.Int32Null(),
|
|
PortGroupID: ut.StringOrNull(portGroupId),
|
|
ZoneID: types.StringValue(zoneId),
|
|
}
|
|
|
|
// Handle port - convert string to int32 if it's a simple port number
|
|
if port != "" {
|
|
if portInt, err := strconv.Atoi(port); err == nil {
|
|
m.Port = types.Int32Value(int32(portInt))
|
|
}
|
|
}
|
|
|
|
// Handle IPs list
|
|
if len(ips) > 0 {
|
|
lIps, d := types.ListValueFrom(context.Background(), types.StringType, ips)
|
|
diags.Append(d...)
|
|
m.IPs = lIps
|
|
}
|
|
return m
|
|
}
|
|
|
|
// FirewallZonePolicySourceModel represents the source configuration for a firewall zone policy
|
|
type FirewallZonePolicySourceModel struct {
|
|
FirewallPolicyTargetModel
|
|
ClientMACs types.List `tfsdk:"client_macs"`
|
|
MAC types.String `tfsdk:"mac"`
|
|
MACs types.List `tfsdk:"macs"`
|
|
MatchOppositeNetworks types.Bool `tfsdk:"match_opposite_networks"`
|
|
NetworkIDs types.List `tfsdk:"network_ids"`
|
|
}
|
|
|
|
func (m *FirewallZonePolicySourceModel) AttributeTypes() map[string]attr.Type {
|
|
attrs := map[string]attr.Type{
|
|
"client_macs": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"mac": types.StringType,
|
|
"macs": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"match_opposite_networks": types.BoolType,
|
|
"network_ids": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
}
|
|
maps.Copy(attrs, m.FirewallPolicyTargetModel.AttributeTypes())
|
|
return attrs
|
|
}
|
|
|
|
// FirewallZonePolicyDestinationModel represents the destination configuration for a firewall zone policy
|
|
type FirewallZonePolicyDestinationModel struct {
|
|
FirewallPolicyTargetModel
|
|
AppCategoryIDs types.List `tfsdk:"app_category_ids"`
|
|
AppIDs types.List `tfsdk:"app_ids"`
|
|
MatchOppositeNetworks types.Bool `tfsdk:"match_opposite_networks"`
|
|
NetworkIDs types.List `tfsdk:"network_ids"`
|
|
Regions types.List `tfsdk:"regions"`
|
|
WebDomains types.List `tfsdk:"web_domains"`
|
|
}
|
|
|
|
func (m *FirewallZonePolicyDestinationModel) AttributeTypes() map[string]attr.Type {
|
|
attrs := map[string]attr.Type{
|
|
"app_category_ids": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"app_ids": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"match_opposite_networks": types.BoolType,
|
|
"network_ids": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"regions": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"web_domains": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
}
|
|
maps.Copy(attrs, m.FirewallPolicyTargetModel.AttributeTypes())
|
|
return attrs
|
|
}
|
|
|
|
// FirewallZonePolicyScheduleModel represents the schedule configuration for a firewall zone policy
|
|
type FirewallZonePolicyScheduleModel struct {
|
|
Date types.String `tfsdk:"date"`
|
|
DateEnd types.String `tfsdk:"date_end"`
|
|
DateStart types.String `tfsdk:"date_start"`
|
|
Mode types.String `tfsdk:"mode"`
|
|
RepeatOnDays types.List `tfsdk:"repeat_on_days"`
|
|
TimeAllDay types.Bool `tfsdk:"time_all_day"`
|
|
TimeTo types.String `tfsdk:"time_to"`
|
|
TimeFrom types.String `tfsdk:"time_from"`
|
|
}
|
|
|
|
func (m *FirewallZonePolicyScheduleModel) AttributeTypes() map[string]attr.Type {
|
|
return map[string]attr.Type{
|
|
"date": types.StringType,
|
|
"date_end": types.StringType,
|
|
"date_start": types.StringType,
|
|
"mode": types.StringType,
|
|
"repeat_on_days": types.ListType{
|
|
ElemType: types.StringType,
|
|
},
|
|
"time_all_day": types.BoolType,
|
|
"time_to": types.StringType,
|
|
"time_from": types.StringType,
|
|
}
|
|
}
|
|
|
|
// FirewallZonePolicyModel represents the data model for firewall zone policies in the UniFi controller
|
|
type FirewallZonePolicyModel struct {
|
|
base.Model
|
|
Action types.String `tfsdk:"action"`
|
|
AutoAllowReturnTraffic types.Bool `tfsdk:"auto_allow_return_traffic"`
|
|
ConnectionStateType types.String `tfsdk:"connection_state_type"`
|
|
ConnectionStates types.List `tfsdk:"connection_states"`
|
|
Description types.String `tfsdk:"description"`
|
|
Destination types.Object `tfsdk:"destination"`
|
|
Enabled types.Bool `tfsdk:"enabled"`
|
|
IPVersion types.String `tfsdk:"ip_version"`
|
|
Index types.Int64 `tfsdk:"index"`
|
|
Logging types.Bool `tfsdk:"logging"`
|
|
MatchIPSecType types.String `tfsdk:"match_ip_sec_type"`
|
|
MatchOppositeProtocol types.Bool `tfsdk:"match_opposite_protocol"`
|
|
Name types.String `tfsdk:"name"`
|
|
Protocol types.String `tfsdk:"protocol"`
|
|
Schedule types.Object `tfsdk:"schedule"`
|
|
Source types.Object `tfsdk:"source"`
|
|
}
|
|
|
|
func (m *FirewallZonePolicyModel) AsUnifiModel(ctx context.Context) (interface{}, diag.Diagnostics) {
|
|
diags := diag.Diagnostics{}
|
|
|
|
model := &unifi.FirewallZonePolicy{
|
|
ID: m.ID.ValueString(),
|
|
SiteID: m.Site.ValueString(),
|
|
Action: m.Action.ValueString(),
|
|
CreateAllowRespond: m.AutoAllowReturnTraffic.ValueBool(),
|
|
ConnectionStateType: m.ConnectionStateType.ValueString(),
|
|
Description: m.Description.ValueString(),
|
|
Enabled: m.Enabled.ValueBool(),
|
|
IPVersion: m.IPVersion.ValueString(),
|
|
Index: int(m.Index.ValueInt64()),
|
|
Logging: m.Logging.ValueBool(),
|
|
MatchIPSecType: m.MatchIPSecType.ValueString(),
|
|
MatchOppositeProtocol: m.MatchOppositeProtocol.ValueBool(),
|
|
Name: m.Name.ValueString(),
|
|
Protocol: m.Protocol.ValueString(),
|
|
}
|
|
diags.Append(m.ConnectionStates.ElementsAs(ctx, &model.ConnectionStates, false)...)
|
|
|
|
if !ut.IsEmptyString(m.MatchIPSecType) {
|
|
model.MatchIPSec = true
|
|
} else {
|
|
model.MatchIPSec = false
|
|
}
|
|
// Handle Source object
|
|
if ut.IsDefined(m.Source) {
|
|
var source FirewallZonePolicySourceModel
|
|
diags.Append(m.Source.As(ctx, &source, basetypes.ObjectAsOptions{})...)
|
|
|
|
unifiSource := &unifi.FirewallZonePolicySource{
|
|
MatchOppositeIPs: source.MatchOppositeIPs.ValueBool(),
|
|
MatchOppositeNetworks: source.MatchOppositeNetworks.ValueBool(),
|
|
MatchOppositePorts: source.MatchOppositePorts.ValueBool(),
|
|
MatchingTarget: "ANY",
|
|
PortMatchingType: "ANY",
|
|
ZoneID: source.ZoneID.ValueString(),
|
|
}
|
|
|
|
if ut.IsDefined(source.MAC) {
|
|
unifiSource.MAC = source.MAC.ValueString()
|
|
unifiSource.MatchMAC = true
|
|
} else {
|
|
unifiSource.MatchMAC = false
|
|
}
|
|
|
|
if ut.IsDefined(source.PortGroupID) {
|
|
unifiSource.PortMatchingType = "OBJECT"
|
|
unifiSource.PortGroupID = source.PortGroupID.ValueString()
|
|
}
|
|
|
|
if ut.IsDefined(source.Port) {
|
|
unifiSource.PortMatchingType = "SPECIFIC"
|
|
unifiSource.Port = strconv.Itoa(int(source.Port.ValueInt32()))
|
|
}
|
|
|
|
if len(source.ClientMACs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(source.ClientMACs, &unifiSource.ClientMACs)...)
|
|
unifiSource.MatchingTarget = "CLIENT"
|
|
unifiSource.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
|
|
if len(source.IPs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(source.IPs, &unifiSource.IPs)...)
|
|
unifiSource.MatchingTarget = "IP"
|
|
unifiSource.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
if ut.IsDefined(source.IPGroupID) {
|
|
unifiSource.IPGroupID = source.IPGroupID.ValueString()
|
|
unifiSource.MatchingTarget = "IP"
|
|
unifiSource.MatchingTargetType = "OBJECT"
|
|
}
|
|
if len(source.MACs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(source.MACs, &unifiSource.MACs)...)
|
|
unifiSource.MatchingTarget = "MAC"
|
|
unifiSource.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
if len(source.NetworkIDs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(source.NetworkIDs, &unifiSource.NetworkIDs)...)
|
|
unifiSource.MatchingTarget = "NETWORK"
|
|
unifiSource.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
model.Source = *unifiSource
|
|
}
|
|
|
|
// Handle Destination object
|
|
if ut.IsDefined(m.Destination) {
|
|
var destination FirewallZonePolicyDestinationModel
|
|
diags.Append(m.Destination.As(ctx, &destination, basetypes.ObjectAsOptions{})...)
|
|
|
|
unifiDestination := &unifi.FirewallZonePolicyDestination{
|
|
MatchOppositeIPs: destination.MatchOppositeIPs.ValueBool(),
|
|
MatchOppositeNetworks: destination.MatchOppositeNetworks.ValueBool(),
|
|
MatchOppositePorts: destination.MatchOppositePorts.ValueBool(),
|
|
MatchingTarget: "ANY",
|
|
PortMatchingType: "ANY",
|
|
ZoneID: destination.ZoneID.ValueString(),
|
|
}
|
|
|
|
if ut.IsDefined(destination.PortGroupID) {
|
|
unifiDestination.PortMatchingType = "OBJECT"
|
|
unifiDestination.PortGroupID = destination.PortGroupID.ValueString()
|
|
}
|
|
|
|
if ut.IsDefined(destination.Port) {
|
|
unifiDestination.PortMatchingType = "SPECIFIC"
|
|
unifiDestination.Port = strconv.Itoa(int(destination.Port.ValueInt32()))
|
|
}
|
|
|
|
if len(destination.AppCategoryIDs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.AppCategoryIDs, &unifiDestination.AppCategoryIDs)...)
|
|
unifiDestination.MatchingTarget = "APP_CATEGORY"
|
|
}
|
|
|
|
if len(destination.AppIDs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.AppIDs, &unifiDestination.AppIDs)...)
|
|
unifiDestination.MatchingTarget = "APP"
|
|
}
|
|
|
|
if len(destination.IPs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.IPs, &unifiDestination.IPs)...)
|
|
unifiDestination.MatchingTarget = "IP"
|
|
unifiDestination.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
if ut.IsDefined(destination.IPGroupID) {
|
|
unifiDestination.IPGroupID = destination.IPGroupID.ValueString()
|
|
unifiDestination.MatchingTarget = "IP"
|
|
unifiDestination.MatchingTargetType = "OBJECT"
|
|
}
|
|
if len(destination.Regions.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.Regions, &unifiDestination.Regions)...)
|
|
unifiDestination.MatchingTarget = "REGION"
|
|
unifiDestination.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
if len(destination.WebDomains.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.WebDomains, &unifiDestination.WebDomains)...)
|
|
unifiDestination.MatchingTarget = "WEB"
|
|
unifiDestination.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
if len(destination.NetworkIDs.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(destination.NetworkIDs, &unifiDestination.NetworkIDs)...)
|
|
unifiDestination.MatchingTarget = "NETWORK"
|
|
unifiDestination.MatchingTargetType = "SPECIFIC"
|
|
}
|
|
model.Destination = *unifiDestination
|
|
}
|
|
|
|
// Handle Schedule object
|
|
if ut.IsDefined(m.Schedule) {
|
|
var schedule FirewallZonePolicyScheduleModel
|
|
diags.Append(m.Schedule.As(ctx, &schedule, basetypes.ObjectAsOptions{})...)
|
|
|
|
unifiSchedule := &unifi.FirewallZonePolicySchedule{
|
|
Date: schedule.Date.ValueString(),
|
|
DateEnd: schedule.DateEnd.ValueString(),
|
|
DateStart: schedule.DateStart.ValueString(),
|
|
Mode: schedule.Mode.ValueString(),
|
|
TimeAllDay: schedule.TimeAllDay.ValueBool(),
|
|
TimeRangeEnd: schedule.TimeTo.ValueString(),
|
|
TimeRangeStart: schedule.TimeFrom.ValueString(),
|
|
}
|
|
if len(schedule.RepeatOnDays.Elements()) > 0 {
|
|
diags.Append(ut.ListElementsAs(schedule.RepeatOnDays, &unifiSchedule.RepeatOnDays)...)
|
|
}
|
|
model.Schedule = *unifiSchedule
|
|
}
|
|
|
|
return model, diags
|
|
}
|
|
|
|
func (m *FirewallZonePolicyModel) mergeSource(ctx context.Context, model *unifi.FirewallZonePolicy) diag.Diagnostics {
|
|
diags := diag.Diagnostics{}
|
|
|
|
sourceModel := &FirewallZonePolicySourceModel{
|
|
FirewallPolicyTargetModel: *NewFirewallPolicyTargetModel(model.Source.IPGroupID, model.Source.IPs, model.Source.MatchOppositeIPs, model.Source.MatchOppositePorts, model.Source.Port, model.Source.PortGroupID, model.Source.ZoneID),
|
|
MAC: ut.StringOrNull(model.Source.MAC),
|
|
MatchOppositeNetworks: types.BoolValue(model.Source.MatchOppositeNetworks),
|
|
MACs: types.ListNull(types.StringType),
|
|
ClientMACs: types.ListNull(types.StringType),
|
|
NetworkIDs: types.ListNull(types.StringType),
|
|
}
|
|
|
|
switch model.Source.MatchingTarget {
|
|
// TODO !
|
|
case "MAC":
|
|
macs, d := types.ListValueFrom(ctx, types.StringType, model.Source.MACs)
|
|
diags.Append(d...)
|
|
sourceModel.MACs = macs
|
|
case "NETWORK":
|
|
networks, d := types.ListValueFrom(ctx, types.StringType, model.Source.NetworkIDs)
|
|
diags.Append(d...)
|
|
sourceModel.NetworkIDs = networks
|
|
case "CLIENT":
|
|
clientMACs, d := types.ListValueFrom(ctx, types.StringType, model.Source.ClientMACs)
|
|
diags.Append(d...)
|
|
sourceModel.ClientMACs = clientMACs
|
|
case "IP":
|
|
case "ANY":
|
|
// do nothing as handled commonly
|
|
default:
|
|
diags.AddWarning("Unexpected matching target", fmt.Sprintf("Source matching target is %s, which is not supported by the provider", model.Source.MatchingTarget))
|
|
}
|
|
|
|
// Create object value from source model
|
|
sourceObject, d := types.ObjectValueFrom(ctx, sourceModel.AttributeTypes(), &sourceModel)
|
|
diags.Append(d...)
|
|
m.Source = sourceObject
|
|
|
|
return diags
|
|
}
|
|
|
|
func (m *FirewallZonePolicyModel) mergeDestination(ctx context.Context, model *unifi.FirewallZonePolicy) diag.Diagnostics {
|
|
diags := diag.Diagnostics{}
|
|
destModel := &FirewallZonePolicyDestinationModel{
|
|
FirewallPolicyTargetModel: *NewFirewallPolicyTargetModel(model.Destination.IPGroupID, model.Destination.IPs, model.Destination.MatchOppositeIPs, model.Destination.MatchOppositePorts, model.Destination.Port, model.Destination.PortGroupID, model.Destination.ZoneID),
|
|
AppCategoryIDs: types.ListNull(types.StringType),
|
|
AppIDs: types.ListNull(types.StringType),
|
|
MatchOppositeNetworks: types.BoolValue(model.Destination.MatchOppositeNetworks),
|
|
NetworkIDs: types.ListNull(types.StringType),
|
|
Regions: types.ListNull(types.StringType),
|
|
WebDomains: types.ListNull(types.StringType),
|
|
}
|
|
switch model.Destination.MatchingTarget {
|
|
case "APP_CATEGORY":
|
|
appCategories, d := types.ListValueFrom(ctx, types.StringType, model.Destination.AppCategoryIDs)
|
|
diags.Append(d...)
|
|
destModel.AppCategoryIDs = appCategories
|
|
case "APP":
|
|
apps, d := types.ListValueFrom(ctx, types.StringType, model.Destination.AppIDs)
|
|
diags.Append(d...)
|
|
destModel.AppIDs = apps
|
|
case "NETWORK":
|
|
networks, d := types.ListValueFrom(ctx, types.StringType, model.Destination.NetworkIDs)
|
|
diags.Append(d...)
|
|
destModel.NetworkIDs = networks
|
|
case "REGION":
|
|
regions, d := types.ListValueFrom(ctx, types.StringType, model.Destination.Regions)
|
|
diags.Append(d...)
|
|
destModel.Regions = regions
|
|
case "WEB":
|
|
webs, d := types.ListValueFrom(ctx, types.StringType, model.Destination.WebDomains)
|
|
diags.Append(d...)
|
|
destModel.WebDomains = webs
|
|
case "IP":
|
|
case "ANY":
|
|
// do nothing as handled commonly
|
|
default:
|
|
diags.AddWarning("Unexpected matching target", fmt.Sprintf("Destination matching target is %s, which is not supported by the provider", model.Destination.MatchingTarget))
|
|
}
|
|
|
|
// Create object value from source model
|
|
destObject, d := types.ObjectValueFrom(ctx, destModel.AttributeTypes(), destModel)
|
|
diags.Append(d...)
|
|
m.Destination = destObject
|
|
|
|
return diags
|
|
}
|
|
|
|
func (m *FirewallZonePolicyModel) mergeSchedule(ctx context.Context, model *unifi.FirewallZonePolicy) diag.Diagnostics {
|
|
diags := diag.Diagnostics{}
|
|
// Set Schedule object
|
|
scheduleModel := &FirewallZonePolicyScheduleModel{
|
|
Date: ut.StringOrNull(model.Schedule.Date),
|
|
DateEnd: ut.StringOrNull(model.Schedule.DateEnd),
|
|
DateStart: ut.StringOrNull(model.Schedule.DateStart),
|
|
Mode: ut.StringOrNull(model.Schedule.Mode),
|
|
TimeAllDay: types.BoolValue(model.Schedule.TimeAllDay),
|
|
TimeTo: ut.StringOrNull(model.Schedule.TimeRangeEnd),
|
|
TimeFrom: ut.StringOrNull(model.Schedule.TimeRangeStart),
|
|
RepeatOnDays: types.ListNull(types.StringType),
|
|
}
|
|
if model.Schedule.Mode == "EVERY_WEEK" || model.Schedule.Mode == "CUSTOM" {
|
|
days, d := types.ListValueFrom(ctx, types.StringType, model.Schedule.RepeatOnDays)
|
|
diags.Append(d...)
|
|
scheduleModel.RepeatOnDays = days
|
|
}
|
|
// `always`, `every_day` (start, end T), `every_week` (days, start / end T, all day),
|
|
// `one_time_only` (date, start / end T), or `custom` (start / end D, start / end T, days, all day).
|
|
// Create object value from schedule model
|
|
scheduleObject, scheduleDiags := types.ObjectValueFrom(ctx, scheduleModel.AttributeTypes(), scheduleModel)
|
|
diags.Append(scheduleDiags...)
|
|
m.Schedule = scheduleObject
|
|
return diags
|
|
}
|
|
|
|
func (m *FirewallZonePolicyModel) Merge(ctx context.Context, other interface{}) diag.Diagnostics {
|
|
diags := diag.Diagnostics{}
|
|
|
|
model, ok := other.(*unifi.FirewallZonePolicy)
|
|
if !ok {
|
|
return diags
|
|
}
|
|
|
|
m.ID = types.StringValue(model.ID)
|
|
m.Action = types.StringValue(model.Action)
|
|
m.AutoAllowReturnTraffic = types.BoolValue(model.CreateAllowRespond)
|
|
m.ConnectionStateType = types.StringValue(model.ConnectionStateType)
|
|
if model.Description != "" {
|
|
m.Description = types.StringValue(model.Description)
|
|
}
|
|
m.Enabled = types.BoolValue(model.Enabled)
|
|
m.IPVersion = types.StringValue(model.IPVersion)
|
|
m.Index = types.Int64Value(int64(model.Index))
|
|
m.Logging = types.BoolValue(model.Logging)
|
|
m.MatchOppositeProtocol = types.BoolValue(model.MatchOppositeProtocol)
|
|
m.Name = types.StringValue(model.Name)
|
|
m.Protocol = types.StringValue(model.Protocol)
|
|
|
|
if model.MatchIPSecType != "" {
|
|
m.MatchIPSecType = types.StringValue(model.MatchIPSecType)
|
|
} else {
|
|
m.MatchIPSecType = types.StringNull()
|
|
}
|
|
|
|
diags.Append(m.mergeSource(ctx, model)...)
|
|
diags.Append(m.mergeDestination(ctx, model)...)
|
|
diags.Append(m.mergeSchedule(ctx, model)...)
|
|
|
|
// Set ConnectionStates
|
|
if model.ConnectionStateType == "custom" {
|
|
connectionStates, d := types.ListValueFrom(ctx, types.StringType, model.ConnectionStates)
|
|
diags.Append(d...)
|
|
m.ConnectionStates = connectionStates
|
|
} else {
|
|
m.ConnectionStates = types.ListNull(types.StringType)
|
|
}
|
|
|
|
return diags
|
|
}
|
|
|
|
type firewallZonePolicyResource struct {
|
|
*base.GenericResource[*FirewallZonePolicyModel]
|
|
}
|
|
|
|
func (r *firewallZonePolicyResource) ConfigValidators(ctx context.Context) []resource.ConfigValidator {
|
|
return []resource.ConfigValidator{
|
|
validators.RequiredSimpleTogetherIf("connection_state_type", types.StringValue("CUSTOM"), "connection_states"),
|
|
validators.RequiredSimpleTogetherIf("connection_state_type", types.StringValue("CUSTOM"), "connection_states"),
|
|
}
|
|
}
|
|
|
|
func (r *firewallZonePolicyResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
|
|
site, diags := r.GetClient().ResolveSiteFromConfig(ctx, req.Config)
|
|
if diags.HasError() {
|
|
resp.Diagnostics.Append(diags...)
|
|
return
|
|
}
|
|
resp.Diagnostics.Append(r.RequireFeaturesEnabled(ctx, site, features.ZoneBasedFirewall, features.ZoneBasedFirewallMigration)...)
|
|
}
|
|
|
|
// NewFirewallZonePolicyResource creates a new instance of the firewall zone policy resource
|
|
func NewFirewallZonePolicyResource() resource.Resource {
|
|
return &firewallZonePolicyResource{
|
|
GenericResource: base.NewGenericResource(
|
|
"unifi_firewall_zone_policy",
|
|
func() *FirewallZonePolicyModel { return &FirewallZonePolicyModel{} },
|
|
base.ResourceFunctions{
|
|
Read: func(ctx context.Context, client *base.Client, site, id string) (interface{}, error) {
|
|
return client.GetFirewallZonePolicy(ctx, site, id)
|
|
},
|
|
Create: func(ctx context.Context, client *base.Client, site string, model interface{}) (interface{}, error) {
|
|
return client.CreateFirewallZonePolicy(ctx, site, model.(*unifi.FirewallZonePolicy))
|
|
},
|
|
Update: func(ctx context.Context, client *base.Client, site string, model interface{}) (interface{}, error) {
|
|
return client.UpdateFirewallZonePolicy(ctx, site, model.(*unifi.FirewallZonePolicy))
|
|
},
|
|
Delete: func(ctx context.Context, client *base.Client, site, id string) error {
|
|
return client.DeleteFirewallZonePolicy(ctx, site, id)
|
|
},
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
// Schema defines the schema for the resource
|
|
func (r *firewallZonePolicyResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
|
resp.Schema = schema.Schema{
|
|
MarkdownDescription: "The `unifi_firewall_zone_policy` resource manages firewall policies between zones in the UniFi controller. " +
|
|
"This resource allows you to create, update, and delete policies that define allowed or blocked traffic between zones.\n\n" +
|
|
"!> This is experimental feature, that requires UniFi OS 9.0.0 or later and Zone Based Firewall feature enabled. " +
|
|
"Check [official documentation](https://help.ui.com/hc/en-us/articles/28223082254743-Migrating-to-Zone-Based-Firewalls-in-UniFi) how to migate to Zone-Based firewalls.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": ut.ID(),
|
|
"site": ut.SiteAttribute(),
|
|
"name": schema.StringAttribute{
|
|
MarkdownDescription: "The name of the firewall zone policy.",
|
|
Required: true,
|
|
},
|
|
"enabled": schema.BoolAttribute{
|
|
MarkdownDescription: "Enable the policy",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(true),
|
|
},
|
|
"description": schema.StringAttribute{
|
|
MarkdownDescription: "Description of the firewall zone policy.",
|
|
Optional: true,
|
|
},
|
|
"action": schema.StringAttribute{
|
|
MarkdownDescription: "Determines which action to take on matching traffic. Must be one of `BLOCK`, `ALLOW`, or `REJECT`.",
|
|
Required: true,
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf("BLOCK", "ALLOW", "REJECT"),
|
|
},
|
|
},
|
|
"auto_allow_return_traffic": schema.BoolAttribute{
|
|
MarkdownDescription: "Creates a built-in policy for the opposite Zone Pair to automatically allow the return traffic. If disabled, return traffic must be manually allowed",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"index": schema.Int64Attribute{
|
|
MarkdownDescription: "Priority index for the policy. This value is assigned by the UniFi controller and cannot be set directly. " +
|
|
"To control policy ordering, use the `unifi_firewall_zone_policy_order` resource (planned for future release).",
|
|
Computed: true,
|
|
},
|
|
"logging": schema.BoolAttribute{
|
|
MarkdownDescription: "Enable to generate syslog entries when traffic is matched.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"protocol": schema.StringAttribute{
|
|
MarkdownDescription: "Optionally match a specific protocol. Valid values include: `all`, `tcp_udp`, `tcp`, `udp`, etc.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: stringdefault.StaticString("all"),
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf(
|
|
"all", "tcp_udp", "tcp", "udp", "ah", "dccp", "eigrp", "esp", "gre",
|
|
"icmp", "icmpv6", "igmp", "igp", "ip", "ipcomp", "ipip", "ipv6",
|
|
"isis", "l2tp", "manet", "mobility-header", "mpls-in-ip", "number",
|
|
"ospf", "pim", "pup", "rdp", "rohc", "rspf", "rcvp", "sctp", "shim6",
|
|
"skip", "st", "vmtp", "vrrp", "wesp", "xtp",
|
|
),
|
|
},
|
|
},
|
|
"ip_version": schema.StringAttribute{
|
|
MarkdownDescription: "Optionally match on only IPv4 or IPv6. Valid values are `BOTH`, `IPV4`, or `IPV6`.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: stringdefault.StaticString("BOTH"),
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf("BOTH", "IPV4", "IPV6"),
|
|
},
|
|
},
|
|
"connection_state_type": schema.StringAttribute{
|
|
MarkdownDescription: "Optionally match on a firewall connection state such as traffic associated with an already existing connection. Valid values are `ALL`, `RESPOND_ONLY`, or `CUSTOM`.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: stringdefault.StaticString("ALL"),
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf("ALL", "RESPOND_ONLY", "CUSTOM"),
|
|
},
|
|
},
|
|
"connection_states": schema.ListAttribute{
|
|
MarkdownDescription: "Connection states to match when `connection_state_type` is `CUSTOM`. Valid values include `ESTABLISHED`, `NEW`, `RELATED`, and `INVALID`.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.UniqueValues(),
|
|
listvalidator.ValueStringsAre(
|
|
stringvalidator.OneOf("ESTABLISHED", "NEW", "RELATED", "INVALID"),
|
|
),
|
|
},
|
|
},
|
|
"match_ip_sec_type": schema.StringAttribute{
|
|
MarkdownDescription: "Optionally match on traffic encrypted by IPsec. This is typically used for Ipsec Policy-Based VPNs. Valid values are `MATCH_IP_SEC` or `MATCH_NON_IP_SEC`.",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf("MATCH_IP_SEC", "MATCH_NON_IP_SEC"),
|
|
},
|
|
},
|
|
"match_opposite_protocol": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether to match the opposite protocol.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"source": schema.SingleNestedAttribute{
|
|
MarkdownDescription: "The zone matching the source of the traffic. Optionally match on a specific source inside the zone.",
|
|
Required: true,
|
|
PlanModifiers: []planmodifier.Object{
|
|
objectplanmodifier.UseStateForUnknown(),
|
|
},
|
|
Attributes: mergedTargetAttributes(map[string]schema.Attribute{
|
|
"mac": schema.StringAttribute{
|
|
MarkdownDescription: "Source MAC address.",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
stringvalidator.RegexMatches(utils.MacAddressRegexp, "must be a valid MAC address"),
|
|
stringvalidator.Any(
|
|
stringvalidator.AlsoRequires(path.MatchRoot("source").AtName("ips")),
|
|
stringvalidator.AlsoRequires(path.MatchRoot("source").AtName("network_ids")),
|
|
),
|
|
},
|
|
},
|
|
"macs": schema.ListAttribute{
|
|
MarkdownDescription: "List of MAC addresses.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ValueStringsAre(
|
|
stringvalidator.RegexMatches(utils.MacAddressRegexp, "must be a valid MAC address"),
|
|
),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("source").AtName("client_macs"),
|
|
path.MatchRoot("source").AtName("ips"),
|
|
path.MatchRoot("source").AtName("mac"),
|
|
path.MatchRoot("source").AtName("network_ids"),
|
|
),
|
|
},
|
|
},
|
|
"client_macs": schema.ListAttribute{
|
|
MarkdownDescription: "List of client MAC addresses.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ValueStringsAre(
|
|
stringvalidator.RegexMatches(utils.MacAddressRegexp, "must be a valid MAC address"),
|
|
),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("source").AtName("ips"),
|
|
path.MatchRoot("source").AtName("mac"),
|
|
path.MatchRoot("source").AtName("macs"),
|
|
path.MatchRoot("source").AtName("network_ids"),
|
|
),
|
|
},
|
|
},
|
|
"network_ids": schema.ListAttribute{
|
|
MarkdownDescription: "List of network IDs.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("source").AtName("client_macs"),
|
|
path.MatchRoot("source").AtName("ips"),
|
|
path.MatchRoot("source").AtName("macs"),
|
|
),
|
|
},
|
|
},
|
|
"match_opposite_networks": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether to match opposite networks.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
}),
|
|
},
|
|
"destination": schema.SingleNestedAttribute{
|
|
MarkdownDescription: "The zone matching the destination of the traffic. Optionally match on a specific destination inside the zone.",
|
|
Required: true,
|
|
PlanModifiers: []planmodifier.Object{
|
|
objectplanmodifier.UseStateForUnknown(),
|
|
},
|
|
Attributes: mergedTargetAttributes(map[string]schema.Attribute{
|
|
"app_category_ids": schema.ListAttribute{
|
|
MarkdownDescription: "List of application category IDs.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("destination").AtName("app_ids"),
|
|
path.MatchRoot("destination").AtName("ips"),
|
|
path.MatchRoot("destination").AtName("network_ids"),
|
|
path.MatchRoot("destination").AtName("regions"),
|
|
path.MatchRoot("destination").AtName("web_domains"),
|
|
),
|
|
},
|
|
},
|
|
"app_ids": schema.ListAttribute{
|
|
MarkdownDescription: "List of application IDs.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("destination").AtName("app_category_ids"),
|
|
path.MatchRoot("destination").AtName("ips"),
|
|
path.MatchRoot("destination").AtName("network_ids"),
|
|
path.MatchRoot("destination").AtName("regions"),
|
|
path.MatchRoot("destination").AtName("web_domains"),
|
|
),
|
|
},
|
|
},
|
|
"match_opposite_networks": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether to match opposite networks.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"network_ids": schema.ListAttribute{
|
|
MarkdownDescription: "List of network IDs.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("destination").AtName("app_category_ids"),
|
|
path.MatchRoot("destination").AtName("app_ids"),
|
|
path.MatchRoot("destination").AtName("ips"),
|
|
path.MatchRoot("destination").AtName("regions"),
|
|
path.MatchRoot("destination").AtName("web_domains"),
|
|
),
|
|
},
|
|
},
|
|
"regions": schema.ListAttribute{
|
|
MarkdownDescription: "List of regions.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ValueStringsAre(validators.CountryCodeAlpha2()),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("destination").AtName("app_category_ids"),
|
|
path.MatchRoot("destination").AtName("app_ids"),
|
|
path.MatchRoot("destination").AtName("ips"),
|
|
path.MatchRoot("destination").AtName("network_ids"),
|
|
path.MatchRoot("destination").AtName("web_domains"),
|
|
),
|
|
},
|
|
},
|
|
"web_domains": schema.ListAttribute{
|
|
MarkdownDescription: "List of web domains.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.ValueStringsAre(
|
|
validators.Hostname(),
|
|
),
|
|
listvalidator.ConflictsWith(
|
|
path.MatchRoot("destination").AtName("app_category_ids"),
|
|
path.MatchRoot("destination").AtName("app_ids"),
|
|
path.MatchRoot("destination").AtName("ips"),
|
|
path.MatchRoot("destination").AtName("network_ids"),
|
|
path.MatchRoot("destination").AtName("regions"),
|
|
),
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
"schedule": schema.SingleNestedAttribute{
|
|
MarkdownDescription: "Enforce this policy at specific times.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: objectdefault.StaticValue(ut.ObjectValueMust(ctx, &FirewallZonePolicyScheduleModel{
|
|
Mode: types.StringValue("ALWAYS"),
|
|
TimeAllDay: types.BoolValue(false),
|
|
RepeatOnDays: types.ListNull(types.StringType),
|
|
})),
|
|
PlanModifiers: []planmodifier.Object{
|
|
objectplanmodifier.UseStateForUnknown(),
|
|
},
|
|
Attributes: map[string]schema.Attribute{
|
|
"date": schema.StringAttribute{
|
|
MarkdownDescription: "Date for the schedule.",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
validators.DateFormat,
|
|
},
|
|
},
|
|
"date_end": schema.StringAttribute{
|
|
MarkdownDescription: "End date for the schedule.",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
validators.DateFormat,
|
|
},
|
|
},
|
|
"date_start": schema.StringAttribute{
|
|
MarkdownDescription: "Start date for the schedule.",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
validators.DateFormat,
|
|
},
|
|
},
|
|
"mode": schema.StringAttribute{
|
|
MarkdownDescription: "Schedule mode. Valid values are `ALWAYS`, `EVERY_DAY`, `EVERY_WEEK`, `ONE_TIME_ONLY`, or `CUSTOM`.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: stringdefault.StaticString("ALWAYS"),
|
|
Validators: []validator.String{
|
|
stringvalidator.OneOf("ALWAYS", "EVERY_DAY", "EVERY_WEEK", "ONE_TIME_ONLY", "CUSTOM"),
|
|
},
|
|
},
|
|
"repeat_on_days": schema.ListAttribute{
|
|
MarkdownDescription: "Days of the week when schedule repeats. Valid values include `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, and `sun`.",
|
|
Optional: true,
|
|
ElementType: types.StringType,
|
|
Validators: []validator.List{
|
|
listvalidator.SizeAtLeast(1),
|
|
listvalidator.UniqueValues(),
|
|
listvalidator.ValueStringsAre(
|
|
stringvalidator.OneOf("mon", "tue", "wed", "thu", "fri", "sat", "sun"),
|
|
),
|
|
},
|
|
},
|
|
"time_all_day": schema.BoolAttribute{
|
|
MarkdownDescription: "Whether the schedule applies all day.",
|
|
Optional: true,
|
|
Computed: true,
|
|
Default: booldefault.StaticBool(false),
|
|
},
|
|
"time_from": schema.StringAttribute{
|
|
MarkdownDescription: "Schedule starting time in 24-hour format (HH:MM).",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
validators.TimeFormat,
|
|
},
|
|
},
|
|
"time_to": schema.StringAttribute{
|
|
MarkdownDescription: "Schedule ending time in 24-hour format (HH:MM).",
|
|
Optional: true,
|
|
Validators: []validator.String{
|
|
validators.TimeFormat,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|