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" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/openp2p-cn/inp2p/internal/server" "github.com/openp2p-cn/inp2p/internal/server"
"github.com/openp2p-cn/inp2p/pkg/auth" "github.com/openp2p-cn/inp2p/pkg/auth"
@@ -84,25 +86,246 @@ func main() {
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN) startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
} }
// ─── Signaling Server ───
// ─── Signaling Server ─── // ─── Signaling Server ───
srv := server.New(cfg) srv := server.New(cfg)
srv.StartCleanup() 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 := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS) 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") w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes())) 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 { if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
w.Header().Set("Content-Type", "application/json") 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()) _ = 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 { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@@ -112,13 +335,190 @@ func main() {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return 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 { if err := srv.SetSDWAN(req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"}) _ = 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 ─── // ─── HTTP Listener ───
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort)) 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) 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() { go func() {
if err := httpSrv.Serve(ln); err != http.ErrServerClosed { if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
log.Fatalf("[main] serve: %v", err) 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 github.com/gorilla/websocket v1.5.3
require golang.org/x/sys v0.41.0 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.publicIP = natResult.PublicIP
c.publicPort = natResult.Port1 c.publicPort = natResult.Port1
c.localPort = natResult.LocalPort 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 // 2. WSS Connect
scheme := "ws" scheme := "ws"
@@ -642,28 +642,34 @@ func (c *Client) tunReadLoop() {
if c.IsStopping() { if c.IsStopping() {
return return
} }
time.Sleep(100 * time.Millisecond) // 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) log.Printf("[client] tun read error: %v", err)
} }
time.Sleep(100 * time.Millisecond)
continue
}
// Skip empty packets or non-IPv4
if n == 0 || n < 20 { if n == 0 || n < 20 {
log.Printf("[client] tun read error: %v", err) continue
} }
pkt := buf[:n] pkt := buf[:n]
version := pkt[0] >> 4 version := pkt[0] >> 4
if version != 4 { if version != 4 {
log.Printf("[client] tun read error: %v", err) continue // skip non-IPv4
} }
dstIP := net.IP(pkt[16:20]).String() dstIP := net.IP(pkt[16:20]).String()
c.sdwanMu.RLock() c.sdwanMu.RLock()
self := c.sdwanIP self := c.sdwanIP
c.sdwanMu.RUnlock() c.sdwanMu.RUnlock()
if dstIP == self { if dstIP == self {
log.Printf("[client] tun read error: %v", err) continue // skip packets to self
} }
// send raw binary to avoid JSON base64 overhead // 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) 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" "log"
"time" "time"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/protocol" "github.com/openp2p-cn/inp2p/pkg/protocol"
) )
@@ -17,12 +18,12 @@ import (
// HandleConnectReq processes a connection request from node A to node B. // HandleConnectReq processes a connection request from node A to node B.
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error { 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() { 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{ from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 1, Error: 1,
Detail: fmt.Sprintf("node %s offline", req.To), Detail: "node not found",
From: req.To, From: req.To,
To: req.From, To: req.From,
}) })
@@ -38,6 +39,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: from.PublicPort, Port: from.PublicPort,
NATType: from.NATType, NATType: from.NATType,
HasIPv4: from.HasIPv4, HasIPv4: from.HasIPv4,
Token: auth.GenTOTP(from.Token, time.Now().Unix()),
} }
from.mu.RUnlock() from.mu.RUnlock()
@@ -47,6 +49,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: to.PublicPort, Port: to.PublicPort,
NATType: to.NATType, NATType: to.NATType,
HasIPv4: to.HasIPv4, HasIPv4: to.HasIPv4,
Token: auth.GenTOTP(to.Token, time.Now().Unix()),
} }
to.mu.RUnlock() to.mu.RUnlock()

Binary file not shown.

Binary file not shown.

View File

@@ -15,10 +15,11 @@ type sdwanStore struct {
mu sync.RWMutex mu sync.RWMutex
path string path string
cfg protocol.SDWANConfig cfg protocol.SDWANConfig
multi map[int64]protocol.SDWANConfig
} }
func newSDWANStore(path string) *sdwanStore { func newSDWANStore(path string) *sdwanStore {
s := &sdwanStore{path: path} s := &sdwanStore{path: path, multi: make(map[int64]protocol.SDWANConfig)}
_ = s.load() _ = s.load()
return s return s
} }
@@ -33,6 +34,15 @@ func (s *sdwanStore) load() error {
} }
return err 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 var c protocol.SDWANConfig
if err := json.Unmarshal(b, &c); err != nil { if err := json.Unmarshal(b, &c); err != nil {
return err return err
@@ -57,12 +67,40 @@ func (s *sdwanStore) save(cfg protocol.SDWANConfig) error {
return nil 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 { func (s *sdwanStore) get() protocol.SDWANConfig {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.cfg 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 { func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
if c.Mode == "" { if c.Mode == "" {
c.Mode = "hub" c.Mode = "hub"

View File

@@ -11,6 +11,10 @@ func (s *Server) GetSDWAN() protocol.SDWANConfig {
return s.sdwan.get() 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 { func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
if err := s.sdwan.save(cfg); err != nil { if err := s.sdwan.save(cfg); err != nil {
return err return err
@@ -19,6 +23,14 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil 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) { func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" { if !cfg.Enabled || cfg.GatewayCIDR == "" {
return 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) { func (s *Server) pushSDWANPeer(to *NodeInfo, peer protocol.SDWANPeer) {
if to == nil || !to.IsOnline() { if to == nil || !to.IsOnline() {
return return
@@ -48,7 +74,14 @@ func (s *Server) pushSDWANDel(to *NodeInfo, peer protocol.SDWANPeer) {
} }
func (s *Server) announceSDWANNodeOnline(nodeName string) { 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 == "" { if cfg.GatewayCIDR == "" {
return return
} }
@@ -64,7 +97,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
} }
s.mu.RLock() s.mu.RLock()
newNode := s.nodes[nodeName] newNode = s.nodes[nodeName]
if newNode == nil || !newNode.IsOnline() { if newNode == nil || !newNode.IsOnline() {
s.mu.RUnlock() s.mu.RUnlock()
return return
@@ -74,7 +107,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
continue continue
} }
other := s.nodes[n.Node] other := s.nodes[n.Node]
if other == nil || !other.IsOnline() { if other == nil || !other.IsOnline() || other.TenantID != newNode.TenantID {
continue continue
} }
// existing -> new // existing -> new
@@ -86,7 +119,13 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
} }
func (s *Server) announceSDWANNodeOffline(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 == "" { if cfg.GatewayCIDR == "" {
return return
} }
@@ -100,7 +139,7 @@ func (s *Server) announceSDWANNodeOffline(nodeName string) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, n := range s.nodes { for _, n := range s.nodes {
if n.Name == nodeName || !n.IsOnline() { if n.Name == nodeName || !n.IsOnline() || n.TenantID != old.TenantID {
continue continue
} }
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false}) 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 { if from == nil {
return 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 { if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
return return
} }
@@ -124,20 +169,29 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
toNode := "" toNode := ""
for _, n := range cfg.Nodes { for _, n := range cfg.Nodes {
if n.IP == pkt.DstIP { if n.IP == pkt.DstIP {
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node toNode = n.Node
break break
} }
}
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst { if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node toNode = n.Node
break break
} }
} }
}
if toNode == "" || toNode == from.Name { if toNode == "" || toNode == from.Name {
return return
} }
s.mu.RLock() s.mu.RLock()
to := s.nodes[toNode] to := s.nodes[toNode]
if to != nil && to.TenantID != from.TenantID {
to = nil
}
s.mu.RUnlock() s.mu.RUnlock()
if to == nil || !to.IsOnline() { if to == nil || !to.IsOnline() {
return return

View File

@@ -2,6 +2,7 @@
package server package server
import ( import (
"fmt"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -10,6 +11,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/pkg/auth" "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/config"
"github.com/openp2p-cn/inp2p/pkg/protocol" "github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal" "github.com/openp2p-cn/inp2p/pkg/signal"
@@ -17,26 +19,27 @@ import (
// NodeInfo represents a connected client node. // NodeInfo represents a connected client node.
type NodeInfo struct { type NodeInfo struct {
Name string Name string `json:"name"`
Token uint64 Token uint64 `json:"-"`
User string TenantID int64 `json:"tenantId"`
Version string User string `json:"user"`
NATType protocol.NATType Version string `json:"version"`
PublicIP string NATType protocol.NATType `json:"natType"`
PublicPort int PublicIP string `json:"publicIP"`
LanIP string PublicPort int `json:"publicPort"`
OS string LanIP string `json:"lanIP"`
Mac string OS string `json:"os"`
ShareBandwidth int Mac string `json:"mac"`
RelayEnabled bool ShareBandwidth int `json:"shareBandwidth"`
SuperRelay bool RelayEnabled bool `json:"relayEnabled"`
HasIPv4 int SuperRelay bool `json:"superRelay"`
IPv6 string HasIPv4 int `json:"hasIPv4"`
LoginTime time.Time IPv6 string `json:"ipv6"`
LastHeartbeat time.Time LoginTime time.Time `json:"loginTime"`
Conn *signal.Conn LastHeartbeat time.Time `json:"lastHeartbeat"`
Apps []protocol.AppConfig Conn *signal.Conn `json:"-"`
mu sync.RWMutex Apps []protocol.AppConfig `json:"apps"`
mu sync.RWMutex `json:"-"`
} }
// IsOnline checks if node has sent heartbeat recently. // IsOnline checks if node has sent heartbeat recently.
@@ -49,25 +52,43 @@ func (n *NodeInfo) IsOnline() bool {
// Server is the INP2P signaling server. // Server is the INP2P signaling server.
type Server struct { type Server struct {
cfg config.ServerConfig cfg config.ServerConfig
nodes map[string]*NodeInfo // node name → info nodes map[string]*NodeInfo
mu sync.RWMutex mu sync.RWMutex
upgrader websocket.Upgrader upgrader websocket.Upgrader
quit chan struct{} quit chan struct{}
sdwanPath string sdwanPath string
sdwan *sdwanStore sdwan *sdwanStore
store *store.Store
tokens map[uint64]bool
} }
func (s *Server) Store() *store.Store { return s.store }
// New creates a new server. // New creates a new server.
func New(cfg config.ServerConfig) *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" 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{ return &Server{
cfg: cfg, cfg: cfg,
nodes: make(map[string]*NodeInfo), nodes: make(map[string]*NodeInfo),
sdwanPath: sdwanPath, sdwanPath: sdwanPath,
sdwan: newSDWANStore(sdwanPath), sdwan: newSDWANStore(sdwanPath),
store: st,
tokens: tokens,
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
}, },
quit: make(chan struct{}), quit: make(chan struct{}),
} }
@@ -93,6 +114,42 @@ func (s *Server) GetOnlineNodes() []*NodeInfo {
return out 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. // GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay // Priority: same-user private relay → super relay
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo { 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...) 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. // HandleWS is the WebSocket handler for client connections.
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil) ws, err := s.upgrader.Upgrade(w, r, nil)
@@ -151,8 +230,26 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
return return
} }
// Verify token // Verify token: master token OR tenant API key (DB) OR node_secret (DB)
if loginReq.Token != s.cfg.Token { 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) log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{ conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 1, Error: 1,
@@ -174,6 +271,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
node := &NodeInfo{ node := &NodeInfo{
Name: loginReq.Node, Name: loginReq.Node,
Token: loginReq.Token, Token: loginReq.Token,
TenantID: tenantID,
User: loginReq.User, User: loginReq.User,
Version: loginReq.Version, Version: loginReq.Version,
NATType: loginReq.NATType, NATType: loginReq.NATType,
@@ -211,6 +309,15 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
s.broadcastNodeOnline(loginReq.Node) s.broadcastNodeOnline(loginReq.Node)
// Push current SDWAN config right after login (if exists and enabled) // Push current SDWAN config right after login (if exists and enabled)
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 cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil { if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err) log.Printf("[server] sdwan config push failed: %v", err)
@@ -218,6 +325,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node) log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
} }
} }
}
// Event-driven SDWAN peer notification // Event-driven SDWAN peer notification
s.announceSDWANNodeOnline(loginReq.Node) s.announceSDWANNodeOnline(loginReq.Node)
@@ -378,10 +486,13 @@ func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req
// PushConnect sends a punch coordination message to a peer node. // PushConnect sends a punch coordination message to a peer node.
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error { 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() { if toNode == nil || !toNode.IsOnline() {
return &NodeOfflineError{Node: toNodeName} return &NodeOfflineError{Node: toNodeName}
} }
if fromNode.TenantID != 0 && toNode.TenantID != fromNode.TenantID {
return &NodeOfflineError{Node: toNodeName}
}
// Push connect request to the destination // Push connect request to the destination
req := protocol.ConnectReq{ req := protocol.ConnectReq{
@@ -392,6 +503,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
IP: fromNode.PublicIP, IP: fromNode.PublicIP,
NATType: fromNode.NATType, NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4, HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
}, },
AppName: app.AppName, AppName: app.AppName,
Protocol: app.Protocol, 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. // broadcastNodeOnline notifies interested nodes that a peer came online.
func (s *Server) broadcastNodeOnline(nodeName string) { func (s *Server) broadcastNodeOnline(nodeName string) {
s.mu.RLock() s.mu.RLock()
newNode := s.nodes[nodeName]
defer s.mu.RUnlock() defer s.mu.RUnlock()
if newNode == nil {
return
}
for _, n := range s.nodes { for _, n := range s.nodes {
if n.Name == nodeName { if n.Name == nodeName {
continue 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 // Check if this node has any app targeting the new node
n.mu.RLock() n.mu.RLock()
interested := false 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" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
) )
// Version info (set via -ldflags) // Version info (set via -ldflags)
@@ -47,6 +48,7 @@ type ServerConfig struct {
KeyFile string `json:"keyFile"` KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
Token uint64 `json:"token"` // master token for auth Token uint64 `json:"token"` // master token for auth
Tokens []uint64 `json:"tokens"` // additional tenant tokens
JWTKey string `json:"jwtKey"` // auto-generated if empty JWTKey string `json:"jwtKey"` // auto-generated if empty
AdminUser string `json:"adminUser"` AdminUser string `json:"adminUser"`
@@ -82,6 +84,18 @@ func (c *ServerConfig) FillFromEnv() {
if v := os.Getenv("INP2PS_TOKEN"); v != "" { if v := os.Getenv("INP2PS_TOKEN"); v != "" {
c.Token, _ = strconv.ParseUint(v, 10, 64) 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 != "" { if v := os.Getenv("INP2PS_CERT"); v != "" {
c.CertFile = v c.CertFile = v
} }
@@ -96,8 +110,8 @@ func (c *ServerConfig) FillFromEnv() {
} }
func (c *ServerConfig) Validate() error { func (c *ServerConfig) Validate() error {
if c.Token == 0 { if c.Token == 0 && len(c.Tokens) == 0 {
return fmt.Errorf("token is required (INP2PS_TOKEN or -token)") return fmt.Errorf("token is required (INP2PS_TOKEN or INP2PS_TOKENS)")
} }
return nil return nil
} }
@@ -108,6 +122,7 @@ type ClientConfig struct {
ServerPort int `json:"serverPort"` ServerPort int `json:"serverPort"`
Node string `json:"node"` Node string `json:"node"`
Token uint64 `json:"token"` Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
Insecure bool `json:"insecure"` // skip TLS verify Insecure bool `json:"insecure"` // skip TLS verify
@@ -156,8 +171,8 @@ func (c *ClientConfig) Validate() error {
if c.ServerHost == "" { if c.ServerHost == "" {
return fmt.Errorf("serverHost is required") return fmt.Errorf("serverHost is required")
} }
if c.Token == 0 { if c.Token == 0 && c.NodeSecret == "" {
return fmt.Errorf("token is required") return fmt.Errorf("token or nodeSecret is required")
} }
if c.Node == "" { if c.Node == "" {
hostname, _ := os.Hostname() hostname, _ := os.Hostname()

View File

@@ -65,6 +65,7 @@ const (
SubPushSDWANConfig // push sdwan config to client SubPushSDWANConfig // push sdwan config to client
SubPushSDWANPeer // push sdwan peer online/update SubPushSDWANPeer // push sdwan peer online/update
SubPushSDWANDel // push sdwan peer offline/delete SubPushSDWANDel // push sdwan peer offline/delete
SubPushConfig // generic remote config push
) )
// Sub types: MsgTunnel // Sub types: MsgTunnel
@@ -191,6 +192,7 @@ func DecodePayload(data []byte, v interface{}) error {
type LoginReq struct { type LoginReq struct {
Node string `json:"node"` Node string `json:"node"`
Token uint64 `json:"token"` Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
Version string `json:"version"` Version string `json:"version"`
NATType NATType `json:"natType"` 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(). // ReadLoop reads messages and dispatches to handlers. Blocks until error or Close().
func (c *Conn) ReadLoop() error { 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 { for {
_, msg, err := c.ws.ReadMessage() _, msg, err := c.ws.ReadMessage()
if err != nil { 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>