320 lines
15 KiB
Go
320 lines
15 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
|
|
"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/defaults"
|
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
|
|
"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/types"
|
|
|
|
"git.veillax.com/Veillax/hhctl/client"
|
|
)
|
|
|
|
var (
|
|
_ resource.Resource = &portResource{}
|
|
_ resource.ResourceWithConfigure = &portResource{}
|
|
_ resource.ResourceWithImportState = &portResource{}
|
|
)
|
|
|
|
func NewPort() resource.Resource { return &portResource{} }
|
|
|
|
type portResource struct{ c *client.Client }
|
|
|
|
type targetModel struct {
|
|
SiteID types.String `tfsdk:"site_id"`
|
|
Host types.String `tfsdk:"host"`
|
|
Port types.Int64 `tfsdk:"port"`
|
|
}
|
|
|
|
type ruleModel struct {
|
|
Action types.String `tfsdk:"action"`
|
|
Match types.String `tfsdk:"match"`
|
|
Value types.String `tfsdk:"value"`
|
|
}
|
|
|
|
var (
|
|
targetType = types.ObjectType{AttrTypes: map[string]attr.Type{"site_id": types.StringType, "host": types.StringType, "port": types.Int64Type}}
|
|
ruleType = types.ObjectType{AttrTypes: map[string]attr.Type{"action": types.StringType, "match": types.StringType, "value": types.StringType}}
|
|
)
|
|
|
|
type portModel struct {
|
|
ID types.String `tfsdk:"id"`
|
|
Name types.String `tfsdk:"name"`
|
|
Proto types.String `tfsdk:"proto"`
|
|
EdgeID types.String `tfsdk:"edge_id"`
|
|
BindIP types.String `tfsdk:"bind_ip"`
|
|
Port types.Int64 `tfsdk:"port"`
|
|
PortEnd types.Int64 `tfsdk:"port_end"`
|
|
Hostname types.String `tfsdk:"hostname"`
|
|
Enabled types.Bool `tfsdk:"enabled"`
|
|
Tags types.Set `tfsdk:"tags"`
|
|
ProxyProtocol types.String `tfsdk:"proxy_protocol"`
|
|
RedirectTo types.String `tfsdk:"redirect_to"`
|
|
RedirectPermanent types.Bool `tfsdk:"redirect_permanent"`
|
|
RedirectKeepPath types.Bool `tfsdk:"redirect_keep_path"`
|
|
RedirectHTTP types.Bool `tfsdk:"redirect_http"`
|
|
BackendHTTPS types.Bool `tfsdk:"backend_https"`
|
|
RewriteHost types.String `tfsdk:"rewrite_host"`
|
|
HealthPath types.String `tfsdk:"health_path"`
|
|
HealthSeconds types.Int64 `tfsdk:"health_seconds"`
|
|
Targets types.List `tfsdk:"target"`
|
|
Rules types.List `tfsdk:"rule"`
|
|
AuthSSO types.Bool `tfsdk:"auth_sso"`
|
|
AuthEmail types.Bool `tfsdk:"auth_email"`
|
|
AuthRoles types.Set `tfsdk:"auth_allowed_roles"`
|
|
AuthUsers types.Set `tfsdk:"auth_allowed_users"`
|
|
AuthMails types.Set `tfsdk:"auth_allowed_mails"`
|
|
AuthSessionHours types.Int64 `tfsdk:"auth_session_hours"`
|
|
Password types.String `tfsdk:"password"`
|
|
Pin types.String `tfsdk:"pin"`
|
|
}
|
|
|
|
func (r *portResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
|
resp.TypeName = req.ProviderTypeName + "_port"
|
|
}
|
|
|
|
func emptySet() defaults.Set { return setdefault.StaticValue(setOf(nil)) }
|
|
|
|
func (r *portResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
|
str := func(desc string) schema.StringAttribute {
|
|
return schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString(""), Description: desc}
|
|
}
|
|
flag := func(desc string) schema.BoolAttribute {
|
|
return schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), Description: desc}
|
|
}
|
|
num := func(desc string) schema.Int64Attribute {
|
|
return schema.Int64Attribute{Optional: true, Computed: true, Default: int64default.StaticInt64(0), Description: desc}
|
|
}
|
|
set := func(desc string) schema.SetAttribute {
|
|
return schema.SetAttribute{Optional: true, Computed: true, ElementType: types.StringType, Default: emptySet(), Description: desc}
|
|
}
|
|
resp.Schema = schema.Schema{
|
|
Description: "A port: a public TCP/UDP port, or an HTTP(S)/TLS hostname, on an edge, forwarded to targets at a site.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": schema.StringAttribute{Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}},
|
|
"name": schema.StringAttribute{Required: true},
|
|
"proto": schema.StringAttribute{Required: true, Description: "tcp, udp, http, https or tls."},
|
|
"edge_id": schema.StringAttribute{Required: true, Description: "The edge that listens."},
|
|
"bind_ip": schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString("0.0.0.0"), Description: "Address on the edge to listen on; 0.0.0.0 is every address."},
|
|
"port": schema.Int64Attribute{Required: true, Description: "Public port."},
|
|
"port_end": num("Last port of a tcp/udp range (0 = just port)."),
|
|
"hostname": str("Hostname for http, https and tls; a wildcard like *.example.com is allowed."),
|
|
"enabled": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(true)},
|
|
"tags": set("Labels for grouping and filtering in the dashboard."),
|
|
|
|
"proxy_protocol": str("v1 or v2: prefix tcp/tls connections with a PROXY protocol header."),
|
|
"redirect_to": str("Answer with a redirect to this URL instead of forwarding (http/https)."),
|
|
"redirect_permanent": flag("Redirect with 301 instead of 302."),
|
|
"redirect_keep_path": flag("Append the visitor's path to the redirect."),
|
|
"redirect_http": flag("https: also answer on port 80 with a redirect."),
|
|
"backend_https": flag("Talk TLS (unverified) to the targets."),
|
|
"rewrite_host": str("Host header sent to the target; empty keeps the original."),
|
|
"health_path": str("http(s): GET this path for health checks; empty is a TCP connect check."),
|
|
"health_seconds": num("Seconds between health checks; 0 is the default (30), -1 turns them off."),
|
|
|
|
"auth_sso": flag("Visitors sign in with a Hammerhead account."),
|
|
"auth_email": flag("Visitors get a one-time code by email."),
|
|
"auth_allowed_roles": set("Roles allowed to sign in with SSO."),
|
|
"auth_allowed_users": set("User IDs allowed to sign in with SSO."),
|
|
"auth_allowed_mails": set("Addresses allowed for email codes; \"*@example.com\" is fine."),
|
|
"auth_session_hours": num("How long a visitor stays signed in; 0 is the default (24)."),
|
|
"password": schema.StringAttribute{Optional: true, Sensitive: true, Description: "Shared password visitors must enter (at least 6 characters)."},
|
|
"pin": schema.StringAttribute{Optional: true, Sensitive: true, Description: "4-12 digit PIN visitors must enter."},
|
|
},
|
|
Blocks: map[string]schema.Block{
|
|
"target": schema.ListNestedBlock{
|
|
Description: "A backend to forward to. UDP uses only the first.",
|
|
NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{
|
|
"site_id": schema.StringAttribute{Required: true},
|
|
"host": schema.StringAttribute{Required: true, Description: "Address of the service as seen from the site."},
|
|
"port": schema.Int64Attribute{Required: true},
|
|
}},
|
|
},
|
|
"rule": schema.ListNestedBlock{
|
|
Description: "Access rule evaluated before authentication, top to bottom.",
|
|
NestedObject: schema.NestedBlockObject{Attributes: map[string]schema.Attribute{
|
|
"action": schema.StringAttribute{Required: true, Description: "allow (skip auth) or deny."},
|
|
"match": schema.StringAttribute{Required: true, Description: "path (prefix) or ip (address or CIDR)."},
|
|
"value": schema.StringAttribute{Required: true},
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *portResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
|
|
r.c = clientFrom(req.ProviderData)
|
|
}
|
|
|
|
// toAPI builds the request. old is the prior state (nil on create); passwords are only re-sent when they change,
|
|
// because every change signs all visitors out.
|
|
func (m *portModel) toAPI(ctx context.Context, old *portModel) (client.Port, diag.Diagnostics) {
|
|
p := client.Port{
|
|
ID: m.ID.ValueString(), Name: m.Name.ValueString(), Proto: m.Proto.ValueString(), EdgeID: m.EdgeID.ValueString(),
|
|
BindIP: m.BindIP.ValueString(), Port: int(m.Port.ValueInt64()), PortEnd: int(m.PortEnd.ValueInt64()),
|
|
Hostname: m.Hostname.ValueString(), Enabled: m.Enabled.ValueBool(), Tags: stringsOf(ctx, m.Tags),
|
|
ProxyProtocol: m.ProxyProtocol.ValueString(), RedirectTo: m.RedirectTo.ValueString(),
|
|
RedirectPermanent: m.RedirectPermanent.ValueBool(), RedirectKeepPath: m.RedirectKeepPath.ValueBool(),
|
|
RedirectHTTP: m.RedirectHTTP.ValueBool(), BackendHTTPS: m.BackendHTTPS.ValueBool(),
|
|
RewriteHost: m.RewriteHost.ValueString(), HealthPath: m.HealthPath.ValueString(), HealthSeconds: int(m.HealthSeconds.ValueInt64()),
|
|
Targets: []client.Target{}, Rules: []client.Rule{},
|
|
Auth: client.Auth{SSO: m.AuthSSO.ValueBool(), Email: m.AuthEmail.ValueBool(), SessionHours: int(m.AuthSessionHours.ValueInt64()),
|
|
AllowedRoles: stringsOf(ctx, m.AuthRoles), AllowedUsers: stringsOf(ctx, m.AuthUsers), AllowedMails: stringsOf(ctx, m.AuthMails),
|
|
Password: !m.Password.IsNull(), Pin: !m.Pin.IsNull()},
|
|
}
|
|
var ts []targetModel
|
|
if d := m.Targets.ElementsAs(ctx, &ts, false); d.HasError() {
|
|
return p, d
|
|
}
|
|
for _, t := range ts {
|
|
p.Targets = append(p.Targets, client.Target{SiteID: t.SiteID.ValueString(), Host: t.Host.ValueString(), Port: int(t.Port.ValueInt64())})
|
|
}
|
|
var rs []ruleModel
|
|
if d := m.Rules.ElementsAs(ctx, &rs, false); d.HasError() {
|
|
return p, d
|
|
}
|
|
for _, x := range rs {
|
|
p.Rules = append(p.Rules, client.Rule{Action: x.Action.ValueString(), Match: x.Match.ValueString(), Value: x.Value.ValueString()})
|
|
}
|
|
p.NewPassword = secretChange(m.Password, old, func(o *portModel) types.String { return o.Password })
|
|
p.NewPin = secretChange(m.Pin, old, func(o *portModel) types.String { return o.Pin })
|
|
return p, nil
|
|
}
|
|
|
|
// secretChange returns what to send in newPassword/newPin: nil keeps the stored secret.
|
|
func secretChange(want types.String, old *portModel, get func(*portModel) types.String) *string {
|
|
var was types.String = types.StringNull()
|
|
if old != nil {
|
|
was = get(old)
|
|
}
|
|
switch {
|
|
case want.IsNull() && was.IsNull():
|
|
return nil
|
|
case want.IsNull():
|
|
e := ""
|
|
return &e
|
|
case want.Equal(was):
|
|
return nil
|
|
}
|
|
v := want.ValueString()
|
|
return &v
|
|
}
|
|
|
|
// fromAPI refreshes m from the server's view; secrets the server never returns stay as they are.
|
|
func (m *portModel) fromAPI(ctx context.Context, p *client.Port) (diags diag.Diagnostics) {
|
|
m.ID, m.Name, m.Proto, m.EdgeID = types.StringValue(p.ID), types.StringValue(p.Name), types.StringValue(p.Proto), types.StringValue(p.EdgeID)
|
|
m.BindIP, m.Port, m.PortEnd = types.StringValue(p.BindIP), types.Int64Value(int64(p.Port)), types.Int64Value(int64(p.PortEnd))
|
|
m.Hostname, m.Enabled, m.Tags = types.StringValue(p.Hostname), types.BoolValue(p.Enabled), setOf(p.Tags)
|
|
m.ProxyProtocol, m.RedirectTo = types.StringValue(p.ProxyProtocol), types.StringValue(p.RedirectTo)
|
|
m.RedirectPermanent, m.RedirectKeepPath = types.BoolValue(p.RedirectPermanent), types.BoolValue(p.RedirectKeepPath)
|
|
m.RedirectHTTP, m.BackendHTTPS = types.BoolValue(p.RedirectHTTP), types.BoolValue(p.BackendHTTPS)
|
|
m.RewriteHost, m.HealthPath, m.HealthSeconds = types.StringValue(p.RewriteHost), types.StringValue(p.HealthPath), types.Int64Value(int64(p.HealthSeconds))
|
|
m.AuthSSO, m.AuthEmail, m.AuthSessionHours = types.BoolValue(p.Auth.SSO), types.BoolValue(p.Auth.Email), types.Int64Value(int64(p.Auth.SessionHours))
|
|
m.AuthRoles, m.AuthUsers, m.AuthMails = setOf(p.Auth.AllowedRoles), setOf(p.Auth.AllowedUsers), setOf(p.Auth.AllowedMails)
|
|
if !p.Auth.Password {
|
|
m.Password = types.StringNull()
|
|
}
|
|
if !p.Auth.Pin {
|
|
m.Pin = types.StringNull()
|
|
}
|
|
ts := make([]targetModel, 0, len(p.Targets))
|
|
for _, t := range p.Targets {
|
|
ts = append(ts, targetModel{types.StringValue(t.SiteID), types.StringValue(t.Host), types.Int64Value(int64(t.Port))})
|
|
}
|
|
rs := make([]ruleModel, 0, len(p.Rules))
|
|
for _, x := range p.Rules {
|
|
rs = append(rs, ruleModel{types.StringValue(x.Action), types.StringValue(x.Match), types.StringValue(x.Value)})
|
|
}
|
|
var d diag.Diagnostics
|
|
m.Targets, d = types.ListValueFrom(ctx, targetType, ts)
|
|
diags.Append(d...)
|
|
m.Rules, d = types.ListValueFrom(ctx, ruleType, rs)
|
|
diags.Append(d...)
|
|
return diags
|
|
}
|
|
|
|
func (r *portResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
|
var plan portModel
|
|
if resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
body, d := plan.toAPI(ctx, nil)
|
|
if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
got, err := r.c.CreatePort(body)
|
|
if err != nil {
|
|
resp.Diagnostics.AddError("Creating port", err.Error())
|
|
return
|
|
}
|
|
plan.ID = types.StringValue(got.ID)
|
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
|
}
|
|
|
|
func (r *portResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
|
var state portModel
|
|
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
p, err := r.c.GetPort(state.ID.ValueString())
|
|
if client.IsNotFound(err) {
|
|
resp.State.RemoveResource(ctx)
|
|
return
|
|
}
|
|
if err != nil {
|
|
resp.Diagnostics.AddError("Reading port", err.Error())
|
|
return
|
|
}
|
|
if resp.Diagnostics.Append(state.fromAPI(ctx, p)...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
|
}
|
|
|
|
func (r *portResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
|
var plan, state portModel
|
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
|
if resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
plan.ID = state.ID
|
|
body, d := plan.toAPI(ctx, &state)
|
|
if resp.Diagnostics.Append(d...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
if _, err := r.c.UpdatePort(body); err != nil {
|
|
resp.Diagnostics.AddError("Updating port", err.Error())
|
|
return
|
|
}
|
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
|
}
|
|
|
|
func (r *portResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
|
var state portModel
|
|
if resp.Diagnostics.Append(req.State.Get(ctx, &state)...); resp.Diagnostics.HasError() {
|
|
return
|
|
}
|
|
if err := r.c.DeletePort(state.ID.ValueString()); err != nil && !client.IsNotFound(err) {
|
|
resp.Diagnostics.AddError("Deleting port", err.Error())
|
|
}
|
|
}
|
|
|
|
func (r *portResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
|
importByID(ctx, req, resp)
|
|
}
|
|
|
|
func importByID(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
|
|
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), req.ID)...)
|
|
}
|