package proxmox import ( "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" ) type Client struct { baseURL string tokenID string tokenSecret string httpClient *http.Client } type APIError struct { StatusCode int Message string Path string } func (e APIError) Error() string { if e.Message == "" { return fmt.Sprintf("proxmox returned %d for %s", e.StatusCode, e.Path) } return fmt.Sprintf("proxmox returned %d for %s: %s", e.StatusCode, e.Path, e.Message) } func (e APIError) IsPermissionDenied() bool { return e.StatusCode == http.StatusForbidden } type clusterNodesResponse struct { Data []struct { Node string `json:"node"` Status string `json:"status"` } `json:"data"` } type nodeStatusResponse struct { Data struct { CPU float64 `json:"cpu"` Memory struct { Used uint64 `json:"used"` Total uint64 `json:"total"` } `json:"memory"` RootFS struct { Avail uint64 `json:"avail"` Total uint64 `json:"total"` } `json:"rootfs"` } `json:"data"` } type networkResponse struct { Data []struct { Iface string `json:"iface"` Type string `json:"type"` Active int `json:"active"` } `json:"data"` } type storageResponse struct { Data []struct { Storage string `json:"storage"` Type string `json:"type"` Avail uint64 `json:"avail"` Total uint64 `json:"total"` } `json:"data"` } type NodeInfo struct { Name string Networks []string Storages []StorageInfo FreeMemoryMB int TotalMemoryMB int FreeDiskGB int } type StorageInfo struct { Name string Type string FreeGB int SupportsISO bool SupportsDisk bool } func New(baseURL, tokenID, tokenSecret string, insecure bool) *Client { transport := &http.Transport{} if insecure { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } return &Client{ baseURL: strings.TrimRight(baseURL, "/"), tokenID: tokenID, tokenSecret: tokenSecret, httpClient: &http.Client{Timeout: 15 * time.Second, Transport: transport}, } } func (c *Client) Discover() ([]NodeInfo, error) { var nodesResp clusterNodesResponse if err := c.getJSON("/cluster/resources?type=node", &nodesResp); err != nil { return nil, err } result := make([]NodeInfo, 0, len(nodesResp.Data)) for _, node := range nodesResp.Data { if node.Node == "" || node.Status != "online" { continue } info, err := c.describeNode(node.Node) if err != nil { var apiErr APIError if ok := AsAPIError(err, &apiErr); ok && apiErr.IsPermissionDenied() { continue } return nil, err } result = append(result, info) } if len(result) == 0 { return nil, fmt.Errorf("no Proxmox nodes could be inspected; token likely needs Sys.Audit on target nodes") } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result, nil } func (c *Client) describeNode(node string) (NodeInfo, error) { var status nodeStatusResponse if err := c.getJSON("/nodes/"+node+"/status", &status); err != nil { return NodeInfo{}, err } var networks networkResponse if err := c.getJSON("/nodes/"+node+"/network", &networks); err != nil { return NodeInfo{}, err } var storages storageResponse if err := c.getJSON("/nodes/"+node+"/storage", &storages); err != nil { return NodeInfo{}, err } info := NodeInfo{ Name: node, FreeMemoryMB: int((status.Data.Memory.Total - status.Data.Memory.Used) / 1024 / 1024), TotalMemoryMB: int(status.Data.Memory.Total / 1024 / 1024), FreeDiskGB: int(status.Data.RootFS.Avail / 1024 / 1024 / 1024), } for _, net := range networks.Data { if net.Active == 1 && (net.Type == "bridge" || net.Type == "eth") { info.Networks = append(info.Networks, net.Iface) } } for _, storage := range storages.Data { info.Storages = append(info.Storages, StorageInfo{ Name: storage.Storage, Type: storage.Type, FreeGB: int(storage.Avail / 1024 / 1024 / 1024), SupportsISO: storage.Type == "dir" || storage.Type == "nfs", SupportsDisk: storage.Type == "dir" || storage.Type == "nfs" || storage.Type == "lvm" || storage.Type == "lvmthin" || storage.Type == "zfspool" || storage.Type == "btrfs", }) } return info, nil } func (c *Client) getJSON(path string, target any) error { request, err := http.NewRequest(http.MethodGet, c.baseURL+"/api2/json"+path, nil) if err != nil { return err } request.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s=%s", c.tokenID, c.tokenSecret)) response, err := c.httpClient.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode >= 300 { body, _ := io.ReadAll(response.Body) return APIError{StatusCode: response.StatusCode, Message: strings.TrimSpace(string(body)), Path: path} } return json.NewDecoder(response.Body).Decode(target) } func AsAPIError(err error, target *APIError) bool { apiErr, ok := err.(APIError) if ok { *target = apiErr } return ok } func BuildAPIURL(host string) string { if strings.HasPrefix(host, "http") { return strings.TrimRight(host, "/") } return (&url.URL{Scheme: "https", Host: host, Path: "/api2/json"}).String() }