900 lines
24 KiB
Go
900 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
)
|
|
|
|
const defaultBaseURL = "https://uisp.vntx.net"
|
|
|
|
type Client struct {
|
|
BaseURL string
|
|
Token string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func NewClient() (*Client, error) {
|
|
tok := os.Getenv("UISP_KEY")
|
|
if tok == "" {
|
|
tok = os.Getenv("UISP_TOKEN")
|
|
}
|
|
if tok == "" {
|
|
return nil, errors.New("UISP_KEY env var is not set")
|
|
}
|
|
base := os.Getenv("UISP_URL")
|
|
if base == "" {
|
|
base = defaultBaseURL
|
|
}
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(base, "/"),
|
|
Token: tok,
|
|
HTTP: &http.Client{Timeout: 60 * time.Second},
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body any, out any) error {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
url := c.BaseURL + "/nms/api/v2.1" + path
|
|
req, err := http.NewRequest(method, url, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("X-Auth-Token", c.Token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data)))
|
|
}
|
|
if out != nil && len(data) > 0 {
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("decode %s: %w (body: %s)", url, err, truncate(string(data), 200))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// Device shapes — UISP returns rich nested JSON; we only model what we use.
|
|
type Device struct {
|
|
Identification struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hostname string `json:"hostname"`
|
|
DisplayName string `json:"displayName"`
|
|
Model string `json:"model"`
|
|
ModelName string `json:"modelName"`
|
|
Type string `json:"type"`
|
|
Category string `json:"category"`
|
|
MAC string `json:"mac"`
|
|
Authorized bool `json:"authorized"`
|
|
FirmwareVersion string `json:"firmwareVersion"`
|
|
PlatformID string `json:"platformId"`
|
|
PlatformName string `json:"platformName"`
|
|
Site *struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"site"`
|
|
} `json:"identification"`
|
|
Firmware struct {
|
|
Current string `json:"current"`
|
|
Latest string `json:"latest"`
|
|
LatestStatus string `json:"latestStatus"`
|
|
Compatible bool `json:"compatible"`
|
|
} `json:"firmware"`
|
|
Overview struct {
|
|
Status string `json:"status"`
|
|
Unauthorized bool `json:"unauthorized"`
|
|
CanUpgrade bool `json:"canUpgrade"`
|
|
} `json:"overview"`
|
|
Discovery *struct {
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
Protocol string `json:"protocol"`
|
|
IsProcessing bool `json:"isProcessing"`
|
|
} `json:"discovery"`
|
|
}
|
|
|
|
// Firmware is one entry from /nms/api/v2.1/firmwares.
|
|
// UISP wraps everything under "identification".
|
|
type Firmware struct {
|
|
Identification struct {
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
Stable bool `json:"stable"`
|
|
Lite bool `json:"lite"`
|
|
PlatformID string `json:"platformId"`
|
|
Models []string `json:"models"`
|
|
Origin string `json:"origin"`
|
|
FirmwareCompatibility string `json:"firmwareCompatibility"`
|
|
} `json:"identification"`
|
|
Semver struct {
|
|
Major int `json:"major"`
|
|
Minor int `json:"minor"`
|
|
Patch int `json:"patch"`
|
|
} `json:"semver"`
|
|
}
|
|
|
|
func (c *Client) ListFirmwares() ([]Firmware, error) {
|
|
var fws []Firmware
|
|
if err := c.do("GET", "/firmwares", nil, &fws); err != nil {
|
|
return nil, err
|
|
}
|
|
return fws, nil
|
|
}
|
|
|
|
// latestFirmwareFor finds the highest-version stable firmware matching the
|
|
// device's platformId and model. Returns "" if none found.
|
|
func latestFirmwareFor(fws []Firmware, d Device) string {
|
|
plat := d.Identification.PlatformID
|
|
model := d.Identification.Model
|
|
if plat == "" || model == "" {
|
|
return ""
|
|
}
|
|
var best string
|
|
for _, fw := range fws {
|
|
if fw.Identification.PlatformID != plat {
|
|
continue
|
|
}
|
|
if !modelMatches(fw, model) {
|
|
continue
|
|
}
|
|
v := fw.Identification.Version
|
|
if best == "" || versionLess(best, v) {
|
|
best = v
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func modelMatches(fw Firmware, model string) bool {
|
|
if len(fw.Identification.Models) == 0 {
|
|
return true // some firmwares apply to a whole platform
|
|
}
|
|
for _, m := range fw.Identification.Models {
|
|
if m == model {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// versionLess compares two firmware version strings (e.g. "6.3.24", "8.7.13").
|
|
// Returns true if a < b. Treats trailing non-numeric segments as 0.
|
|
func versionLess(a, b string) bool {
|
|
ap, bp := parseVersion(a), parseVersion(b)
|
|
for i := 0; i < len(ap) || i < len(bp); i++ {
|
|
var av, bv int
|
|
if i < len(ap) {
|
|
av = ap[i]
|
|
}
|
|
if i < len(bp) {
|
|
bv = bp[i]
|
|
}
|
|
if av != bv {
|
|
return av < bv
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseVersion(v string) []int {
|
|
v = strings.TrimPrefix(v, "v")
|
|
// Cut at first non-version separator like "-" or "+".
|
|
if i := strings.IndexAny(v, "-+ "); i >= 0 {
|
|
v = v[:i]
|
|
}
|
|
parts := strings.Split(v, ".")
|
|
out := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
n := 0
|
|
for _, r := range p {
|
|
if r < '0' || r > '9' {
|
|
break
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) ListDevices() ([]Device, error) {
|
|
var devs []Device
|
|
if err := c.do("GET", "/devices?withInterfaces=false", nil, &devs); err != nil {
|
|
return nil, err
|
|
}
|
|
return devs, nil
|
|
}
|
|
|
|
// ListDiscovered returns devices UISP has detected but not yet adopted
|
|
// (the "Pending Adoption" list in the UI).
|
|
func (c *Client) ListDiscovered() ([]Device, error) {
|
|
var devs []Device
|
|
if err := c.do("GET", "/devices/discovered", nil, &devs); err != nil {
|
|
return nil, err
|
|
}
|
|
return devs, nil
|
|
}
|
|
|
|
// ConnectUbnt adopts one or more discovered Ubiquiti devices using the
|
|
// supplied credentials. UISP attempts to log in with these creds; on success
|
|
// the devices appear in /devices and are assignable to a site.
|
|
func (c *Client) ConnectUbnt(deviceIDs []string, username, password string, httpsPort int) error {
|
|
if httpsPort == 0 {
|
|
httpsPort = 443
|
|
}
|
|
body := map[string]any{
|
|
"deviceIds": deviceIDs,
|
|
"username": username,
|
|
"password": password,
|
|
"httpsPort": httpsPort,
|
|
}
|
|
return c.do("POST", "/discovery/connect/ubnt", body, nil)
|
|
}
|
|
|
|
type Site struct {
|
|
ID string `json:"id"`
|
|
Identification struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
} `json:"identification"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (c *Client) ListSites() ([]Site, error) {
|
|
var sites []Site
|
|
if err := c.do("GET", "/sites", nil, &sites); err != nil {
|
|
return nil, err
|
|
}
|
|
return sites, nil
|
|
}
|
|
|
|
func (c *Client) FindSiteByName(name string) (string, error) {
|
|
sites, err := c.ListSites()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
want := strings.ToLower(strings.TrimSpace(name))
|
|
for _, s := range sites {
|
|
// API returns site name under identification.name; fall back to top-level fields.
|
|
n := s.Identification.Name
|
|
if n == "" {
|
|
n = s.Name
|
|
}
|
|
if strings.ToLower(n) == want {
|
|
id := s.Identification.ID
|
|
if id == "" {
|
|
id = s.ID
|
|
}
|
|
return id, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("site %q not found", name)
|
|
}
|
|
|
|
// AssignDevicesToSite uses the same /devices/authorize endpoint the UI
|
|
// hits after adoption — supplying siteId + deviceIds bulk-assigns them.
|
|
func (c *Client) AssignDevicesToSite(deviceIDs []string, siteID string) error {
|
|
body := map[string]any{
|
|
"siteId": siteID,
|
|
"deviceIds": deviceIDs,
|
|
}
|
|
return c.do("POST", "/devices/authorize", body, nil)
|
|
}
|
|
|
|
// StandardCredentials are the canonical username/password combos used across
|
|
// the VNTX fleet. They're tried in order; first 2xx wins.
|
|
var StandardCredentials = []struct{ User, Pass string }{
|
|
{"ubnt", "fngckewl"},
|
|
{"ubnt", "vntx1830"},
|
|
{"ubnt", "Vntx1830"},
|
|
{"ubnt", "vntx1830vntx"},
|
|
}
|
|
|
|
func (c *Client) SetDeviceCredentials(id, user, pw string) error {
|
|
body := map[string]any{"username": user, "password": pw}
|
|
return c.do("POST", "/devices/"+id+"/credentials", body, nil)
|
|
}
|
|
|
|
// TryStandardCredentials walks the StandardCredentials list and stops on the
|
|
// first one the API accepts (2xx). Returns the user/pw that worked.
|
|
func (c *Client) TryStandardCredentials(id string) (string, string, error) {
|
|
var lastErr error
|
|
for _, kp := range StandardCredentials {
|
|
if err := c.SetDeviceCredentials(id, kp.User, kp.Pass); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
return kp.User, kp.Pass, nil
|
|
}
|
|
if lastErr == nil {
|
|
lastErr = errors.New("no credentials attempted")
|
|
}
|
|
return "", "", lastErr
|
|
}
|
|
|
|
func (c *Client) UpgradeDevice(id string) error {
|
|
return c.do("POST", "/devices/"+id+"/system/upgrade", nil, nil)
|
|
}
|
|
|
|
func dispName(d Device) string {
|
|
if d.Identification.Hostname != "" {
|
|
return d.Identification.Hostname
|
|
}
|
|
if d.Identification.Name != "" {
|
|
return d.Identification.Name
|
|
}
|
|
return d.Identification.MAC
|
|
}
|
|
|
|
func cmdList(args []string) error {
|
|
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
|
unauth := fs.Bool("unauthorized", false, "show only unauthorized devices")
|
|
upgradable := fs.Bool("upgradable", false, "show only devices with a firmware update available")
|
|
jsonOut := fs.Bool("json", false, "output parsed JSON (struct shape)")
|
|
rawOut := fs.Bool("raw", false, "dump raw JSON from the API (use to debug field paths)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
c, err := NewClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *rawOut {
|
|
req, _ := http.NewRequest("GET", c.BaseURL+"/nms/api/v2.1/devices?withInterfaces=false", nil)
|
|
req.Header.Set("X-Auth-Token", c.Token)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
var raw any
|
|
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
|
return err
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(raw)
|
|
}
|
|
var devs []Device
|
|
if *unauth {
|
|
devs, err = c.ListDiscovered()
|
|
} else {
|
|
devs, err = c.ListDevices()
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fws, fwErr := c.ListFirmwares()
|
|
if fwErr != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
|
|
}
|
|
filtered := devs[:0]
|
|
for _, d := range devs {
|
|
if *upgradable && !needsUpgrade(d, fws) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, d)
|
|
}
|
|
sort.Slice(filtered, func(i, j int) bool { return dispName(filtered[i]) < dispName(filtered[j]) })
|
|
|
|
if *jsonOut {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(filtered)
|
|
}
|
|
|
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(tw, "ID\tNAME\tMODEL\tSITE\tFW (cur → latest)\tSTATUS\tAUTH\tUPGRADE?")
|
|
for _, d := range filtered {
|
|
site := ""
|
|
if d.Identification.Site != nil {
|
|
site = d.Identification.Site.Name
|
|
}
|
|
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
|
|
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
|
|
fw := cur
|
|
if latest != "" && latest != cur && versionLess(cur, latest) {
|
|
fw = cur + " → " + latest
|
|
}
|
|
if fw == "" {
|
|
fw = "-"
|
|
}
|
|
auth := "yes"
|
|
if isUnauthorized(d) {
|
|
auth = "NO"
|
|
}
|
|
up := ""
|
|
if needsUpgrade(d, fws) {
|
|
up = "yes"
|
|
}
|
|
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
d.Identification.ID, dispName(d), d.Identification.ModelName,
|
|
site, fw, d.Overview.Status, auth, up)
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func isUnauthorized(d Device) bool {
|
|
return d.Overview.Unauthorized || !d.Identification.Authorized
|
|
}
|
|
|
|
func canUpgrade(d Device) bool {
|
|
if d.Overview.CanUpgrade {
|
|
return true
|
|
}
|
|
if d.Firmware.Latest != "" && d.Firmware.Current != "" && d.Firmware.Latest != d.Firmware.Current {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// needsUpgrade decides whether a device should be upgraded. We require a
|
|
// real model and current version, then compare against the /firmwares
|
|
// catalog. UISP's overview.canUpgrade flag is unreliable — it stays true
|
|
// for blackBox/UNKNOWN devices we can't actually update — so we ignore it
|
|
// when there's no catalog answer.
|
|
func needsUpgrade(d Device, fws []Firmware) bool {
|
|
if d.Identification.Type == "blackBox" {
|
|
return false
|
|
}
|
|
model := d.Identification.Model
|
|
if model == "" || model == "UNKNOWN" {
|
|
return false
|
|
}
|
|
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
|
|
if cur == "" {
|
|
return false
|
|
}
|
|
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
|
|
if latest == "" {
|
|
return false
|
|
}
|
|
return versionLess(cur, latest)
|
|
}
|
|
|
|
func cmdApprove(args []string) error {
|
|
fs := flag.NewFlagSet("approve", flag.ExitOnError)
|
|
all := fs.Bool("all", false, "approve all discovered (pending-adoption) devices")
|
|
dry := fs.Bool("dry-run", false, "show what would be approved without doing it")
|
|
site := fs.String("site", "vntx", "site name to assign devices to (empty = skip)")
|
|
port := fs.Int("port", 443, "HTTPS port UISP uses to reach the device")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
c, err := NewClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
discovered, err := c.ListDiscovered()
|
|
if err != nil {
|
|
return fmt.Errorf("listing discovered devices: %w", err)
|
|
}
|
|
|
|
// Build the working set: --all = every discovered device; otherwise the
|
|
// IDs passed on the CLI must be in the discovered list.
|
|
byID := make(map[string]Device, len(discovered))
|
|
for _, d := range discovered {
|
|
byID[d.Identification.ID] = d
|
|
}
|
|
var queue []Device
|
|
if *all {
|
|
queue = discovered
|
|
} else {
|
|
ids := fs.Args()
|
|
if len(ids) == 0 {
|
|
return errors.New("usage: uisp approve <device-id>... | --all")
|
|
}
|
|
for _, id := range ids {
|
|
d, ok := byID[id]
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "skip %s: not in discovered/pending list\n", id)
|
|
continue
|
|
}
|
|
queue = append(queue, d)
|
|
}
|
|
}
|
|
|
|
if len(queue) == 0 {
|
|
fmt.Println("no devices pending adoption.")
|
|
return nil
|
|
}
|
|
|
|
for _, d := range queue {
|
|
fmt.Printf(" pending: %s %s %s\n", d.Identification.ID, dispName(d), d.Identification.ModelName)
|
|
}
|
|
|
|
var siteID string
|
|
if *site != "" {
|
|
siteID, err = c.FindSiteByName(*site)
|
|
if err != nil {
|
|
return fmt.Errorf("looking up site %q: %w", *site, err)
|
|
}
|
|
fmt.Printf("site %q resolved to id %s\n", *site, siteID)
|
|
}
|
|
|
|
if *dry {
|
|
fmt.Printf("\ndry-run: would adopt %d device(s) by trying %d credential combo(s)",
|
|
len(queue), len(StandardCredentials))
|
|
if siteID != "" {
|
|
fmt.Printf(", then assign to site %q", *site)
|
|
}
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
// Walk the credential list; each pass triggers UISP to retry connect on
|
|
// every queued device with the new creds. UISP de-dupes — it will only
|
|
// actually reconnect devices that haven't been adopted yet. After each
|
|
// pass we poll discovery.isProcessing until everything settles.
|
|
queueIDs := make([]string, 0, len(queue))
|
|
for _, d := range queue {
|
|
queueIDs = append(queueIDs, d.Identification.ID)
|
|
}
|
|
|
|
for i, kp := range StandardCredentials {
|
|
fmt.Printf("\n[%d/%d] trying user=%s pw=%s on %d device(s)...\n",
|
|
i+1, len(StandardCredentials), kp.User, kp.Pass, len(queueIDs))
|
|
if err := c.ConnectUbnt(queueIDs, kp.User, kp.Pass, *port); err != nil {
|
|
fmt.Fprintf(os.Stderr, " connect call failed: %v\n", err)
|
|
continue
|
|
}
|
|
statuses, err := waitForDiscoverySettle(c, queueIDs, 60*time.Second)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " poll failed: %v\n", err)
|
|
continue
|
|
}
|
|
// Stop early if every device has either succeeded or hit a state
|
|
// that won't change with new credentials.
|
|
stillRetryable := false
|
|
for _, s := range statuses {
|
|
if s.Status == "authentication-failed" || s.Status == "authenticating" || s.Status == "starting" {
|
|
stillRetryable = true
|
|
break
|
|
}
|
|
}
|
|
if !stillRetryable {
|
|
fmt.Println(" no more credential-retryable devices, stopping cred sweep.")
|
|
break
|
|
}
|
|
}
|
|
|
|
// Final state: re-fetch /devices (where adopted devices live) and the
|
|
// discovered list (residual + status). A device is "adopted" if it shows
|
|
// up in /devices with a real type (not blackBox) — UISP creates the
|
|
// blackBox SNMP record before adoption succeeds.
|
|
allDevs, err := c.ListDevices()
|
|
if err != nil {
|
|
return fmt.Errorf("post-adoption ListDevices: %w", err)
|
|
}
|
|
devByID := map[string]Device{}
|
|
for _, d := range allDevs {
|
|
devByID[d.Identification.ID] = d
|
|
}
|
|
finalDiscovered, _ := c.ListDiscovered()
|
|
discByID := map[string]Device{}
|
|
for _, d := range finalDiscovered {
|
|
discByID[d.Identification.ID] = d
|
|
}
|
|
|
|
var adoptedIDs, stillFailed []string
|
|
for _, id := range queueIDs {
|
|
d, inFleet := devByID[id]
|
|
if inFleet && d.Identification.Type != "blackBox" {
|
|
adoptedIDs = append(adoptedIDs, id)
|
|
} else {
|
|
stillFailed = append(stillFailed, id)
|
|
}
|
|
}
|
|
|
|
if len(adoptedIDs) > 0 {
|
|
fmt.Printf("\nadopted %d device(s):\n", len(adoptedIDs))
|
|
for _, id := range adoptedIDs {
|
|
fmt.Printf(" %s %s\n", id, dispName(devByID[id]))
|
|
}
|
|
}
|
|
|
|
if len(stillFailed) > 0 {
|
|
fmt.Printf("\n%d device(s) still pending:\n", len(stillFailed))
|
|
for _, id := range stillFailed {
|
|
d := byID[id]
|
|
cur := discByID[id]
|
|
status, errMsg := "?", ""
|
|
if cur.Discovery != nil {
|
|
status = cur.Discovery.Status
|
|
errMsg = cur.Discovery.Error
|
|
}
|
|
line := fmt.Sprintf(" %s %-32s status=%s", id, dispName(d), status)
|
|
if errMsg != "" {
|
|
line += " err=" + errMsg
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
}
|
|
|
|
if siteID != "" && len(adoptedIDs) > 0 {
|
|
fmt.Printf("\nassigning %d adopted device(s) to site %q...\n", len(adoptedIDs), *site)
|
|
if err := c.AssignDevicesToSite(adoptedIDs, siteID); err != nil {
|
|
return fmt.Errorf("site assign: %w", err)
|
|
}
|
|
fmt.Println(" done.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitForDiscoverySettle polls /devices/discovered for the given IDs until
|
|
// none of them have isProcessing=true, or until the timeout fires.
|
|
func waitForDiscoverySettle(c *Client, ids []string, timeout time.Duration) (map[string]struct{ Status, Error string }, error) {
|
|
want := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
want[id] = struct{}{}
|
|
}
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
discovered, err := c.ListDiscovered()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
statuses := map[string]struct{ Status, Error string }{}
|
|
stillProcessing := 0
|
|
for _, d := range discovered {
|
|
if _, ok := want[d.Identification.ID]; !ok {
|
|
continue
|
|
}
|
|
if d.Discovery != nil {
|
|
statuses[d.Identification.ID] = struct{ Status, Error string }{d.Discovery.Status, d.Discovery.Error}
|
|
if d.Discovery.IsProcessing {
|
|
stillProcessing++
|
|
}
|
|
}
|
|
}
|
|
if stillProcessing == 0 || time.Now().After(deadline) {
|
|
fmt.Printf(" settled (%d still processing at timeout)\n", stillProcessing)
|
|
return statuses, nil
|
|
}
|
|
fmt.Printf(" ...waiting on %d device(s)\n", stillProcessing)
|
|
time.Sleep(5 * time.Second)
|
|
}
|
|
}
|
|
|
|
func cmdUpgrade(args []string) error {
|
|
fs := flag.NewFlagSet("upgrade", flag.ExitOnError)
|
|
all := fs.Bool("all", false, "upgrade every device with an available update")
|
|
yes := fs.Bool("yes", false, "actually perform upgrades (default is dry-run)")
|
|
force := fs.Bool("force", false, "upgrade even if current == latest firmware")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
c, err := NewClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
type target struct {
|
|
id, name, fw string
|
|
}
|
|
var targets []target
|
|
|
|
devs, err := c.ListDevices()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fws, fwErr := c.ListFirmwares()
|
|
if fwErr != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
|
|
}
|
|
byID := make(map[string]Device, len(devs))
|
|
for _, d := range devs {
|
|
byID[d.Identification.ID] = d
|
|
}
|
|
|
|
makeTarget := func(d Device) target {
|
|
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
|
|
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
|
|
arrow := cur
|
|
if latest != "" && latest != cur {
|
|
arrow = cur + " → " + latest
|
|
}
|
|
return target{id: d.Identification.ID, name: dispName(d), fw: arrow}
|
|
}
|
|
|
|
if *all {
|
|
for _, d := range devs {
|
|
if !needsUpgrade(d, fws) {
|
|
continue
|
|
}
|
|
targets = append(targets, makeTarget(d))
|
|
}
|
|
} else {
|
|
ids := fs.Args()
|
|
if len(ids) == 0 {
|
|
return errors.New("usage: uisp upgrade <device-id>... [--yes] [--force] | --all [--yes]")
|
|
}
|
|
for _, id := range ids {
|
|
d, ok := byID[id]
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "skip %s: device not found\n", id)
|
|
continue
|
|
}
|
|
if !needsUpgrade(d, fws) && !*force {
|
|
cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
|
|
latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
|
|
fmt.Fprintf(os.Stderr, "skip %s (%s): already on %s (latest %s) — pass --force to override\n",
|
|
id, dispName(d), cur, firstNonEmpty(latest, "?"))
|
|
continue
|
|
}
|
|
targets = append(targets, makeTarget(d))
|
|
}
|
|
}
|
|
|
|
if len(targets) == 0 {
|
|
fmt.Println("no devices need upgrade.")
|
|
return nil
|
|
}
|
|
|
|
for _, t := range targets {
|
|
fmt.Printf(" %s %s %s\n", t.id, t.name, t.fw)
|
|
}
|
|
if !*yes {
|
|
fmt.Printf("\ndry-run: %d device(s) would be upgraded. Pass --yes to execute.\n", len(targets))
|
|
return nil
|
|
}
|
|
|
|
var failed int
|
|
for _, t := range targets {
|
|
if err := c.UpgradeDevice(t.id); err != nil {
|
|
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", t.id, err)
|
|
failed++
|
|
continue
|
|
}
|
|
fmt.Printf("OK %s upgrade triggered\n", t.id)
|
|
}
|
|
if failed > 0 {
|
|
return fmt.Errorf("%d/%d upgrades failed", failed, len(targets))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cmdCreds(args []string) error {
|
|
fs := flag.NewFlagSet("creds", flag.ExitOnError)
|
|
all := fs.Bool("all", false, "try standard creds against every device")
|
|
user := fs.String("user", "", "single username (overrides standard list)")
|
|
pass := fs.String("pass", "", "single password (used with --user)")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
c, err := NewClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var ids []string
|
|
if *all {
|
|
devs, err := c.ListDevices()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, d := range devs {
|
|
ids = append(ids, d.Identification.ID)
|
|
}
|
|
} else {
|
|
ids = fs.Args()
|
|
if len(ids) == 0 {
|
|
return errors.New("usage: uisp creds <device-id>... | --all [--user U --pass P]")
|
|
}
|
|
}
|
|
|
|
manual := *user != "" || *pass != ""
|
|
if manual && (*user == "" || *pass == "") {
|
|
return errors.New("--user and --pass must be used together")
|
|
}
|
|
|
|
for _, id := range ids {
|
|
if manual {
|
|
if err := c.SetDeviceCredentials(id, *user, *pass); err != nil {
|
|
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
|
|
continue
|
|
}
|
|
fmt.Printf("OK %s (user=%s)\n", id, *user)
|
|
continue
|
|
}
|
|
u, p, err := c.TryStandardCredentials(id)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
|
|
continue
|
|
}
|
|
fmt.Printf("OK %s (user=%s pw=%s)\n", id, u, p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func usage() {
|
|
fmt.Fprint(os.Stderr, `uisp - CLI for UISP at $UISP_URL (default `+defaultBaseURL+`)
|
|
|
|
Auth: export UISP_KEY=<x-auth-token from UISP user settings>
|
|
|
|
Commands:
|
|
list [--unauthorized] [--upgradable] [--json]
|
|
approve <device-id>... | --all [--site vntx] [--try-creds] [--dry-run]
|
|
creds <device-id>... | --all [--user U --pass P]
|
|
upgrade <device-id>... | --all [--yes] (dry-run unless --yes)
|
|
|
|
Examples:
|
|
uisp list --upgradable
|
|
uisp approve --all # approve, assign to "vntx", try standard creds
|
|
uisp approve --all --site vntx --dry-run
|
|
uisp creds --all # retry standard creds across the fleet
|
|
uisp upgrade --all --yes
|
|
`)
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "list", "ls":
|
|
err = cmdList(os.Args[2:])
|
|
case "approve":
|
|
err = cmdApprove(os.Args[2:])
|
|
case "creds":
|
|
err = cmdCreds(os.Args[2:])
|
|
case "upgrade":
|
|
err = cmdUpgrade(os.Args[2:])
|
|
case "-h", "--help", "help":
|
|
usage()
|
|
return
|
|
default:
|
|
usage()
|
|
os.Exit(2)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "error:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|