Compare commits

..

5 Commits

Author SHA1 Message Date
67bc6ecae6 web: restore full single-file console and fix enroll revoke route 2026-03-03 18:46:47 +08:00
10473020d2 fix: multi issues - TUN read loop, SDWAN routing for TenantID=0, WS keepalive 10s 2026-03-03 11:24:00 +08:00
9f6e065f3a feat: web console with real management operations
Backend APIs added:
- POST /api/v1/nodes/kick - disconnect a node
- POST /api/v1/connect - trigger P2P tunnel between nodes
- GET /api/v1/stats - detailed server statistics

Frontend features:
- Dashboard: real stats from /api/v1/stats (cone/symm/relay counts)
- Node management: table view, kick node, configure tunnels
- SDWAN: enable/disable, CIDR config, IP allocation, online status
- P2P Connect: create tunnel between two nodes from UI
- Event log: tracks all operations
2026-03-03 00:42:01 +08:00
6d5b1f50ab feat: optimize web console - add error handling, loading states, settings page 2026-03-03 00:31:35 +08:00
71a4a29220 docs: add web console development plan 2026-03-02 23:09:22 +08:00
23 changed files with 2200 additions and 67 deletions

324
WEB_CONSOLE_PLAN.md Normal file
View File

@@ -0,0 +1,324 @@
# INP2P Web 控制台开发方案
## 项目概览
- **后端**: Go + Gin (已集成在 inp2ps)
- **前端**: Vue 3 + Element Plus (推荐) 或 React + Tailwind
- **现有 API**: http://127.0.0.1:27183 (inp2ps 内置 HTTP)
- **数据格式**: JSON
---
## 一、API 对接清单
### 1. 基础信息 API
| 方法 | 路径 | 说明 | 返回示例 |
|------|------|------|----------|
| GET | `/api/v1/health` | 健康检查 + 节点数 | `{"status":"ok","version":"0.1.0","nodes":2}` |
| GET | `/api/v1/nodes` | 在线节点列表 | 见下方 Node 结构 |
| GET | `/api/v1/sdwans` | SDWAN 配置 | 见下方 SDWAN 结构 |
### 2. 节点管理 API
**NodeInfo 结构体** (Go):
```go
type NodeInfo struct {
Name string // 节点名称
User string // 用户名
Version string // 客户端版本
NATType int // NAT 类型: 1=Cone, 2=Symmetric, 0=Unknown
PublicIP string // 公网 IP
PublicPort int // 公网端口
LocalPort int // 本地端口
RelayEnabled bool // 是否开启中继
SuperRelay bool // 是否超级中继
LoginTime int64 // 登录时间戳
LastHeartbeat int64 // 最后心跳时间
IsOnline bool // 在线状态
}
```
**GET /api/v1/nodes** 返回:
```json
{
"nodes": [
{
"name": "hcss-ecs-8626",
"user": "",
"version": "0.1.0",
"natType": 1,
"publicIP": "189.1.238.33",
"publicPort": 49163,
"localPort": 49163,
"relayEnabled": false,
"superRelay": false,
"loginTime": 1772445856,
"lastHeartbeat": 1772445900,
"isOnline": true
}
]
}
```
### 3. SDWAN 管理 API
**SDWANConfig 结构体**:
```go
type SDWANConfig struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
GatewayCIDR string `json:"gatewayCIDR"` // 如 "10.10.0.0/24"
Mode string `json:"mode"` // "hub" | "mesh"
Routes []string `json:"routes"`
MTU int `json:"mtu,omitempty"`
Nodes []SDWANNode `json:"nodes"`
UpdatedAt int64 `json:"updatedAt"`
}
type SDWANNode struct {
Node string `json:"node"` // 节点名称
IP string `json:"ip"` // 虚拟 IP如 "10.10.0.2"
}
```
**GET /api/v1/sdwans** 返回:
```json
{
"enabled": true,
"name": "sdwan-main",
"gatewayCIDR": "10.10.0.0/24",
"mode": "mesh",
"routes": ["10.10.0.0/24"],
"nodes": [
{"node": "hcss-ecs-8626", "ip": "10.10.0.3"},
{"node": "i-6986ef49a8f84db00bcd0f24", "ip": "10.10.0.2"}
],
"updatedAt": 1772445856
}
```
**POST /api/v1/sdwan/edit** 请求体:
```json
{
"enabled": true,
"gatewayCIDR": "10.10.0.0/24",
"mode": "mesh",
"nodes": [
{"node": "nodeA", "ip": "10.10.0.2"},
{"node": "nodeB", "ip": "10.10.0.3"}
]
}
```
### 4. 待实现的 API (需新增)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/peers` | P2P 连接状态 (UDP/TCP/Relay) |
| POST | `/api/v1/connect` | 手动触发 P2P 连接 |
| GET | `/api/v1/relay/nodes` | 中继节点列表 |
| GET | `/api/v1/stats` | 流量统计 |
---
## 二、前端页面设计
### 1. 首页 / 仪表盘
- 显示服务器状态 (在线节点数、版本、运行时间)
- 显示 SDWAN 状态 (虚拟网络是否启用、在线节点)
- 快速操作按钮 (启用/禁用 SDWAN)
### 2. 节点管理页面
- 表格展示所有在线节点
- 列: 节点名 | NAT类型 | 公网IP | 中继状态 | 在线时长 | 最后心跳
- 支持搜索、过滤
- 节点详情弹窗 (连接信息、版本)
### 3. SDWAN 管理页面
- 当前配置展示 (网关 CIDR、模式、节点列表)
- 节点 IP 分配表单
- 启用/禁用开关
- 拓扑图 (可选): 显示节点间连接状态
### 4. 中继管理页面 (可选)
- 中继节点列表
- 带宽使用情况
- 负载均衡状态
---
## 三、前端技术栈建议
### 方案 A: Vue 3 + Element Plus (推荐)
```bash
# 创建项目
npm create vite@latest inp2p-console -- --template vue
cd inp2p-console
npm install element-plus axios vue-router@4
# 目录结构
src/
├── api/
│ └── index.js # API 调用封装
├── views/
│ ├── Dashboard.vue # 首页
│ ├── Nodes.vue # 节点管理
│ └── SDWAN.vue # SDWAN 管理
├── components/
└── App.vue
```
### 方案 B: React + Tailwind
```bash
npm create vite@latest inp2p-console -- --template react
npm install axios react-router-dom tailwindcss
```
---
## 四、后端集成方式
### 方案 1: 独立部署 (推荐)
前端打包后放入 `inp2ps` 同目录,通过 `-web-dir` 参数指定:
```bash
# 后端添加参数
./bin/inp2ps -ws-port 27183 -web-dir ./dist ...
```
**需新增代码** (`cmd/inp2ps/main.go`):
```go
var webDir string
flag.StringVar(&webDir, "web-dir", "", "Static web files directory")
// HTTP mux 中添加:
if webDir != "" {
mux.Handle("/", http.FileServer(http.Dir(webDir)))
}
```
### 方案 2: 独立端口
前端单独部署在不同端口 (如 8080),通过 API 调用后端。
---
## 五、开发步骤
### Step 1: 环境准备
```bash
# 1. 克隆项目
git clone https://gitea.king.nyc.mn/openclaw/inp2p.git
cd inp2p
# 2. 启动后端
./bin/inp2ps -ws-port 27183 -stun-udp1 27182 -stun-udp2 27183 \
-stun-tcp1 27180 -stun-tcp2 27181 \
-token 12063420751575908257
# 3. 验证 API
curl http://127.0.0.1:27183/api/v1/health
curl http://127.0.0.1:27183/api/v1/sdwans
```
### Step 2: 前端脚手架
```bash
npm create vite@latest web -- --template vue
cd web
npm install element-plus axios
```
### Step 3: API 封装
创建 `src/api/index.js`:
```javascript
import axios from 'axios'
const api = axios.create({
baseURL: 'http://127.0.0.1:27183/api/v1',
timeout: 5000
})
export const getHealth = () => api.get('/health')
export const getNodes = () => api.get('/nodes')
export const getSDWAN = () => api.get('/sdwans')
export const updateSDWAN = (data) => api.post('/sdwan/edit', data)
```
### Step 4: 页面开发
1. **Dashboard.vue**: 调用 `getHealth()`, `getSDWAN()`
2. **Nodes.vue**: 调用 `getNodes()`,表格展示
3. **SDWAN.vue**: 调用 `getSDWAN()`, `updateSDWAN()`
### Step 5: 后端增强
根据需要添加:
```go
// cmd/inp2ps/main.go
mux.HandleFunc("/api/v1/nodes", func(w http.ResponseWriter, r *http.Request) {
nodes := srv.GetOnlineNodes()
json.NewEncoder(w).Encode(map[string][]*NodeInfo{"nodes": nodes})
})
mux.HandleFunc("/api/v1/connect", func(w http.ResponseWriter, r *http.Request) {
// 触发 P2P 连接
})
```
---
## 六、关键代码位置
| 功能 | 文件 |
|------|------|
| HTTP 路由注册 | `cmd/inp2ps/main.go` |
| 节点管理 | `internal/server/server.go` |
| SDWAN 配置 | `internal/server/sdwan_api.go` |
| SDWAN 数据面 | `internal/server/sdwan.go` |
---
## 七、注意事项
1. **CORS**: 如果前后端分离,需要在后端添加 CORS 中间件
2. **认证**: 当前 API 无认证,生产环境需添加 token 验证
3. **WebSocket**: 可选添加 ws 实时推送节点状态变化
4. **静态文件**: 后端添加 `-web-dir` 支持前端嵌入
---
## 八、测试数据
当前测试环境:
- **服务器**: 127.0.0.1:27183 (token: 12063420751575908257)
- **SDWAN 配置**:
```json
{
"enabled": true,
"gatewayCIDR": "10.10.0.0/24",
"mode": "mesh",
"nodes": [
{"node": "hcss-ecs-8626", "ip": "10.10.0.3"},
{"node": "i-6986ef49a8f84db00bcd0f24", "ip": "10.10.0.2"}
]
}
```
---
完成上述清单后,你将拥有一个完整的 INP2P Web 管理控制台。

View File

@@ -6,12 +6,14 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/openp2p-cn/inp2p/internal/server"
"github.com/openp2p-cn/inp2p/pkg/auth"
@@ -84,25 +86,246 @@ func main() {
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
}
// ─── Signaling Server ───
// ─── Signaling Server ───
srv := server.New(cfg)
srv.StartCleanup()
// Admin-only Middleware
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
next(w, r)
return
}
authHeader := r.Header.Get("Authorization")
valid := authHeader == fmt.Sprintf("Bearer %d", cfg.Token)
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
// RBAC: admin only
if srv.Store() != nil {
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 || u.Role != "admin" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
next(w, r)
}
}
// Tenant or Admin Middleware
tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
next(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == fmt.Sprintf("Bearer %d", cfg.Token) {
next(w, r)
return
}
// check API key + RBAC
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
// role check if user exists
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
if u.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" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
next(w, r)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
// Serve Static Web Console
webDir := "/root/.openclaw/workspace/inp2p/web"
mux.Handle("/", http.FileServer(http.Dir(webDir)))
// Tenant APIs (API key auth inside handlers)
mux.HandleFunc("/api/v1/admin/tenants", adminMiddleware(srv.HandleAdminCreateTenant))
mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey))
mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers))
mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers))
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/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Support two modes:
// 1) token login: {"token":"xxxx"} (admin/master only, backward compatible)
// 2) user login: {"tenant":1,"username":"admin","password":"pass"}
var reqTok struct {
Token string `json:"token"`
}
var reqUser struct {
TenantID int64 `json:"tenant"`
Username string `json:"username"`
Password string `json:"password"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &reqTok)
_ = json.Unmarshal(body, &reqUser)
// --- user login ---
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`)
return
}
u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password)
if err != nil || u == nil || u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
// issue API key for this tenant and return subnet
key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", key, u.Role, u.Status, ""}
if ten != nil {
resp.Subnet = ten.Subnet
}
b, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
return
}
// --- token login (legacy/admin) ---
valid := false
role := "admin"
status := 1
if reqTok.Token != "" {
// support numeric token as string
if reqTok.Token == fmt.Sprintf("%d", cfg.Token) {
valid = true
} else {
for _, t := range cfg.Tokens {
if reqTok.Token == fmt.Sprintf("%d", t) {
valid = true
break
}
}
}
}
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
return
}
if srv.Store() != nil {
if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil {
if u.Role != "" {
role = u.Role
}
status = u.Status
}
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status)
})
mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
})
mux.HandleFunc("/api/v1/sdwans", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/nodes", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 {
nodes := srv.GetOnlineNodesByTenant(tenantID)
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
return
}
nodes := srv.GetOnlineNodes()
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
}))
mux.HandleFunc("/api/v1/sdwans", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 {
_ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID))
return
}
_ = json.NewEncoder(w).Encode(srv.GetSDWAN())
})
mux.HandleFunc("/api/v1/sdwan/edit", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/sdwan/edit", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -112,13 +335,190 @@ func main() {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 {
if err := srv.SetSDWANTenant(tenantID, req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
return
}
if err := srv.SetSDWAN(req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
})
}))
// Remote Config Push API
mux.HandleFunc("/api/v1/nodes/apps", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
Apps []protocol.AppConfig `json:"apps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// Push to client
_ = node.Conn.Write(protocol.MsgPush, protocol.SubPushConfig, req.Apps)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "config push sent"})
}))
// Kick (disconnect) a node
mux.HandleFunc("/api/v1/nodes/kick", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found or offline", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
node.Conn.Close()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "node kicked"})
}))
// Trigger P2P connect between two nodes
mux.HandleFunc("/api/v1/connect", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
From string `json:"from"`
To string `json:"to"`
SrcPort int `json:"srcPort"`
DstPort int `json:"dstPort"`
AppName string `json:"appName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fromNode := srv.GetNode(req.From)
if fromNode == nil {
http.Error(w, "source node offline", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
if tenantID > 0 && fromNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
app := protocol.AppConfig{
AppName: req.AppName,
Protocol: "tcp",
SrcPort: req.SrcPort,
PeerNode: req.To,
DstHost: "127.0.0.1",
DstPort: req.DstPort,
Enabled: 1,
}
// enforce same-tenant target
if tenantID > 0 {
toNode := srv.GetNode(req.To)
if toNode == nil || toNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
}
if err := srv.PushConnect(fromNode, req.To, app); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"error": 1, "message": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "connect request sent"})
}))
// Server uptime + detailed stats
mux.HandleFunc("/api/v1/stats", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
nodes := srv.GetOnlineNodes()
coneCount, symmCount, unknCount := 0, 0, 0
relayCount := 0
for _, n := range nodes {
switch n.NATType {
case 1:
coneCount++
case 2:
symmCount++
default:
unknCount++
}
if n.RelayEnabled || n.SuperRelay {
relayCount++
}
}
sdwan := srv.GetSDWAN()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"nodes": len(nodes),
"relay": relayCount,
"cone": coneCount,
"symmetric": symmCount,
"unknown": unknCount,
"sdwan": sdwan.Enabled,
"version": config.Version,
})
}))
// ─── HTTP Listener ───
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort))
@@ -127,7 +527,13 @@ func main() {
}
log.Printf("[main] signaling server on :%d (no TLS — use reverse proxy for production)", cfg.WSPort)
httpSrv := &http.Server{Handler: mux}
// Enable TCP keepalive at server level
httpSrv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
log.Fatalf("[main] serve: %v", err)

17
go.mod
View File

@@ -7,3 +7,20 @@ toolchain go1.24.4
require github.com/gorilla/websocket v1.5.3
require golang.org/x/sys v0.41.0
require modernc.org/sqlite v1.29.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.41.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

37
go.sum
View File

@@ -1,4 +1,41 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/sqlite v1.29.0 h1:lQVw+ZsFM3aRG5m4myG70tbXpr3S/J1ej0KHIP4EvjM=
modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

BIN
inp2pc Executable file

Binary file not shown.

BIN
inp2ps Executable file

Binary file not shown.

BIN
inp2ps.db-shm Normal file

Binary file not shown.

BIN
inp2ps.db-wal Normal file

Binary file not shown.

View File

@@ -95,7 +95,7 @@ func (c *Client) connectAndRun() error {
c.publicIP = natResult.PublicIP
c.publicPort = natResult.Port1
c.localPort = natResult.LocalPort
log.Printf("[client] NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
log.Printf("[client] SENDING_LOGIN_TOKEN=%d NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
// 2. WSS Connect
scheme := "ws"
@@ -642,28 +642,34 @@ func (c *Client) tunReadLoop() {
if c.IsStopping() {
return
}
// Log only real errors, not EOF or timeout
if err.Error() != "EOF" && err.Error() != "resource temporarily unavailable" {
log.Printf("[client] tun read error: %v", err)
}
time.Sleep(100 * time.Millisecond)
log.Printf("[client] tun read error: %v", err)
continue
}
// Skip empty packets or non-IPv4
if n == 0 || n < 20 {
log.Printf("[client] tun read error: %v", err)
continue
}
pkt := buf[:n]
version := pkt[0] >> 4
if version != 4 {
log.Printf("[client] tun read error: %v", err)
continue // skip non-IPv4
}
dstIP := net.IP(pkt[16:20]).String()
c.sdwanMu.RLock()
self := c.sdwanIP
c.sdwanMu.RUnlock()
if dstIP == self {
log.Printf("[client] tun read error: %v", err)
continue // skip packets to self
}
// send raw binary to avoid JSON base64 overhead
log.Printf("[client] tun: read pkt len=%d dst=%s", n, dstIP)
frame := protocol.EncodeRaw(protocol.MsgTunnel, protocol.SubTunnelSDWANRaw, pkt)
_ = c.conn.WriteRaw(frame)
if err := c.conn.WriteRaw(frame); err != nil {
log.Printf("[client] tun write failed: %v", err)
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -5,6 +5,7 @@ import (
"log"
"time"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
@@ -17,12 +18,12 @@ import (
// HandleConnectReq processes a connection request from node A to node B.
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error {
to := s.GetNode(req.To)
to := s.GetNodeForUser(req.To, from.Token)
if to == nil || !to.IsOnline() {
// Peer offline — respond with error
// Peer offline or not visible — respond with generic not found
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 1,
Detail: fmt.Sprintf("node %s offline", req.To),
Detail: "node not found",
From: req.To,
To: req.From,
})
@@ -38,6 +39,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: from.PublicPort,
NATType: from.NATType,
HasIPv4: from.HasIPv4,
Token: auth.GenTOTP(from.Token, time.Now().Unix()),
}
from.mu.RUnlock()
@@ -47,6 +49,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: to.PublicPort,
NATType: to.NATType,
HasIPv4: to.HasIPv4,
Token: auth.GenTOTP(to.Token, time.Now().Unix()),
}
to.mu.RUnlock()

Binary file not shown.

Binary file not shown.

View File

@@ -12,13 +12,14 @@ import (
)
type sdwanStore struct {
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
multi map[int64]protocol.SDWANConfig
}
func newSDWANStore(path string) *sdwanStore {
s := &sdwanStore{path: path}
s := &sdwanStore{path: path, multi: make(map[int64]protocol.SDWANConfig)}
_ = s.load()
return s
}
@@ -33,6 +34,15 @@ func (s *sdwanStore) load() error {
}
return err
}
// try multi-tenant first
var m map[int64]protocol.SDWANConfig
if err := json.Unmarshal(b, &m); err == nil && len(m) > 0 {
for k, v := range m {
m[k] = normalizeSDWAN(v)
}
s.multi = m
return nil
}
var c protocol.SDWANConfig
if err := json.Unmarshal(b, &c); err != nil {
return err
@@ -57,12 +67,40 @@ func (s *sdwanStore) save(cfg protocol.SDWANConfig) error {
return nil
}
func (s *sdwanStore) saveTenant(tenantID int64, cfg protocol.SDWANConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg = normalizeSDWAN(cfg)
cfg.UpdatedAt = time.Now().Unix()
if s.multi == nil {
s.multi = make(map[int64]protocol.SDWANConfig)
}
s.multi[tenantID] = cfg
b, err := json.MarshalIndent(s.multi, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(s.path, b, 0644); err != nil {
return err
}
return nil
}
func (s *sdwanStore) get() protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
func (s *sdwanStore) getTenant(tenantID int64) protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
if s.multi == nil {
return protocol.SDWANConfig{}
}
return s.multi[tenantID]
}
func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
if c.Mode == "" {
c.Mode = "hub"

View File

@@ -11,6 +11,10 @@ func (s *Server) GetSDWAN() protocol.SDWANConfig {
return s.sdwan.get()
}
func (s *Server) GetSDWANTenant(tenantID int64) protocol.SDWANConfig {
return s.sdwan.getTenant(tenantID)
}
func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
if err := s.sdwan.save(cfg); err != nil {
return err
@@ -19,6 +23,14 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil
}
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error {
if err := s.sdwan.saveTenant(tenantID, cfg); err != nil {
return err
}
s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID))
return nil
}
func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
@@ -33,6 +45,20 @@ func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
}
}
func (s *Server) broadcastSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if !n.IsOnline() || n.TenantID != tenantID {
continue
}
_ = n.Conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg)
}
}
func (s *Server) pushSDWANPeer(to *NodeInfo, peer protocol.SDWANPeer) {
if to == nil || !to.IsOnline() {
return
@@ -48,7 +74,14 @@ func (s *Server) pushSDWANDel(to *NodeInfo, peer protocol.SDWANPeer) {
}
func (s *Server) announceSDWANNodeOnline(nodeName string) {
cfg := s.sdwan.get()
// pick tenant config by node
s.mu.RLock()
newNode := s.nodes[nodeName]
s.mu.RUnlock()
if newNode == nil {
return
}
cfg := s.sdwan.getTenant(newNode.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -64,7 +97,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
s.mu.RLock()
newNode := s.nodes[nodeName]
newNode = s.nodes[nodeName]
if newNode == nil || !newNode.IsOnline() {
s.mu.RUnlock()
return
@@ -74,7 +107,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
continue
}
other := s.nodes[n.Node]
if other == nil || !other.IsOnline() {
if other == nil || !other.IsOnline() || other.TenantID != newNode.TenantID {
continue
}
// existing -> new
@@ -86,7 +119,13 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
func (s *Server) announceSDWANNodeOffline(nodeName string) {
cfg := s.sdwan.get()
s.mu.RLock()
old := s.nodes[nodeName]
s.mu.RUnlock()
if old == nil {
return
}
cfg := s.sdwan.getTenant(old.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -100,7 +139,7 @@ func (s *Server) announceSDWANNodeOffline(nodeName string) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if n.Name == nodeName || !n.IsOnline() {
if n.Name == nodeName || !n.IsOnline() || n.TenantID != old.TenantID {
continue
}
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false})
@@ -112,7 +151,13 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
if from == nil {
return
}
cfg := s.sdwan.get()
// Use global config for untrusted nodes (TenantID=0), otherwise use tenant config
var cfg protocol.SDWANConfig
if from.TenantID == 0 {
cfg = s.sdwan.get()
} else {
cfg = s.sdwan.getTenant(from.TenantID)
}
if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
return
}
@@ -124,12 +169,18 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
toNode := ""
for _, n := range cfg.Nodes {
if n.IP == pkt.DstIP {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
}
if toNode == "" || toNode == from.Name {
@@ -138,6 +189,9 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
s.mu.RLock()
to := s.nodes[toNode]
if to != nil && to.TenantID != from.TenantID {
to = nil
}
s.mu.RUnlock()
if to == nil || !to.IsOnline() {
return

View File

@@ -2,6 +2,7 @@
package server
import (
"fmt"
"log"
"net"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal"
@@ -17,26 +19,27 @@ import (
// NodeInfo represents a connected client node.
type NodeInfo struct {
Name string
Token uint64
User string
Version string
NATType protocol.NATType
PublicIP string
PublicPort int
LanIP string
OS string
Mac string
ShareBandwidth int
RelayEnabled bool
SuperRelay bool
HasIPv4 int
IPv6 string
LoginTime time.Time
LastHeartbeat time.Time
Conn *signal.Conn
Apps []protocol.AppConfig
mu sync.RWMutex
Name string `json:"name"`
Token uint64 `json:"-"`
TenantID int64 `json:"tenantId"`
User string `json:"user"`
Version string `json:"version"`
NATType protocol.NATType `json:"natType"`
PublicIP string `json:"publicIP"`
PublicPort int `json:"publicPort"`
LanIP string `json:"lanIP"`
OS string `json:"os"`
Mac string `json:"mac"`
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"`
SuperRelay bool `json:"superRelay"`
HasIPv4 int `json:"hasIPv4"`
IPv6 string `json:"ipv6"`
LoginTime time.Time `json:"loginTime"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
Conn *signal.Conn `json:"-"`
Apps []protocol.AppConfig `json:"apps"`
mu sync.RWMutex `json:"-"`
}
// IsOnline checks if node has sent heartbeat recently.
@@ -49,25 +52,43 @@ func (n *NodeInfo) IsOnline() bool {
// Server is the INP2P signaling server.
type Server struct {
cfg config.ServerConfig
nodes map[string]*NodeInfo // node name → info
nodes map[string]*NodeInfo
mu sync.RWMutex
upgrader websocket.Upgrader
quit chan struct{}
sdwanPath string
sdwan *sdwanStore
store *store.Store
tokens map[uint64]bool
}
func (s *Server) Store() *store.Store { return s.store }
// New creates a new server.
func New(cfg config.ServerConfig) *Server {
// Use absolute path for sdwan config to avoid working directory issues
sdwanPath := "/root/.openclaw/workspace/inp2p/sdwan.json"
tokens := make(map[uint64]bool)
if cfg.Token != 0 {
tokens[cfg.Token] = true
}
for _, t := range cfg.Tokens {
tokens[t] = true
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Printf("[server] open store failed: %v", err)
}
return &Server{
cfg: cfg,
nodes: make(map[string]*NodeInfo),
sdwanPath: sdwanPath,
sdwan: newSDWANStore(sdwanPath),
store: st,
tokens: tokens,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
},
quit: make(chan struct{}),
}
@@ -93,6 +114,42 @@ func (s *Server) GetOnlineNodes() []*NodeInfo {
return out
}
// GetNodeForUser returns node if token matches (legacy) or tenant matches.
func (s *Server) GetNodeForUser(name string, token uint64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil {
return nil
}
if n.Token != token && n.TenantID == 0 {
return nil
}
return n
}
func (s *Server) GetNodeForTenant(name string, tenantID int64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil || n.TenantID != tenantID {
return nil
}
return n
}
func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []*NodeInfo
for _, n := range s.nodes {
if n.IsOnline() && n.TenantID == tenantID {
out = append(out, n)
}
}
return out
}
// GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
@@ -119,6 +176,28 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
return append(privateRelays, superRelays...)
}
// GetRelayNodesByTenant returns relay nodes within tenant.
func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
excludeSet[n] = true
}
s.mu.RLock()
defer s.mu.RUnlock()
var relays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] {
continue
}
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) {
relays = append(relays, n)
}
}
return relays
}
// HandleWS is the WebSocket handler for client connections.
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
@@ -151,8 +230,26 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
return
}
// Verify token
if loginReq.Token != s.cfg.Token {
// Verify token: master token OR tenant API key (DB) OR node_secret (DB)
valid := s.tokens[loginReq.Token]
log.Printf("[server] login check: token=%d, cfg.Token=%d, valid=%v", loginReq.Token, s.cfg.Token, valid)
var tenantID int64
if !valid && s.store != nil {
// try api key (string) or node secret
if loginReq.NodeSecret != "" {
if ten, err := s.store.VerifyNodeSecret(loginReq.Node, loginReq.NodeSecret); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
if !valid {
if ten, err := s.store.VerifyAPIKey(fmt.Sprintf("%d", loginReq.Token)); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
}
if !valid {
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 1,
@@ -174,6 +271,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
node := &NodeInfo{
Name: loginReq.Node,
Token: loginReq.Token,
TenantID: tenantID,
User: loginReq.User,
Version: loginReq.Version,
NATType: loginReq.NATType,
@@ -211,11 +309,21 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
s.broadcastNodeOnline(loginReq.Node)
// Push current SDWAN config right after login (if exists and enabled)
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
if node.TenantID > 0 {
if cfg := s.sdwan.getTenant(node.TenantID); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
} else {
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
}
// Event-driven SDWAN peer notification
@@ -378,10 +486,13 @@ func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req
// PushConnect sends a punch coordination message to a peer node.
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error {
toNode := s.GetNode(toNodeName)
toNode := s.GetNodeForUser(toNodeName, fromNode.Token)
if toNode == nil || !toNode.IsOnline() {
return &NodeOfflineError{Node: toNodeName}
}
if fromNode.TenantID != 0 && toNode.TenantID != fromNode.TenantID {
return &NodeOfflineError{Node: toNodeName}
}
// Push connect request to the destination
req := protocol.ConnectReq{
@@ -392,6 +503,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
IP: fromNode.PublicIP,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
},
AppName: app.AppName,
Protocol: app.Protocol,
@@ -406,12 +518,19 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
// broadcastNodeOnline notifies interested nodes that a peer came online.
func (s *Server) broadcastNodeOnline(nodeName string) {
s.mu.RLock()
newNode := s.nodes[nodeName]
defer s.mu.RUnlock()
if newNode == nil {
return
}
for _, n := range s.nodes {
if n.Name == nodeName {
continue
}
if n.Token != newNode.Token && (newNode.TenantID == 0 || n.TenantID != newNode.TenantID) {
continue
}
// Check if this node has any app targeting the new node
n.mu.RLock()
interested := false

View File

@@ -0,0 +1,185 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/openp2p-cn/inp2p/internal/store"
)
// helpers
func BearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 {
return ""
}
if strings.ToLower(parts[0]) != "bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, body)
}
func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ten, err := s.store.CreateTenant(req.Name)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenant int64 `json:"tenant_id"`
Subnet string `json:"subnet"`
}{0, "ok", ten.ID, ten.Subnet}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleAdminCreateAPIKey(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
}
// /api/v1/admin/tenants/{id}/keys
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 || parts[5] != "keys" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// parts: api v1 admin tenants {id} keys
idPart := parts[4]
var tenantID int64
_, _ = fmt.Sscanf(idPart, "%d", &tenantID)
if tenantID == 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
var req struct {
Scope string `json:"scope"`
TTL int64 `json:"ttl"` // seconds
}
_ = json.NewDecoder(r.Body).Decode(&req)
var ttl time.Duration
if req.TTL > 0 {
ttl = time.Duration(req.TTL) * time.Second
}
key, err := s.store.CreateAPIKey(tenantID, req.Scope, ttl)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create key failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
APIKey string `json:"api_key"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", key, tenantID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// tenant auth by API key
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
tok := BearerToken(r)
ten, err := s.store.VerifyAPIKey(tok)
if err != nil || ten == nil {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Code string `json:"enroll_code"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", code, ten.ID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Code string `json:"code"`
NodeName string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.NodeName == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
et, err := s.store.ConsumeEnrollToken(req.Code)
if err != nil {
s.store.IncEnrollAttempt(req.Code)
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"invalid enroll"}`)
return
}
cred, err := s.store.CreateNodeCredential(et.TenantID, req.NodeName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create node failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
// placeholder to avoid unused import
var _ = store.Tenant{}

343
internal/store/store.go Normal file
View File

@@ -0,0 +1,343 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
DB *sql.DB
}
type Tenant struct {
ID int64
Name string
Status int
Subnet string
}
type APIKey struct {
ID int64
TenantID int64
Hash string
Scope string
Expires *time.Time
Status int
}
type NodeCredential struct {
NodeID int64
NodeName string
Secret string
VirtualIP string
TenantID int64
}
type EnrollToken struct {
ID int64
TenantID int64
Hash string
ExpiresAt int64
UsedAt *int64
MaxAttempt int
Attempts int
Status int
}
func Open(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
return nil, err
}
s := &Store{DB: db}
if err := s.migrate(); err != nil {
return nil, err
}
if err := s.ensureSubnetPool(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status INTEGER NOT NULL DEFAULT 1,
subnet TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
role TEXT NOT NULL,
email TEXT,
password_hash TEXT,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scope TEXT,
expires_at INTEGER,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
node_name TEXT NOT NULL,
node_pubkey TEXT,
node_secret_hash TEXT,
virtual_ip TEXT,
status INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS enroll_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
used_at INTEGER,
max_attempt INTEGER NOT NULL DEFAULT 5,
attempts INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS peering_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_tenant_id INTEGER NOT NULL,
dst_tenant_id INTEGER NOT NULL,
rules TEXT,
expires_at INTEGER,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(src_tenant_id) REFERENCES tenants(id),
FOREIGN KEY(dst_tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_type TEXT,
actor_id TEXT,
action TEXT,
target_type TEXT,
target_id TEXT,
detail TEXT,
ip TEXT,
created_at INTEGER NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS subnet_pool (
subnet TEXT PRIMARY KEY,
status INTEGER NOT NULL DEFAULT 0,
reserved INTEGER NOT NULL DEFAULT 0,
tenant_id INTEGER,
updated_at INTEGER NOT NULL
);`,
}
for _, stmt := range stmts {
if _, err := s.DB.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) ensureSubnetPool() error {
// pool: 10.10.1.0/24 .. 10.10.254.0/24
// reserve: 10.10.0.0/24 and 10.10.255.0/24
rows, err := s.DB.Query(`SELECT COUNT(1) FROM subnet_pool;`)
if err != nil {
return err
}
defer rows.Close()
var count int
if rows.Next() {
_ = rows.Scan(&count)
}
if count > 0 {
return nil
}
now := time.Now().Unix()
insert := `INSERT INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`
// reserved
_, _ = s.DB.Exec(insert, "10.10.0.0/24", 0, 1, nil, now)
_, _ = s.DB.Exec(insert, "10.10.255.0/24", 0, 1, nil, now)
for i := 1; i <= 254; i++ {
sn := fmt.Sprintf("10.10.%d.0/24", i)
_, _ = s.DB.Exec(insert, sn, 0, 0, nil, now)
}
return nil
}
func (s *Store) AllocateSubnet() (string, error) {
// find first available subnet
row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`)
var subnet string
if err := row.Scan(&subnet); err != nil {
return "", err
}
if subnet == "" {
return "", errors.New("no subnet available")
}
return subnet, nil
}
func (s *Store) CreateTenant(name string) (*Tenant, error) {
sn, err := s.AllocateSubnet()
if err != nil {
return nil, err
}
now := time.Now().Unix()
res, err := s.DB.Exec(`INSERT INTO tenants(name,status,subnet,created_at) VALUES(?,?,?,?)`, name, 1, sn, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil
}
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken()
h := hashTokenString(secret)
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, 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 FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) CreateAPIKey(tenantID int64, scope string, ttl time.Duration) (string, error) {
token := randToken()
h := hashTokenString(token)
now := time.Now().Unix()
if ttl > 0 {
e := time.Now().Add(ttl)
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, e.Unix(), now)
return token, err
}
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, nil, now)
return token, err
}
func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt int) (string, error) {
code := randToken()
h := hashTokenString(code)
exp := time.Now().Add(ttl).Unix()
now := time.Now().Unix()
_, err := s.DB.Exec(`INSERT INTO enroll_tokens(tenant_id,token_hash,expires_at,max_attempt,attempts,status,created_at) VALUES(?,?,?,?,0,1,?)`, tenantID, h, exp, maxAttempt, now)
return code, err
}
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
h := hashTokenString(code)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h)
var et EnrollToken
var used sql.NullInt64
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status); err != nil {
return nil, err
}
if used.Valid {
return nil, errors.New("token already used")
}
if et.Status != 1 {
return nil, errors.New("token disabled")
}
if et.Attempts >= et.MaxAttempt {
return nil, errors.New("token attempts exceeded")
}
if now > et.ExpiresAt {
return nil, errors.New("token expired")
}
// mark used
_, err := s.DB.Exec(`UPDATE enroll_tokens SET used_at=?, attempts=attempts+1 WHERE id=?`, now, et.ID)
if err != nil {
return nil, err
}
et.UsedAt = &now
return &et, nil
}
func (s *Store) IncEnrollAttempt(code string) {
h := hashTokenString(code)
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
}
func hashToken(token uint64) string {
b := make([]byte, 8)
for i := uint(0); i < 8; i++ {
b[7-i] = byte(token >> (i * 8))
}
return hashTokenBytes(b)
}
func hashTokenString(token string) string {
return hashTokenBytes([]byte(token))
}
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, nil
}
func hashTokenBytes(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func randToken() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// helper to avoid unused import (net)
var _ = net.IPv4len

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
)
// Version info (set via -ldflags)
@@ -46,8 +47,9 @@ type ServerConfig struct {
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
Token uint64 `json:"token"` // master token for auth
JWTKey string `json:"jwtKey"` // auto-generated if empty
Token uint64 `json:"token"` // master token for auth
Tokens []uint64 `json:"tokens"` // additional tenant tokens
JWTKey string `json:"jwtKey"` // auto-generated if empty
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPass"`
@@ -82,6 +84,18 @@ func (c *ServerConfig) FillFromEnv() {
if v := os.Getenv("INP2PS_TOKEN"); v != "" {
c.Token, _ = strconv.ParseUint(v, 10, 64)
}
if v := os.Getenv("INP2PS_TOKENS"); v != "" {
parts := strings.Split(v, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if tv, err := strconv.ParseUint(p, 10, 64); err == nil {
c.Tokens = append(c.Tokens, tv)
}
}
}
if v := os.Getenv("INP2PS_CERT"); v != "" {
c.CertFile = v
}
@@ -96,8 +110,8 @@ func (c *ServerConfig) FillFromEnv() {
}
func (c *ServerConfig) Validate() error {
if c.Token == 0 {
return fmt.Errorf("token is required (INP2PS_TOKEN or -token)")
if c.Token == 0 && len(c.Tokens) == 0 {
return fmt.Errorf("token is required (INP2PS_TOKEN or INP2PS_TOKENS)")
}
return nil
}
@@ -108,6 +122,7 @@ type ClientConfig struct {
ServerPort int `json:"serverPort"`
Node string `json:"node"`
Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"`
Insecure bool `json:"insecure"` // skip TLS verify
@@ -156,8 +171,8 @@ func (c *ClientConfig) Validate() error {
if c.ServerHost == "" {
return fmt.Errorf("serverHost is required")
}
if c.Token == 0 {
return fmt.Errorf("token is required")
if c.Token == 0 && c.NodeSecret == "" {
return fmt.Errorf("token or nodeSecret is required")
}
if c.Node == "" {
hostname, _ := os.Hostname()

View File

@@ -65,6 +65,7 @@ const (
SubPushSDWANConfig // push sdwan config to client
SubPushSDWANPeer // push sdwan peer online/update
SubPushSDWANDel // push sdwan peer offline/delete
SubPushConfig // generic remote config push
)
// Sub types: MsgTunnel
@@ -191,6 +192,7 @@ func DecodePayload(data []byte, v interface{}) error {
type LoginReq struct {
Node string `json:"node"`
Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"`
Version string `json:"version"`
NATType NATType `json:"natType"`

View File

@@ -103,6 +103,33 @@ func (c *Conn) Request(mainType, subType uint16, payload interface{},
// ReadLoop reads messages and dispatches to handlers. Blocks until error or Close().
func (c *Conn) ReadLoop() error {
// keepalive to avoid idle close (read deadline = 3x ping interval)
_ = c.ws.SetReadDeadline(time.Now().Add(90 * time.Second))
c.ws.SetPongHandler(func(string) error {
_ = c.ws.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
// Send ping frames periodically to keep NAT/WSS alive
// Increased frequency to 10s for better resilience against network hiccups
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.quit:
return
case <-ticker.C:
c.writeMu.Lock()
_ = c.ws.SetWriteDeadline(time.Now().Add(5 * time.Second))
err := c.ws.WriteMessage(websocket.PingMessage, []byte(time.Now().Format("20060102150405")))
if err != nil {
log.Printf("[signal] ping failed: %v, will reconnect", err)
}
c.writeMu.Unlock()
}
}
}()
for {
_, msg, err := c.ws.ReadMessage()
if err != nil {

557
web/index.html Normal file
View File

@@ -0,0 +1,557 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>INP2P Console</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak]{display:none!important}
body{background:#070a14;color:#e2e8f0;font-family:system-ui,sans-serif}
.glass{background:rgba(15,20,37,.7);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.05)}
.ipt{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.65rem .9rem;font-size:.82rem;outline:none;color:#93c5fd}
.ipt:focus{border-color:#3b82f6}
.btn{background:#3b82f6;color:#fff;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem}
.btn:hover{background:#2563eb}
.btn:disabled{opacity:.55;cursor:not-allowed}
.btn2{background:rgba(255,255,255,.06);color:#cbd5e1;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem;border:1px solid rgba(255,255,255,.1)}
.btn2:hover{background:rgba(255,255,255,.1);color:#fff}
.chip{font-size:.7rem;border-radius:999px;padding:.15rem .5rem;border:1px solid rgba(255,255,255,.1)}
.tab{padding:.7rem .8rem;border-radius:.65rem;font-size:.82rem;color:#94a3b8;font-weight:700;cursor:pointer}
.tab:hover{background:rgba(255,255,255,.05);color:#fff}
.tab.active{background:rgba(59,130,246,.14);border:1px solid rgba(59,130,246,.45);color:#fff}
.ok{color:#22c55e}.warn{color:#eab308}.err{color:#ef4444}
</style>
</head>
<body>
<div id="app" v-cloak class="min-h-screen">
<div v-if="!loggedIn" class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md glass rounded-3xl p-8">
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
<div class="space-y-3">
<input v-model="loginTenant" class="ipt" placeholder="Tenant ID用户登录" @keyup.enter="login">
<input v-model="loginUser" class="ipt" placeholder="用户名(如 admin" @keyup.enter="login">
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
<div class="text-xs text-slate-500 text-center">或使用主 Token 登录(管理员)</div>
<input v-model="loginToken" class="ipt" type="password" placeholder="Master Token" @keyup.enter="login">
<button class="btn w-full" :disabled="busy" @click="login">{{ busy ? '登录中...' : '登录' }}</button>
<div class="text-[11px] text-slate-500 text-center">Build: {{ buildVersion }}</div>
<div v-if="loginErr" class="text-red-400 text-sm">{{ loginErr }}</div>
</div>
</div>
</div>
<div v-else class="max-w-7xl mx-auto p-4 md:p-6">
<div class="glass rounded-2xl p-4 flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<div class="text-white font-black">INP2P Console</div>
<div class="text-xs text-slate-500">Role: {{ role || 'unknown' }} · Build: {{ buildVersion }}</div>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">自动刷新(s)</label>
<input class="ipt w-20" type="number" min="5" max="300" v-model.number="refreshSec">
<button class="btn2" :disabled="busy" @click="refreshAll">刷新</button>
<button class="btn" @click="logout">登出</button>
</div>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<div v-for="t in filteredTabs" :key="t.id" class="tab" :class="{active: tab===t.id}" @click="tab=t.id">{{ t.name }}</div>
</div>
<div v-if="msg" class="mb-4 text-sm" :class="msgType==='error'?'err':'ok'">{{ msg }}</div>
<div v-if="tab==='dashboard'" class="space-y-4">
<div class="grid grid-cols-2 md:grid-cols-6 gap-3">
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">在线节点</div><div class="text-xl font-black">{{ stats.nodes || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">中继</div><div class="text-xl font-black">{{ stats.relay || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Cone</div><div class="text-xl font-black">{{ stats.cone || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Symmetric</div><div class="text-xl font-black">{{ stats.symmetric || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Unknown</div><div class="text-xl font-black">{{ stats.unknown || 0 }}</div></div>
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">SDWAN</div><div class="text-xl font-black" :class="stats.sdwan ? 'ok':'warn'">{{ stats.sdwan ? 'ON':'OFF' }}</div></div>
</div>
<div class="glass rounded-xl p-4 text-sm text-slate-300">
<div>服务版本:{{ stats.version || '-' }}</div>
<div>健康状态:<span :class="health.status==='ok'?'ok':'err'">{{ health.status || '-' }}</span></div>
<div>健康上报节点:{{ health.nodes || 0 }}</div>
</div>
</div>
<div v-if="tab==='nodes'" class="glass rounded-2xl overflow-hidden">
<div class="p-3 border-b border-white/10 flex gap-2">
<input v-model="nodeKeyword" class="ipt" placeholder="筛选节点名 / IP">
</div>
<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>
</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>
<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">
<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>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='sdwan'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-3">
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm"><input type="checkbox" v-model="sd.enabled"> 启用 SDWAN</label>
<input class="ipt max-w-xs" v-model="sd.name" placeholder="名称">
<input class="ipt max-w-xs" v-model="sd.gatewayCIDR" placeholder="网段,如 10.10.0.0/24">
<select class="ipt max-w-[140px]" v-model="sd.mode"><option value="mesh">mesh</option><option value="hub">hub</option></select>
<input class="ipt max-w-[120px]" type="number" min="1200" max="9000" v-model.number="sd.mtu" placeholder="MTU">
</div>
<div class="flex gap-2">
<button class="btn2" @click="autoAssignIPs">自动分配 IP</button>
<button class="btn" :disabled="busy" @click="saveSDWAN">保存 SDWAN</button>
</div>
</div>
<div class="glass rounded-xl p-4">
<div class="font-bold mb-3">节点映射</div>
<div class="space-y-2">
<div v-for="(n,i) in sd.nodes" :key="i" class="grid grid-cols-1 md:grid-cols-5 gap-2">
<select class="ipt" v-model="n.node">
<option value="">选择节点</option>
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ x.name }}</option>
</select>
<input class="ipt md:col-span-2" v-model="n.ip" placeholder="10.10.0.X">
<button class="btn2" @click="removeSDWANNode(i)">删除</button>
</div>
</div>
<button class="btn2 mt-3" @click="addSDWANNode">+ 添加节点</button>
</div>
</div>
<div v-if="tab==='p2p'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-3">
<div class="font-bold">手动触发连接</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
<select class="ipt" v-model="connectForm.from"><option value="">From</option><option v-for="n in nodes" :key="'f'+n.name" :value="n.name">{{ n.name }}</option></select>
<select class="ipt" v-model="connectForm.to"><option value="">To</option><option v-for="n in nodes" :key="'t'+n.name" :value="n.name">{{ n.name }}</option></select>
<input class="ipt" type="number" v-model.number="connectForm.srcPort" placeholder="srcPort">
<input class="ipt" type="number" v-model.number="connectForm.dstPort" placeholder="dstPort">
</div>
<input class="ipt" v-model="connectForm.appName" placeholder="appName可空">
<button class="btn" :disabled="busy" @click="doConnect">发送连接请求</button>
</div>
<div class="glass rounded-xl p-4 space-y-3">
<div class="font-bold">远程推配置(/api/v1/nodes/apps</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<select class="ipt" v-model="appPushNode">
<option value="">选择目标节点</option>
<option v-for="n in nodes" :key="'p'+n.name" :value="n.name">{{ n.name }}</option>
</select>
<div class="md:col-span-2 text-xs text-slate-400">示例:[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]</div>
</div>
<textarea class="ipt" style="min-height:130px" v-model="appPushRaw"></textarea>
<button class="btn2" :disabled="busy" @click="pushAppConfigs">发送配置</button>
</div>
</div>
<div v-if="tab==='tenants'" class="space-y-4">
<div class="glass rounded-xl p-4 space-y-2">
<div class="font-bold">创建租户</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
<input class="ipt" v-model="tenantForm.name" placeholder="租户名">
<input class="ipt" v-model="tenantForm.admin_password" placeholder="admin 密码">
<input class="ipt" v-model="tenantForm.operator_password" placeholder="operator 密码">
<button class="btn" :disabled="busy" @click="createTenant">创建</button>
</div>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">名称</th><th class="p-2 text-left">子网</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="t in tenants" :key="t.id" class="border-t border-white/5">
<td class="p-2">{{ t.id }}</td><td class="p-2">{{ t.name }}</td><td class="p-2">{{ t.subnet || '-' }}</td><td class="p-2">{{ t.status===1?'启用':'停用' }}</td>
<td class="p-2 flex gap-2">
<button class="btn2" @click="setTenantStatus(t.id, t.status===1?0:1)">{{ t.status===1?'停用':'启用' }}</button>
<button class="btn2" @click="activeTenant=t.id;tab='apikeys';loadKeys()">Key</button>
<button class="btn2" @click="activeTenant=t.id;tab='users';loadUsers()">用户</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='apikeys'" class="space-y-4">
<div class="glass rounded-xl p-4 flex flex-wrap gap-2 items-center">
<input class="ipt max-w-[120px]" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
<input class="ipt max-w-[120px]" type="number" v-model.number="keyForm.ttl" placeholder="TTL(s)">
<input class="ipt max-w-[140px]" v-model="keyForm.scope" placeholder="scope(all)">
<button class="btn" @click="createKey">创建 API Key</button>
<button class="btn2" @click="loadKeys">刷新 Key</button>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[900px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Scope</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="k in keys" :key="k.id" class="border-t border-white/5">
<td class="p-2">{{ k.id }}</td><td class="p-2">{{ k.scope }}</td><td class="p-2">{{ k.status===1?'启用':'停用' }}</td><td class="p-2">{{ fmtTime(k.expires_at) }}</td>
<td class="p-2"><button class="btn2" @click="setKeyStatus(k.id, k.status===1?0:1)">{{ k.status===1?'停用':'启用' }}</button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='users'" class="space-y-4">
<div class="glass rounded-xl p-4 grid grid-cols-1 md:grid-cols-6 gap-2">
<input class="ipt" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
<select class="ipt" v-model="userForm.role"><option value="admin">admin</option><option value="operator">operator</option></select>
<input class="ipt" v-model="userForm.email" placeholder="email/username">
<input class="ipt" v-model="userForm.password" placeholder="password">
<button class="btn" @click="createUser">创建用户</button>
<button class="btn2" @click="loadUsers">刷新用户</button>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[1000px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Role</th><th class="p-2 text-left">Email</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="border-t border-white/5">
<td class="p-2">{{ u.id }}</td><td class="p-2">{{ u.role }}</td><td class="p-2">{{ u.email }}</td><td class="p-2">{{ u.status===1?'启用':'停用' }}</td>
<td class="p-2 flex gap-2">
<button class="btn2" @click="setUserStatus(u.id, u.status===1?0:1)">{{ u.status===1?'停用':'启用' }}</button>
<button class="btn2" @click="resetUserPassword(u.id)">重置密码</button>
<button class="btn2" @click="deleteUser(u.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="tab==='enroll'" class="space-y-4">
<div class="glass rounded-xl p-4 flex gap-2">
<button class="btn" @click="createEnroll">生成 enroll_code</button>
<button class="btn2" @click="loadEnrolls">刷新 enroll</button>
</div>
<div class="glass rounded-xl p-4 overflow-auto">
<table class="w-full text-sm min-w-[900px]">
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Code</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
<tbody>
<tr v-for="e in enrolls" :key="e.id" class="border-t border-white/5">
<td class="p-2">{{ e.id }}</td><td class="p-2">{{ e.code || '-' }}</td><td class="p-2">{{ e.status===1?'可用':'停用' }}</td><td class="p-2">{{ fmtTime(e.expires_at) }}</td>
<td class="p-2"><button class="btn2" @click="setEnrollStatus(e.id,0)">作废</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, watch } = Vue;
createApp({
setup(){
const buildVersion = ref('20260303-1838');
const tab = ref('dashboard');
const tabs = [
{id:'dashboard',name:'仪表盘'},{id:'nodes',name:'节点'},{id:'sdwan',name:'SDWAN'},{id:'p2p',name:'P2P'},
{id:'tenants',name:'租户'},{id:'apikeys',name:'API Key'},{id:'users',name:'用户'},{id:'enroll',name:'Enroll'}
];
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
const role = ref(''), status = ref(1);
const loginTenant = ref('1'), loginUser = ref('admin'), loginPass = ref('admin'), loginToken = ref(''), loginErr = ref('');
const refreshSec = ref(15), timer = ref(null);
const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', mtu:1420, nodes:[], routes:['10.10.0.0/24'] });
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
const tenantForm = ref({ name:'', admin_password:'', operator_password:'' });
const keyForm = ref({ scope:'all', ttl:0 });
const userForm = ref({ role:'operator', email:'', password:'' });
const isAdmin = computed(() => role.value === 'admin' && localStorage.getItem('t') === localStorage.getItem('master_t'));
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
const filteredNodes = computed(() => {
const k = (nodeKeyword.value || '').trim().toLowerCase();
if (!k) return nodes.value;
return nodes.value.filter(n => (n.name||'').toLowerCase().includes(k) || (n.publicIP||'').toLowerCase().includes(k));
});
const toast = (text, t='ok') => { msg.value = text; msgType.value = t; setTimeout(() => { if (msg.value === text) msg.value = ''; }, 2500); };
const bearer = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('t') || '') });
const api = async (path, opt={}) => {
const headers = { 'Content-Type': 'application/json', ...(opt.headers||{}), ...bearer() };
const r = await fetch(path, { ...opt, headers });
let d = {};
try { d = await r.json(); } catch(_) {}
if (!r.ok) {
if (r.status === 401) {
loggedIn.value = false;
throw new Error('401 登录已过期');
}
throw new Error(d.message || ('HTTP ' + r.status));
}
return d;
};
const natText = t => t===1?'Cone':(t===2?'Symmetric':'Unknown');
const uptime = ts => {
if(!ts) return '-';
const sec = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000));
if(sec < 60) return sec + 's';
if(sec < 3600) return Math.floor(sec/60) + 'm';
if(sec < 86400) return Math.floor(sec/3600) + 'h';
return Math.floor(sec/86400) + 'd';
};
const fmtTime = t => t ? new Date(t).toLocaleString() : '-';
const login = async () => {
loginErr.value = '';
busy.value = true;
try {
let d;
if ((loginToken.value || '').trim()) {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token: loginToken.value.trim() }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || 'token 登录失败');
localStorage.setItem('master_t', d.token || '');
} else {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tenant: Number(loginTenant.value || 1), username: loginUser.value, password: loginPass.value }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || '用户名密码登录失败');
}
localStorage.setItem('t', d.token || '');
role.value = d.role || '';
status.value = d.status ?? 1;
if (status.value !== 1) throw new Error('账号已停用');
loggedIn.value = true;
await refreshAll();
toast('登录成功');
} catch (e) {
loginErr.value = e.message || '登录失败';
} finally {
busy.value = false;
}
};
const logout = () => {
localStorage.removeItem('t');
localStorage.removeItem('master_t');
loggedIn.value = false;
role.value = '';
stopTimer();
};
const refreshAll = async () => {
if (!loggedIn.value) return;
busy.value = true;
try {
[health.value, stats.value] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]);
const nd = await api('/api/v1/nodes');
nodes.value = nd.nodes || [];
sd.value = await api('/api/v1/sdwans');
} catch (e) {
toast(e.message || '刷新失败', 'error');
} finally {
busy.value = false;
}
};
const saveSDWAN = async () => {
try {
await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(sd.value) });
toast('SDWAN 保存成功');
await refreshAll();
} catch (e) { toast(e.message, 'error'); }
};
const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }];
const removeSDWANNode = i => sd.value.nodes.splice(i, 1);
const autoAssignIPs = () => {
const used = new Set();
(sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
let k = 2;
for (const n of sd.value.nodes || []) {
if (!n.ip) {
while (used.has(k) && k < 254) k++;
n.ip = `10.10.0.${k}`;
used.add(k);
k++;
}
}
toast('已自动分配缺失 IP');
};
const kickNode = async (node) => {
if (!confirm(`确认踢下线节点 ${node} ?`)) return;
try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); toast('已发送踢下线'); 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'; };
const pushAppConfigs = async () => {
if (!appPushNode.value) return toast('请先选择节点', 'error');
let apps;
try { apps = JSON.parse(appPushRaw.value); } catch(_) { return toast('配置 JSON 格式错误', 'error'); }
try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); toast('配置已推送'); }
catch(e){ toast(e.message, 'error'); }
};
const openConnect = (from) => { connectForm.value.from = from; tab.value = 'p2p'; };
const doConnect = async () => {
const req = { ...connectForm.value };
if (!req.from || !req.to) return toast('请选择 from/to 节点', 'error');
try {
await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) });
toast('连接请求已发送');
} catch(e){ toast(e.message, 'error'); }
};
const loadTenants = async () => {
if (!isAdmin.value) { tenants.value = []; return; }
try { const d = await api('/api/v1/admin/tenants'); tenants.value = d.tenants || []; }
catch(e){ toast(e.message, 'error'); }
};
const createTenant = async () => {
if (!tenantForm.value.name) return toast('请输入租户名', 'error');
try {
await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(tenantForm.value) });
tenantForm.value = { name:'', admin_password:'', operator_password:'' };
toast('租户创建成功');
await loadTenants();
} catch(e){ toast(e.message, 'error'); }
};
const setTenantStatus = async (id, st) => {
try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('租户状态已更新'); loadTenants(); }
catch(e){ toast(e.message, 'error'); }
};
const loadKeys = async () => {
if (!activeTenant.value) return;
try { const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`); keys.value = d.keys || []; }
catch(e){ toast(e.message, 'error'); }
};
const createKey = async () => {
if (!activeTenant.value) return toast('请先填写 Tenant ID', 'error');
try {
const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(keyForm.value) });
toast(`API Key 创建成功: ${d.api_key || ''}`);
await loadKeys();
} catch(e){ toast(e.message, 'error'); }
};
const setKeyStatus = async (id, st) => {
try { await api(`/api/v1/admin/tenants/${activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('Key 状态已更新'); loadKeys(); }
catch(e){ toast(e.message, 'error'); }
};
const loadUsers = async () => {
if (!activeTenant.value) return;
try { const d = await api(`/api/v1/admin/users?tenant=${activeTenant.value}`); users.value = d.users || []; }
catch(e){ toast(e.message, 'error'); }
};
const createUser = async () => {
if (!activeTenant.value || !userForm.value.email || !userForm.value.password) return toast('请补全用户信息', 'error');
try {
await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(activeTenant.value), ...userForm.value }) });
userForm.value = { role:'operator', email:'', password:'' };
toast('用户创建成功');
loadUsers();
} catch(e){ toast(e.message, 'error'); }
};
const setUserStatus = async (id, st) => {
try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('用户状态已更新'); loadUsers(); }
catch(e){ toast(e.message, 'error'); }
};
const resetUserPassword = async (id) => {
const p = prompt('请输入新密码至少6位');
if (!p) return;
try { await api(`/api/v1/admin/users/${id}/password`, { method:'POST', body: JSON.stringify({ password: p }) }); toast('密码已重置'); }
catch(e){ toast(e.message, 'error'); }
};
const deleteUser = async (id) => {
if (!confirm('确认删除该用户?')) return;
try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); toast('用户已删除'); loadUsers(); }
catch(e){ toast(e.message, 'error'); }
};
const loadEnrolls = async () => {
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
catch(e){ toast(e.message, 'error'); }
};
const createEnroll = async () => {
try {
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
toast(`enroll_code: ${d.enroll_code || ''}`);
loadEnrolls();
} catch(e){ toast(e.message, 'error'); }
};
const setEnrollStatus = async (id, st) => {
try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('enroll 状态已更新'); loadEnrolls(); }
catch(e){ toast(e.message, 'error'); }
};
const consumeEnroll = async () => {
toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用');
};
const updateCharts = () => {};
const stopTimer = () => {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
};
const startTimer = () => {
stopTimer();
if (!loggedIn.value) return;
const sec = Math.max(5, Number(refreshSec.value || 15));
timer.value = setInterval(refreshAll, sec * 1000);
};
watch(refreshSec, startTimer);
watch(loggedIn, (v) => {
if (v) startTimer();
else stopTimer();
});
onMounted(() => {
logout();
});
return {
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status,
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
natText, uptime, fmtTime,
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, autoAssignIPs,
kickNode, openAppManager, pushAppConfigs, openConnect, doConnect,
createTenant, loadTenants, setTenantStatus,
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
updateCharts
};
}
}).mount('#app');
</script>
</body>
</html>