diff --git a/docs/resources/radius_profile.md b/docs/resources/radius_profile.md new file mode 100644 index 0000000..164bbca --- /dev/null +++ b/docs/resources/radius_profile.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "unifi_radius_profile Resource - terraform-provider-unifi" +subcategory: "" +description: |- + unifi_radius_profile manages radius profiles. +--- + +# unifi_radius_profile (Resource) + +`unifi_radius_profile` manages radius profiles. + + + + +## Schema + +### Required + +- `name` (String) The name of the profile. + +### Optional + +- `accounting_enabled` (Boolean) Specifies whether to use radius accounting. Defaults to `false`. +- `acct_server` (Block List) RADIUS accounting servers. (see [below for nested schema](#nestedblock--acct_server)) +- `auth_server` (Block List) RADIUS authentication servers. (see [below for nested schema](#nestedblock--auth_server)) +- `interim_update_enabled` (Boolean) Specifies whether to use interim_update. Defaults to `false`. +- `interim_update_interval` (Number) Specifies interim_update interval. Defaults to `3600`. +- `site` (String) The name of the site to associate the settings with. +- `use_usg_acct_server` (Boolean) Specifies whether to use usg as a radius accounting server. Defaults to `false`. +- `use_usg_auth_server` (Boolean) Specifies whether to use usg as a radius authentication server. Defaults to `false`. +- `vlan_enabled` (Boolean) Specifies whether to use vlan on wired connections. Defaults to `false`. +- `vlan_wlan_mode` (String) Specifies whether to use vlan on wireless connections. Must be one of `disabled`, `optional`, or `required`. Defaults to ``. + +### Read-Only + +- `id` (String) The ID of the settings. + + +### Nested Schema for `acct_server` + +Required: + +- `ip` (String) IP address of accounting service server. +- `xsecret` (String, Sensitive) RADIUS secret. + +Optional: + +- `port` (Number) Port of accounting service. Defaults to `1813`. + + + +### Nested Schema for `auth_server` + +Required: + +- `ip` (String) IP address of authentication service server. +- `xsecret` (String, Sensitive) RADIUS secret. + +Optional: + +- `port` (Number) Port of authentication service. Defaults to `1812`. + + diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f131f11..1c4ba25 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -90,6 +90,7 @@ func New(version string) func() *schema.Provider { "unifi_user_group": resourceUserGroup(), "unifi_user": resourceUser(), "unifi_wlan": resourceWLAN(), + "unifi_radius_profile": resourceRadiusProfile(), "unifi_setting_mgmt": resourceSettingMgmt(), "unifi_setting_usg": resourceSettingUsg(), diff --git a/internal/provider/resource_radius_profile.go b/internal/provider/resource_radius_profile.go new file mode 100644 index 0000000..a116e0d --- /dev/null +++ b/internal/provider/resource_radius_profile.go @@ -0,0 +1,415 @@ +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/paultyng/go-unifi/unifi" + "strings" +) + +func resourceRadiusProfile() *schema.Resource { + return &schema.Resource{ + Description: "`unifi_radius_profile` manages radius profiles.", + + CreateContext: resourceRadiusProfileCreate, + ReadContext: resourceRadiusProfileRead, + UpdateContext: resourceRadiusProfileUpdate, + DeleteContext: resourceRadiusProfileDelete, + Importer: &schema.ResourceImporter{ + StateContext: importRadiusProfile, + }, + + Schema: map[string]*schema.Schema{ + "id": { + Description: "The ID of the settings.", + Type: schema.TypeString, + Computed: true, + }, + "site": { + Description: "The name of the site to associate the settings with.", + Type: schema.TypeString, + Computed: true, + Optional: true, + ForceNew: true, + }, + "name": { + Description: "The name of the profile.", + Type: schema.TypeString, + Required: true, + }, + "accounting_enabled": { + Description: "Specifies whether to use radius accounting.", + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "interim_update_enabled": { + Description: "Specifies whether to use interim_update.", + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "interim_update_interval": { + Description: "Specifies interim_update interval.", + Type: schema.TypeInt, + Default: 3600, + Optional: true, + }, + "use_usg_acct_server": { + Description: "Specifies whether to use usg as a radius accounting server.", + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "use_usg_auth_server": { + Description: "Specifies whether to use usg as a radius authentication server.", + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "vlan_enabled": { + Description: "Specifies whether to use vlan on wired connections.", + Type: schema.TypeBool, + Default: false, + Optional: true, + }, + "vlan_wlan_mode": { + Description: "Specifies whether to use vlan on wireless connections. Must be one of `disabled`, `optional`, or `required`.", + Type: schema.TypeString, + Default: "", + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"disabled", "optional", "required"}, false), + }, + "auth_server": { + Description: "RADIUS authentication servers.", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ip": { + Description: "IP address of authentication service server.", + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.IsIPAddress, + }, + "port": { + Description: "Port of authentication service.", + Type: schema.TypeInt, + Optional: true, + Default: 1812, + ValidateFunc: validation.IsPortNumber, + }, + "xsecret": { + Description: "RADIUS secret.", + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + }, + }, + }, + "acct_server": { + Description: "RADIUS accounting servers.", + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ip": { + Description: "IP address of accounting service server.", + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.IsIPAddress, + }, + "port": { + Description: "Port of accounting service.", + Type: schema.TypeInt, + Optional: true, + Default: 1813, + ValidateFunc: validation.IsPortNumber, + }, + "xsecret": { + Description: "RADIUS secret.", + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + }, + }, + }, + }, + } +} + +func setToAuthServers(set []interface{}) ([]unifi.RADIUSProfileAuthServers, error) { + var authServers []unifi.RADIUSProfileAuthServers + for _, item := range set { + data, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected data in block") + } + authServer, err := toAuthServer(data) + if err != nil { + return nil, fmt.Errorf("unable to create port override: %w", err) + } + authServers = append(authServers, authServer) + } + return authServers, nil +} + +func setToAcctServers(set []interface{}) ([]unifi.RADIUSProfileAcctServers, error) { + var acctServers []unifi.RADIUSProfileAcctServers + for _, item := range set { + data, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected data in block") + } + accServer, err := toAcctServer(data) + if err != nil { + return nil, fmt.Errorf("unable to create port override: %w", err) + } + acctServers = append(acctServers, accServer) + } + return acctServers, nil +} + +func toAuthServer(data map[string]interface{}) (unifi.RADIUSProfileAuthServers, error) { + return unifi.RADIUSProfileAuthServers{ + IP: data["ip"].(string), + Port: data["port"].(int), + XSecret: data["xsecret"].(string), + }, nil +} + +func toAcctServer(data map[string]interface{}) (unifi.RADIUSProfileAcctServers, error) { + return unifi.RADIUSProfileAcctServers{ + IP: data["ip"].(string), + Port: data["port"].(int), + XSecret: data["xsecret"].(string), + }, nil +} + +func setFromAuthServers(authServers []unifi.RADIUSProfileAuthServers) ([]map[string]interface{}, error) { + list := make([]map[string]interface{}, 0, len(authServers)) + for _, authServer := range authServers { + v, err := fromAuthServer(authServer) + if err != nil { + return nil, fmt.Errorf("unable to parse ssh key: %w", err) + } + list = append(list, v) + } + return list, nil +} + +func setFromAcctServers(acctServers []unifi.RADIUSProfileAcctServers) ([]map[string]interface{}, error) { + list := make([]map[string]interface{}, 0, len(acctServers)) + for _, acctServer := range acctServers { + v, err := fromAcctServer(acctServer) + if err != nil { + return nil, fmt.Errorf("unable to parse ssh key: %w", err) + } + list = append(list, v) + } + return list, nil +} + +func fromAuthServer(sshKey unifi.RADIUSProfileAuthServers) (map[string]interface{}, error) { + return map[string]interface{}{ + "ip": sshKey.IP, + "port": sshKey.Port, + "xsecret": sshKey.XSecret, + }, nil +} + +func fromAcctServer(sshKey unifi.RADIUSProfileAcctServers) (map[string]interface{}, error) { + return map[string]interface{}{ + "ip": sshKey.IP, + "port": sshKey.Port, + "xsecret": sshKey.XSecret, + }, nil +} + +func resourceRadiusProfileCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + req, err := resourceRadiusProfileGetResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + resp, err := c.c.CreateRADIUSProfile(ctx, site, req) + if err != nil { + return diag.FromErr(err) + } + d.SetId(resp.ID) + + return resourceRadiusProfileSetResourceData(resp, d, site) +} + +func resourceRadiusProfileGetResourceData(d *schema.ResourceData) (*unifi.RADIUSProfile, error) { + authServers, err := setToAuthServers(d.Get("auth_server").([]interface{})) + if err != nil { + return nil, fmt.Errorf("unable to auth_server ssh_key block: %w", err) + } + acctServers, err := setToAcctServers(d.Get("acct_server").([]interface{})) + if err != nil { + return nil, fmt.Errorf("unable to acct_server ssh_key block: %w", err) + } + return &unifi.RADIUSProfile{ + Name: d.Get("name").(string), + InterimUpdateEnabled: d.Get("interim_update_enabled").(bool), + InterimUpdateInterval: d.Get("interim_update_interval").(int), + AccountingEnabled: d.Get("accounting_enabled").(bool), + UseUsgAcctServer: d.Get("use_usg_acct_server").(bool), + UseUsgAuthServer: d.Get("use_usg_auth_server").(bool), + VLANEnabled: d.Get("vlan_enabled").(bool), + VLANWLANMode: d.Get("vlan_wlan_mode").(string), + AuthServers: authServers, + AcctServers: acctServers, + }, nil +} + +func resourceRadiusProfileSetResourceData(resp *unifi.RADIUSProfile, d *schema.ResourceData, site string) diag.Diagnostics { + authServers, err := setFromAuthServers(resp.AuthServers) + if err != nil { + return diag.FromErr(err) + } + acctServers, err := setFromAcctServers(resp.AcctServers) + if err != nil { + return diag.FromErr(err) + } + + d.Set("site", site) + d.Set("name", resp.Name) + + d.Set("interim_update_enabled", resp.InterimUpdateEnabled) + d.Set("interim_update_interval", resp.InterimUpdateInterval) + d.Set("accounting_enabled", resp.AccountingEnabled) + d.Set("use_usg_acct_server", resp.UseUsgAcctServer) + d.Set("use_usg_auth_server", resp.UseUsgAuthServer) + d.Set("vlan_enabled", resp.VLANEnabled) + d.Set("vlan_wlan_mode", resp.VLANWLANMode) + d.Set("auth_server", authServers) + d.Set("acct_server", acctServers) + return nil +} + +func resourceRadiusProfileRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + id := d.Id() + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + resp, err := c.c.GetRADIUSProfile(ctx, site, id) + if _, ok := err.(*unifi.NotFoundError); ok { + d.SetId("") + return nil + } + if err != nil { + return diag.FromErr(err) + } + + return resourceRadiusProfileSetResourceData(resp, d, site) +} + +func resourceRadiusProfileUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + req, err := resourceRadiusProfileGetResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + req.ID = d.Id() + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + req.SiteID = site + + resp, err := c.c.UpdateRADIUSProfile(ctx, site, req) + if err != nil { + return diag.FromErr(err) + } + + return resourceRadiusProfileSetResourceData(resp, d, site) +} + +func resourceRadiusProfileDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + id := d.Id() + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + err := c.c.DeleteRADIUSProfile(ctx, site, id) + return diag.FromErr(err) +} + +func importRadiusProfile(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + c := meta.(*client) + id := d.Id() + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + if strings.Contains(id, ":") { + importParts := strings.SplitN(id, ":", 2) + site = importParts[0] + id = importParts[1] + } + + if strings.HasPrefix(id, "name=") { + targetName := strings.TrimPrefix(id, "name=") + var err error + if id, err = getRadiusProfileIDByName(ctx, c.c, targetName, site); err != nil { + return nil, err + } + } + + if id != "" { + d.SetId(id) + } + if site != "" { + d.Set("site", site) + } + + return []*schema.ResourceData{d}, nil +} + +func getRadiusProfileIDByName(ctx context.Context, client unifiClient, profileName, site string) (string, error) { + radiusProfiles, err := client.ListRADIUSProfile(ctx, site) + if err != nil { + return "", err + } + + idMatchingName := "" + allNames := []string{} + for _, profile := range radiusProfiles { + allNames = append(allNames, profile.Name) + if profile.Name != profileName { + continue + } + if idMatchingName != "" { + return "", fmt.Errorf("Found multiple radius profiles with name '%s'", profileName) + } + idMatchingName = profile.ID + } + if idMatchingName == "" { + return "", fmt.Errorf("Found no radius profile with name '%s', found: %s", profileName, strings.Join(allNames, ", ")) + } + return idMatchingName, nil +} diff --git a/internal/provider/resource_radius_profile_test.go b/internal/provider/resource_radius_profile_test.go new file mode 100644 index 0000000..2d94d6c --- /dev/null +++ b/internal/provider/resource_radius_profile_test.go @@ -0,0 +1,113 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "testing" +) + +func TestAccRadiusProfile_basic(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { preCheck(t) }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccRadiusProfileConfig("test"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_radius_profile.test", "name", "test"), + ), + }, + importStep("unifi_radius_profile.test"), + }, + }) +} + +func TestAccRadiusProfile_servers(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { preCheck(t) }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccRadiusProfileConfigServer(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_radius_profile.test", "name", "test"), + ), + }, + importStep("unifi_radius_profile.test"), + }, + }) +} + +func TestAccRadiusProfile_importByName(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { preCheck(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + // Apply and import network by name. + { + Config: testAccRadiusProfileImport(), + }, + { + Config: testAccRadiusProfileImport(), + ResourceName: "unifi_radius_profile.test", + ImportState: true, + ImportStateVerify: true, + ImportStateId: "name=imported", + }, + }, + }) +} + +func testAccRadiusProfileConfigServer() string { + return ` +resource "unifi_radius_profile" "test" { + name = "test" + auth_server { + ip = "192.168.1.1" + xsecret = "securepw1" + } + auth_server { + ip = "192.168.10.1" + port = 8888 + xsecret = "securepw2" + } + acct_server { + ip = "192.168.1.1" + xsecret = "securepw1" + } + acct_server { + ip = "192.168.10.1" + port = 9999 + xsecret = "securepw2" + } + use_usg_acct_server = false + use_usg_auth_server = false +} +` +} + +func testAccRadiusProfileConfig(name string) string { + return fmt.Sprintf(` +resource "unifi_radius_profile" "test" { + name = "%[1]s" +} +`, name) +} + +func testAccRadiusProfileImport() string { + return ` +resource "unifi_radius_profile" "test" { + name = "imported" + auth_server { + ip = "192.168.1.1" + port = 1812 + xsecret = "securepw" + } + use_usg_auth_server = true + vlan_enabled = true + vlan_wlan_mode = "required" +} +` +}