From ae962b16bf1368859bb9386ccff98bf99f9414ec Mon Sep 17 00:00:00 2001 From: Paul Tyng Date: Mon, 30 Dec 2019 04:13:09 -0500 Subject: [PATCH] [wip] unifi_user resource --- provider/lazy_client.go | 24 ++++ provider/provider.go | 9 ++ provider/resource_user.go | 202 +++++++++++++++++++++++++++++++++ provider/resource_user_test.go | 160 ++++++++++++++++++++++++++ unifi/unifi.go | 20 ++++ unifi/user.go | 185 ++++++++++++++++++++++++++++++ 6 files changed, 600 insertions(+) create mode 100644 provider/resource_user.go create mode 100644 provider/resource_user_test.go create mode 100644 unifi/user.go diff --git a/provider/lazy_client.go b/provider/lazy_client.go index 25134eb..66839bc 100644 --- a/provider/lazy_client.go +++ b/provider/lazy_client.go @@ -78,3 +78,27 @@ func (c *lazyClient) UpdateUserGroup(site string, d *unifi.UserGroup) (*unifi.Us c.init() return c.inner.UpdateUserGroup(site, d) } +func (c *lazyClient) GetUser(site, id string) (*unifi.User, error) { + c.init() + return c.inner.GetUser(site, id) +} +func (c *lazyClient) CreateUser(site string, d *unifi.User) (*unifi.User, error) { + c.init() + return c.inner.CreateUser(site, d) +} +func (c *lazyClient) UpdateUser(site string, d *unifi.User) (*unifi.User, error) { + c.init() + return c.inner.UpdateUser(site, d) +} +func (c *lazyClient) DeleteUserByMAC(site, mac string) error { + c.init() + return c.inner.DeleteUserByMAC(site, mac) +} +func (c *lazyClient) BlockUserByMAC(site, mac string) error { + c.init() + return c.inner.BlockUserByMAC(site, mac) +} +func (c *lazyClient) UnblockUserByMAC(site, mac string) error { + c.init() + return c.inner.UnblockUserByMAC(site, mac) +} diff --git a/provider/provider.go b/provider/provider.go index 6a22764..4d71beb 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -45,6 +45,7 @@ func Provider() terraform.ResourceProvider { ResourcesMap: map[string]*schema.Resource{ "unifi_network": resourceNetwork(), "unifi_user_group": resourceUserGroup(), + "unifi_user": resourceUser(), "unifi_wlan": resourceWLAN(), }, } @@ -90,6 +91,14 @@ type unifiClient interface { DeleteWLAN(site, id string) error CreateWLAN(site string, d *unifi.WLAN) (*unifi.WLAN, error) GetWLAN(site, id string) (*unifi.WLAN, error) + + GetUser(site, id string) (*unifi.User, error) + // GetUserByMAC(site, mac string) (*unifi.User, error) + CreateUser(site string, d *unifi.User) (*unifi.User, error) + BlockUserByMAC(site, mac string) error + UnblockUserByMAC(site, mac string) error + UpdateUser(site string, d *unifi.User) (*unifi.User, error) + DeleteUserByMAC(site, mac string) error } type client struct { diff --git a/provider/resource_user.go b/provider/resource_user.go new file mode 100644 index 0000000..071f505 --- /dev/null +++ b/provider/resource_user.go @@ -0,0 +1,202 @@ +package provider + +import ( + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + + "github.com/paultyng/terraform-provider-unifi/unifi" +) + +func resourceUser() *schema.Resource { + return &schema.Resource{ + Create: resourceUserCreate, + Read: resourceUserRead, + Update: resourceUserUpdate, + Delete: resourceUserDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "mac": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + old = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(old), "-", ":")) + new = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(new), "-", ":")) + return old == new + }, + // Validation: + }, + "name": { + Type: schema.TypeString, + Required: true, + }, + "user_group_id": { + Type: schema.TypeString, + Optional: true, + }, + "note": { + Type: schema.TypeString, + Optional: true, + }, + "fixed_ip": { + Type: schema.TypeString, + Optional: true, + // TODO: Validate + }, + "network_id": { + Type: schema.TypeString, + Optional: true, + }, + "blocked": { + Type: schema.TypeBool, + Optional: true, + }, + + // this is a "meta" attribute that controls TF UX + "allow_existing": { + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + // TODO: "skip_forget_on_destroy": { + }, + } +} + +func resourceUserCreate(d *schema.ResourceData, meta interface{}) error { + c := meta.(*client) + + req, err := resourceUserGetResourceData(d) + if err != nil { + return err + } + + allowExisting := d.Get("allow_existing").(bool) + + resp, err := c.c.CreateUser(c.site, req) + if err != nil { + apiErr, ok := err.(*unifi.APIError) + if !ok || (apiErr.Message != "api.err.MacUsed" && !allowExisting) { + return err + } + // TODO: handle mac in use flow + return fmt.Errorf("allow_existing not yet implemented") + } + + d.SetId(resp.ID) + + if d.Get("blocked").(bool) { + err := c.c.BlockUserByMAC(c.site, d.Get("mac").(string)) + if err != nil { + return err + } + } + + return resourceUserSetResourceData(resp, d) +} + +func resourceUserGetResourceData(d *schema.ResourceData) (*unifi.User, error) { + fixedIP := d.Get("fixed_ip").(string) + + return &unifi.User{ + MAC: d.Get("mac").(string), + Name: d.Get("name").(string), + UserGroupID: d.Get("user_group_id").(string), + Note: d.Get("note").(string), + FixedIP: fixedIP, + UseFixedIP: fixedIP != "", + NetworkID: d.Get("network_id").(string), + // not sure if this matters/works + Blocked: d.Get("blocked").(bool), + }, nil +} + +func resourceUserSetResourceData(resp *unifi.User, d *schema.ResourceData) error { + fixedIP := "" + if resp.UseFixedIP { + fixedIP = resp.FixedIP + } + + d.Set("mac", resp.MAC) + d.Set("name", resp.Name) + d.Set("user_group_id", resp.UserGroupID) + d.Set("note", resp.Note) + d.Set("fixed_ip", fixedIP) + d.Set("network_id", resp.NetworkID) + d.Set("blocked", resp.Blocked) + + return nil +} + +func resourceUserRead(d *schema.ResourceData, meta interface{}) error { + c := meta.(*client) + + id := d.Id() + + resp, err := c.c.GetUser(c.site, id) + if _, ok := err.(*unifi.NotFoundError); ok { + d.SetId("") + return nil + } + if err != nil { + return err + } + + return resourceUserSetResourceData(resp, d) +} + +func resourceUserUpdate(d *schema.ResourceData, meta interface{}) error { + c := meta.(*client) + + if d.HasChange("blocked") { + mac := d.Get("mac").(string) + if d.Get("blocked").(bool) { + err := c.c.BlockUserByMAC(c.site, mac) + if err != nil { + return err + } + } else { + err := c.c.UnblockUserByMAC(c.site, mac) + if err != nil { + return err + } + } + } + + req, err := resourceUserGetResourceData(d) + if err != nil { + return err + } + + req.ID = d.Id() + req.SiteID = c.site + + resp, err := c.c.UpdateUser(c.site, req) + if err != nil { + return err + } + + return resourceUserSetResourceData(resp, d) +} + +func resourceUserDelete(d *schema.ResourceData, meta interface{}) error { + c := meta.(*client) + + id := d.Id() + + u, err := c.c.GetUser(c.site, id) + if _, ok := err.(*unifi.NotFoundError); ok { + return nil + } + if err != nil { + return err + } + + err = c.c.DeleteUserByMAC(c.site, u.MAC) + return err +} diff --git a/provider/resource_user_test.go b/provider/resource_user_test.go new file mode 100644 index 0000000..0d1b236 --- /dev/null +++ b/provider/resource_user_test.go @@ -0,0 +1,160 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" +) + +func TestAccUser_basic(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + Providers: providers, + PreCheck: func() { preCheck(t) }, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccUserConfig("00:00:5E:00:53:00", "tfacc", "tfacc note"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "note", "tfacc note"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + { + Config: testAccUserConfig("00:00:5E:00:53:00", "tfacc-2", "tfacc note 2"), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("unifi_user.test", "note", "tfacc note 2"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + }, + }) +} + +func TestAccUser_fixed_ip(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + Providers: providers, + PreCheck: func() { preCheck(t) }, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccUserConfig("00:00:5E:00:53:10", "tfacc", "tfacc fixed ip"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "fixed_ip", ""), + ), + }, + importStep("unifi_user.test", "allow_existing"), + { + Config: testAccUserConfig_fixedIP("00:00:5E:00:53:10"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "fixed_ip", "10.1.10.50"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + { + // this passes the network again even though its not used + // to avoid a destroy order of operations issue, can + // maybe work it out some other way + Config: testAccUserConfig_network + testAccUserConfig("00:00:5E:00:53:10", "tfacc", "tfacc fixed ip"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "fixed_ip", ""), + ), + }, + importStep("unifi_user.test", "allow_existing"), + }, + }) +} + +func TestAccUser_blocking(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + Providers: providers, + PreCheck: func() { preCheck(t) }, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccUserConfig_block("00:00:5E:00:53:20", false), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "blocked", "false"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + { + Config: testAccUserConfig_block("00:00:5E:00:53:20", true), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "blocked", "true"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + { + Config: testAccUserConfig_block("00:00:5E:00:53:20", false), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_user.test", "blocked", "false"), + ), + }, + importStep("unifi_user.test", "allow_existing"), + }, + }) +} + +// for test MAC addresses, see https://tools.ietf.org/html/rfc7042#section-2.1.2 +// func TestAccUser_existing_mac_allow(t *testing.T) { +// func TestAccUser_existing_mac_deny(t *testing.T) { + +func testAccUserConfig(mac, name, note string) string { + return fmt.Sprintf(` +resource "unifi_user" "test" { + mac = "%s" + name = "%s" + note = "%s" +} +`, mac, name, note) +} + +const testAccUserConfig_network = ` +variable "subnet" { + default = "10.1.10.1/24" +} + +resource "unifi_network" "test" { + name = "tfaccfixedip" + purpose = "corporate" + + vlan_id = 66 + subnet = var.subnet + dhcp_start = cidrhost(var.subnet, 6) + dhcp_stop = cidrhost(var.subnet, 254) + dhcp_enabled = true +} +` + +func testAccUserConfig_fixedIP(mac string) string { + return fmt.Sprintf(testAccUserConfig_network+` +resource "unifi_user" "test" { + mac = "%s" + name = "tfacc" + note = "tfacc fixed ip" + + fixed_ip = "10.1.10.50" + network_id = unifi_network.test.id +} +`, mac) +} + +func testAccUserConfig_block(mac string, blocked bool) string { + return fmt.Sprintf(` +resource "unifi_user" "test" { + mac = "%s" + name = "tfacc" + note = "tfacc block %t" + + blocked = %t +} +`, mac, blocked, blocked) +} diff --git a/unifi/unifi.go b/unifi/unifi.go index fca059b..5f39843 100644 --- a/unifi/unifi.go +++ b/unifi/unifi.go @@ -21,6 +21,15 @@ func (err *NotFoundError) Error() string { return "not found" } +type APIError struct { + RC string + Message string +} + +func (err *APIError) Error() string { + return err.Message +} + type Client struct { c *http.Client baseURL *url.URL @@ -131,3 +140,14 @@ type meta struct { RC string `json:"rc"` Message string `json:"msg"` } + +func (m *meta) error() error { + if m.RC != "ok" { + return &APIError{ + RC: m.RC, + Message: m.Message, + } + } + + return nil +} diff --git a/unifi/user.go b/unifi/user.go new file mode 100644 index 0000000..a2c5a8d --- /dev/null +++ b/unifi/user.go @@ -0,0 +1,185 @@ +package unifi + +import "fmt" + +// GET https://73.212.25.176:8443/api/s/default/stat/user/e4:f0:42:bf:bd:11 +// {"meta":{"rc":"ok"},"data":[{"_id":"5deeac76439adf048407dd01","mac":"e4:f0:42:bf:bd:11","site_id":"5d6d8b07439adf048407dcd9","oui":"Google","is_guest":false,"first_seen":1575922805,"last_seen":1577907316,"is_wired":true,"fingerprint_engine":"tdts","dev_cat":6,"dev_family":9,"os_class":16,"os_name":56,"dev_vendor":7,"dev_id":2822,"priority":101,"fingerprint_source":0,"name":"Google WiFi","usergroup_id":"","noted":true,"fixed_ip":"10.0.6.11","note":"","confidence":100,"network_id":"5d6d8b0c439adf048407dce7","use_fixedip":true,"duration":1966599,"tx_bytes":2391811124,"tx_packets":2227771,"rx_bytes":1431694370,"rx_packets":2606611,"wifi_tx_attempts":0,"tx_retries":0,"assoc_time":1577889972,"latest_assoc_time":1577889972,"user_id":"5deeac76439adf048407dd01","_uptime_by_ugw":1984511,"_last_seen_by_ugw":1577907316,"_is_guest_by_ugw":false,"gw_mac":"74:83:c2:d6:ff:83","network":"LAN","ip":"192.168.1.135","uptime":17344,"tx_bytes-r":0,"rx_bytes-r":0,"authorized":true,"qos_policy_applied":true,"_uptime_by_usw":1984511,"_last_seen_by_usw":1577907316,"_is_guest_by_usw":false,"sw_mac":"74:83:c2:d6:ff:83","sw_depth":0,"sw_port":4,"wired-tx_bytes":2176183895,"wired-rx_bytes":1406877963,"wired-tx_packets":2156208,"wired-rx_packets":2444036,"wired-tx_bytes-r":1968908,"wired-rx_bytes-r":105359}]} + +// PUT https://73.212.25.176:8443/api/s/default/rest/user/5deeac76439adf048407dd01 +// { use_fixedip: true, network_id: "5df7f70f1e801c052a1ab032", fixed_ip: "10.0.6.11" } +// { note: "my note", usergroup_id: "", name: "Google WiFi alias"} +// {"meta":{"rc":"ok"},"data":[{"_id":"5deeac76439adf048407dd01","mac":"e4:f0:42:bf:bd:11","site_id":"5d6d8b07439adf048407dcd9","oui":"Google","is_guest":false,"first_seen":1575922805,"last_seen":1577889639,"is_wired":true,"fingerprint_engine":"tdts","dev_cat":6,"dev_family":9,"os_class":16,"os_name":56,"dev_vendor":7,"dev_id":2822,"priority":101,"fingerprint_source":0,"name":"Google WiFi","usergroup_id":"","noted":true,"fixed_ip":"10.0.6.11","note":"","confidence":100,"network_id":"5df7f70f1e801c052a1ab032","use_fixedip":true}]} + +type User struct { + ID string `json:"_id,omitempty"` + SiteID string `json:"site_id,omitempty"` + Name string `json:"name"` + MAC string `json:"mac"` + + UserGroupID string `json:"user_group_id"` + Note string `json:"note"` + UseFixedIP bool `json:"use_fixedip"` + FixedIP string `json:"fixed_ip,omitempty"` + NetworkID string `json:"network_id"` + + // not sure if you can end this for create/update, etc, only + // observed modifying via stamgr + Blocked bool `json:"blocked,omitempty"` +} + +func (c *Client) ListUser(site string) ([]User, error) { + var respBody struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } + + err := c.do("GET", fmt.Sprintf("s/%s/rest/user", site), nil, &respBody) + if err != nil { + return nil, err + } + + return respBody.Data, nil +} + +func (c *Client) GetUser(site, id string) (*User, error) { + var respBody struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } + + err := c.do("GET", fmt.Sprintf("s/%s/rest/user/%s", site, id), nil, &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) != 1 { + return nil, &NotFoundError{} + } + + d := respBody.Data[0] + return &d, nil +} + +func (c *Client) GetUserByMAC(site, mac string) (*User, error) { + var respBody struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } + + err := c.do("GET", fmt.Sprintf("s/%s/stat/user/%s", site, mac), nil, &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) != 1 { + return nil, &NotFoundError{} + } + + d := respBody.Data[0] + return &d, nil +} + +func (c *Client) CreateUser(site string, d *User) (*User, error) { + reqBody := struct { + Objects []struct { + Data *User `json:"data"` + } `json:"objects"` + }{ + Objects: []struct { + Data *User `json:"data"` + }{ + {Data: d}, + }, + } + + var respBody struct { + Meta meta `json:"meta"` + Data []struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } `json:"data"` + } + + err := c.do("POST", fmt.Sprintf("s/%s/group/user", site), reqBody, &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) != 1 { + return nil, fmt.Errorf("malformed group response") + } + + if err := respBody.Data[0].Meta.error(); err != nil { + return nil, err + } + + if len(respBody.Data[0].Data) != 1 { + return nil, &NotFoundError{} + } + + new := respBody.Data[0].Data[0] + + return &new, nil +} + +func (c *Client) stamgr(site, cmd string, data map[string]interface{}) error { + reqBody := map[string]interface{}{} + + for k, v := range data { + reqBody[k] = v + } + + reqBody["cmd"] = cmd + + var respBody struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } + + err := c.do("POST", fmt.Sprintf("s/%s/cmd/stamgr", site), reqBody, &respBody) + if err != nil { + return err + } + + // TODO: confirm count/state of returned Data? + + return nil +} + +func (c *Client) BlockUserByMAC(site, mac string) error { + return c.stamgr(site, "block-sta", map[string]interface{}{ + "mac": mac, + }) +} + +func (c *Client) UnblockUserByMAC(site, mac string) error { + return c.stamgr(site, "unblock-sta", map[string]interface{}{ + "mac": mac, + }) +} + +func (c *Client) UpdateUser(site string, d *User) (*User, error) { + var respBody struct { + Meta meta `json:"meta"` + Data []User `json:"data"` + } + + err := c.do("PUT", fmt.Sprintf("s/%s/rest/user/%s", site, d.ID), d, &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) != 1 { + return nil, &NotFoundError{} + } + + new := respBody.Data[0] + + return &new, nil +} + +func (c *Client) DeleteUserByMAC(site, mac string) error { + return c.stamgr(site, "forget-sta", map[string]interface{}{ + "macs": []string{mac}, + }) +}