diff --git a/docs/resources/network_acl.md b/docs/resources/network_acl.md new file mode 100644 index 00000000..132eaf38 --- /dev/null +++ b/docs/resources/network_acl.md @@ -0,0 +1,111 @@ +# lxd_network_acl + +Manages an LXD network ACL. + +See LXD network ACL [configuration reference](https://documentation.ubuntu.com/lxd/en/latest/howto/network_acls/) for how to configure network ACLs. + +## Example Usage + +```hcl +resource "lxd_network_acl" "acl1" { + name = "my-acl" + + egress = [ + { + description = "DNS to cloudflare public resolvers (UDP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "udp" + state = "enabled" + }, + { + description = "DNS to cloudflare public resolvers (TCP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "tcp" + state = "enabled" + }, + ] + + ingress = [ + { + description = "Incoming SSH connections" + action = "allow" + source = "@external" + destination_port = "22" + protocol = "tcp" + state = "logged" + } + ] +} +``` + +## Argument Reference + +* `name` - **Required** - Name of the network ACL. + +* `description` - *Optional* - Description of the network ACL. + +* `ingress` - *Optional* - List of network ACL rules for ingress traffic. See reference below. + +* `egress` - *Optional* - List of network ACL rules for egress traffic. See reference below. + +* `config` - *Optional* - Map of key/value pairs of + [network ACL config settings](https://documentation.ubuntu.com/lxd/en/latest/howto/network_acls/). + +* `project` - *Optional* - Name of the project where the network ACL will be created. + +* `remote` - *Optional* - The remote in which the resource will be created. If + not provided, the provider's default remote will be used. + +The network ACL rule supports: + +* `action` - **Required** - Action to take for the matching traffic. Possible values are `allow`, `allow-stateless`, `drop`, or `reject`. + +* `description` - *Optional* - Description of the network ACL rule. + +* `destination` - *Optional* - Comma-separated list of CIDR or IP ranges, destination subject name selectors (for egress rules), or leave the value empty for any. + +* `destination_port` - *Optional* - If the protocol is `udp` or `tcp` you can specify a comma-separated list of ports or port ranges (start-end), or leave the value empty for any. + +* `icmp_code` - *Optional* - If the protocol is `icmp4` or `icmp6` you can specify the ICMP code number, or leave the value empty for any. + +* `icmp_type` - *Optional* - If the protocol is `icmp4` or `icmp6` you can specify the ICMP type number, or leave the value empty for any. + +* `protocol` - *Optional* - Protocol to match. Possible values are `icmp4`, `icmp6`, `tcp`, or `udp`. Leave the value empty for any protocol. + +* `source` - *Optional* - Comma-separated list of CIDR or IP ranges, source subject name selectors (for ingress rules), or leave the value empty for any. + +* `state` - *Optional* - State of the rule. Possible values are `enabled`, `disabled`, and `logged`. Defaults to `enabled`. + +## Importing + +Import ID syntax: `[:][/]` + +* `` - *Optional* - Remote name. +* `` - *Optional* - Project name. +* `` - **Required** - Network name. + +### Import example + +Example using terraform import command: + +```shell +$ terraform import lxd_network_acl.acl1 proj/my-acl +``` + +Example using the import block (only available in Terraform v1.5.0 and later): + +```hcl +resource "lxd_network_acl" "acl1" { + name = "my-acl" + project = "proj" +} + +import { + to = lxd_network_acl.acl1 + id = "proj/my-acl" +} +``` diff --git a/docs/resources/network_forward.md b/docs/resources/network_forward.md new file mode 100644 index 00000000..bc802530 --- /dev/null +++ b/docs/resources/network_forward.md @@ -0,0 +1,108 @@ +# lxd_network_forward + +Manages an LXD network forward. + +See LXD network forward [configuration reference](https://documentation.ubuntu.com/lxd/en/latest/howto/network_forwards/) for how to configure network forwards. + +## Example Usage + +```hcl +resource "lxd_network" "my_network" { + name = "my-network" + + config = { + "ipv4.address" = "10.150.19.1/24" + "ipv4.nat" = "true" + "ipv6.address" = "fd42:474b:622d:259d::1/64" + "ipv6.nat" = "true" + } +} + +resource "lxd_network_forward" "my_forward" { + network = lxd_network.my_network.name + listen_address = "10.150.19.10" + + config = { + target_address = "10.150.19.111" + } + + ports = [ + { + description = "SSH" + protocol = "tcp" + listen_port = "22" + target_port = "2022" + target_address = "10.150.19.112" + }, + { + description = "HTTP" + protocol = "tcp" + listen_port = "80" + target_port = "8080" + target_address = "10.150.19.112" + } + ] +} +``` + +## Argument Reference + +* `network` - **Required** - Name of the network. + +* `listen_address` - **Required** - IP address to listen on. + +* `description` - *Optional* - Description of the network forward. + +* `ports` - *Optional* - List of port specifications. See reference below. + +* `config` - *Optional* - Map of key/value pairs of + [network forward config settings](https://documentation.ubuntu.com/lxd/en/latest/howto/network_forwards/). + +* `project` - *Optional* - Name of the project where the network forward will be created. + +* `remote` - *Optional* - The remote in which the resource will be created. If + not provided, the provider's default remote will be used. + +The network forward port supports: + +* `protocol` - **Required** - Protocol for the port(s). Possible values are `tcp` and `udp`. + +* `target_address` - **Required** - IP address to forward to + +* `listen_port` - **Required** - Listen port(s) (e.g. `80,90-100`) + +* `target_port` - *Optional* - Target port(s) (e.g. `70,80-90` or `90`). + +* `description` - *Optional* - Description of port(s) + +## Importing + +Import ID syntax: `[:][/]/` + +* `` - *Optional* - Remote name. +* `` - *Optional* - Project name. +* `` - **Required** - Network name. +* `` - **Required** - IP Listen Address. + +### Import example + +Example using terraform import command: + +```shell +$ terraform import lxd_network_forward.forward1 proj/my-network/10.150.19.10 +``` + +Example using the import block (only available in Terraform v1.5.0 and later): + +```hcl +resource "lxd_network_forward" "forward1" { + network = "my-network" + listen_address = "10.150.19.10" + project = "proj" +} + +import { + to = lxd_network_forward.forward1 + id = "proj/my-network/10.150.19.10" +} +``` diff --git a/internal/network/resource_network_acl.go b/internal/network/resource_network_acl.go new file mode 100644 index 00000000..ca7b5b83 --- /dev/null +++ b/internal/network/resource_network_acl.go @@ -0,0 +1,485 @@ +package network + +import ( + "context" + "fmt" + + lxd "github.com/canonical/lxd/client" + "github.com/canonical/lxd/shared/api" + "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/mapdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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/terraform-lxd/terraform-provider-lxd/internal/common" + "github.com/terraform-lxd/terraform-provider-lxd/internal/errors" + provider_config "github.com/terraform-lxd/terraform-provider-lxd/internal/provider-config" +) + +// NetworkAclModel resource data model that matches the schema. +type NetworkAclModel struct { + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Project types.String `tfsdk:"project"` + Remote types.String `tfsdk:"remote"` + Config types.Map `tfsdk:"config"` + Egress types.Set `tfsdk:"egress"` + Ingress types.Set `tfsdk:"ingress"` +} + +// NetworkAclRuleModel resource data model that matches the schema. +type NetworkAclRuleModel struct { + Action types.String `tfsdk:"action"` + Destination types.String `tfsdk:"destination"` + DestinationPort types.String `tfsdk:"destination_port"` + Protocol types.String `tfsdk:"protocol"` + Description types.String `tfsdk:"description"` + State types.String `tfsdk:"state"` + Source types.String `tfsdk:"source"` + ICMPType types.String `tfsdk:"icmp_type"` + ICMPCode types.String `tfsdk:"icmp_code"` +} + +// NetworkAclResource represent LXD network ACL resource. +type NetworkAclResource struct { + provider *provider_config.LxdProviderConfig +} + +func NewNetworkAclResource() resource.Resource { + return &NetworkAclResource{} +} + +func (r *NetworkAclResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_network_acl", req.ProviderTypeName) +} +func (r *NetworkAclResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + aclRuleObjectType := aclRuleObjectType() + + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Required: true, + }, + + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + + "project": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + + "remote": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "config": schema.MapAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + + "egress": schema.SetNestedAttribute{ + Optional: true, + Computed: true, + Default: setdefault.StaticValue(types.SetNull(aclRuleObjectType)), + NestedObject: schema.NestedAttributeObject{ + Attributes: aclRuleAttributes(), + }, + }, + + "ingress": schema.SetNestedAttribute{ + Optional: true, + Computed: true, + Default: setdefault.StaticValue(types.SetNull(aclRuleObjectType)), + NestedObject: schema.NestedAttributeObject{ + Attributes: aclRuleAttributes(), + }, + }, + }, + } +} + +func aclRuleObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "action": types.StringType, + "destination": types.StringType, + "destination_port": types.StringType, + "protocol": types.StringType, + "description": types.StringType, + "state": types.StringType, + "source": types.StringType, + "icmp_type": types.StringType, + "icmp_code": types.StringType, + }, + } +} + +func aclRuleAttributes() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "action": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("allow", "allow-stateless", "drop", "reject"), + }, + }, + + "destination": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "destination_port": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "protocol": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "state": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("enabled", "disabled", "logged"), + }, + }, + + "source": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "icmp_type": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + + "icmp_code": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + } +} + +func (r *NetworkAclResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + data := req.ProviderData + if data == nil { + return + } + + provider, ok := data.(*provider_config.LxdProviderConfig) + if !ok { + resp.Diagnostics.Append(errors.NewProviderDataTypeError(req.ProviderData)) + return + } + + r.provider = provider +} + +func (r *NetworkAclResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan NetworkAclModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := plan.Remote.ValueString() + project := plan.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + config, diags := common.ToConfigMap(ctx, plan.Config) + resp.Diagnostics.Append(diags...) + + egress, diags := ToNetworkAclRules(ctx, plan.Egress) + resp.Diagnostics.Append(diags...) + + ingress, diags := ToNetworkAclRules(ctx, plan.Ingress) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + aclName := plan.Name.ValueString() + aclReq := api.NetworkACLsPost{ + NetworkACLPost: api.NetworkACLPost{ + Name: aclName, + }, + NetworkACLPut: api.NetworkACLPut{ + Description: plan.Description.ValueString(), + Config: config, + Egress: egress, + Ingress: ingress, + }, + } + + err = server.CreateNetworkACL(aclReq) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create network ACL %q", aclName), err.Error()) + return + } + + diags = r.SyncState(ctx, &resp.State, server, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkAclResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state NetworkAclModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + diags = r.SyncState(ctx, &resp.State, server, state) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkAclResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan NetworkAclModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := plan.Remote.ValueString() + project := plan.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + aclName := plan.Name.ValueString() + _, etag, err := server.GetNetworkACL(aclName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve existing network ACL %q", aclName), err.Error()) + return + } + + config, diags := common.ToConfigMap(ctx, plan.Config) + resp.Diagnostics.Append(diags...) + + egress, diags := ToNetworkAclRules(ctx, plan.Egress) + resp.Diagnostics.Append(diags...) + + ingress, diags := ToNetworkAclRules(ctx, plan.Ingress) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + aclReq := api.NetworkACLPut{ + Description: plan.Description.ValueString(), + Config: config, + Egress: egress, + Ingress: ingress, + } + + err = server.UpdateNetworkACL(aclName, aclReq, etag) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to update network ACL %q", aclName), err.Error()) + return + } + + diags = r.SyncState(ctx, &resp.State, server, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkAclResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state NetworkAclModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + aclName := state.Name.ValueString() + err = server.DeleteNetworkACL(aclName) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to remove network ACL %q", aclName), err.Error()) + } +} + +func (r *NetworkAclResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + meta := common.ImportMetadata{ + ResourceName: "network_acl", + RequiredFields: []string{"name"}, + } + + fields, diags := meta.ParseImportID(req.ID) + if diags != nil { + resp.Diagnostics.Append(diags) + return + } + + for k, v := range fields { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(k), v)...) + } +} + +func (r *NetworkAclResource) SyncState(ctx context.Context, tfState *tfsdk.State, server lxd.InstanceServer, m NetworkAclModel) diag.Diagnostics { + aclName := m.Name.ValueString() + acl, _, err := server.GetNetworkACL(aclName) + if err != nil { + if errors.IsNotFoundError(err) { + tfState.RemoveResource(ctx) + return nil + } + + return diag.Diagnostics{diag.NewErrorDiagnostic( + fmt.Sprintf("Failed to retrieve network ACL %q", aclName), err.Error(), + )} + } + + config, diags := common.ToConfigMapType(ctx, common.ToNullableConfig(acl.Config), m.Config) + if diags.HasError() { + return diags + } + + egress, diags := ToNetworkAclRulesSetType(acl.Egress) + if diags.HasError() { + return diags + } + + ingress, diags := ToNetworkAclRulesSetType(acl.Ingress) + if diags.HasError() { + return diags + } + + m.Name = types.StringValue(acl.Name) + m.Description = types.StringValue(acl.Description) + m.Config = config + m.Egress = egress + m.Ingress = ingress + + return tfState.Set(ctx, &m) +} + +// ToNetworkAclRules converts ACL rules from type types.Set into []api.NetworkACLRule. +func ToNetworkAclRules(ctx context.Context, aclRuleList types.Set) ([]api.NetworkACLRule, diag.Diagnostics) { + if aclRuleList.IsNull() { + return []api.NetworkACLRule{}, nil + } + + aclRuleModelList := make([]NetworkAclRuleModel, 0, len(aclRuleList.Elements())) + diags := aclRuleList.ElementsAs(ctx, &aclRuleModelList, false) + if diags.HasError() { + return nil, diags + } + + aclRules := make([]api.NetworkACLRule, len(aclRuleModelList)) + for i, aclRuleModel := range aclRuleModelList { + protocol := aclRuleModel.Protocol.ValueString() + + aclRule := api.NetworkACLRule{ + Action: aclRuleModel.Action.ValueString(), + Destination: aclRuleModel.Destination.ValueString(), + DestinationPort: aclRuleModel.DestinationPort.ValueString(), + Protocol: protocol, + Description: aclRuleModel.Description.ValueString(), + State: aclRuleModel.State.ValueString(), + Source: aclRuleModel.Source.ValueString(), + } + + if protocol == "icmp4" || protocol == "icmp6" { + aclRule.ICMPType = aclRuleModel.ICMPType.ValueString() + aclRule.ICMPCode = aclRuleModel.ICMPCode.ValueString() + } + + aclRules[i] = aclRule + } + + return aclRules, nil +} + +// ToNetworkAclRulesSetType converts []api.NetworkACLRule into acl rules of type types.Set. +func ToNetworkAclRulesSetType(networkACLRules []api.NetworkACLRule) (types.Set, diag.Diagnostics) { + aclRuleObjectType := aclRuleObjectType() + nilSet := types.SetNull(aclRuleObjectType) + + if len(networkACLRules) == 0 { + return nilSet, nil + } + + aclRuleList := make([]attr.Value, 0, len(networkACLRules)) + for _, rule := range networkACLRules { + // Create the attribute map for each rule + aclRuleMap := map[string]attr.Value{ + "action": types.StringValue(rule.Action), + "destination": types.StringValue(rule.Destination), + "destination_port": types.StringValue(rule.DestinationPort), + "protocol": types.StringValue(rule.Protocol), + "description": types.StringValue(rule.Description), + "state": types.StringValue(rule.State), + "source": types.StringValue(rule.Source), + "icmp_type": types.StringValue(rule.ICMPType), + "icmp_code": types.StringValue(rule.ICMPCode), + } + + aclRuleObject, diags := types.ObjectValue(aclRuleObjectType.AttrTypes, aclRuleMap) + if diags.HasError() { + return nilSet, diags + } + + aclRuleList = append(aclRuleList, aclRuleObject) + } + + return types.SetValue(aclRuleObjectType, aclRuleList) +} diff --git a/internal/network/resource_network_acl_test.go b/internal/network/resource_network_acl_test.go new file mode 100644 index 00000000..4cbd0d8f --- /dev/null +++ b/internal/network/resource_network_acl_test.go @@ -0,0 +1,237 @@ +package network_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/terraform-lxd/terraform-provider-lxd/internal/acctest" +) + +func TestAccNetworkACL_basic(t *testing.T) { + aclName := acctest.GenerateName(2, "-") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkACL(aclName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_acl.acl", "name", aclName), + resource.TestCheckResourceAttr("lxd_network_acl.acl", "description", "Network ACL"), + ), + }, + }, + }) +} + +func TestAccNetworkACL_egress(t *testing.T) { + aclName := acctest.GenerateName(2, "-") + + entry1 := map[string]string{ + "action": "allow", + "destination": "1.1.1.1,1.0.0.1", + "destination_port": "53", + "protocol": "udp", + "description": "DNS to cloudflare public resolvers (UDP)", + "state": "enabled", + } + + entry2 := map[string]string{ + "action": "allow", + "destination": "1.1.1.1,1.0.0.1", + "destination_port": "53", + "protocol": "tcp", + "description": "DNS to cloudflare public resolvers (TCP)", + "state": "enabled", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkACL_withEgressRules(aclName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_acl.acl", "name", aclName), + resource.TestCheckResourceAttr("lxd_network_acl.acl", "description", "Network ACL"), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "egress.*", entry1), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "egress.*", entry2), + ), + }, + }, + }) +} + +func TestAccNetworkACL_ingress(t *testing.T) { + aclName := acctest.GenerateName(2, "-") + + entry := map[string]string{ + "action": "allow", + "source": "@external", + "destination_port": "22", + "protocol": "tcp", + "description": "Incoming SSH connections", + "state": "logged", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkACL_withIngressRules(aclName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_acl.acl", "name", aclName), + resource.TestCheckResourceAttr("lxd_network_acl.acl", "description", "Network ACL"), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "ingress.*", entry), + ), + }, + }, + }) +} + +func TestAccNetworkACL_egressAndIngress(t *testing.T) { + aclName := acctest.GenerateName(2, "-") + + ingresEntry := map[string]string{ + "action": "allow", + "source": "@external", + "destination_port": "22", + "protocol": "tcp", + "description": "Incoming SSH connections", + "state": "logged", + } + + egressEntry1 := map[string]string{ + "action": "allow", + "destination": "1.1.1.1,1.0.0.1", + "destination_port": "53", + "protocol": "udp", + "description": "DNS to cloudflare public resolvers (UDP)", + "state": "enabled", + } + + egressEntry2 := map[string]string{ + "action": "allow", + "destination": "1.1.1.1,1.0.0.1", + "destination_port": "53", + "protocol": "tcp", + "description": "DNS to cloudflare public resolvers (TCP)", + "state": "enabled", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkACL_withTrafficRules(aclName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_acl.acl", "name", aclName), + resource.TestCheckResourceAttr("lxd_network_acl.acl", "description", "Network ACL"), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "egress.*", egressEntry1), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "egress.*", egressEntry2), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_acl.acl", "ingress.*", ingresEntry), + ), + }, + }, + }) +} + +func testAccNetworkACL(aclName string) string { + return fmt.Sprintf(` +resource "lxd_network_acl" "acl" { + name = "%s" + description = "Network ACL" +} + `, aclName) +} + +func testAccNetworkACL_withEgressRules(aclName string) string { + return fmt.Sprintf(` +resource "lxd_network_acl" "acl" { + name = "%s" + description = "Network ACL" + + egress = [ + { + description = "DNS to cloudflare public resolvers (UDP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "udp" + state = "enabled" + }, + { + description = "DNS to cloudflare public resolvers (TCP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "tcp" + state = "enabled" + } + ] +} + `, aclName) +} + +func testAccNetworkACL_withIngressRules(aclName string) string { + return fmt.Sprintf(` +resource "lxd_network_acl" "acl" { + name = "%s" + description = "Network ACL" + + ingress = [ + { + description = "Incoming SSH connections" + action = "allow" + source = "@external" + destination_port = "22" + protocol = "tcp" + state = "logged" + } + ] +} + `, aclName) +} + +func testAccNetworkACL_withTrafficRules(aclName string) string { + return fmt.Sprintf(` +resource "lxd_network_acl" "acl" { + name = "%[1]s" + description = "Network ACL" + + egress = [ + { + description = "DNS to cloudflare public resolvers (UDP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "udp" + state = "enabled" + }, + { + description = "DNS to cloudflare public resolvers (TCP)" + action = "allow" + destination = "1.1.1.1,1.0.0.1" + destination_port = "53" + protocol = "tcp" + state = "enabled" + } + ] + + ingress = [ + { + description = "Incoming SSH connections" + action = "allow" + source = "@external" + destination_port = "22" + protocol = "tcp" + state = "logged" + } + ] +} + `, aclName) +} diff --git a/internal/network/resource_network_forward.go b/internal/network/resource_network_forward.go new file mode 100644 index 00000000..e784846b --- /dev/null +++ b/internal/network/resource_network_forward.go @@ -0,0 +1,428 @@ +package network + +import ( + "context" + "fmt" + + lxd "github.com/canonical/lxd/client" + "github.com/canonical/lxd/shared/api" + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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/terraform-lxd/terraform-provider-lxd/internal/common" + "github.com/terraform-lxd/terraform-provider-lxd/internal/errors" + provider_config "github.com/terraform-lxd/terraform-provider-lxd/internal/provider-config" +) + +// NetworkForwardModel resource data model that matches the schema. +type NetworkForwardModel struct { + Network types.String `tfsdk:"network"` + ListenAddress types.String `tfsdk:"listen_address"` + Ports types.Set `tfsdk:"ports"` + Description types.String `tfsdk:"description"` + Project types.String `tfsdk:"project"` + Remote types.String `tfsdk:"remote"` + Config types.Map `tfsdk:"config"` +} + +// NetworkForwardModel resource data model that matches the schema. +type NetworkForwardPortModel struct { + Description types.String `tfsdk:"description"` + Protocol types.String `tfsdk:"protocol"` + ListenPort types.String `tfsdk:"listen_port"` + TargetPort types.String `tfsdk:"target_port"` + TargetAddress types.String `tfsdk:"target_address"` +} + +// NetworkForwardResource represent network forward resource. +type NetworkForwardResource struct { + provider *provider_config.LxdProviderConfig +} + +func NewNetworkForwardResource() resource.Resource { + return &NetworkForwardResource{} +} + +func (r *NetworkForwardResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = fmt.Sprintf("%s_network_forward", req.ProviderTypeName) +} + +func (r *NetworkForwardResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + portObjectType := portObjectType() + + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "network": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "listen_address": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + + "project": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + + "remote": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + + "config": schema.MapAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + + "ports": schema.SetNestedAttribute{ + Optional: true, + Computed: true, + Default: setdefault.StaticValue(types.SetNull(portObjectType)), + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "description": schema.StringAttribute{ + Optional: true, + Description: "Port description", + }, + + "protocol": schema.StringAttribute{ + Required: true, + Description: "Port protocol", + Validators: []validator.String{ + stringvalidator.OneOf("tcp", "udp"), + }, + }, + + "listen_port": schema.StringAttribute{ + Required: true, + Description: "Listen port to forward", + }, + + "target_port": schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "Target port to forward listen port to. Defaults to the value of listen_port", + }, + + "target_address": schema.StringAttribute{ + Required: true, + Description: "Target address to forward listen port to", + }, + }, + }, + }, + }, + } +} + +func portObjectType() types.ObjectType { + return types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "description": types.StringType, + "protocol": types.StringType, + "listen_port": types.StringType, + "target_port": types.StringType, + "target_address": types.StringType, + }, + } +} + +func (r *NetworkForwardResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + data := req.ProviderData + if data == nil { + return + } + + provider, ok := data.(*provider_config.LxdProviderConfig) + if !ok { + resp.Diagnostics.Append(errors.NewProviderDataTypeError(req.ProviderData)) + return + } + + r.provider = provider +} + +func (r *NetworkForwardResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan NetworkForwardModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := plan.Remote.ValueString() + project := plan.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + ports, diags := ToNetworkForwardPortList(ctx, plan.Ports) + resp.Diagnostics.Append(diags...) + + config, diags := common.ToConfigMap(ctx, plan.Config) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + networkName := plan.Network.ValueString() + listenAddress := plan.ListenAddress.ValueString() + + createRequest := api.NetworkForwardsPost{ + ListenAddress: listenAddress, + NetworkForwardPut: api.NetworkForwardPut{ + Description: plan.Description.ValueString(), + Ports: ports, + Config: config, + }, + } + + err = server.CreateNetworkForward(networkName, createRequest) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to create network forward for %q", listenAddress), err.Error()) + return + } + + diags = r.SyncState(ctx, &resp.State, server, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkForwardResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state NetworkForwardModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + diags = r.SyncState(ctx, &resp.State, server, state) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkForwardResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan NetworkForwardModel + + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := plan.Remote.ValueString() + project := plan.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + ports, diags := ToNetworkForwardPortList(ctx, plan.Ports) + resp.Diagnostics.Append(diags...) + + config, diags := common.ToConfigMap(ctx, plan.Config) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + + networkName := plan.Network.ValueString() + listenAddress := plan.ListenAddress.ValueString() + + updateRequest := api.NetworkForwardPut{ + Description: plan.Description.ValueString(), + Ports: ports, + Config: config, + } + + _, etag, err := server.GetNetworkForward(networkName, listenAddress) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to retrieve network forward for %q", listenAddress), err.Error()) + } + + err = server.UpdateNetworkForward(networkName, listenAddress, updateRequest, etag) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to update network forward for %q", listenAddress), err.Error()) + return + } + + diags = r.SyncState(ctx, &resp.State, server, plan) + resp.Diagnostics.Append(diags...) +} + +func (r *NetworkForwardResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state NetworkForwardModel + + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + remote := state.Remote.ValueString() + project := state.Project.ValueString() + server, err := r.provider.InstanceServer(remote, project, "") + if err != nil { + resp.Diagnostics.Append(errors.NewInstanceServerError(err)) + return + } + + networkName := state.Network.ValueString() + listenAddress := state.ListenAddress.ValueString() + + err = server.DeleteNetworkForward(networkName, listenAddress) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete network forward for %q", listenAddress), err.Error()) + } +} + +func (r *NetworkForwardResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + meta := common.ImportMetadata{ + ResourceName: "network_forward", + RequiredFields: []string{"network", "listen_address"}, + } + + fields, diags := meta.ParseImportID(req.ID) + if diags != nil { + resp.Diagnostics.Append(diags) + return + } + + for k, v := range fields { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(k), v)...) + } +} + +func (r *NetworkForwardResource) SyncState(ctx context.Context, tfState *tfsdk.State, server lxd.InstanceServer, m NetworkForwardModel) diag.Diagnostics { + networkName := m.Network.ValueString() + listenAddress := m.ListenAddress.ValueString() + networkForward, _, err := server.GetNetworkForward(networkName, listenAddress) + if err != nil { + if errors.IsNotFoundError(err) { + tfState.RemoveResource(ctx) + return nil + } + + return diag.Diagnostics{diag.NewErrorDiagnostic( + fmt.Sprintf("Failed to retrieve network forward %q", listenAddress), err.Error(), + )} + } + + ports, diags := ToNetworkForwardPortSetType(ctx, networkForward.Ports) + if diags.HasError() { + return diags + } + + config, diags := common.ToConfigMapType(ctx, common.ToNullableConfig(networkForward.Config), m.Config) + if diags.HasError() { + return diags + } + + m.Description = types.StringValue(networkForward.Description) + m.Ports = ports + m.Config = config + + return tfState.Set(ctx, &m) +} + +// ToNetworkForwardPortList converts forward ports from type types.Set into []api.NetworkForwardPort. +func ToNetworkForwardPortList(ctx context.Context, portsSet types.Set) ([]api.NetworkForwardPort, diag.Diagnostics) { + if portsSet.IsNull() || portsSet.IsUnknown() { + return []api.NetworkForwardPort{}, nil + } + + modelPorts := make([]NetworkForwardPortModel, 0, len(portsSet.Elements())) + diags := portsSet.ElementsAs(ctx, &modelPorts, false) + if diags.HasError() { + return nil, diags + } + + ports := make([]api.NetworkForwardPort, 0, len(modelPorts)) + for _, modelPort := range modelPorts { + port := api.NetworkForwardPort{ + Description: modelPort.Description.ValueString(), + Protocol: modelPort.Protocol.ValueString(), + ListenPort: modelPort.ListenPort.ValueString(), + TargetPort: modelPort.TargetPort.ValueString(), + TargetAddress: modelPort.TargetAddress.ValueString(), + } + + ports = append(ports, port) + } + + return ports, nil +} + +// ToNetworkForwardPortSetType converts []api.NetworkForwardPort into forward ports of type types.Set. +func ToNetworkForwardPortSetType(ctx context.Context, ports []api.NetworkForwardPort) (types.Set, diag.Diagnostics) { + portObjectType := portObjectType() + nilSet := types.SetNull(portObjectType) + + if len(ports) == 0 { + return nilSet, nil + } + + portList := make([]attr.Value, 0, len(ports)) + for _, port := range ports { + portMap := map[string]attr.Value{ + "description": types.StringValue(port.Description), + "protocol": types.StringValue(port.Protocol), + "listen_port": types.StringValue(port.ListenPort), + "target_port": types.StringValue(port.TargetPort), + "target_address": types.StringValue(port.TargetAddress), + } + + portObject, diags := types.ObjectValue(portObjectType.AttrTypes, portMap) + if diags.HasError() { + return nilSet, diags + } + + portList = append(portList, portObject) + } + + return types.SetValue(portObjectType, portList) +} diff --git a/internal/network/resource_network_forward_test.go b/internal/network/resource_network_forward_test.go new file mode 100644 index 00000000..226f5009 --- /dev/null +++ b/internal/network/resource_network_forward_test.go @@ -0,0 +1,131 @@ +package network_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/terraform-lxd/terraform-provider-lxd/internal/acctest" +) + +func TestAccNetworkForward_basic(t *testing.T) { + networkName := acctest.GenerateName(1, "") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkForward(networkName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_forward.forward", "listen_address", "10.150.19.10"), + resource.TestCheckResourceAttr("lxd_network_forward.forward", "description", "Network Forward"), + resource.TestCheckResourceAttr("lxd_network_forward.forward", "config.target_address", "10.150.19.111"), + ), + }, + }, + }) +} + +func TestAccNetworkForward_Ports(t *testing.T) { + networkName := acctest.GenerateName(1, "") + + entry1 := map[string]string{ + "description": "SSH", + "protocol": "tcp", + "listen_port": "22", + "target_port": "2022", + "target_address": "10.150.19.112", + } + + entry2 := map[string]string{ + "description": "HTTP", + "protocol": "tcp", + "listen_port": "80", + "target_port": "", + "target_address": "10.150.19.112", + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccNetworkForward_withPorts(networkName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("lxd_network_forward.forward", "listen_address", "10.150.19.10"), + resource.TestCheckResourceAttr("lxd_network_forward.forward", "description", "Network Forward"), + resource.TestCheckResourceAttr("lxd_network_forward.forward", "config.target_address", "10.150.19.111"), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_forward.forward", "ports.*", entry1), + resource.TestCheckTypeSetElemNestedAttrs("lxd_network_forward.forward", "ports.*", entry2), + ), + }, + }, + }) +} + +func testAccNetworkForward(networkName string) string { + return fmt.Sprintf(` +resource "lxd_network" "forward" { + name = "%s" + + config = { + "ipv4.address" = "10.150.19.1/24" + "ipv4.nat" = "true" + "ipv6.address" = "fd42:474b:622d:259d::1/64" + "ipv6.nat" = "true" + } +} + +resource "lxd_network_forward" "forward" { + network = lxd_network.forward.name + description = "Network Forward" + listen_address = "10.150.19.10" + + config = { + target_address = "10.150.19.111" + } +} + `, networkName) +} + +func testAccNetworkForward_withPorts(networkName string) string { + return fmt.Sprintf(` +resource "lxd_network" "forward" { + name = "%s" + + config = { + "ipv4.address" = "10.150.19.1/24" + "ipv4.nat" = "true" + "ipv6.address" = "fd42:474b:622d:259d::1/64" + "ipv6.nat" = "true" + } +} + +resource "lxd_network_forward" "forward" { + network = lxd_network.forward.name + description = "Network Forward" + listen_address = "10.150.19.10" + + config = { + target_address = "10.150.19.111" + } + + ports = [ + { + description = "SSH" + protocol = "tcp" + listen_port = "22" + target_port = "2022" + target_address = "10.150.19.112" + }, + { + description = "HTTP" + protocol = "tcp" + listen_port = "80" + target_address = "10.150.19.112" + } + ] +} + `, networkName) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4d692530..85c07c54 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -317,6 +317,8 @@ func (p *LxdProvider) Resources(_ context.Context) []func() resource.Resource { instance.NewInstanceFileResource, instance.NewInstanceSnapshotResource, network.NewNetworkResource, + network.NewNetworkAclResource, + network.NewNetworkForwardResource, network.NewNetworkLBResource, network.NewNetworkZoneResource, network.NewNetworkZoneRecordResource,