phase-b: add node uuid/alias/ip metadata APIs and node list enrichment
This commit is contained in:
@@ -125,7 +125,7 @@ func main() {
|
||||
}
|
||||
if ac.Kind == "session" && ac.Role == "operator" {
|
||||
path := r.URL.Path
|
||||
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" {
|
||||
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/nodes/alias" && path != "/api/v1/nodes/ip" && path != "/api/v1/stats" && path != "/api/v1/health" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
|
||||
@@ -146,6 +146,39 @@ func main() {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
listNodesOut := func(nodes []*server.NodeInfo, tenantID int64) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
item := map[string]any{
|
||||
"name": n.Name,
|
||||
"displayName": n.Name,
|
||||
"publicIP": n.PublicIP,
|
||||
"publicPort": n.PublicPort,
|
||||
"natType": n.NATType,
|
||||
"tenantId": n.TenantID,
|
||||
"version": n.Version,
|
||||
"relayEnabled": n.RelayEnabled,
|
||||
"superRelay": n.SuperRelay,
|
||||
"loginTime": n.LoginTime,
|
||||
"lastHeartbeat": n.LastHeartbeat,
|
||||
"nodeUUID": "",
|
||||
"alias": "",
|
||||
"virtualIP": "",
|
||||
}
|
||||
if tenantID > 0 && srv.Store() != nil {
|
||||
if nc, err := srv.Store().GetNodeCredentialByName(tenantID, n.Name); err == nil && nc != nil {
|
||||
item["nodeUUID"] = nc.NodeUUID
|
||||
item["alias"] = nc.Alias
|
||||
item["virtualIP"] = nc.VirtualIP
|
||||
if nc.Alias != "" {
|
||||
item["displayName"] = nc.Alias
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws", srv.HandleWS)
|
||||
@@ -162,6 +195,8 @@ func main() {
|
||||
mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll)
|
||||
mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume)
|
||||
mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume)
|
||||
mux.HandleFunc("/api/v1/nodes/alias", tenantMiddleware(srv.HandleNodeMeta))
|
||||
mux.HandleFunc("/api/v1/nodes/ip", tenantMiddleware(srv.HandleNodeMeta))
|
||||
|
||||
mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -276,11 +311,11 @@ func main() {
|
||||
tenantID := getTenantID(r)
|
||||
if tenantID > 0 {
|
||||
nodes := srv.GetOnlineNodesByTenant(tenantID)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, tenantID)})
|
||||
return
|
||||
}
|
||||
nodes := srv.GetOnlineNodes()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, 0)})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/v1/sdwans", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
59
internal/server/node_api.go
Normal file
59
internal/server/node_api.go
Normal 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"}`)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,25 +86,32 @@
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full text-sm min-w-[900px]">
|
||||
<thead class="text-slate-400"><tr>
|
||||
<th class="p-3 text-left">节点</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</th>
|
||||
<th class="p-3 text-left">节点</th><th class="p-3 text-left">唯一ID</th><th class="p-3 text-left">虚拟IP</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="n in filteredNodes" :key="n.name" class="border-t border-white/5">
|
||||
<td class="p-3">{{ n.name }}</td>
|
||||
<tr v-for="n in filteredNodes" :key="n.nodeUUID || n.name" class="border-t border-white/5">
|
||||
<td class="p-3">
|
||||
<div class="font-semibold">{{ n.alias || n.name }}</div>
|
||||
<div class="text-xs text-slate-500">hostname: {{ n.name }}</div>
|
||||
</td>
|
||||
<td class="p-3 text-xs text-slate-400">{{ n.nodeUUID || '-' }}</td>
|
||||
<td class="p-3">{{ n.virtualIP || '-' }}</td>
|
||||
<td class="p-3">{{ n.publicIP }}:{{ n.publicPort }}</td>
|
||||
<td class="p-3">{{ natText(n.natType) }}</td>
|
||||
<td class="p-3">{{ n.tenantId || 0 }}</td>
|
||||
<td class="p-3">{{ n.version || '-' }}</td>
|
||||
<td class="p-3">{{ uptime(n.loginTime) }}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button class="btn2" @click="renameNode(n)">改昵称</button>
|
||||
<button class="btn2" @click="changeNodeIP(n)">改IP</button>
|
||||
<button class="btn2" @click="openConnect(n.name)">发起连接</button>
|
||||
<button class="btn2" @click="openAppManager(n.name)">推配置</button>
|
||||
<button class="btn2" @click="kickNode(n.name)">踢下线</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="7">暂无节点</td></tr>
|
||||
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="9">暂无节点</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -407,6 +414,28 @@ createApp({
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const renameNode = async (node) => {
|
||||
if (!node.nodeUUID) return toast('该节点尚无UUID,稍后重连后再试', 'error');
|
||||
const nextAlias = prompt('输入新昵称(留空则清除)', node.alias || '');
|
||||
if (nextAlias === null) return;
|
||||
try {
|
||||
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: nextAlias }) });
|
||||
toast('昵称已更新');
|
||||
refreshAll();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const changeNodeIP = async (node) => {
|
||||
if (!node.nodeUUID) return toast('该节点尚无UUID,稍后重连后再试', 'error');
|
||||
const nextIP = prompt('输入新的虚拟IP(必须在本网络CIDR内)', node.virtualIP || '');
|
||||
if (!nextIP) return;
|
||||
try {
|
||||
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: nextIP }) });
|
||||
toast('IP已更新,节点将按新IP重连');
|
||||
refreshAll();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const appPushNode = ref('');
|
||||
const appPushRaw = ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
|
||||
const openAppManager = (node) => { appPushNode.value = node; toast(`已选中 ${node},请在控制台执行推配置`); tab.value = 'p2p'; };
|
||||
@@ -546,7 +575,7 @@ createApp({
|
||||
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
||||
natText, uptime, fmtTime,
|
||||
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, autoAssignIPs,
|
||||
kickNode, openAppManager, pushAppConfigs, openConnect, doConnect,
|
||||
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
|
||||
createTenant, loadTenants, setTenantStatus,
|
||||
createKey, loadKeys, setKeyStatus,
|
||||
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
|
||||
|
||||
Reference in New Issue
Block a user