diff --git a/README.md b/README.md index bb780f1..8e161c5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ provider "tomswallapi" { | `tomswallapi_provider` | id | multi-ISP routing provider on a `device` | | `tomswallapi_route` | id | static route on a `device` (`oif` = egress iface) | | `tomswallapi_routing_rule` | id | policy routing to a provider table (rtrules) | +| `tomswallapi_tunnel` | id | VPN tunnel definition on a `device` | +| `tomswallapi_stopped_rule` | id | traffic allowed while the firewall is stopped | +| `tomswallapi_proxy_arp` / `_proxy_ndp` | id | proxy ARP/NDP on a `device` | +| `tomswallapi_arp_rule` | id | ARP-level rule on a `device` | +| `tomswallapi_maclist` | id | MAC/IP verification on a `device` interface | ## Example diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 244361f..b5e8d8e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -85,6 +85,12 @@ func (p *tomswallProvider) Resources(_ context.Context) []func() resource.Resour NewProviderResource, NewRouteResource, NewRoutingRuleResource, + NewTunnelResource, + NewStoppedRuleResource, + NewProxyARPResource, + NewProxyNDPResource, + NewArpRuleResource, + NewMaclistResource, } } diff --git a/internal/provider/resource_arp_rule.go b/internal/provider/resource_arp_rule.go new file mode 100644 index 0000000..55bceee --- /dev/null +++ b/internal/provider/resource_arp_rule.go @@ -0,0 +1,171 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &arpRuleResource{} + _ resource.ResourceWithImportState = &arpRuleResource{} +) + +type arpRuleResource struct{ client *apiClient } + +type arpRuleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + ActionAddress types.String `tfsdk:"action_address"` + ActionMAC types.String `tfsdk:"action_mac"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Opcode types.Int64 `tfsdk:"opcode"` + Comment types.String `tfsdk:"comment"` +} + +type arpRuleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + ActionAddress string `json:"action_address,omitempty"` + ActionMAC string `json:"action_mac,omitempty"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Opcode int `json:"opcode,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewArpRuleResource() resource.Resource { return &arpRuleResource{} } + +func (r *arpRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_arp_rule" +} + +func (r *arpRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "An ARP-level rule on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Required: true}, + "action_address": schema.StringAttribute{Optional: true}, + "action_mac": schema.StringAttribute{Optional: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "opcode": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *arpRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *arpRuleResource) body(plan arpRuleModel) arpRuleAPI { + return arpRuleAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + ActionAddress: plan.ActionAddress.ValueString(), + ActionMAC: plan.ActionMAC.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Opcode: int(plan.Opcode.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *arpRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan arpRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.post(ctx, "/api/v1/arp-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create arp_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *arpRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state arpRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.post(ctx, "/api/v1/arp-rules", r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate arp_rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old arp_rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *arpRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state arpRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out arpRuleAPI + if err := r.client.get(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read arp_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *arpRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state arpRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/arp-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete arp_rule failed", err.Error()) + } +} + +func (r *arpRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "arp_rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *arpRuleResource) toModel(api arpRuleAPI, prior arpRuleModel) arpRuleModel { + return arpRuleModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + ActionAddress: optionalString(api.ActionAddress, prior.ActionAddress), + ActionMAC: optionalString(api.ActionMAC, prior.ActionMAC), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Opcode: optionalInt64(api.Opcode, prior.Opcode), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_maclist.go b/internal/provider/resource_maclist.go new file mode 100644 index 0000000..e3d3909 --- /dev/null +++ b/internal/provider/resource_maclist.go @@ -0,0 +1,175 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &maclistResource{} + _ resource.ResourceWithImportState = &maclistResource{} +) + +type maclistResource struct{ client *apiClient } + +type maclistModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Interface types.String `tfsdk:"interface"` + MAC types.String `tfsdk:"mac"` + Addresses types.List `tfsdk:"addresses"` + Log types.String `tfsdk:"log"` + Comment types.String `tfsdk:"comment"` +} + +type maclistAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Interface string `json:"interface"` + MAC string `json:"mac,omitempty"` + Addresses []string `json:"addresses,omitempty"` + Log string `json:"log,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewMaclistResource() resource.Resource { return &maclistResource{} } + +func (r *maclistResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_maclist" +} + +func (r *maclistResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A MAC/IP verification entry on a device's interface.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Description: "accept, drop, ...", Required: true}, + "interface": schema.StringAttribute{Required: true}, + "mac": schema.StringAttribute{Optional: true}, + "addresses": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "log": schema.StringAttribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *maclistResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *maclistResource) body(ctx context.Context, plan maclistModel, diags *diag.Diagnostics) maclistAPI { + return maclistAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + Interface: plan.Interface.ValueString(), + MAC: plan.MAC.ValueString(), + Addresses: listToStrings(ctx, plan.Addresses, diags), + Log: plan.Log.ValueString(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *maclistResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan maclistModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.post(ctx, "/api/v1/maclist", body, &out); err != nil { + resp.Diagnostics.AddError("create maclist failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *maclistResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state maclistModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.post(ctx, "/api/v1/maclist", body, &out); err != nil { + resp.Diagnostics.AddError("recreate maclist failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/maclist/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old maclist failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *maclistResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state maclistModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out maclistAPI + if err := r.client.get(ctx, "/api/v1/maclist/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read maclist failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *maclistResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state maclistModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/maclist/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete maclist failed", err.Error()) + } +} + +func (r *maclistResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "maclist id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *maclistResource) toModel(ctx context.Context, api maclistAPI, prior maclistModel, diags *diag.Diagnostics) maclistModel { + return maclistModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + Interface: types.StringValue(api.Interface), + MAC: optionalString(api.MAC, prior.MAC), + Addresses: optionalList(ctx, api.Addresses, prior.Addresses, diags), + Log: optionalString(api.Log, prior.Log), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_proxy.go b/internal/provider/resource_proxy.go new file mode 100644 index 0000000..67f1186 --- /dev/null +++ b/internal/provider/resource_proxy.go @@ -0,0 +1,178 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// proxyResource backs both tomswallapi_proxy_arp and tomswallapi_proxy_ndp +// (identical shape); typeSuffix and endpoint distinguish them. +type proxyResource struct { + client *apiClient + typeSuffix string + endpoint string +} + +func NewProxyARPResource() resource.Resource { + return &proxyResource{typeSuffix: "_proxy_arp", endpoint: "/api/v1/proxy-arp"} +} +func NewProxyNDPResource() resource.Resource { + return &proxyResource{typeSuffix: "_proxy_ndp", endpoint: "/api/v1/proxy-ndp"} +} + +var ( + _ resource.Resource = &proxyResource{} + _ resource.ResourceWithImportState = &proxyResource{} +) + +type proxyModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Address types.String `tfsdk:"address"` + Interface types.String `tfsdk:"interface"` + External types.String `tfsdk:"external"` + HaveRoute types.Bool `tfsdk:"haveroute"` + Persistent types.Bool `tfsdk:"persistent"` + Comment types.String `tfsdk:"comment"` +} + +type proxyAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Address string `json:"address"` + Interface string `json:"interface,omitempty"` + External string `json:"external"` + HaveRoute bool `json:"haveroute,omitempty"` + Persistent bool `json:"persistent,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func (r *proxyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + r.typeSuffix +} + +func (r *proxyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Answers ARP/NDP on behalf of another host (proxy arp/ndp), on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "address": schema.StringAttribute{Required: true}, + "interface": schema.StringAttribute{Optional: true}, + "external": schema.StringAttribute{Description: "External interface.", Required: true}, + "haveroute": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "persistent": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false)}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *proxyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *proxyResource) body(plan proxyModel) proxyAPI { + return proxyAPI{ + Device: plan.Device.ValueString(), + Address: plan.Address.ValueString(), + Interface: plan.Interface.ValueString(), + External: plan.External.ValueString(), + HaveRoute: plan.HaveRoute.ValueBool(), + Persistent: plan.Persistent.ValueBool(), + Comment: plan.Comment.ValueString(), + } +} + +func (r *proxyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan proxyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.post(ctx, r.endpoint, r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("create proxy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *proxyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state proxyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.post(ctx, r.endpoint, r.body(plan), &out); err != nil { + resp.Diagnostics.AddError("recreate proxy failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, r.endpoint+"/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old proxy failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, plan))...) +} + +func (r *proxyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state proxyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out proxyAPI + if err := r.client.get(ctx, r.endpoint+"/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read proxy failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(out, state))...) +} + +func (r *proxyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state proxyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, r.endpoint+"/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete proxy failed", err.Error()) + } +} + +func (r *proxyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "proxy id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *proxyResource) toModel(api proxyAPI, prior proxyModel) proxyModel { + return proxyModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Address: types.StringValue(api.Address), + Interface: optionalString(api.Interface, prior.Interface), + External: types.StringValue(api.External), + HaveRoute: types.BoolValue(api.HaveRoute), + Persistent: types.BoolValue(api.Persistent), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_stopped_rule.go b/internal/provider/resource_stopped_rule.go new file mode 100644 index 0000000..ae23836 --- /dev/null +++ b/internal/provider/resource_stopped_rule.go @@ -0,0 +1,180 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &stoppedRuleResource{} + _ resource.ResourceWithImportState = &stoppedRuleResource{} +) + +type stoppedRuleResource struct{ client *apiClient } + +type stoppedRuleModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Action types.String `tfsdk:"action"` + Source types.String `tfsdk:"source"` + Dest types.String `tfsdk:"dest"` + Proto types.String `tfsdk:"proto"` + DPort types.List `tfsdk:"dport"` + SPort types.List `tfsdk:"sport"` + Comment types.String `tfsdk:"comment"` +} + +type stoppedRuleAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Action string `json:"action"` + Source string `json:"source,omitempty"` + Dest string `json:"dest,omitempty"` + Proto string `json:"proto,omitempty"` + DPort []string `json:"dport,omitempty"` + SPort []string `json:"sport,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewStoppedRuleResource() resource.Resource { return &stoppedRuleResource{} } + +func (r *stoppedRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_stopped_rule" +} + +func (r *stoppedRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Traffic permitted when the firewall is stopped, on a device.", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "action": schema.StringAttribute{Description: "accept, drop, ...", Required: true}, + "source": schema.StringAttribute{Optional: true}, + "dest": schema.StringAttribute{Optional: true}, + "proto": schema.StringAttribute{Optional: true}, + "dport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "sport": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *stoppedRuleResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *stoppedRuleResource) body(ctx context.Context, plan stoppedRuleModel, diags *diag.Diagnostics) stoppedRuleAPI { + return stoppedRuleAPI{ + Device: plan.Device.ValueString(), + Action: plan.Action.ValueString(), + Source: plan.Source.ValueString(), + Dest: plan.Dest.ValueString(), + Proto: plan.Proto.ValueString(), + DPort: listToStrings(ctx, plan.DPort, diags), + SPort: listToStrings(ctx, plan.SPort, diags), + Comment: plan.Comment.ValueString(), + } +} + +func (r *stoppedRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan stoppedRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.post(ctx, "/api/v1/stopped-rules", body, &out); err != nil { + resp.Diagnostics.AddError("create stopped_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state stoppedRuleModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.post(ctx, "/api/v1/stopped-rules", body, &out); err != nil { + resp.Diagnostics.AddError("recreate stopped_rule failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old stopped_rule failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state stoppedRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out stoppedRuleAPI + if err := r.client.get(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read stopped_rule failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *stoppedRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state stoppedRuleModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/stopped-rules/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete stopped_rule failed", err.Error()) + } +} + +func (r *stoppedRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "stopped_rule id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *stoppedRuleResource) toModel(ctx context.Context, api stoppedRuleAPI, prior stoppedRuleModel, diags *diag.Diagnostics) stoppedRuleModel { + return stoppedRuleModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Action: types.StringValue(api.Action), + Source: optionalString(api.Source, prior.Source), + Dest: optionalString(api.Dest, prior.Dest), + Proto: optionalString(api.Proto, prior.Proto), + DPort: optionalList(ctx, api.DPort, prior.DPort, diags), + SPort: optionalList(ctx, api.SPort, prior.SPort, diags), + Comment: optionalString(api.Comment, prior.Comment), + } +} diff --git a/internal/provider/resource_tunnel.go b/internal/provider/resource_tunnel.go new file mode 100644 index 0000000..a9b20d9 --- /dev/null +++ b/internal/provider/resource_tunnel.go @@ -0,0 +1,175 @@ +package provider + +import ( + "context" + "strconv" + + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &tunnelResource{} + _ resource.ResourceWithImportState = &tunnelResource{} +) + +type tunnelResource struct{ client *apiClient } + +type tunnelModel struct { + ID types.Int64 `tfsdk:"id"` + Device types.String `tfsdk:"device"` + Type types.String `tfsdk:"type"` + Zone types.String `tfsdk:"zone"` + Gateways types.List `tfsdk:"gateways"` + GatewayZones types.List `tfsdk:"gateway_zones"` + Port types.Int64 `tfsdk:"port"` + Comment types.String `tfsdk:"comment"` +} + +type tunnelAPI struct { + ID int64 `json:"id,omitempty"` + Device string `json:"device"` + Type string `json:"type"` + Zone string `json:"zone"` + Gateways []string `json:"gateways,omitempty"` + GatewayZones []string `json:"gateway_zones,omitempty"` + Port int `json:"port,omitempty"` + Comment string `json:"comment,omitempty"` +} + +func NewTunnelResource() resource.Resource { return &tunnelResource{} } + +func (r *tunnelResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tunnel" +} + +func (r *tunnelResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A VPN tunnel definition on a device (allows encapsulated traffic).", + Attributes: map[string]schema.Attribute{ + "id": schema.Int64Attribute{Computed: true, PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}}, + "device": schema.StringAttribute{Description: "Owning device.", Required: true}, + "type": schema.StringAttribute{Description: "Tunnel type (ipsec, openvpn:udp, ...).", Required: true}, + "zone": schema.StringAttribute{Required: true}, + "gateways": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "gateway_zones": schema.ListAttribute{Optional: true, ElementType: types.StringType}, + "port": schema.Int64Attribute{Optional: true}, + "comment": schema.StringAttribute{Optional: true}, + }, + } +} + +func (r *tunnelResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = configureClient(req, resp) +} + +func (r *tunnelResource) body(ctx context.Context, plan tunnelModel, diags *diag.Diagnostics) tunnelAPI { + return tunnelAPI{ + Device: plan.Device.ValueString(), + Type: plan.Type.ValueString(), + Zone: plan.Zone.ValueString(), + Gateways: listToStrings(ctx, plan.Gateways, diags), + GatewayZones: listToStrings(ctx, plan.GatewayZones, diags), + Port: int(plan.Port.ValueInt64()), + Comment: plan.Comment.ValueString(), + } +} + +func (r *tunnelResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tunnelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.post(ctx, "/api/v1/tunnels", body, &out); err != nil { + resp.Diagnostics.AddError("create tunnel failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state tunnelModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + body := r.body(ctx, plan, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.post(ctx, "/api/v1/tunnels", body, &out); err != nil { + resp.Diagnostics.AddError("recreate tunnel failed", err.Error()) + return + } + if id := state.ID.ValueInt64(); id != 0 { + if err := r.client.del(ctx, "/api/v1/tunnels/"+strconv.FormatInt(id, 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete old tunnel failed", err.Error()) + return + } + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, plan, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tunnelModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + var out tunnelAPI + if err := r.client.get(ctx, "/api/v1/tunnels/"+strconv.FormatInt(state.ID.ValueInt64(), 10), &out); err != nil { + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("read tunnel failed", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, r.toModel(ctx, out, state, &resp.Diagnostics))...) +} + +func (r *tunnelResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state tunnelModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if err := r.client.del(ctx, "/api/v1/tunnels/"+strconv.FormatInt(state.ID.ValueInt64(), 10)); err != nil && !isNotFound(err) { + resp.Diagnostics.AddError("delete tunnel failed", err.Error()) + } +} + +func (r *tunnelResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + id, err := strconv.ParseInt(req.ID, 10, 64) + if err != nil { + resp.Diagnostics.AddError("invalid import ID", "tunnel id must be an integer") + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), id)...) +} + +func (r *tunnelResource) toModel(ctx context.Context, api tunnelAPI, prior tunnelModel, diags *diag.Diagnostics) tunnelModel { + return tunnelModel{ + ID: types.Int64Value(api.ID), + Device: types.StringValue(api.Device), + Type: types.StringValue(api.Type), + Zone: types.StringValue(api.Zone), + Gateways: optionalList(ctx, api.Gateways, prior.Gateways, diags), + GatewayZones: optionalList(ctx, api.GatewayZones, prior.GatewayZones, diags), + Port: optionalInt64(api.Port, prior.Port), + Comment: optionalString(api.Comment, prior.Comment), + } +}