package service import ( "context" "fmt" "net/url" "strings" "pve-confsync/internal/config" "pve-confsync/internal/pve" "pve-confsync/internal/sshx" ) // DiscoveredNode is a node listed by GET /cluster/status. type DiscoveredNode struct { Name string `json:"name"` IP string `json:"ip"` Online bool `json:"online"` } // DiscoverNodes lists the nodes of a cluster with their address in the // cluster. A standalone node without address gets the host of the API URL. func DiscoverNodes(ctx context.Context, cl config.Cluster) ([]DiscoveredNode, error) { cl.SSHHostKey = "" c, err := cl.PVE() if err != nil { return nil, err } var status []map[string]any if err := c.Get(ctx, "cluster/status", nil, &status); err != nil { return nil, err } var out []DiscoveredNode for _, s := range status { if pve.Str(s["type"]) != "node" { continue } n := DiscoveredNode{Name: pve.Str(s["name"]), IP: pve.Str(s["ip"]), Online: pve.Str(s["online"]) == "1"} if n.IP == "" { if u, err := url.Parse(cl.URL); err == nil { n.IP = u.Hostname() } } out = append(out, n) } return out, nil } // NodeCheck is the result of the SSH test of a node. type NodeCheck struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Version string `json:"version,omitempty"` SSHHostKey string `json:"-"` SSHFingerprint string `json:"sshFingerprint,omitempty"` SSHMismatch bool `json:"sshMismatch,omitempty"` } // CheckNode tests SSH as root on a node: the host key (recorded on the first // connection, trust accepts a new one), pveversion, and the host name, which // must be the name of the node in its cluster (a wrong address is refused). func CheckNode(ctx context.Context, node string, o sshx.Options, trust bool) NodeCheck { var res NodeCheck fail := func(err error) NodeCheck { res.Error = err.Error() return res } if o.HostKey == "" || trust { seen, fp, err := sshx.Scan(ctx, o) if err != nil { return fail(fmt.Errorf("SSH: %w", err)) } o.HostKey, res.SSHHostKey, res.SSHFingerprint = seen, seen, fp } sh, err := sshx.New(o) if err != nil { return fail(fmt.Errorf("SSH: %w", err)) } out, err := sh.Run(ctx, "pveversion; hostname", nil) if err != nil { if strings.Contains(err.Error(), "host key mismatch") { _, fp, _ := sshx.Scan(ctx, o) res.SSHMismatch, res.SSHFingerprint = true, fp return fail(fmt.Errorf("SSH: node %s presents host key %s instead of the recorded %s", o.Host, fp, sshx.Fingerprint(o.HostKey))) } return fail(fmt.Errorf("SSH: %w", err)) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if !strings.HasPrefix(lines[0], "pve-manager/") { return fail(fmt.Errorf("SSH: %s is not a PVE node (pveversion: %q)", o.Host, lines[0])) } res.Version = strings.SplitN(strings.TrimPrefix(lines[0], "pve-manager/"), "/", 2)[0] host := "" if len(lines) > 1 { host = strings.TrimSpace(lines[len(lines)-1]) } if short, _, _ := strings.Cut(host, "."); !strings.EqualFold(short, node) { res.SSHHostKey = "" // not the expected node: nothing is recorded return fail(fmt.Errorf("SSH: %s answers as %q, not as node %s", o.Host, host, node)) } res.OK = true return res }