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 ... | --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 ... [--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 ... | --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= Commands: list [--unauthorized] [--upgradable] [--json] approve ... | --all [--site vntx] [--try-creds] [--dry-run] creds ... | --all [--user U --pass P] upgrade ... | --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) } }