Compare commits

...

15 Commits

Author SHA1 Message Date
8aad25bf26 fix: sdwan page blank (computed Set in template) 2026-03-06 16:11:21 +08:00
9b590dd08f feat: mark offline nodes in sdwan mapping 2026-03-06 15:50:32 +08:00
5fc7611108 feat: add save buttons for sdwan sections 2026-03-06 15:45:32 +08:00
36bbcf15f3 fix: keep session on frontend load 2026-03-06 15:30:21 +08:00
ba63085de9 feat: admin settings and audit UI 2026-03-06 15:20:33 +08:00
57b4dadd42 feat: audit api, sdwan persist, relay fallback updates 2026-03-06 14:47:03 +08:00
e96a2e5dd9 sdwan: add hub node selection and auto fallback to mesh 2026-03-05 22:03:26 +08:00
5fe5c76375 node: return observable metrics for ip change broadcast/reconnect 2026-03-03 20:35:38 +08:00
065f9ba5b6 phase-b: add node uuid/alias/ip metadata APIs and node list enrichment 2026-03-03 20:29:44 +08:00
3b555df56c auth: switch user login to session token and decouple tenant access 2026-03-03 19:45:09 +08:00
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
30 changed files with 4153 additions and 134 deletions

3
.gitignore vendored
View File

@@ -4,6 +4,9 @@ bin/
*.dll
*.so
*.dylib
inp2pc
inp2ps
web/vendor/
# Test binary
*.test

220
ARCH_V1_USER_NETWORK.md Normal file
View File

@@ -0,0 +1,220 @@
# INP2P 最终方案 v1用户网络模型
> 状态:已冻结(按 2026-03-03 讨论结论)
> 目标:稳定优先,先完成认证/网络模型收敛,再做增量体验优化。
---
## 0. 冻结决策(已确认)
1. 一账号=一网络1:1
2. Hub 离线自动回 Mesh
3. 改 IP 后:目标节点重连 + 同网广播更新
4. API Key保留后台能力前台不突出
5. 暂不上 Refresh Token
6. Node 唯一标识采用 UUID稳定优先
---
## 1. 核心对象模型
### 1.1 Account账号
- 控制台登录主体(人)
- 字段:
- `account_id` (int64 PK)
- `username` (unique)
- `password_hash`
- `status` (1/0)
- `created_at`
### 1.2 Network用户网络
- 每个账号唯一绑定一个网络
- 字段:
- `network_id` (int64 PK)
- `account_id` (unique FK -> accounts)
- `cidr` (e.g. `10.0.1.0/24`)
- `mode` (`mesh`|`hub`)
- `hub_node_id` (nullable, FK -> nodes.node_id)
- `fallback_to_mesh` (bool, default true)
- `status` (1/0)
- `created_at`, `updated_at`
### 1.3 Node设备
- 设备唯一标识不变;展示名可变
- 字段:
- `node_id` (UUID, PK) ✅ 后端唯一键
- `network_id` (FK)
- `hostname` (设备上报)
- `alias` (用户自定义昵称,可空)
- `virtual_ip` (network cidr 内)
- `node_secret_hash`
- `relay_enabled` (bool)
- `online` (bool)
- `last_seen`
- `created_at`, `updated_at`
### 1.4 凭证对象
- `session_tokens`(控制台会话,短期)
- `enroll_tokens`(一次性/短期入网码)
- `api_keys`(后台自动化,非控制台主要路径)
---
## 2. 凭证与认证边界(彻底拆分)
### 2.1 会话 token控制台
- 登录成功后返回 `session_token`
- 内含:`account_id`, `network_id`, `role=owner`, `exp`
- 用于所有控制台 API
### 2.2 enroll token设备入网
- 控制台生成,一次性,短时有效(默认 10 分钟)
- 设备携带 `hostname` + enroll token 兑换 `node_id + node_secret + virtual_ip`
### 2.3 node_secret设备长期
- 设备 WS 登录凭证
- 仅设备连接信令使用,不用于控制台
### 2.4 API Key后台能力
- 用于自动化脚本/平台对接
- 不作为控制台默认登录路径
- 前台不突出,仅高级设置页可见
---
## 3. 网络与 IPAM 规则
### 3.1 子网分配
- 池:`10.0.1.0/24` ~ `10.0.254.0/24`
- 保留:`10.0.0.0/24``10.0.255.0/24`
- 账号注册时分配第一个可用网段
### 3.2 地址分配
- 每网络内自动分配:从 `.2` 开始
- 保留 `.0/.255/.1`
- 禁止重复占用
### 3.3 手动改 IP
- 必须在网络 CIDR 内
- 不可与现有节点冲突
- 成功后触发:
1) 目标节点收到 `ip_changed` 推送并重连
2) 服务端广播 `peer_ip_changed` 给同网在线节点
---
## 4. 拓扑模式状态机
### 4.1 Mesh默认
- 同网节点按策略尝试直连UDP/TCP
- 失败后按中继策略兜底
### 4.2 Hub
- 条件:`hub_node_id` 必填,且必须“在线 + 同网络 + relay_enabled=true”
- 数据面按 hub 转发
### 4.3 Hub 离线回退
- 监测阈值15~30 秒无心跳
- 自动回 `mesh`(记录审计日志)
- Hub 恢复后暂不自动切回v1 简化)
---
## 5. API 草图v1
## 5.1 认证
- `POST /api/v1/auth/register`
- req: `{username,password}`
- rsp: `{account_id, network:{cidr,mode}}`
- `POST /api/v1/auth/login`
- req: `{username,password}`
- rsp: `{session_token, expires_in, network}`
- `POST /api/v1/auth/logout`
## 5.2 网络配置
- `GET /api/v1/network`
- `POST /api/v1/network/mode`
- req: `{mode:"mesh"|"hub", hub_node_id?}`
- `POST /api/v1/network/hub`
- req: `{hub_node_id}`(含在线校验)
## 5.3 节点管理
- `GET /api/v1/nodes`
- `POST /api/v1/nodes/{node_id}/alias`
- req: `{alias}`
- `POST /api/v1/nodes/{node_id}/ip`
- req: `{virtual_ip}`
- `POST /api/v1/nodes/{node_id}/kick`
## 5.4 入网与设备凭证
- `POST /api/v1/enroll/create`
- `POST /api/v1/enroll/revoke/{id}`
- `POST /api/v1/enroll/consume`
- req: `{code, hostname}`
- rsp: `{node_id, node_secret, virtual_ip, network_cidr}`
## 5.5 自动化(低显著)
- `GET/POST /api/v1/settings/api-keys`
---
## 6. 前端展示规范
- 节点主显示名:`alias || hostname`
- 次级显示:`node_id`(短)
- 明确区分:
- "控制台会话"session
- "设备凭证"node_secret
- "自动化凭证"api key
- API Key 页面放到“高级设置”折叠区,不作为主流程入口
---
## 7. 迁移计划(分阶段)
### Phase A认证收敛必做
1. `auth/login` 改为返回 session token不再返回 API key
2. 中间件基于 session 解出 `account_id/network_id`
3. 控制台 API 全量改用 session 鉴权
### Phase B节点模型升级
1. 引入 `node_id(UUID)`
2. 增加 `alias` 字段
3. 节点唯一性改以 `node_id` 为准(非 hostname
### Phase CIPAM + 拓扑闭环)
1. 改 IP 重连与广播机制
2. Hub 在线校验 + 自动回 mesh
3. 观测与审计日志完善
---
## 8. 验收标准v1
1. 用户登录后拿到 session token不是 API key
2. 同一网络外的数据不可见
3. 改 IP 后目标节点重连,其他节点收到变更通知
4. hub 下线后自动回 mesh
5. node_id 在重命名 hostname/alias 后仍不变
6. API Key 不影响控制台主流程
---
## 9. 风险与规避
- 风险:旧 token 体系与新 session 并存期可能混淆
- 规避:版本开关 + 明确响应字段(`token_type=session`
- 风险:节点重连窗口导致短时抖动
- 规避:变更前提示 + 逐节点串行生效
- 风险hub 恢复/掉线频繁导致模式抖动
- 规避:加入最小驻留时间(如 60s
---
## 10. 下一步实施顺序(立即执行)
1. 后端:新增 session token 生成/校验(无 refresh
2. 后端:中间件切换到 session 识别 network
3. 前端:登录流程改读 `session_token`
4. 后端:保留 API key 能力但移出主登录流程
5. 回归联调登录、节点、sdwan、connect、enroll、hub 回退

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

@@ -25,8 +25,9 @@ func main() {
user := flag.String("user", "", "Username for token generation")
pass := flag.String("password", "", "Password for token generation")
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
flag.BoolVar(&cfg.RelayEnabled, "relay", false, "Enable relay capability")
flag.BoolVar(&cfg.SuperRelay, "super", false, "Register as super relay node (implies -relay)")
flag.BoolVar(&cfg.RelayEnabled, "relay", cfg.RelayEnabled, "Enable relay capability")
flag.BoolVar(&cfg.SuperRelay, "super", cfg.SuperRelay, "Register as super relay node (implies -relay)")
flag.BoolVar(&cfg.RelayOfficial, "official-relay", cfg.RelayOfficial, "Register as official relay node")
flag.IntVar(&cfg.RelayPort, "relay-port", cfg.RelayPort, "Relay listen port")
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
@@ -49,9 +50,7 @@ func main() {
// Load config file first (unless -newconfig)
if !*newConfig {
if data, err := os.ReadFile(*configFile); err == nil {
var fileCfg config.ClientConfig
if err := json.Unmarshal(data, &fileCfg); err == nil {
cfg = fileCfg
if err := json.Unmarshal(data, &cfg); err == nil {
// fill defaults for missing fields
if cfg.ServerPort == 0 {
cfg.ServerPort = config.DefaultWSPort
@@ -101,6 +100,9 @@ func main() {
case "super":
cfg.SuperRelay = true
cfg.RelayEnabled = true // super implies relay
case "official-relay":
cfg.RelayOfficial = true
cfg.RelayEnabled = true
case "bw":
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
}

View File

@@ -6,20 +6,64 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/openp2p-cn/inp2p/internal/server"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/nat"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
type rateLimiter struct {
mu sync.Mutex
m map[string]*rateEntry
max int
window time.Duration
}
type rateEntry struct {
count int
reset time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{m: make(map[string]*rateEntry), max: max, window: window}
}
func (r *rateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.m[key]
now := time.Now()
if !ok || now.After(e.reset) {
r.m[key] = &rateEntry{count: 1, reset: now.Add(r.window)}
return true
}
if e.count >= r.max {
return false
}
e.count++
return true
}
func clientIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func main() {
cfg := config.DefaultServerConfig()
@@ -36,6 +80,8 @@ func main() {
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
user := flag.String("user", "", "Username for token generation (requires -password)")
pass := flag.String("password", "", "Password for token generation")
bootstrapAdmin := flag.String("bootstrap-admin", "", "Bootstrap system admin username (letters only, >=6)")
bootstrapPass := flag.String("bootstrap-password", "", "Bootstrap system admin password")
version := flag.Bool("version", false, "Print version and exit")
flag.Parse()
@@ -45,6 +91,46 @@ func main() {
os.Exit(0)
}
// Bootstrap system admin (optional)
if *bootstrapAdmin != "" {
if !server.IsValidGlobalUsername(*bootstrapAdmin) {
log.Fatalf("[main] invalid bootstrap-admin username (letters only, >=6)")
}
if *bootstrapPass == "" {
log.Fatalf("[main] bootstrap-password required")
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("[main] open store failed: %v", err)
}
_ = st.SetSetting("bootstrapped_admin", "1")
// ensure default tenant exists
if _, gErr := st.GetTenantByID(1); gErr != nil {
_, _, _, _ = st.CreateTenantWithUsers("default", "admin", "admin")
}
// update/create admin user in tenant 1
users, _ := st.ListUsers(1)
var adminID int64
for _, u := range users {
if u.Role == "admin" {
adminID = u.ID
break
}
}
if adminID > 0 {
_ = st.UpdateUserEmail(adminID, *bootstrapAdmin)
_ = st.UpdateUserPassword(adminID, *bootstrapPass)
log.Printf("[main] bootstrapped admin updated: %s", *bootstrapAdmin)
} else {
_, err = st.CreateUser(1, "admin", *bootstrapAdmin, *bootstrapPass, 1)
if err != nil {
log.Fatalf("[main] bootstrap admin create failed: %v", err)
}
log.Printf("[main] bootstrapped admin created: %s", *bootstrapAdmin)
}
os.Exit(0)
}
// Token: either direct value or generated from user+password
if *token > 0 {
cfg.Token = *token
@@ -84,25 +170,242 @@ func main() {
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
}
// ─── Signaling Server ───
// ─── Signaling Server ───
srv := server.New(cfg)
srv.StartCleanup()
// Admin-only Middleware (System Admin session only)
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
}
ac, ok := srv.ResolveAccess(r, cfg.Token)
if !ok || ac.Kind != "session" || ac.Role != "admin" || ac.TenantID != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
next(w, r)
}
}
// Tenant Middleware (session/apikey only, no operator role)
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
}
ac, ok := srv.ResolveAccess(r, cfg.Token)
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
// reject master token for tenant APIs
if ac.Kind == "master" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
next(w, r)
}
}
getTenantID := func(r *http.Request) int64 {
tok := server.BearerToken(r)
if tok == "" {
return 0
}
if ac, ok := srv.ResolveTenantAccessToken(tok); ok && ac.Kind != "master" {
return ac.TenantID
}
return 0
}
listNodesOut := func(nodes []*server.NodeInfo, tenantID int64) []map[string]any {
out := make([]map[string]any, 0, len(nodes))
for _, n := range nodes {
item := map[string]any{
"name": n.Name,
"displayName": n.Name,
"publicIP": n.PublicIP,
"publicPort": n.PublicPort,
"natType": n.NATType,
"tenantId": n.TenantID,
"version": n.Version,
"relayEnabled": n.RelayEnabled,
"superRelay": n.SuperRelay,
"loginTime": n.LoginTime,
"lastHeartbeat": n.LastHeartbeat,
"nodeUUID": "",
"alias": "",
"virtualIP": "",
}
if tenantID > 0 && srv.Store() != nil {
if nc, err := srv.Store().GetNodeCredentialByName(tenantID, n.Name); err == nil && nc != nil {
item["nodeUUID"] = nc.NodeUUID
item["alias"] = nc.Alias
item["virtualIP"] = nc.VirtualIP
if nc.Alias != "" {
item["displayName"] = nc.Alias
}
}
}
out = append(out, item)
}
return out
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
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/admin/settings", adminMiddleware(srv.HandleAdminSettings))
mux.HandleFunc("/api/v1/admin/audit", adminMiddleware(srv.HandleAdminAudit))
mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll)
// enroll consume with rate-limit
rl := newRateLimiter(10, time.Minute)
mux.HandleFunc("/api/v1/enroll/consume", func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r.RemoteAddr)
if !rl.Allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`)
return
}
srv.HandleEnrollConsume(w, r)
})
mux.HandleFunc("/api/v1/enroll/consume/", func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r.RemoteAddr)
if !rl.Allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`)
return
}
srv.HandleEnrollConsume(w, r)
})
mux.HandleFunc("/api/v1/nodes/alias", tenantMiddleware(srv.HandleNodeMeta))
mux.HandleFunc("/api/v1/nodes/ip", tenantMiddleware(srv.HandleNodeMeta))
mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// single mode: username/password login
var reqUser struct {
Username string `json:"username"`
Password string `json:"password"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &reqUser)
if reqUser.Username == "" || reqUser.Password == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":1,"message":"username and password required"}`)
return
}
if !server.IsValidGlobalUsername(reqUser.Username) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":1,"message":"username must be letters only and >=6"}`)
return
}
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().VerifyUserPasswordGlobal(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
}
sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, u.TenantID, u.Role, 24*time.Hour)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(u.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", sessionToken, "session", exp, 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)
})
mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
})
mux.HandleFunc("/api/v1/sdwans", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/nodes", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
nodes := srv.GetOnlineNodesByTenant(tenantID)
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, tenantID)})
return
}
nodes := srv.GetOnlineNodes()
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, 0)})
}))
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 session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
_ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID))
return
}
_ = json.NewEncoder(w).Encode(srv.GetSDWAN())
})
mux.HandleFunc("/api/v1/sdwan/edit", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/sdwan/edit", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -112,13 +415,199 @@ func main() {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Mode == "hub" && req.HubNode == "" {
http.Error(w, "hub mode requires hubNode", http.StatusBadRequest)
return
}
// subnet proxy validation (basic)
for _, sp := range req.SubnetProxies {
if sp.Node == "" || sp.LocalCIDR == "" || sp.VirtualCIDR == "" {
http.Error(w, "subnet proxy requires node/localCIDR/virtualCIDR", http.StatusBadRequest)
return
}
_, lnet, lerr := net.ParseCIDR(sp.LocalCIDR)
_, vnet, verr := net.ParseCIDR(sp.VirtualCIDR)
if lerr != nil || verr != nil || lnet == nil || vnet == nil {
http.Error(w, "subnet proxy CIDR invalid", http.StatusBadRequest)
return
}
lOnes, _ := lnet.Mask.Size()
vOnes, _ := vnet.Mask.Size()
if lOnes != vOnes {
http.Error(w, "subnet proxy CIDR mask mismatch", http.StatusBadRequest)
return
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
ac := server.GetAccessContext(r)
actorType, actorID := "", ""
if ac != nil {
actorType = ac.Kind
actorID = fmt.Sprintf("%d", ac.UserID)
}
if err := srv.SetSDWANTenant(tenantID, req, actorType, actorID, r.RemoteAddr); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
return
}
if err := srv.SetSDWAN(req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
})
}))
// Remote Config Push API
mux.HandleFunc("/api/v1/nodes/apps", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
Apps []protocol.AppConfig `json:"apps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
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 session/apikey
tenantID := getTenantID(r)
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 session/apikey
tenantID := getTenantID(r)
if tenantID > 0 && fromNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
app := protocol.AppConfig{
AppName: req.AppName,
Protocol: "tcp",
SrcPort: req.SrcPort,
PeerNode: req.To,
DstHost: "127.0.0.1",
DstPort: req.DstPort,
Enabled: 1,
}
// enforce same-tenant target
if tenantID > 0 {
toNode := srv.GetNode(req.To)
if toNode == nil || toNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
}
}
if err := srv.PushConnect(fromNode, req.To, app); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"error": 1, "message": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "connect request sent"})
}))
// Server uptime + detailed stats
mux.HandleFunc("/api/v1/stats", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
nodes := srv.GetOnlineNodes()
coneCount, symmCount, unknCount := 0, 0, 0
relayCount := 0
for _, n := range nodes {
switch n.NATType {
case 1:
coneCount++
case 2:
symmCount++
default:
unknCount++
}
if n.RelayEnabled || n.SuperRelay {
relayCount++
}
}
sdwan := srv.GetSDWAN()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"nodes": len(nodes),
"relay": relayCount,
"cone": coneCount,
"symmetric": symmCount,
"unknown": unknCount,
"sdwan": sdwan.Enabled,
"version": config.Version,
})
}))
// ─── HTTP Listener ───
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort))
@@ -127,7 +616,13 @@ func main() {
}
log.Printf("[main] signaling server on :%d (no TLS — use reverse proxy for production)", cfg.WSPort)
httpSrv := &http.Server{Handler: mux}
// Enable TCP keepalive at server level
httpSrv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
log.Fatalf("[main] serve: %v", err)

22
go.mod
View File

@@ -6,4 +6,24 @@ toolchain go1.24.4
require github.com/gorilla/websocket v1.5.3
require golang.org/x/sys v0.41.0
require (
golang.org/x/crypto v0.23.0
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
)

39
go.sum
View File

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

BIN
inp2ps.db-shm Normal file

Binary file not shown.

BIN
inp2ps.db-wal Normal file

Binary file not shown.

View File

@@ -3,6 +3,7 @@ package client
import (
"crypto/tls"
"encoding/json"
"fmt"
"log"
"net"
@@ -45,6 +46,7 @@ type Client struct {
sdwanStop chan struct{}
tunMu sync.Mutex
tunFile *os.File
sdwanPath string
quit chan struct{}
wg sync.WaitGroup
}
@@ -53,6 +55,7 @@ type Client struct {
func New(cfg config.ClientConfig) *Client {
c := &Client{
cfg: cfg,
sdwanPath: "/etc/inp2p/sdwan.json",
natType: protocol.NATUnknown,
tunnels: make(map[string]*tunnel.Tunnel),
sdwanStop: make(chan struct{}),
@@ -62,7 +65,7 @@ func New(cfg config.ClientConfig) *Client {
}
if cfg.RelayEnabled {
c.relayMgr = relay.NewManager(cfg.RelayPort, true, cfg.SuperRelay, cfg.MaxRelayLoad, cfg.Token)
c.relayMgr = relay.NewManager(cfg.RelayPort, true, cfg.SuperRelay, cfg.MaxRelayLoad, cfg.Token, cfg.ShareBandwidth)
}
return c
@@ -95,7 +98,7 @@ func (c *Client) connectAndRun() error {
c.publicIP = natResult.PublicIP
c.publicPort = natResult.Port1
c.localPort = natResult.LocalPort
log.Printf("[client] NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
log.Printf("[client] SENDING_LOGIN_TOKEN=%d NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.cfg.Token, c.natType, c.publicIP, c.publicPort, c.localPort)
// 2. WSS Connect
scheme := "ws"
@@ -130,12 +133,14 @@ func (c *Client) connectAndRun() error {
loginReq := protocol.LoginReq{
Node: c.cfg.Node,
Token: c.cfg.Token,
NodeSecret: c.cfg.NodeSecret,
User: c.cfg.User,
Version: config.Version,
NATType: c.natType,
ShareBandwidth: c.cfg.ShareBandwidth,
RelayEnabled: c.cfg.RelayEnabled,
SuperRelay: c.cfg.SuperRelay,
RelayOfficial: c.cfg.RelayOfficial,
PublicIP: c.publicIP,
PublicPort: c.publicPort,
}
@@ -236,7 +241,6 @@ func (c *Client) registerHandlers() {
return nil
}
log.Printf("[client] sdwan config received: gateway=%s nodes=%d mode=%s", cfg.GatewayCIDR, len(cfg.Nodes), cfg.Mode)
_ = os.WriteFile("sdwan.json", data[protocol.HeaderSize:], 0644)
// apply control+data plane
if err := c.applySDWAN(cfg); err != nil {
@@ -396,7 +400,7 @@ func (c *Client) connectApp(app config.AppConfig) {
)
if err != nil {
log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err)
c.tryRelay(app)
c.tryRelay(app, "tenant")
return
}
@@ -404,7 +408,7 @@ func (c *Client) connectApp(app config.AppConfig) {
protocol.DecodePayload(rspData, &rsp)
if rsp.Error != 0 {
log.Printf("[client] connect denied: %s", rsp.Detail)
c.tryRelay(app)
c.tryRelay(app, "tenant")
return
}
@@ -420,7 +424,7 @@ func (c *Client) connectApp(app config.AppConfig) {
if result.Error != nil {
log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error)
c.tryRelay(app)
c.tryRelay(app, "tenant")
c.reportConnect(app, protocol.ReportConnect{
PeerNode: app.PeerNode, Error: result.Error.Error(),
NATType: c.natType, PeerNATType: rsp.Peer.NATType,
@@ -448,12 +452,12 @@ func (c *Client) connectApp(app config.AppConfig) {
}
// tryRelay attempts to use a relay node.
func (c *Client) tryRelay(app config.AppConfig) {
log.Printf("[client] trying relay for %s", app.PeerNode)
func (c *Client) tryRelay(app config.AppConfig, mode string) {
log.Printf("[client] trying relay(%s) for %s", mode, app.PeerNode)
rspData, err := c.conn.Request(
protocol.MsgRelay, protocol.SubRelayNodeReq,
protocol.RelayNodeReq{PeerNode: app.PeerNode},
protocol.RelayNodeReq{PeerNode: app.PeerNode, Mode: mode},
protocol.MsgRelay, protocol.SubRelayNodeRsp,
10*time.Second,
)
@@ -465,6 +469,11 @@ func (c *Client) tryRelay(app config.AppConfig) {
var rsp protocol.RelayNodeRsp
protocol.DecodePayload(rspData, &rsp)
if rsp.Error != 0 {
if mode != "official" {
log.Printf("[client] no relay available for %s, fallback official", app.PeerNode)
go c.tryRelay(app, "official")
return
}
log.Printf("[client] no relay available for %s", app.PeerNode)
return
}
@@ -545,6 +554,19 @@ func (c *Client) reportConnect(app config.AppConfig, rc protocol.ReportConnect)
c.conn.Write(protocol.MsgReport, protocol.SubReportConnect, rc)
}
func (c *Client) writeSDWANConfig(cfg protocol.SDWANConfig) error {
path := c.sdwanPath
if path == "" {
path = "/etc/inp2p/sdwan.json"
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
_ = os.MkdirAll("/etc/inp2p", 0755)
return os.WriteFile(path, b, 0644)
}
func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
selfIP := ""
for _, n := range cfg.Nodes {
@@ -578,11 +600,24 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
// fallback broad route for hub mode / compatibility
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun")
// refresh rule/table 100 for sdwan
_ = runCmd("ip", "rule", "add", "pref", "100", "from", selfIP, "table", "100")
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun", "table", "100")
c.sdwanMu.Lock()
c.sdwan = cfg
c.sdwanIP = selfIP
c.sdwanMu.Unlock()
// persist sdwan config for local use/diagnostics
if err := c.writeSDWANConfig(cfg); err != nil {
log.Printf("[client] write sdwan.json failed: %v", err)
}
// Apply subnet proxy (if configured)
if err := c.applySubnetProxy(cfg); err != nil {
log.Printf("[client] applySubnetProxy failed: %v", err)
}
// Try to start TUN reader, but don't fail SDWAN apply if it errors
if err := c.ensureTUNReader(); err != nil {
log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err)
@@ -591,6 +626,39 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
return nil
}
// applySubnetProxy configures local subnet proxying based on SDWAN config.
func (c *Client) applySubnetProxy(cfg protocol.SDWANConfig) error {
if len(cfg.SubnetProxies) == 0 {
return nil
}
self := c.cfg.Node
for _, sp := range cfg.SubnetProxies {
if sp.Node != self {
// for non-proxy nodes, add route to virtualCIDR via proxy node IP
proxyIP := ""
for _, n := range cfg.Nodes {
if n.Node == sp.Node {
proxyIP = strings.TrimSpace(n.IP)
break
}
}
if proxyIP == "" {
continue
}
_ = runCmd("ip", "route", "replace", sp.VirtualCIDR, "via", proxyIP, "dev", "optun")
continue
}
// This node is the proxy
_ = runCmd("sysctl", "-w", "net.ipv4.ip_forward=1")
// map virtualCIDR -> localCIDR (NETMAP)
if sp.VirtualCIDR != "" && sp.LocalCIDR != "" {
_ = runCmd("iptables", "-t", "nat", "-A", "PREROUTING", "-d", sp.VirtualCIDR, "-j", "NETMAP", "--to", sp.LocalCIDR)
_ = runCmd("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", sp.LocalCIDR, "-j", "MASQUERADE")
}
}
return nil
}
func (c *Client) ensureTUNReader() error {
c.tunMu.Lock()
defer c.tunMu.Unlock()
@@ -637,33 +705,39 @@ func (c *Client) tunReadLoop() {
if f == nil {
return
}
n, err := f.Read(buf)
n, err := unix.Read(int(f.Fd()), buf)
if err != nil {
if c.IsStopping() {
return
}
// Ignore transient errors
if err != unix.EINTR && err != unix.EAGAIN {
log.Printf("[client] tun read error: %v", err)
}
time.Sleep(100 * time.Millisecond)
log.Printf("[client] tun read error: %v", err)
continue
}
// Skip empty packets or non-IPv4
if n == 0 || n < 20 {
log.Printf("[client] tun read error: %v", err)
continue
}
pkt := buf[:n]
version := pkt[0] >> 4
if version != 4 {
log.Printf("[client] tun read error: %v", err)
continue // skip non-IPv4
}
dstIP := net.IP(pkt[16:20]).String()
c.sdwanMu.RLock()
self := c.sdwanIP
c.sdwanMu.RUnlock()
if dstIP == self {
log.Printf("[client] tun read error: %v", err)
continue // skip packets to self
}
// send raw binary to avoid JSON base64 overhead
log.Printf("[client] tun: read pkt len=%d dst=%s", n, dstIP)
frame := protocol.EncodeRaw(protocol.MsgTunnel, protocol.SubTunnelSDWANRaw, pkt)
_ = c.conn.WriteRaw(frame)
if err := c.conn.WriteRaw(frame); err != nil {
log.Printf("[client] tun write failed: %v", err)
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,56 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
)
// GET /api/v1/admin/settings
// POST /api/v1/admin/settings {key,value}
func (s *Server) HandleAdminSettings(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
if r.Method == http.MethodGet {
settings, err := s.store.ListSettings()
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list settings failed"}`)
return
}
b, _ := json.Marshal(map[string]any{"error": 0, "settings": settings})
writeJSON(w, http.StatusOK, string(b))
return
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// allowlist
switch req.Key {
case "advanced_impersonate", "advanced_force_network", "advanced_cross_tenant":
default:
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"invalid key"}`)
return
}
if req.Value == "" {
req.Value = "0"
}
if err := s.store.SetSetting(req.Key, req.Value); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"set failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "setting_change", "setting", req.Key, req.Value, r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
}

View File

@@ -0,0 +1,40 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
)
// GET /api/v1/admin/audit?tenant=3&limit=50&offset=0
func (s *Server) HandleAdminAudit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 && i <= 500 {
limit = i
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i >= 0 {
offset = i
}
}
tenantID := int64(0)
if v := r.URL.Query().Get("tenant"); v != "" {
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
tenantID = i
}
}
logs, err := s.store.ListAuditLogs(tenantID, limit, offset)
if 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, "logs": logs})
}

65
internal/server/authz.go Normal file
View File

@@ -0,0 +1,65 @@
package server
import (
"net/http"
"strconv"
)
type AccessContext struct {
Kind string
TenantID int64
UserID int64
Role string
Token string
}
func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessContext, bool) {
tok := BearerToken(r)
if tok == "" {
return nil, false
}
if tok == strconv.FormatUint(masterToken, 10) {
return &AccessContext{Kind: "master", Role: "admin", Token: tok}, true
}
return s.ResolveTenantAccessToken(tok)
}
func GetAccessContext(r *http.Request) *AccessContext {
v := r.Context().Value(ServerCtxKeyAccess{})
if v == nil {
return nil
}
if ac, ok := v.(*AccessContext); ok {
return ac
}
return nil
}
func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) {
if tok == "" || s.store == nil {
return nil, false
}
if ss, err := s.store.VerifySessionToken(tok); err == nil && ss != nil {
return &AccessContext{
Kind: "session",
TenantID: ss.TenantID,
UserID: ss.UserID,
Role: ss.Role,
Token: tok,
}, true
}
if ten, err := s.store.VerifyAPIKey(tok); err == nil && ten != nil {
return &AccessContext{
Kind: "apikey",
TenantID: ten.ID,
Role: "apikey",
Token: tok,
}, true
}
return nil, false
}

View File

@@ -3,8 +3,10 @@ package server
import (
"fmt"
"log"
"os"
"time"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
@@ -17,12 +19,12 @@ import (
// HandleConnectReq processes a connection request from node A to node B.
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error {
to := s.GetNode(req.To)
to := s.GetNodeForUser(req.To, from.Token)
if to == nil || !to.IsOnline() {
// Peer offline — respond with error
// Peer offline or not visible — respond with generic not found
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 1,
Detail: fmt.Sprintf("node %s offline", req.To),
Detail: "node not found",
From: req.To,
To: req.From,
})
@@ -38,6 +40,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: from.PublicPort,
NATType: from.NATType,
HasIPv4: from.HasIPv4,
Token: auth.GenTOTP(from.Token, time.Now().Unix()),
}
from.mu.RUnlock()
@@ -47,6 +50,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: to.PublicPort,
NATType: to.NATType,
HasIPv4: to.HasIPv4,
Token: auth.GenTOTP(to.Token, time.Now().Unix()),
}
to.mu.RUnlock()
@@ -65,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
return nil
}
// Debug: force relay path if explicit env set
if os.Getenv("INP2P_FORCE_RELAY") == "1" {
log.Printf("[coord] %s → %s: force relay requested", from.Name, to.Name)
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 0,
From: to.Name,
To: from.Name,
Peer: toParams,
Detail: "punch-failed",
})
return nil
}
// Push PunchStart to BOTH sides simultaneously
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())

7
internal/server/ctx.go Normal file
View File

@@ -0,0 +1,7 @@
package server
// ctx key alias for main
// NOTE: main sets this type to avoid import cycles
// use GetAccessContext to retrieve
type ServerCtxKeyAccess struct{}

View File

@@ -0,0 +1,92 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
func (s *Server) HandleNodeMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ac, ok := s.ResolveTenantAccessToken(BearerToken(r))
if !ok || ac.TenantID <= 0 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/alias") {
var req struct {
NodeUUID string `json:"node_uuid"`
Alias string `json:"alias"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NodeUUID == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if err := s.store.SetNodeAlias(ac.TenantID, req.NodeUUID, req.Alias); err != nil {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`)
return
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/ip") {
var req struct {
NodeUUID string `json:"node_uuid"`
VirtualIP string `json:"virtual_ip"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NodeUUID == "" || req.VirtualIP == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if err := s.store.SetNodeVirtualIP(ac.TenantID, req.NodeUUID, req.VirtualIP); err != nil {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`)
return
}
nodes := s.GetOnlineNodesByTenant(ac.TenantID)
affectedNode := ""
reconnectTriggered := false
broadcastCount := 0
for _, n := range nodes {
nc, err := s.store.GetNodeCredentialByName(ac.TenantID, n.Name)
if err != nil || nc == nil {
continue
}
peer := map[string]any{"node": n.Name, "ip": nc.VirtualIP, "online": n.IsOnline()}
if nc.NodeUUID == req.NodeUUID {
affectedNode = n.Name
_ = n.Conn.Write(protocol.MsgPush, protocol.SubPushSDWANDel, peer)
n.Conn.Close()
reconnectTriggered = true
continue
}
_ = n.Conn.Write(protocol.MsgPush, protocol.SubPushSDWANPeer, peer)
broadcastCount++
}
resp, _ := json.Marshal(map[string]any{
"error": 0,
"message": "ok",
"affected_node": affectedNode,
"target_node_uuid": req.NodeUUID,
"new_virtual_ip": req.VirtualIP,
"broadcast_count": broadcastCount,
"reconnect_triggered": reconnectTriggered,
})
writeJSON(w, http.StatusOK, string(resp))
return
}
writeJSON(w, http.StatusNotFound, `{"error":1,"message":"not found"}`)
}

View File

@@ -12,13 +12,14 @@ import (
)
type sdwanStore struct {
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
multi map[int64]protocol.SDWANConfig
}
func newSDWANStore(path string) *sdwanStore {
s := &sdwanStore{path: path}
s := &sdwanStore{path: path, multi: make(map[int64]protocol.SDWANConfig)}
_ = s.load()
return s
}
@@ -33,6 +34,15 @@ func (s *sdwanStore) load() error {
}
return err
}
// try multi-tenant first
var m map[int64]protocol.SDWANConfig
if err := json.Unmarshal(b, &m); err == nil && len(m) > 0 {
for k, v := range m {
m[k] = normalizeSDWAN(v)
}
s.multi = m
return nil
}
var c protocol.SDWANConfig
if err := json.Unmarshal(b, &c); err != nil {
return err
@@ -57,12 +67,40 @@ func (s *sdwanStore) save(cfg protocol.SDWANConfig) error {
return nil
}
func (s *sdwanStore) saveTenant(tenantID int64, cfg protocol.SDWANConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg = normalizeSDWAN(cfg)
cfg.UpdatedAt = time.Now().Unix()
if s.multi == nil {
s.multi = make(map[int64]protocol.SDWANConfig)
}
s.multi[tenantID] = cfg
b, err := json.MarshalIndent(s.multi, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(s.path, b, 0644); err != nil {
return err
}
return nil
}
func (s *sdwanStore) get() protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
func (s *sdwanStore) getTenant(tenantID int64) protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
if s.multi == nil {
return protocol.SDWANConfig{}
}
return s.multi[tenantID]
}
func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
if c.Mode == "" {
c.Mode = "hub"
@@ -83,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip})
}
sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Node < c.Nodes[j].Node })
// de-dup subnet proxies by node+cidr
if len(c.SubnetProxies) > 0 {
m2 := make(map[string]protocol.SubnetProxy)
for _, sp := range c.SubnetProxies {
if sp.Node == "" || sp.VirtualCIDR == "" || sp.LocalCIDR == "" {
continue
}
key := sp.Node + "|" + sp.VirtualCIDR + "|" + sp.LocalCIDR
m2[key] = sp
}
c.SubnetProxies = c.SubnetProxies[:0]
for _, sp := range m2 {
c.SubnetProxies = append(c.SubnetProxies, sp)
}
}
return c
}

View File

@@ -1,6 +1,8 @@
package server
import (
"errors"
"fmt"
"log"
"net/netip"
@@ -11,6 +13,10 @@ func (s *Server) GetSDWAN() protocol.SDWANConfig {
return s.sdwan.get()
}
func (s *Server) GetSDWANTenant(tenantID int64) protocol.SDWANConfig {
return s.sdwan.getTenant(tenantID)
}
func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
if err := s.sdwan.save(cfg); err != nil {
return err
@@ -19,6 +25,27 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil
}
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig, actorType, actorID, ip string) error {
if cfg.Mode == "hub" {
if cfg.HubNode == "" {
return errors.New("hub mode requires hubNode")
}
hub := s.GetNode(cfg.HubNode)
if hub == nil || !hub.IsOnline() || hub.TenantID != tenantID || !hub.RelayEnabled {
return errors.New("hub node must be online and relay-enabled")
}
}
if err := s.sdwan.saveTenant(tenantID, cfg); err != nil {
return err
}
if actorType != "" && s.store != nil {
detail := fmt.Sprintf("mode=%s hub=%s nodes=%d subnetProxies=%d", cfg.Mode, cfg.HubNode, len(cfg.Nodes), len(cfg.SubnetProxies))
_ = s.store.AddAuditLog(actorType, actorID, "sdwan_update", "tenant", fmt.Sprintf("%d", tenantID), detail, ip)
}
s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID))
return nil
}
func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
@@ -33,6 +60,20 @@ func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
}
}
func (s *Server) broadcastSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if !n.IsOnline() || n.TenantID != tenantID {
continue
}
_ = n.Conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg)
}
}
func (s *Server) pushSDWANPeer(to *NodeInfo, peer protocol.SDWANPeer) {
if to == nil || !to.IsOnline() {
return
@@ -48,7 +89,14 @@ func (s *Server) pushSDWANDel(to *NodeInfo, peer protocol.SDWANPeer) {
}
func (s *Server) announceSDWANNodeOnline(nodeName string) {
cfg := s.sdwan.get()
// pick tenant config by node
s.mu.RLock()
newNode := s.nodes[nodeName]
s.mu.RUnlock()
if newNode == nil {
return
}
cfg := s.sdwan.getTenant(newNode.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -64,7 +112,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
s.mu.RLock()
newNode := s.nodes[nodeName]
newNode = s.nodes[nodeName]
if newNode == nil || !newNode.IsOnline() {
s.mu.RUnlock()
return
@@ -74,7 +122,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
continue
}
other := s.nodes[n.Node]
if other == nil || !other.IsOnline() {
if other == nil || !other.IsOnline() || other.TenantID != newNode.TenantID {
continue
}
// existing -> new
@@ -86,7 +134,13 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
func (s *Server) announceSDWANNodeOffline(nodeName string) {
cfg := s.sdwan.get()
s.mu.RLock()
old := s.nodes[nodeName]
s.mu.RUnlock()
if old == nil {
return
}
cfg := s.sdwan.getTenant(old.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -100,7 +154,7 @@ func (s *Server) announceSDWANNodeOffline(nodeName string) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if n.Name == nodeName || !n.IsOnline() {
if n.Name == nodeName || !n.IsOnline() || n.TenantID != old.TenantID {
continue
}
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false})
@@ -112,7 +166,13 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
if from == nil {
return
}
cfg := s.sdwan.get()
// Use global config for untrusted nodes (TenantID=0), otherwise use tenant config
var cfg protocol.SDWANConfig
if from.TenantID == 0 {
cfg = s.sdwan.get()
} else {
cfg = s.sdwan.getTenant(from.TenantID)
}
if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
return
}
@@ -124,12 +184,18 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
toNode := ""
for _, n := range cfg.Nodes {
if n.IP == pkt.DstIP {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
}
if toNode == "" || toNode == from.Name {
@@ -138,6 +204,9 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
s.mu.RLock()
to := s.nodes[toNode]
if to != nil && to.TenantID != from.TenantID {
to = nil
}
s.mu.RUnlock()
if to == nil || !to.IsOnline() {
return

View File

@@ -2,6 +2,7 @@
package server
import (
"fmt"
"log"
"net"
"net/http"
@@ -9,6 +10,7 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/protocol"
@@ -17,26 +19,28 @@ import (
// NodeInfo represents a connected client node.
type NodeInfo struct {
Name string
Token uint64
User string
Version string
NATType protocol.NATType
PublicIP string
PublicPort int
LanIP string
OS string
Mac string
ShareBandwidth int
RelayEnabled bool
SuperRelay bool
HasIPv4 int
IPv6 string
LoginTime time.Time
LastHeartbeat time.Time
Conn *signal.Conn
Apps []protocol.AppConfig
mu sync.RWMutex
Name string `json:"name"`
Token uint64 `json:"-"`
TenantID int64 `json:"tenantId"`
User string `json:"user"`
Version string `json:"version"`
NATType protocol.NATType `json:"natType"`
PublicIP string `json:"publicIP"`
PublicPort int `json:"publicPort"`
LanIP string `json:"lanIP"`
OS string `json:"os"`
Mac string `json:"mac"`
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"`
SuperRelay bool `json:"superRelay"`
RelayOfficial bool `json:"relayOfficial"`
HasIPv4 int `json:"hasIPv4"`
IPv6 string `json:"ipv6"`
LoginTime time.Time `json:"loginTime"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
Conn *signal.Conn `json:"-"`
Apps []protocol.AppConfig `json:"apps"`
mu sync.RWMutex `json:"-"`
}
// IsOnline checks if node has sent heartbeat recently.
@@ -49,25 +53,52 @@ func (n *NodeInfo) IsOnline() bool {
// Server is the INP2P signaling server.
type Server struct {
cfg config.ServerConfig
nodes map[string]*NodeInfo // node name → info
nodes map[string]*NodeInfo
mu sync.RWMutex
upgrader websocket.Upgrader
quit chan struct{}
sdwanPath string
sdwan *sdwanStore
store *store.Store
tokens map[uint64]bool
}
func (s *Server) Store() *store.Store { return s.store }
// New creates a new server.
func New(cfg config.ServerConfig) *Server {
// Use absolute path for sdwan config to avoid working directory issues
sdwanPath := "/root/.openclaw/workspace/inp2p/sdwan.json"
tokens := make(map[uint64]bool)
if cfg.Token != 0 {
tokens[cfg.Token] = true
}
for _, t := range cfg.Tokens {
tokens[t] = true
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Printf("[server] open store failed: %v", err)
} else {
// bootstrap default tenant if missing
if _, gErr := st.GetTenantByID(1); gErr != nil {
if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil {
log.Printf("[server] bootstrap default tenant failed: %v", cErr)
} else {
log.Printf("[server] bootstrap default tenant created (tenant=1)")
}
}
}
return &Server{
cfg: cfg,
nodes: make(map[string]*NodeInfo),
sdwanPath: sdwanPath,
sdwan: newSDWANStore(sdwanPath),
store: st,
tokens: tokens,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
},
quit: make(chan struct{}),
}
@@ -93,8 +124,44 @@ func (s *Server) GetOnlineNodes() []*NodeInfo {
return out
}
// GetNodeForUser returns node if token matches (legacy) or tenant matches.
func (s *Server) GetNodeForUser(name string, token uint64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil {
return nil
}
if n.Token != token && n.TenantID == 0 {
return nil
}
return n
}
func (s *Server) GetNodeForTenant(name string, tenantID int64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil || n.TenantID != tenantID {
return nil
}
return n
}
func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []*NodeInfo
for _, n := range s.nodes {
if n.IsOnline() && n.TenantID == tenantID {
out = append(out, n)
}
}
return out
}
// GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay
// Priority: same-user private relay → super relay (exclude official relays)
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
@@ -106,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
var privateRelays, superRelays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial {
continue
}
if n.User == forUser {
@@ -119,6 +186,48 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
return append(privateRelays, superRelays...)
}
// GetRelayNodesByTenant returns relay nodes within tenant.
func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
excludeSet[n] = true
}
s.mu.RLock()
defer s.mu.RUnlock()
var relays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] {
continue
}
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !n.RelayOfficial {
relays = append(relays, n)
}
}
return relays
}
// GetOfficialRelays returns official relay nodes (global pool)
func (s *Server) GetOfficialRelays(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] || !n.RelayEnabled || !n.RelayOfficial {
continue
}
relays = append(relays, n)
}
return relays
}
// HandleWS is the WebSocket handler for client connections.
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
@@ -151,8 +260,26 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
return
}
// Verify token
if loginReq.Token != s.cfg.Token {
// Verify token: master token OR tenant API key (DB) OR node_secret (DB)
valid := s.tokens[loginReq.Token]
log.Printf("[server] login check: token=%d, cfg.Token=%d, valid=%v", loginReq.Token, s.cfg.Token, valid)
var tenantID int64
if !valid && s.store != nil {
// try api key (string) or node secret
if loginReq.NodeSecret != "" {
if ten, err := s.store.VerifyNodeSecret(loginReq.Node, loginReq.NodeSecret); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
if !valid {
if ten, err := s.store.VerifyAPIKey(fmt.Sprintf("%d", loginReq.Token)); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
}
if !valid {
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 1,
@@ -174,12 +301,14 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
node := &NodeInfo{
Name: loginReq.Node,
Token: loginReq.Token,
TenantID: tenantID,
User: loginReq.User,
Version: loginReq.Version,
NATType: loginReq.NATType,
ShareBandwidth: loginReq.ShareBandwidth,
RelayEnabled: loginReq.RelayEnabled,
SuperRelay: loginReq.SuperRelay,
RelayOfficial: loginReq.RelayOfficial,
PublicIP: loginReq.PublicIP,
PublicPort: loginReq.PublicPort,
LoginTime: time.Now(),
@@ -211,11 +340,21 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
s.broadcastNodeOnline(loginReq.Node)
// Push current SDWAN config right after login (if exists and enabled)
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
if node.TenantID > 0 {
if cfg := s.sdwan.getTenant(node.TenantID); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
} else {
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
}
// Event-driven SDWAN peer notification
@@ -347,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
// handleRelayNodeReq finds and returns the best relay node.
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
mode := "tenant"
if req.Mode == "official" {
mode = "official"
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relay := official[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode)
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
Error: 1,
RelayName: relay.Name,
RelayIP: relay.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: mode,
Error: 0,
})
}
// prefer hub relay if sdwan mode=hub
if requester.TenantID > 0 && s.sdwan != nil {
cfg := s.sdwan.getTenant(requester.TenantID)
if cfg.Mode == "hub" && cfg.HubNode != "" && cfg.HubNode != requester.Name && cfg.HubNode != req.PeerNode {
hub := s.GetNode(cfg.HubNode)
if hub != nil && hub.IsOnline() && hub.TenantID == requester.TenantID && hub.RelayEnabled {
log.Printf("[server] relay selected: %s (hub) for %s → %s", hub.Name, requester.Name, req.PeerNode)
totp := auth.GenTOTP(hub.Token, time.Now().Unix())
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
RelayName: hub.Name,
RelayIP: hub.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: "private",
Error: 0,
})
}
}
}
// prefer same-tenant relays, exclude requester and peer
relays := s.GetRelayNodesByTenant(requester.TenantID, requester.Name, req.PeerNode)
if len(relays) == 0 {
// fallback to same-user (private) then super
relays = s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
// final fallback: official relays
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relays = official
mode = "official"
} else if relays[0].User != requester.User {
mode = "super"
} else {
mode = "private"
}
}
// Pick the first (best) relay
relay := relays[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
mode := "private"
if relay.User != requester.User {
mode = "super"
}
log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode)
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
@@ -378,10 +562,13 @@ func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req
// PushConnect sends a punch coordination message to a peer node.
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error {
toNode := s.GetNode(toNodeName)
toNode := s.GetNodeForUser(toNodeName, fromNode.Token)
if toNode == nil || !toNode.IsOnline() {
return &NodeOfflineError{Node: toNodeName}
}
if fromNode.TenantID != 0 && toNode.TenantID != fromNode.TenantID {
return &NodeOfflineError{Node: toNodeName}
}
// Push connect request to the destination
req := protocol.ConnectReq{
@@ -390,8 +577,10 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
FromIP: fromNode.PublicIP,
Peer: protocol.PunchParams{
IP: fromNode.PublicIP,
Port: fromNode.PublicPort,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
},
AppName: app.AppName,
Protocol: app.Protocol,
@@ -406,12 +595,19 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
// broadcastNodeOnline notifies interested nodes that a peer came online.
func (s *Server) broadcastNodeOnline(nodeName string) {
s.mu.RLock()
newNode := s.nodes[nodeName]
defer s.mu.RUnlock()
if newNode == nil {
return
}
for _, n := range s.nodes {
if n.Name == nodeName {
continue
}
if n.Token != newNode.Token && (newNode.TenantID == 0 || n.TenantID != newNode.TenantID) {
continue
}
// Check if this node has any app targeting the new node
n.mu.RLock()
interested := false
@@ -431,7 +627,7 @@ func (s *Server) broadcastNodeOnline(nodeName string) {
}
}
// StartCleanup periodically removes stale nodes.
// StartCleanup periodically removes stale nodes and checks SDWAN hub health.
func (s *Server) StartCleanup() {
go func() {
ticker := time.NewTicker(30 * time.Second)
@@ -448,6 +644,35 @@ func (s *Server) StartCleanup() {
}
}
s.mu.Unlock()
// hub offline -> auto mesh (tenant configs)
if s.sdwan != nil {
sd := s.sdwan
sd.mu.RLock()
m := make(map[int64]protocol.SDWANConfig, len(sd.multi))
for k, v := range sd.multi {
m[k] = v
}
sd.mu.RUnlock()
for tid, cfg := range m {
if cfg.Mode != "hub" || cfg.HubNode == "" {
continue
}
hub := s.GetNode(cfg.HubNode)
if hub != nil && hub.IsOnline() && hub.TenantID == tid {
continue
}
// auto fallback to mesh
cfg.Mode = "mesh"
cfg.HubNode = ""
_ = s.sdwan.saveTenant(tid, cfg)
if s.store != nil {
_ = s.store.AddAuditLog("system", "0", "sdwan_update", "tenant", fmt.Sprintf("%d", tid), "hub->mesh (hub offline)", "")
}
s.broadcastSDWANTenant(tid, cfg)
log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid)
}
}
case <-s.quit:
return
}

View File

@@ -0,0 +1,319 @@
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.MethodGet {
tenants, err := s.store.ListTenants()
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list tenants failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenants []store.Tenant `json:"tenants"`
}{0, "ok", tenants}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update tenant status via /api/v1/admin/tenants/{id}?status=0|1
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateTenantStatus(id, status)
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
}
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Name string `json:"name"`
AdminPassword string `json:"admin_password"`
OperatorPassword string `json:"operator_password"`
}
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
}
var ten *store.Tenant
var admin *store.User
var op *store.User
var err error
if req.AdminPassword != "" && req.OperatorPassword != "" {
ten, admin, op, err = s.store.CreateTenantWithUsers(req.Name, req.AdminPassword, req.OperatorPassword)
} else {
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"`
AdminUser string `json:"admin_user"`
OperatorUser string `json:"operator_user"`
}{0, "ok", ten.ID, ten.Subnet, "", ""}
if admin != nil {
resp.AdminUser = admin.Email
}
if op != nil {
resp.OperatorUser = op.Email
}
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 && r.Method != http.MethodGet {
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
}
if r.Method == http.MethodGet {
keys, err := s.store.ListAPIKeys(tenantID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list keys failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Keys []store.APIKey `json:"keys"`
}{0, "ok", keys}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update key status via /api/v1/admin/tenants/{id}/keys/{keyId}?status=0|1
if strings.Contains(r.URL.Path, "/keys/") {
parts2 := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var keyID int64
_, _ = fmt.Sscanf(parts2[len(parts2)-1], "%d", &keyID)
st := r.URL.Query().Get("status")
if keyID > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateAPIKeyStatus(keyID, status)
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_status", "apikey", fmt.Sprintf("%d", keyID), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
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))
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), req.Scope, r.RemoteAddr)
}
}
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// tenant auth by session/apikey
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
tok := BearerToken(r)
ac, ok := s.ResolveTenantAccessToken(tok)
if !ok || ac.TenantID <= 0 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
ten, err := s.store.GetTenantByID(ac.TenantID)
if err != nil || ten == nil || ten.Status != 1 {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
if r.Method == http.MethodGet {
tokens, err := s.store.ListEnrollTokens(ten.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list enroll failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Enrolls []store.EnrollToken `json:"enrolls"`
}{0, "ok", tokens}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
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
}
// revoke support: /api/v1/enroll/consume/{id}?status=0
if strings.Contains(r.URL.Path, "/enroll/consume/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) >= 4 {
var id int64
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
st := r.URL.Query().Get("status")
if id > 0 && st != "" {
status := 0
if st == "1" {
status = 1
}
_ = s.store.UpdateEnrollStatus(id, status)
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
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"`
NodeUUID string `json:"node_uuid"`
NodeName string `json:"node_name"`
Alias string `json:"alias"`
VirtualIP string `json:"virtual_ip"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
CreatedAt int64 `json:"created_at"`
}{0, "ok", cred.NodeID, cred.NodeUUID, cred.NodeName, cred.Alias, cred.VirtualIP, cred.Secret, cred.TenantID, cred.CreatedAt}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
// placeholder to avoid unused import
var _ = store.Tenant{}

176
internal/server/user_api.go Normal file
View File

@@ -0,0 +1,176 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"unicode"
)
// Admin user management
// GET /api/v1/admin/users?tenant=1
// POST /api/v1/admin/users {tenant, role, email, password}
// POST /api/v1/admin/users/{id}?status=0|1
// POST /api/v1/admin/users/{id}/password {password}
func IsValidGlobalUsername(v string) bool {
if len(v) < 6 {
return false
}
for _, r := range v {
if r > unicode.MaxASCII || !unicode.IsLetter(r) {
return false
}
}
return true
}
func (s *Server) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
// list
if r.Method == http.MethodGet {
tenantID := int64(0)
_ = r.ParseForm()
fmt.Sscanf(r.Form.Get("tenant"), "%d", &tenantID)
if tenantID <= 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"tenant required"}`)
return
}
users, err := s.store.ListUsers(tenantID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list users failed"}`)
return
}
// strip password hash
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"tenant_id": u.TenantID,
"role": u.Role,
"email": u.Email,
"status": u.Status,
"created_at": u.CreatedAt,
})
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Users interface{} `json:"users"`
}{0, "ok", out}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update status or password
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/users/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var id int64
// /api/v1/admin/users/{id}/password
if strings.HasSuffix(r.URL.Path, "/password") && len(parts) >= 5 {
_, _ = fmt.Sscanf(parts[len(parts)-2], "%d", &id)
} else if strings.HasSuffix(r.URL.Path, "/delete") && len(parts) >= 5 {
_, _ = fmt.Sscanf(parts[len(parts)-2], "%d", &id)
} else {
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
}
if id <= 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// /password
if strings.HasSuffix(r.URL.Path, "/password") {
var req struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Password == "" || len(req.Password) < 6 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"password too short"}`)
return
}
if err := s.store.UpdateUserPassword(id, req.Password); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"update password failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_password", "user", fmt.Sprintf("%d", id), "", r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// delete
if strings.HasSuffix(r.URL.Path, "/delete") {
if err := s.store.UpdateUserStatus(id, 0); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"delete failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_delete", "user", fmt.Sprintf("%d", id), "", r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// status
st := r.URL.Query().Get("status")
if st == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"status required"}`)
return
}
status := 0
if st == "1" {
status = 1
}
if err := s.store.UpdateUserStatus(id, status); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"update status failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_status", "user", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// create
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
TenantID int64 `json:"tenant"`
Role string `json:"role"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TenantID <= 0 || req.Role == "" || req.Email == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if len(req.Password) < 6 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"password too short"}`)
return
}
if !IsValidGlobalUsername(req.Email) {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"username must be letters only and >=6"}`)
return
}
if exists, err := s.store.UserEmailExistsGlobal(req.Email); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"check user failed"}`)
return
} else if exists {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"username exists"}`)
return
}
if _, err := s.store.CreateUser(req.TenantID, req.Role, req.Email, req.Password, 1); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create user failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_create", "tenant", fmt.Sprintf("%d", req.TenantID), req.Email, r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
}

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

@@ -0,0 +1,940 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
type Store struct {
DB *sql.DB
}
type Tenant struct {
ID int64
Name string
Status int
Subnet string
CreatedAt int64
}
type AuditLog struct {
ID int64 `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Detail string `json:"detail"`
IP string `json:"ip"`
CreatedAt int64 `json:"created_at"`
}
type User struct {
ID int64
TenantID int64
Role string
Email string
PasswordHash string
Status int
CreatedAt int64
}
type APIKey struct {
ID int64
TenantID int64
Hash string
Scope string
ExpiresAt *int64
Status int
CreatedAt int64
Plain string
}
type NodeCredential struct {
NodeID int64
NodeUUID string
NodeName string
Alias string
Secret string
VirtualIP string
TenantID int64
Status int
CreatedAt int64
LastSeen *int64
}
type EnrollToken struct {
ID int64
TenantID int64
Hash string
ExpiresAt int64
UsedAt *int64
MaxAttempt int
Attempts int
Status int
CreatedAt int64
}
type SessionToken struct {
ID int64
UserID int64
TenantID int64
Role string
TokenHash string
ExpiresAt int64
Status int
CreatedAt int64
}
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
}
if err := s.ensureSettings(); err != nil {
return nil, err
}
if err := s.backfillNodeIdentity(); 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_uuid TEXT,
node_name TEXT NOT NULL,
alias TEXT DEFAULT '',
node_pubkey TEXT,
node_secret_hash TEXT,
virtual_ip TEXT,
status INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER,
created_at INTEGER NOT NULL DEFAULT 0,
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 system_settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_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
);`,
`CREATE TABLE IF NOT EXISTS session_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
role TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
}
for _, stmt := range stmts {
if _, err := s.DB.Exec(stmt); err != nil {
return err
}
}
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN node_uuid TEXT`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return err
}
if _, err := s.DB.Exec(`ALTER TABLE nodes ADD COLUMN alias TEXT DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return err
}
if _, err := s.DB.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_node_uuid ON nodes(node_uuid)`); err != nil {
return err
}
return nil
}
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, CreatedAt: now}, nil
}
func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword string) (*Tenant, *User, *User, error) {
if adminPassword == "" || operatorPassword == "" {
return nil, nil, nil, errors.New("password required")
}
ten, err := s.CreateTenant(name)
if err != nil {
return nil, nil, nil, err
}
admin, err := s.CreateUser(ten.ID, "admin", "admin@"+name, adminPassword, 1)
if err != nil {
return nil, nil, nil, err
}
op, err := s.CreateUser(ten.ID, "operator", "operator@"+name, operatorPassword, 1)
if err != nil {
return nil, nil, nil, err
}
return ten, admin, op, nil
}
func (s *Store) AllocateNodeIP(tenantID int64) (string, error) {
ten, err := s.GetTenantByID(tenantID)
if err != nil || ten == nil {
return "", errors.New("tenant not found")
}
ip, ipnet, err := net.ParseCIDR(ten.Subnet)
if err != nil {
return "", err
}
base := ip.To4()
if base == nil {
return "", errors.New("only ipv4 subnet supported")
}
rows, err := s.DB.Query(`SELECT virtual_ip FROM nodes WHERE tenant_id=? AND virtual_ip IS NOT NULL AND virtual_ip<>''`, tenantID)
if err != nil {
return "", err
}
defer rows.Close()
used := map[string]bool{}
for rows.Next() {
var v string
if rows.Scan(&v) == nil && v != "" {
used[v] = true
}
}
for host := 2; host <= 254; host++ {
cand := net.IPv4(base[0], base[1], base[2], byte(host)).String()
if !ipnet.Contains(net.ParseIP(cand)) {
continue
}
if used[cand] {
continue
}
return cand, nil
}
return "", errors.New("no available ip")
}
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken()
h := hashTokenString(secret)
now := time.Now().Unix()
nodeUUID := randUUID()
vip, err := s.AllocateNodeIP(tenantID)
if err != nil {
return nil, err
}
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_uuid,node_name,alias,node_secret_hash,virtual_ip,status,last_seen,created_at) VALUES(?,?,?,?,?,?,1,?,?)`, tenantID, nodeUUID, nodeName, "", h, vip, now, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeUUID: nodeUUID, NodeName: nodeName, Alias: "", Secret: secret, VirtualIP: vip, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at 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 AND t.status=1`, nodeName, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetNodeCredentialByName(tenantID int64, nodeName string) (*NodeCredential, error) {
row := s.DB.QueryRow(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes WHERE tenant_id=? AND node_name=? ORDER BY id DESC LIMIT 1`, tenantID, nodeName)
var n NodeCredential
var seen sql.NullInt64
if err := row.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil {
return nil, err
}
if seen.Valid {
v := seen.Int64
n.LastSeen = &v
}
return &n, nil
}
func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error {
alias = strings.TrimSpace(alias)
res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return errors.New("node not found")
}
return nil
}
func (s *Store) SetNodeVirtualIP(tenantID int64, nodeUUID, ip string) error {
ten, err := s.GetTenantByID(tenantID)
if err != nil || ten == nil {
return errors.New("tenant not found")
}
if net.ParseIP(ip) == nil {
return errors.New("invalid ip")
}
_, ipnet, err := net.ParseCIDR(ten.Subnet)
if err != nil {
return err
}
if !ipnet.Contains(net.ParseIP(ip)) {
return errors.New("ip not in subnet")
}
var c int
if err := s.DB.QueryRow(`SELECT COUNT(1) FROM nodes WHERE tenant_id=? AND virtual_ip=? AND node_uuid<>?`, tenantID, ip, nodeUUID).Scan(&c); err != nil {
return err
}
if c > 0 {
return errors.New("ip conflict")
}
res, err := s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE tenant_id=? AND node_uuid=?`, ip, tenantID, nodeUUID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return errors.New("node not found")
}
return nil
}
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
h := hashToken(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetTenantByID(id int64) (*Tenant, error) {
row := s.DB.QueryRow(`SELECT id,name,status,subnet,created_at FROM tenants WHERE id=?`, id)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); 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,created_at 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, &et.CreatedAt); 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 (s *Store) ensureSettings() error {
defaults := map[string]string{
"advanced_impersonate": "0",
"advanced_force_network": "0",
"advanced_cross_tenant": "0",
}
now := time.Now().Unix()
for k, v := range defaults {
_, _ = s.DB.Exec(`INSERT OR IGNORE INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, k, v, now)
}
return nil
}
func (s *Store) GetSetting(key string) (string, bool, error) {
row := s.DB.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key)
var v string
if err := row.Scan(&v); err != nil {
return "", false, err
}
return v, true, nil
}
func (s *Store) ListSettings() (map[string]string, error) {
rows, err := s.DB.Query(`SELECT key,value FROM system_settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err == nil {
out[k] = v
}
}
return out, nil
}
func (s *Store) SetSetting(key, value string) error {
now := time.Now().Unix()
_, err := s.DB.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, now)
return err
}
func (s *Store) AddAuditLog(actorType, actorID, action, targetType, targetID, detail, ip string) error {
now := time.Now().Unix()
_, err := s.DB.Exec(`INSERT INTO audit_logs(actor_type,actor_id,action,target_type,target_id,detail,ip,created_at) VALUES(?,?,?,?,?,?,?,?)`, actorType, actorID, action, targetType, targetID, detail, ip, now)
return err
}
func (s *Store) ListAuditLogs(tenantID int64, limit, offset int) ([]AuditLog, error) {
q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs`
args := []any{}
if tenantID > 0 {
// limit to logs related to this tenant
q += ` WHERE (target_type='tenant' AND target_id=?)`
args = append(args, fmt.Sprintf("%d", tenantID))
}
q += ` ORDER BY id DESC`
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
if offset > 0 {
q += ` OFFSET ?`
args = append(args, offset)
}
rows, err := s.DB.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AuditLog{}
for rows.Next() {
var a AuditLog
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.TargetType, &a.TargetID, &a.Detail, &a.IP, &a.CreatedAt); err == nil {
out = append(out, a)
}
}
return out, nil
}
// ListTenants returns all tenants (admin)
func (s *Store) ListTenants() ([]Tenant, error) {
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err == nil {
out = append(out, t)
}
}
return out, nil
}
func (s *Store) UpdateTenantStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE tenants SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteTenant(id int64) error {
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
return err
}
// ListAPIKeys returns api keys of a tenant (admin)
func (s *Store) ListAPIKeys(tenantID int64) ([]APIKey, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,key_hash,scope,expires_at,status,created_at FROM api_keys WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIKey
for rows.Next() {
var k APIKey
var exp sql.NullInt64
if err := rows.Scan(&k.ID, &k.TenantID, &k.Hash, &k.Scope, &exp, &k.Status, &k.CreatedAt); err == nil {
if exp.Valid {
v := exp.Int64
k.ExpiresAt = &v
}
out = append(out, k)
}
}
return out, nil
}
func (s *Store) UpdateAPIKeyStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE api_keys SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteAPIKey(id int64) error {
_, err := s.DB.Exec(`DELETE FROM api_keys WHERE id=?`, id)
return err
}
// ListEnrollTokens returns enroll tokens for a tenant (admin)
func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE tenant_id=? ORDER BY id DESC`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EnrollToken
for rows.Next() {
var et EnrollToken
var used sql.NullInt64
if err := rows.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err == nil {
if used.Valid {
v := used.Int64
et.UsedAt = &v
}
out = append(out, et)
}
}
return out, nil
}
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) DeleteEnrollToken(id int64) error {
_, err := s.DB.Exec(`DELETE FROM enroll_tokens WHERE id=?`, id)
return err
}
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,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetUserByTenant(tenantID int64) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id LIMIT 1`, tenantID)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByToken(token string) (*User, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT u.id,u.tenant_id,u.role,u.email,u.password_hash,u.status,u.created_at FROM api_keys k JOIN users u ON k.tenant_id=u.tenant_id WHERE k.key_hash=? AND k.status=1`, h)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) GetUserByEmail(tenantID int64, email string) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND email=? ORDER BY id LIMIT 1`, tenantID, email)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) CreateUser(tenantID int64, role, email, password string, status int) (*User, error) {
now := time.Now().Unix()
var hash string
if password != "" {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
hash = string(b)
}
res, err := s.DB.Exec(`INSERT INTO users(tenant_id,role,email,password_hash,status,created_at) VALUES(?,?,?,?,?,?)`, tenantID, role, email, hash, status, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &User{ID: id, TenantID: tenantID, Role: role, Email: email, PasswordHash: hash, Status: status, CreatedAt: now}, nil
}
func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) {
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE tenant_id=? AND email=?`, tenantID, email)
var c int
if err := row.Scan(&c); err != nil {
return false, err
}
return c > 0, nil
}
func (s *Store) UserEmailExistsGlobal(email string) (bool, error) {
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE email=?`, email)
var c int
if err := row.Scan(&c); err != nil {
return false, err
}
return c > 0, nil
}
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
u, err := s.GetUserByEmail(tenantID, email)
if err != nil {
// compatibility: allow login by role name (admin/operator)
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND role=? ORDER BY id LIMIT 1`, tenantID, email)
var byRole User
if scanErr := row.Scan(&byRole.ID, &byRole.TenantID, &byRole.Role, &byRole.Email, &byRole.PasswordHash, &byRole.Status, &byRole.CreatedAt); scanErr != nil {
return nil, err
}
u = &byRole
}
if u.PasswordHash == "" {
return nil, errors.New("password not set")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid password")
}
return u, nil
}
func (s *Store) VerifyUserPasswordGlobal(email, password string) (*User, error) {
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE email=? ORDER BY id LIMIT 1`, email)
var u User
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
if u.PasswordHash == "" {
return nil, errors.New("password not set")
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid password")
}
return &u, nil
}
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
tok := randToken()
h := hashTokenString(tok)
now := time.Now().Unix()
exp := time.Now().Add(ttl).Unix()
_, err := s.DB.Exec(`INSERT INTO session_tokens(user_id,tenant_id,role,token_hash,expires_at,status,created_at) VALUES(?,?,?,?,?,1,?)`, userID, tenantID, role, h, exp, now)
if err != nil {
return "", 0, err
}
return tok, exp, nil
}
func (s *Store) VerifySessionToken(token string) (*SessionToken, error) {
h := hashTokenString(token)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,user_id,tenant_id,role,token_hash,expires_at,status,created_at FROM session_tokens WHERE token_hash=? AND status=1 AND expires_at>?`, h, now)
var st SessionToken
if err := row.Scan(&st.ID, &st.UserID, &st.TenantID, &st.Role, &st.TokenHash, &st.ExpiresAt, &st.Status, &st.CreatedAt); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) RevokeSessionToken(token string) error {
h := hashTokenString(token)
_, err := s.DB.Exec(`UPDATE session_tokens SET status=0 WHERE token_hash=?`, h)
return err
}
func (s *Store) ListUsers(tenantID int64) ([]User, error) {
rows, err := s.DB.Query(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, nil
}
func (s *Store) UpdateUserStatus(id int64, status int) error {
_, err := s.DB.Exec(`UPDATE users SET status=? WHERE id=?`, status, id)
return err
}
func (s *Store) UpdateUserPassword(id int64, password string) error {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(b), id)
return err
}
func (s *Store) UpdateUserEmail(id int64, email string) error {
_, err := s.DB.Exec(`UPDATE users SET email=? WHERE id=?`, email, id)
return err
}
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)
}
func randUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
func (s *Store) backfillNodeIdentity() error {
rows, err := s.DB.Query(`SELECT id,tenant_id,node_uuid,virtual_ip FROM nodes ORDER BY id`)
if err != nil {
return err
}
defer rows.Close()
type rowNode struct {
id int64
tenantID int64
uuid string
vip string
}
var list []rowNode
for rows.Next() {
var r rowNode
if err := rows.Scan(&r.id, &r.tenantID, &r.uuid, &r.vip); err == nil {
list = append(list, r)
}
}
for _, n := range list {
if n.uuid == "" {
if _, err := s.DB.Exec(`UPDATE nodes SET node_uuid=? WHERE id=?`, randUUID(), n.id); err != nil {
return err
}
}
if strings.TrimSpace(n.vip) == "" {
vip, err := s.AllocateNodeIP(n.tenantID)
if err == nil && vip != "" {
_, _ = s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE id=?`, vip, n.id)
}
}
}
return nil
}
// helper to avoid unused import (net)
var _ = net.IPv4len

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
)
// Version info (set via -ldflags)
@@ -35,19 +36,20 @@ const (
// ServerConfig holds inp2ps configuration.
type ServerConfig struct {
WSPort int `json:"wsPort"`
STUNUDP1 int `json:"stunUDP1"`
STUNUDP2 int `json:"stunUDP2"`
STUNTCP1 int `json:"stunTCP1"`
STUNTCP2 int `json:"stunTCP2"`
WebPort int `json:"webPort"`
APIPort int `json:"apiPort"`
DBPath string `json:"dbPath"`
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
Token uint64 `json:"token"` // master token for auth
JWTKey string `json:"jwtKey"` // auto-generated if empty
WSPort int `json:"wsPort"`
STUNUDP1 int `json:"stunUDP1"`
STUNUDP2 int `json:"stunUDP2"`
STUNTCP1 int `json:"stunTCP1"`
STUNTCP2 int `json:"stunTCP2"`
WebPort int `json:"webPort"`
APIPort int `json:"apiPort"`
DBPath string `json:"dbPath"`
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
Token uint64 `json:"token"` // master token for auth
Tokens []uint64 `json:"tokens"` // additional tenant tokens
JWTKey string `json:"jwtKey"` // auto-generated if empty
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPass"`
@@ -82,6 +84,18 @@ func (c *ServerConfig) FillFromEnv() {
if v := os.Getenv("INP2PS_TOKEN"); v != "" {
c.Token, _ = strconv.ParseUint(v, 10, 64)
}
if v := os.Getenv("INP2PS_TOKENS"); v != "" {
parts := strings.Split(v, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if tv, err := strconv.ParseUint(p, 10, 64); err == nil {
c.Tokens = append(c.Tokens, tv)
}
}
}
if v := os.Getenv("INP2PS_CERT"); v != "" {
c.CertFile = v
}
@@ -96,8 +110,8 @@ func (c *ServerConfig) FillFromEnv() {
}
func (c *ServerConfig) Validate() error {
if c.Token == 0 {
return fmt.Errorf("token is required (INP2PS_TOKEN or -token)")
if c.Token == 0 && len(c.Tokens) == 0 {
return fmt.Errorf("token is required (INP2PS_TOKEN or INP2PS_TOKENS)")
}
return nil
}
@@ -108,6 +122,7 @@ type ClientConfig struct {
ServerPort int `json:"serverPort"`
Node string `json:"node"`
Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"`
Insecure bool `json:"insecure"` // skip TLS verify
@@ -117,10 +132,11 @@ type ClientConfig struct {
STUNTCP1 int `json:"stunTCP1,omitempty"`
STUNTCP2 int `json:"stunTCP2,omitempty"`
RelayEnabled bool `json:"relayEnabled"` // --relay
SuperRelay bool `json:"superRelay"` // --super
RelayPort int `json:"relayPort"`
MaxRelayLoad int `json:"maxRelayLoad"`
RelayEnabled bool `json:"relayEnabled"` // --relay
SuperRelay bool `json:"superRelay"` // --super
RelayOfficial bool `json:"relayOfficial"` // official relay tag
RelayPort int `json:"relayPort"`
MaxRelayLoad int `json:"maxRelayLoad"`
ShareBandwidth int `json:"shareBandwidth"` // Mbps
LogLevel int `json:"logLevel"`
@@ -148,6 +164,8 @@ func DefaultClientConfig() ClientConfig {
ShareBandwidth: 10,
RelayPort: DefaultRelayPort,
MaxRelayLoad: DefaultMaxRelayLoad,
RelayEnabled: true,
RelayOfficial: false,
LogLevel: 1,
}
}
@@ -156,8 +174,8 @@ func (c *ClientConfig) Validate() error {
if c.ServerHost == "" {
return fmt.Errorf("serverHost is required")
}
if c.Token == 0 {
return fmt.Errorf("token is required")
if c.Token == 0 && c.NodeSecret == "" {
return fmt.Errorf("token or nodeSecret is required")
}
if c.Node == "" {
hostname, _ := os.Hostname()

View File

@@ -65,6 +65,7 @@ const (
SubPushSDWANConfig // push sdwan config to client
SubPushSDWANPeer // push sdwan peer online/update
SubPushSDWANDel // push sdwan peer offline/delete
SubPushConfig // generic remote config push
)
// Sub types: MsgTunnel
@@ -191,12 +192,14 @@ func DecodePayload(data []byte, v interface{}) error {
type LoginReq struct {
Node string `json:"node"`
Token uint64 `json:"token"`
NodeSecret string `json:"nodeSecret,omitempty"`
User string `json:"user,omitempty"`
Version string `json:"version"`
NATType NATType `json:"natType"`
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"` // --relay flag
SuperRelay bool `json:"superRelay"` // --super flag
RelayEnabled bool `json:"relayEnabled"` // --relay flag
SuperRelay bool `json:"superRelay"` // --super flag
RelayOfficial bool `json:"relayOfficial"` // official relay tag
PublicIP string `json:"publicIP,omitempty"`
PublicPort int `json:"publicPort,omitempty"`
}
@@ -262,6 +265,7 @@ type ConnectRsp struct {
// RelayNodeReq asks the server for a relay node.
type RelayNodeReq struct {
PeerNode string `json:"peerNode"`
Mode string `json:"mode,omitempty"` // "tenant" | "official"
}
type RelayNodeRsp struct {
@@ -290,16 +294,24 @@ type SDWANNode struct {
IP string `json:"ip"`
}
type SubnetProxy struct {
Node string `json:"node"`
LocalCIDR string `json:"localCIDR"`
VirtualCIDR string `json:"virtualCIDR"`
}
type SDWANConfig struct {
Enabled bool `json:"enabled,omitempty"`
Name string `json:"name,omitempty"`
GatewayCIDR string `json:"gatewayCIDR"`
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
MTU int `json:"mtu,omitempty"`
Routes []string `json:"routes,omitempty"`
Nodes []SDWANNode `json:"nodes"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Name string `json:"name,omitempty"`
GatewayCIDR string `json:"gatewayCIDR"`
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
HubNode string `json:"hubNode,omitempty"`
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
MTU int `json:"mtu,omitempty"`
Routes []string `json:"routes,omitempty"`
Nodes []SDWANNode `json:"nodes"`
SubnetProxies []SubnetProxy `json:"subnetProxies,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
}
type SDWANPeer struct {

View File

@@ -1,13 +1,13 @@
// Package relay implements relay/super-relay node capabilities.
//
// Relay flow:
// 1. Client A asks server for relay (RelayNodeReq)
// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp)
// 3. Server pushes RelayOffer to R with session info
// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token}
// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token}
// (B gets the session info via server push)
// 6. R verifies both tokens, bridges A↔B
// 1. Client A asks server for relay (RelayNodeReq)
// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp)
// 3. Server pushes RelayOffer to R with session info
// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token}
// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token}
// (B gets the session info via server push)
// 6. R verifies both tokens, bridges A↔B
package relay
import (
@@ -68,6 +68,7 @@ type Manager struct {
enabled bool
superRelay bool
maxLoad int
maxMbps int
token uint64 // this node's auth token
port int
listener net.Listener
@@ -92,11 +93,12 @@ type Session struct {
}
// NewManager creates a relay manager.
func NewManager(port int, enabled, superRelay bool, maxLoad int, token uint64) *Manager {
func NewManager(port int, enabled, superRelay bool, maxLoad int, token uint64, maxMbps int) *Manager {
return &Manager{
enabled: enabled,
superRelay: superRelay,
maxLoad: maxLoad,
maxMbps: maxMbps,
token: token,
port: port,
pending: make(map[string]*pendingSession),
@@ -296,14 +298,47 @@ func (m *Manager) bridge(ps *pendingSession) {
var wg sync.WaitGroup
wg.Add(2)
copyWithLimit := func(dst, src net.Conn) int64 {
if m.maxMbps <= 0 {
n, _ := io.Copy(dst, src)
return n
}
bytesPerSec := int64(m.maxMbps) * 1024 * 1024 / 8
if bytesPerSec < 1 {
bytesPerSec = 1
}
var total int64
buf := make([]byte, 32*1024)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
var allowance = bytesPerSec / 10
for {
n, err := src.Read(buf)
if n > 0 {
// simple token bucket
if allowance < int64(n) {
<-ticker.C
allowance = bytesPerSec / 10
}
allowance -= int64(n)
w, _ := dst.Write(buf[:n])
total += int64(w)
}
if err != nil {
break
}
}
return total
}
go func() {
defer wg.Done()
n, _ := io.Copy(sess.ConnB, sess.ConnA)
n := copyWithLimit(sess.ConnB, sess.ConnA)
atomic.AddInt64(&sess.BytesFwd, n)
}()
go func() {
defer wg.Done()
n, _ := io.Copy(sess.ConnA, sess.ConnB)
n := copyWithLimit(sess.ConnA, sess.ConnB)
atomic.AddInt64(&sess.BytesFwd, n)
}()

View File

@@ -12,7 +12,7 @@ import (
func TestRelayBridge(t *testing.T) {
token := auth.MakeToken("test", "pass")
mgr := NewManager(29700, true, false, 10, token)
mgr := NewManager(29700, true, false, 10, token, 10)
if err := mgr.Start(); err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func TestRelayBridge(t *testing.T) {
func TestRelayLargeData(t *testing.T) {
token := auth.MakeToken("test", "pass")
mgr := NewManager(29701, true, false, 10, token)
mgr := NewManager(29701, true, false, 10, token, 10)
if err := mgr.Start(); err != nil {
t.Fatal(err)
}
@@ -173,7 +173,7 @@ func TestRelayLargeData(t *testing.T) {
func TestRelayAuthDenied(t *testing.T) {
token := auth.MakeToken("real", "token")
mgr := NewManager(29702, true, false, 10, token)
mgr := NewManager(29702, true, false, 10, token, 10)
if err := mgr.Start(); err != nil {
t.Fatal(err)
}

View File

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

690
web/index.html Normal file
View File

@@ -0,0 +1,690 @@
<!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="loginUser" class="ipt" placeholder="用户名全局唯一字母≥6位" @keyup.enter="login">
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
<div class="text-xs text-slate-500 text-center">用户名要求仅字母、长度≥6、全局唯一</div>
<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">唯一ID</th><th class="p-3 text-left">虚拟IP</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</th>
</tr></thead>
<tbody>
<tr v-for="n in filteredNodes" :key="n.nodeUUID || n.name" class="border-t border-white/5">
<td class="p-3">
<div class="font-semibold">{{ n.alias || n.name }}</div>
<div class="text-xs text-slate-500">hostname: {{ n.name }}</div>
</td>
<td class="p-3 text-xs text-slate-400">{{ n.nodeUUID || '-' }}</td>
<td class="p-3">{{ n.virtualIP || '-' }}</td>
<td class="p-3">{{ n.publicIP }}:{{ n.publicPort }}</td>
<td class="p-3">{{ natText(n.natType) }}</td>
<td class="p-3">{{ n.tenantId || 0 }}</td>
<td class="p-3">{{ n.version || '-' }}</td>
<td class="p-3">{{ uptime(n.loginTime) }}</td>
<td class="p-3">
<div class="flex gap-2 flex-wrap">
<button class="btn2" @click="renameNode(n)">改昵称</button>
<button class="btn2" @click="changeNodeIP(n)">改IP</button>
<button class="btn2" @click="openConnect(n.name)">发起连接</button>
<button class="btn2" @click="openAppManager(n.name)">推配置</button>
<button class="btn2" @click="kickNode(n.name)">踢下线</button>
</div>
</td>
</tr>
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="9">暂无节点</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>
<select v-if="sd.mode==='hub'" class="ipt max-w-[220px]" v-model="sd.hubNode">
<option value="">选择 Hub 节点</option>
<option v-for="n in nodes" :key="'hub'+n.name" :value="n.name">{{ n.alias || n.name }}</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 v-if="sd.mode==='hub'" class="text-xs text-slate-400">Hub 离线将自动回 Mesh</div>
</div>
</div>
<div class="glass rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<div class="font-bold">节点映射</div>
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存节点映射</button>
</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.alias || x.name) }}(在线)</option>
<option v-for="x in (sd.nodes||[])" :key="'off'+x.node" v-if="x.node && !isOnline(x.node)" :value="x.node">{{ x.node }}(离线)</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 class="glass rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<div class="font-bold">子网代理Subnet Proxy</div>
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存子网代理</button>
</div>
<div class="text-xs text-slate-400 mb-2">示例local 192.168.0.0/24 → virtual 10.0.100.0/24掩码需一致</div>
<div class="space-y-2">
<div v-for="(s,i) in sd.subnetProxies" :key="i" class="grid grid-cols-1 md:grid-cols-6 gap-2">
<select class="ipt" v-model="s.node">
<option value="">选择节点</option>
<option v-for="x in nodes" :key="'sp'+x.name" :value="x.name">{{ x.name }}</option>
</select>
<input class="ipt md:col-span-2" v-model="s.localCIDR" placeholder="192.168.0.0/24">
<input class="ipt md:col-span-2" v-model="s.virtualCIDR" placeholder="10.0.100.0/24">
<button class="btn2" @click="removeSubnetProxy(i)">删除</button>
</div>
</div>
<button class="btn2 mt-3" @click="addSubnetProxy">+ 添加代理</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==='settings'" class="glass rounded-2xl p-4 space-y-4">
<div class="font-bold">高级设置(越权能力)</div>
<div class="text-xs text-slate-400">仅系统管理员可见。开启会记录审计日志。</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_impersonate" true-value="1" false-value="0"> 代理租户Impersonate</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_force_network" true-value="1" false-value="0"> 强制干预租户网络</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_cross_tenant" true-value="1" false-value="0"> 跨租户互通策略</label>
</div>
<button class="btn" :disabled="busy" @click="saveSettings">保存高级设置</button>
</div>
<div v-if="tab==='audit'" class="glass rounded-2xl p-4 space-y-4">
<div class="font-bold">审计日志</div>
<div class="flex flex-wrap items-center gap-2">
<input class="ipt max-w-[180px]" v-model="auditTenant" placeholder="tenant id (可空)">
<button class="btn2" :disabled="busy" @click="loadAudit">刷新</button>
</div>
<div class="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">Actor</th><th class="p-2 text-left">Action</th><th class="p-2 text-left">Target</th><th class="p-2 text-left">Detail</th><th class="p-2 text-left">IP</th><th class="p-2 text-left">Time</th>
</tr></thead>
<tbody>
<tr v-for="a in audit" :key="a.id" class="border-t border-white/5">
<td class="p-2">{{ a.id }}</td>
<td class="p-2">{{ a.actor_type }}:{{ a.actor_id }}</td>
<td class="p-2">{{ a.action }}</td>
<td class="p-2">{{ a.target_type }}:{{ a.target_id }}</td>
<td class="p-2 text-xs text-slate-400">{{ a.detail }}</td>
<td class="p-2">{{ a.ip }}</td>
<td class="p-2">{{ fmtTime(a.created_at*1000) }}</td>
</tr>
<tr v-if="!audit.length"><td class="p-4 text-center text-slate-500" colspan="7">暂无审计记录</td></tr>
</tbody>
</table>
</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'},
{id:'settings',name:'高级设置'},{id:'audit',name:'审计日志'}
];
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
const role = ref(''), status = ref(1);
const loginUser = ref(''), loginPass = 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', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'], subnetProxies:[] });
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 tokenType = ref('');
const settings = ref({ advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0' });
const audit = ref([]);
const auditTenant = ref('');
const isAdmin = computed(() => role.value === 'admin');
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll','settings','audit'].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 onlineMap = computed(() => new Set((nodes.value || []).map(n => n.name)));
const nodeStatus = (name) => (name && onlineMap.value.has(name)) ? '在线' : '离线';
const isOnline = (name) => (name && onlineMap.value.has(name));
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 {
const uname = (loginUser.value || '').trim();
if (!/^[A-Za-z]{6,}$/.test(uname)) throw new Error('用户名需仅字母且≥6位');
const d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ username: uname, 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;
tokenType.value = d.token_type || 'session';
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 = '';
tokenType.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');
if (isAdmin.value) {
try { settings.value = await api('/api/v1/admin/settings'); } catch(_) {}
try { const a = await api('/api/v1/admin/audit?limit=50'); audit.value = a.logs || []; } catch(_) {}
}
} 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 addSubnetProxy = () => sd.value.subnetProxies = [...(sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }];
const removeSubnetProxy = i => sd.value.subnetProxies.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 renameNode = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextAlias = prompt('输入新昵称(留空则清除)', node.alias || '');
if (nextAlias === null) return;
try {
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: nextAlias }) });
toast('昵称已更新');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const changeNodeIP = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextIP = prompt('输入新的虚拟IP必须在本网络CIDR内', node.virtualIP || '');
if (!nextIP) return;
try {
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: nextIP }) });
toast('IP已更新节点将按新IP重连');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const appPushNode = ref('');
const appPushRaw = ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
const openAppManager = (node) => { appPushNode.value = node; toast(`已选中 ${node},请在控制台执行推配置`); tab.value = 'p2p'; };
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 saveSettings = async () => {
if (!isAdmin.value) return;
try {
for (const k of Object.keys(settings.value || {})) {
await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(settings.value[k]) }) });
}
toast('高级设置已保存');
settings.value = await api('/api/v1/admin/settings');
} catch (e) { toast(e.message, 'error'); }
};
const loadAudit = async () => {
if (!isAdmin.value) return;
try {
const q = auditTenant.value ? `?tenant=${auditTenant.value}&limit=100` : '?limit=100';
const d = await api('/api/v1/admin/audit' + q);
audit.value = d.logs || [];
toast('审计日志已刷新');
} 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();
});
// keep session token in localStorage; do not force logout on load
onMounted(() => {
if (localStorage.getItem('t')) {
loggedIn.value = true;
refreshAll();
}
});
return {
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
loginUser, loginPass, loginErr, refreshSec,
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
settings, audit, auditTenant,
onlineMap, nodeStatus, isOnline,
natText, uptime, fmtTime,
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
createTenant, loadTenants, setTenantStatus,
createKey, loadKeys, setKeyStatus,
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
saveSettings, loadAudit,
updateCharts
};
}
}).mount('#app');
</script>
</body>
</html>