[wip] unifi_user resource

This commit is contained in:
Paul Tyng
2019-12-30 04:13:09 -05:00
parent 7eeac808b5
commit ae962b16bf
6 changed files with 600 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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 {

202
provider/resource_user.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

185
unifi/user.go Normal file
View File

@@ -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},
})
}