phase-b: add node uuid/alias/ip metadata APIs and node list enrichment

This commit is contained in:
2026-03-03 20:29:44 +08:00
parent 3b555df56c
commit 065f9ba5b6
5 changed files with 270 additions and 12 deletions

View File

@@ -0,0 +1,59 @@
package server
import (
"encoding/json"
"net/http"
"strings"
)
func (s *Server) HandleNodeMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ac, ok := s.ResolveTenantAccessToken(BearerToken(r))
if !ok || ac.TenantID <= 0 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/alias") {
var req struct {
NodeUUID string `json:"node_uuid"`
Alias string `json:"alias"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NodeUUID == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if err := s.store.SetNodeAlias(ac.TenantID, req.NodeUUID, req.Alias); err != nil {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`)
return
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/ip") {
var req struct {
NodeUUID string `json:"node_uuid"`
VirtualIP string `json:"virtual_ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NodeUUID == "" || req.VirtualIP == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if err := s.store.SetNodeVirtualIP(ac.TenantID, req.NodeUUID, req.VirtualIP); err != nil {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`)
return
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
writeJSON(w, http.StatusNotFound, `{"error":1,"message":"not found"}`)
}

View File

@@ -294,10 +294,14 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
NodeUUID string `json:"node_uuid"`
NodeName string `json:"node_name"`
Alias string `json:"alias"`
VirtualIP string `json:"virtual_ip"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
CreatedAt int64 `json:"created_at"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID, cred.CreatedAt}
}{0, "ok", cred.NodeID, cred.NodeUUID, cred.NodeName, cred.Alias, cred.VirtualIP, cred.Secret, cred.TenantID, cred.CreatedAt}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
@@ -49,7 +50,9 @@ type APIKey struct {
type NodeCredential struct {
NodeID int64
NodeUUID string
NodeName string
Alias string
Secret string
VirtualIP string
TenantID int64
@@ -134,7 +137,9 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
node_uuid TEXT,
node_name TEXT NOT NULL,
alias TEXT DEFAULT '',
node_pubkey TEXT,
node_secret_hash TEXT,
virtual_ip TEXT,
@@ -202,6 +207,15 @@ func (s *Store) migrate() error {
return err
}
}
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN node_uuid TEXT`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return err
}
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN alias TEXT DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return err
}
if _, err := s.DB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_node_uuid ON nodes(node_uuid)`); err != nil {
return err
}
return nil
}
@@ -279,16 +293,59 @@ func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword stri
return ten, admin, op, nil
}
func (s *Store) AllocateNodeIP(tenantID int64) (string, error) {
ten, err := s.GetTenantByID(tenantID)
if err != nil || ten == nil {
return "", errors.New("tenant not found")
}
ip, ipnet, err := net.ParseCIDR(ten.Subnet)
if err != nil {
return "", err
}
base := ip.To4()
if base == nil {
return "", errors.New("only ipv4 subnet supported")
}
rows, err := s.DB.Query(`SELECT virtual_ip FROM nodes WHERE tenant_id=? AND virtual_ip IS NOT NULL AND virtual_ip<>''`, tenantID)
if err != nil {
return "", err
}
defer rows.Close()
used := map[string]bool{}
for rows.Next() {
var v string
if rows.Scan(&v) == nil && v != "" {
used[v] = true
}
}
for host := 2; host <= 254; host++ {
cand := net.IPv4(base[0], base[1], base[2], byte(host)).String()
if !ipnet.Contains(net.ParseIP(cand)) {
continue
}
if used[cand] {
continue
}
return cand, nil
}
return "", errors.New("no available ip")
}
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken()
h := hashTokenString(secret)
now := time.Now().Unix()
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status,last_seen,created_at) VALUES(?,?,?,1,?,?)`, tenantID, nodeName, h, now, now)
nodeUUID := randUUID()
vip, err := s.AllocateNodeIP(tenantID)
if err != nil {
return nil, err
}
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_uuid,node_name,alias,node_secret_hash,virtual_ip,status,last_seen,created_at) VALUES(?,?,?,?,?,?,1,?,?)`, tenantID, nodeUUID, nodeName, "", h, vip, now, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
return &NodeCredential{NodeID: id, NodeUUID: nodeUUID, NodeName: nodeName, Alias: "", Secret: secret, VirtualIP: vip, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
@@ -301,6 +358,66 @@ func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
return &t, nil
}
func (s *Store) GetNodeCredentialByName(tenantID int64, nodeName string) (*NodeCredential, error) {
row := s.DB.QueryRow(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes WHERE tenant_id=? AND node_name=? ORDER BY id DESC LIMIT 1`, tenantID, nodeName)
var n NodeCredential
var seen sql.NullInt64
if err := row.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil {
return nil, err
}
if seen.Valid {
v := seen.Int64
n.LastSeen = &v
}
return &n, nil
}
func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error {
alias = strings.TrimSpace(alias)
res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return errors.New("node not found")
}
return nil
}
func (s *Store) SetNodeVirtualIP(tenantID int64, nodeUUID, ip string) error {
ten, err := s.GetTenantByID(tenantID)
if err != nil || ten == nil {
return errors.New("tenant not found")
}
if net.ParseIP(ip) == nil {
return errors.New("invalid ip")
}
_, ipnet, err := net.ParseCIDR(ten.Subnet)
if err != nil {
return err
}
if !ipnet.Contains(net.ParseIP(ip)) {
return errors.New("ip not in subnet")
}
var c int
if err := s.DB.QueryRow(`SELECT COUNT(1) FROM nodes WHERE tenant_id=? AND virtual_ip=? AND node_uuid<>?`, tenantID, ip, nodeUUID).Scan(&c); err != nil {
return err
}
if c > 0 {
return errors.New("ip conflict")
}
res, err := s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE tenant_id=? AND node_uuid=?`, ip, tenantID, nodeUUID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return errors.New("node not found")
}
return nil
}
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
h := hashToken(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
@@ -636,5 +753,19 @@ func randToken() string {
return hex.EncodeToString(b)
}
func randUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
// helper to avoid unused import (net)
var _ = net.IPv4len