diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go index da20514..d853cae 100644 --- a/cmd/inp2ps/main.go +++ b/cmd/inp2ps/main.go @@ -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) { diff --git a/internal/server/node_api.go b/internal/server/node_api.go new file mode 100644 index 0000000..63b9674 --- /dev/null +++ b/internal/server/node_api.go @@ -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"}`) +} diff --git a/internal/server/tenant_api.go b/internal/server/tenant_api.go index a610542..facb30d 100644 --- a/internal/server/tenant_api.go +++ b/internal/server/tenant_api.go @@ -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)) } diff --git a/internal/store/store.go b/internal/store/store.go index e05d84c..71b020e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/web/index.html b/web/index.html index 2fc1ece..5e9527c 100644 --- a/web/index.html +++ b/web/index.html @@ -86,25 +86,32 @@
- + - - + + + + - +
节点公网NAT租户版本在线时长动作节点唯一ID虚拟IP公网NAT租户版本在线时长动作
{{ n.name }}
+
{{ n.alias || n.name }}
+
hostname: {{ n.name }}
+
{{ n.nodeUUID || '-' }}{{ n.virtualIP || '-' }} {{ n.publicIP }}:{{ n.publicPort }} {{ natText(n.natType) }} {{ n.tenantId || 0 }} {{ n.version || '-' }} {{ uptime(n.loginTime) }} -
+
+ +
暂无节点
暂无节点
@@ -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,