Compare commits
15 Commits
064fbf7f05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aad25bf26 | |||
| 9b590dd08f | |||
| 5fc7611108 | |||
| 36bbcf15f3 | |||
| ba63085de9 | |||
| 57b4dadd42 | |||
| e96a2e5dd9 | |||
| 5fe5c76375 | |||
| 065f9ba5b6 | |||
| 3b555df56c | |||
| 67bc6ecae6 | |||
| 10473020d2 | |||
| 9f6e065f3a | |||
| 6d5b1f50ab | |||
| 71a4a29220 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,9 @@ bin/
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
inp2pc
|
||||||
|
inp2ps
|
||||||
|
web/vendor/
|
||||||
|
|
||||||
# Test binary
|
# Test binary
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
220
ARCH_V1_USER_NETWORK.md
Normal file
220
ARCH_V1_USER_NETWORK.md
Normal 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 C(IPAM + 拓扑闭环)
|
||||||
|
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
324
WEB_CONSOLE_PLAN.md
Normal 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 管理控制台。
|
||||||
@@ -25,8 +25,9 @@ func main() {
|
|||||||
user := flag.String("user", "", "Username for token generation")
|
user := flag.String("user", "", "Username for token generation")
|
||||||
pass := flag.String("password", "", "Password for token generation")
|
pass := flag.String("password", "", "Password for token generation")
|
||||||
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
|
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
|
||||||
flag.BoolVar(&cfg.RelayEnabled, "relay", false, "Enable relay capability")
|
flag.BoolVar(&cfg.RelayEnabled, "relay", cfg.RelayEnabled, "Enable relay capability")
|
||||||
flag.BoolVar(&cfg.SuperRelay, "super", false, "Register as super relay node (implies -relay)")
|
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.RelayPort, "relay-port", cfg.RelayPort, "Relay listen port")
|
||||||
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
|
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
|
||||||
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
|
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
|
||||||
@@ -49,9 +50,7 @@ func main() {
|
|||||||
// Load config file first (unless -newconfig)
|
// Load config file first (unless -newconfig)
|
||||||
if !*newConfig {
|
if !*newConfig {
|
||||||
if data, err := os.ReadFile(*configFile); err == nil {
|
if data, err := os.ReadFile(*configFile); err == nil {
|
||||||
var fileCfg config.ClientConfig
|
if err := json.Unmarshal(data, &cfg); err == nil {
|
||||||
if err := json.Unmarshal(data, &fileCfg); err == nil {
|
|
||||||
cfg = fileCfg
|
|
||||||
// fill defaults for missing fields
|
// fill defaults for missing fields
|
||||||
if cfg.ServerPort == 0 {
|
if cfg.ServerPort == 0 {
|
||||||
cfg.ServerPort = config.DefaultWSPort
|
cfg.ServerPort = config.DefaultWSPort
|
||||||
@@ -101,6 +100,9 @@ func main() {
|
|||||||
case "super":
|
case "super":
|
||||||
cfg.SuperRelay = true
|
cfg.SuperRelay = true
|
||||||
cfg.RelayEnabled = true // super implies relay
|
cfg.RelayEnabled = true // super implies relay
|
||||||
|
case "official-relay":
|
||||||
|
cfg.RelayOfficial = true
|
||||||
|
cfg.RelayEnabled = true
|
||||||
case "bw":
|
case "bw":
|
||||||
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
|
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,64 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/openp2p-cn/inp2p/internal/server"
|
"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/auth"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/config"
|
"github.com/openp2p-cn/inp2p/pkg/config"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/nat"
|
"github.com/openp2p-cn/inp2p/pkg/nat"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
"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() {
|
func main() {
|
||||||
cfg := config.DefaultServerConfig()
|
cfg := config.DefaultServerConfig()
|
||||||
|
|
||||||
@@ -36,6 +80,8 @@ func main() {
|
|||||||
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
|
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
|
||||||
user := flag.String("user", "", "Username for token generation (requires -password)")
|
user := flag.String("user", "", "Username for token generation (requires -password)")
|
||||||
pass := flag.String("password", "", "Password for token generation")
|
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")
|
version := flag.Bool("version", false, "Print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -45,6 +91,46 @@ func main() {
|
|||||||
os.Exit(0)
|
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
|
// Token: either direct value or generated from user+password
|
||||||
if *token > 0 {
|
if *token > 0 {
|
||||||
cfg.Token = *token
|
cfg.Token = *token
|
||||||
@@ -84,25 +170,242 @@ func main() {
|
|||||||
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
|
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Signaling Server ───
|
||||||
// ─── Signaling Server ───
|
// ─── Signaling Server ───
|
||||||
srv := server.New(cfg)
|
srv := server.New(cfg)
|
||||||
srv.StartCleanup()
|
srv.StartCleanup()
|
||||||
|
|
||||||
|
// Admin-only Middleware (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 := http.NewServeMux()
|
||||||
mux.HandleFunc("/ws", srv.HandleWS)
|
mux.HandleFunc("/ws", srv.HandleWS)
|
||||||
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
// Serve Static Web Console
|
||||||
|
webDir := "/root/.openclaw/workspace/inp2p/web"
|
||||||
|
mux.Handle("/", http.FileServer(http.Dir(webDir)))
|
||||||
|
|
||||||
|
// Tenant APIs (API key auth inside handlers)
|
||||||
|
mux.HandleFunc("/api/v1/admin/tenants", adminMiddleware(srv.HandleAdminCreateTenant))
|
||||||
|
mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey))
|
||||||
|
mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers))
|
||||||
|
mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers))
|
||||||
|
mux.HandleFunc("/api/v1/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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
|
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
|
||||||
})
|
}))
|
||||||
mux.HandleFunc("/api/v1/sdwans", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
mux.HandleFunc("/api/v1/nodes", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
// tenant filter by 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())
|
_ = json.NewEncoder(w).Encode(srv.GetSDWAN())
|
||||||
})
|
}))
|
||||||
mux.HandleFunc("/api/v1/sdwan/edit", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
mux.HandleFunc("/api/v1/sdwan/edit", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -112,13 +415,199 @@ func main() {
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
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 {
|
if err := srv.SetSDWAN(req); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Remote Config Push API
|
||||||
|
mux.HandleFunc("/api/v1/nodes/apps", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Apps []protocol.AppConfig `json:"apps"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node := srv.GetNode(req.Node)
|
||||||
|
if node == nil {
|
||||||
|
http.Error(w, "node not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// tenant filter by 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 ───
|
// ─── HTTP Listener ───
|
||||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort))
|
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)
|
log.Printf("[main] signaling server on :%d (no TLS — use reverse proxy for production)", cfg.WSPort)
|
||||||
|
|
||||||
httpSrv := &http.Server{Handler: mux}
|
// Enable TCP keepalive at server level
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
|
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
|
||||||
log.Fatalf("[main] serve: %v", err)
|
log.Fatalf("[main] serve: %v", err)
|
||||||
|
|||||||
22
go.mod
22
go.mod
@@ -6,4 +6,24 @@ toolchain go1.24.4
|
|||||||
|
|
||||||
require github.com/gorilla/websocket v1.5.3
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
require golang.org/x/sys v0.41.0
|
require (
|
||||||
|
golang.org/x/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
39
go.sum
@@ -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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||||
|
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||||
|
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
|
modernc.org/sqlite v1.29.0 h1:lQVw+ZsFM3aRG5m4myG70tbXpr3S/J1ej0KHIP4EvjM=
|
||||||
|
modernc.org/sqlite v1.29.0/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
BIN
inp2ps.db-shm
Normal file
BIN
inp2ps.db-shm
Normal file
Binary file not shown.
BIN
inp2ps.db-wal
Normal file
BIN
inp2ps.db-wal
Normal file
Binary file not shown.
@@ -3,6 +3,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -45,6 +46,7 @@ type Client struct {
|
|||||||
sdwanStop chan struct{}
|
sdwanStop chan struct{}
|
||||||
tunMu sync.Mutex
|
tunMu sync.Mutex
|
||||||
tunFile *os.File
|
tunFile *os.File
|
||||||
|
sdwanPath string
|
||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ type Client struct {
|
|||||||
func New(cfg config.ClientConfig) *Client {
|
func New(cfg config.ClientConfig) *Client {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
sdwanPath: "/etc/inp2p/sdwan.json",
|
||||||
natType: protocol.NATUnknown,
|
natType: protocol.NATUnknown,
|
||||||
tunnels: make(map[string]*tunnel.Tunnel),
|
tunnels: make(map[string]*tunnel.Tunnel),
|
||||||
sdwanStop: make(chan struct{}),
|
sdwanStop: make(chan struct{}),
|
||||||
@@ -62,7 +65,7 @@ func New(cfg config.ClientConfig) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.RelayEnabled {
|
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
|
return c
|
||||||
@@ -95,7 +98,7 @@ func (c *Client) connectAndRun() error {
|
|||||||
c.publicIP = natResult.PublicIP
|
c.publicIP = natResult.PublicIP
|
||||||
c.publicPort = natResult.Port1
|
c.publicPort = natResult.Port1
|
||||||
c.localPort = natResult.LocalPort
|
c.localPort = natResult.LocalPort
|
||||||
log.Printf("[client] NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
|
log.Printf("[client] SENDING_LOGIN_TOKEN=%d NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.cfg.Token, c.natType, c.publicIP, c.publicPort, c.localPort)
|
||||||
|
|
||||||
// 2. WSS Connect
|
// 2. WSS Connect
|
||||||
scheme := "ws"
|
scheme := "ws"
|
||||||
@@ -130,12 +133,14 @@ func (c *Client) connectAndRun() error {
|
|||||||
loginReq := protocol.LoginReq{
|
loginReq := protocol.LoginReq{
|
||||||
Node: c.cfg.Node,
|
Node: c.cfg.Node,
|
||||||
Token: c.cfg.Token,
|
Token: c.cfg.Token,
|
||||||
|
NodeSecret: c.cfg.NodeSecret,
|
||||||
User: c.cfg.User,
|
User: c.cfg.User,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
NATType: c.natType,
|
NATType: c.natType,
|
||||||
ShareBandwidth: c.cfg.ShareBandwidth,
|
ShareBandwidth: c.cfg.ShareBandwidth,
|
||||||
RelayEnabled: c.cfg.RelayEnabled,
|
RelayEnabled: c.cfg.RelayEnabled,
|
||||||
SuperRelay: c.cfg.SuperRelay,
|
SuperRelay: c.cfg.SuperRelay,
|
||||||
|
RelayOfficial: c.cfg.RelayOfficial,
|
||||||
PublicIP: c.publicIP,
|
PublicIP: c.publicIP,
|
||||||
PublicPort: c.publicPort,
|
PublicPort: c.publicPort,
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,6 @@ func (c *Client) registerHandlers() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("[client] sdwan config received: gateway=%s nodes=%d mode=%s", cfg.GatewayCIDR, len(cfg.Nodes), cfg.Mode)
|
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
|
// apply control+data plane
|
||||||
if err := c.applySDWAN(cfg); err != nil {
|
if err := c.applySDWAN(cfg); err != nil {
|
||||||
@@ -396,7 +400,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err)
|
log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +408,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
protocol.DecodePayload(rspData, &rsp)
|
protocol.DecodePayload(rspData, &rsp)
|
||||||
if rsp.Error != 0 {
|
if rsp.Error != 0 {
|
||||||
log.Printf("[client] connect denied: %s", rsp.Detail)
|
log.Printf("[client] connect denied: %s", rsp.Detail)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +424,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error)
|
log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
c.reportConnect(app, protocol.ReportConnect{
|
c.reportConnect(app, protocol.ReportConnect{
|
||||||
PeerNode: app.PeerNode, Error: result.Error.Error(),
|
PeerNode: app.PeerNode, Error: result.Error.Error(),
|
||||||
NATType: c.natType, PeerNATType: rsp.Peer.NATType,
|
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.
|
// tryRelay attempts to use a relay node.
|
||||||
func (c *Client) tryRelay(app config.AppConfig) {
|
func (c *Client) tryRelay(app config.AppConfig, mode string) {
|
||||||
log.Printf("[client] trying relay for %s", app.PeerNode)
|
log.Printf("[client] trying relay(%s) for %s", mode, app.PeerNode)
|
||||||
|
|
||||||
rspData, err := c.conn.Request(
|
rspData, err := c.conn.Request(
|
||||||
protocol.MsgRelay, protocol.SubRelayNodeReq,
|
protocol.MsgRelay, protocol.SubRelayNodeReq,
|
||||||
protocol.RelayNodeReq{PeerNode: app.PeerNode},
|
protocol.RelayNodeReq{PeerNode: app.PeerNode, Mode: mode},
|
||||||
protocol.MsgRelay, protocol.SubRelayNodeRsp,
|
protocol.MsgRelay, protocol.SubRelayNodeRsp,
|
||||||
10*time.Second,
|
10*time.Second,
|
||||||
)
|
)
|
||||||
@@ -465,6 +469,11 @@ func (c *Client) tryRelay(app config.AppConfig) {
|
|||||||
var rsp protocol.RelayNodeRsp
|
var rsp protocol.RelayNodeRsp
|
||||||
protocol.DecodePayload(rspData, &rsp)
|
protocol.DecodePayload(rspData, &rsp)
|
||||||
if rsp.Error != 0 {
|
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)
|
log.Printf("[client] no relay available for %s", app.PeerNode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -545,6 +554,19 @@ func (c *Client) reportConnect(app config.AppConfig, rc protocol.ReportConnect)
|
|||||||
c.conn.Write(protocol.MsgReport, protocol.SubReportConnect, rc)
|
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 {
|
func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
||||||
selfIP := ""
|
selfIP := ""
|
||||||
for _, n := range cfg.Nodes {
|
for _, n := range cfg.Nodes {
|
||||||
@@ -578,11 +600,24 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
// fallback broad route for hub mode / compatibility
|
// fallback broad route for hub mode / compatibility
|
||||||
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun")
|
_ = 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.sdwanMu.Lock()
|
||||||
c.sdwan = cfg
|
c.sdwan = cfg
|
||||||
c.sdwanIP = selfIP
|
c.sdwanIP = selfIP
|
||||||
c.sdwanMu.Unlock()
|
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
|
// Try to start TUN reader, but don't fail SDWAN apply if it errors
|
||||||
if err := c.ensureTUNReader(); err != nil {
|
if err := c.ensureTUNReader(); err != nil {
|
||||||
log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err)
|
log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err)
|
||||||
@@ -591,6 +626,39 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
return nil
|
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 {
|
func (c *Client) ensureTUNReader() error {
|
||||||
c.tunMu.Lock()
|
c.tunMu.Lock()
|
||||||
defer c.tunMu.Unlock()
|
defer c.tunMu.Unlock()
|
||||||
@@ -637,33 +705,39 @@ func (c *Client) tunReadLoop() {
|
|||||||
if f == nil {
|
if f == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, err := f.Read(buf)
|
n, err := unix.Read(int(f.Fd()), buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.IsStopping() {
|
if c.IsStopping() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
// Ignore transient errors
|
||||||
|
if err != unix.EINTR && err != unix.EAGAIN {
|
||||||
log.Printf("[client] tun read error: %v", err)
|
log.Printf("[client] tun read error: %v", err)
|
||||||
}
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip empty packets or non-IPv4
|
||||||
if n == 0 || n < 20 {
|
if n == 0 || n < 20 {
|
||||||
log.Printf("[client] tun read error: %v", err)
|
continue
|
||||||
}
|
}
|
||||||
pkt := buf[:n]
|
pkt := buf[:n]
|
||||||
version := pkt[0] >> 4
|
version := pkt[0] >> 4
|
||||||
if version != 4 {
|
if version != 4 {
|
||||||
log.Printf("[client] tun read error: %v", err)
|
continue // skip non-IPv4
|
||||||
}
|
}
|
||||||
dstIP := net.IP(pkt[16:20]).String()
|
dstIP := net.IP(pkt[16:20]).String()
|
||||||
c.sdwanMu.RLock()
|
c.sdwanMu.RLock()
|
||||||
self := c.sdwanIP
|
self := c.sdwanIP
|
||||||
c.sdwanMu.RUnlock()
|
c.sdwanMu.RUnlock()
|
||||||
if dstIP == self {
|
if dstIP == self {
|
||||||
log.Printf("[client] tun read error: %v", err)
|
continue // skip packets to self
|
||||||
}
|
}
|
||||||
// send raw binary to avoid JSON base64 overhead
|
// send raw binary to avoid JSON base64 overhead
|
||||||
log.Printf("[client] tun: read pkt len=%d dst=%s", n, dstIP)
|
|
||||||
frame := protocol.EncodeRaw(protocol.MsgTunnel, protocol.SubTunnelSDWANRaw, pkt)
|
frame := protocol.EncodeRaw(protocol.MsgTunnel, protocol.SubTunnelSDWANRaw, pkt)
|
||||||
_ = c.conn.WriteRaw(frame)
|
if err := c.conn.WriteRaw(frame); err != nil {
|
||||||
|
log.Printf("[client] tun write failed: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
internal/client/inp2ps.db-shm
Normal file
BIN
internal/client/inp2ps.db-shm
Normal file
Binary file not shown.
BIN
internal/client/inp2ps.db-wal
Normal file
BIN
internal/client/inp2ps.db-wal
Normal file
Binary file not shown.
56
internal/server/admin_settings.go
Normal file
56
internal/server/admin_settings.go
Normal 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"}`)
|
||||||
|
}
|
||||||
40
internal/server/audit_api.go
Normal file
40
internal/server/audit_api.go
Normal 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
65
internal/server/authz.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/openp2p-cn/inp2p/pkg/auth"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,12 +19,12 @@ import (
|
|||||||
|
|
||||||
// HandleConnectReq processes a connection request from node A to node B.
|
// HandleConnectReq processes a connection request from node A to node B.
|
||||||
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error {
|
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error {
|
||||||
to := s.GetNode(req.To)
|
to := s.GetNodeForUser(req.To, from.Token)
|
||||||
if to == nil || !to.IsOnline() {
|
if to == nil || !to.IsOnline() {
|
||||||
// Peer offline — respond with error
|
// Peer offline or not visible — respond with generic not found
|
||||||
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
|
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
|
||||||
Error: 1,
|
Error: 1,
|
||||||
Detail: fmt.Sprintf("node %s offline", req.To),
|
Detail: "node not found",
|
||||||
From: req.To,
|
From: req.To,
|
||||||
To: req.From,
|
To: req.From,
|
||||||
})
|
})
|
||||||
@@ -38,6 +40,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
|
|||||||
Port: from.PublicPort,
|
Port: from.PublicPort,
|
||||||
NATType: from.NATType,
|
NATType: from.NATType,
|
||||||
HasIPv4: from.HasIPv4,
|
HasIPv4: from.HasIPv4,
|
||||||
|
Token: auth.GenTOTP(from.Token, time.Now().Unix()),
|
||||||
}
|
}
|
||||||
from.mu.RUnlock()
|
from.mu.RUnlock()
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
|
|||||||
Port: to.PublicPort,
|
Port: to.PublicPort,
|
||||||
NATType: to.NATType,
|
NATType: to.NATType,
|
||||||
HasIPv4: to.HasIPv4,
|
HasIPv4: to.HasIPv4,
|
||||||
|
Token: auth.GenTOTP(to.Token, time.Now().Unix()),
|
||||||
}
|
}
|
||||||
to.mu.RUnlock()
|
to.mu.RUnlock()
|
||||||
|
|
||||||
@@ -65,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
|
|||||||
return nil
|
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
|
// Push PunchStart to BOTH sides simultaneously
|
||||||
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())
|
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())
|
||||||
|
|
||||||
|
|||||||
7
internal/server/ctx.go
Normal file
7
internal/server/ctx.go
Normal 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{}
|
||||||
92
internal/server/node_api.go
Normal file
92
internal/server/node_api.go
Normal 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"}`)
|
||||||
|
}
|
||||||
@@ -15,10 +15,11 @@ type sdwanStore struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
cfg protocol.SDWANConfig
|
cfg protocol.SDWANConfig
|
||||||
|
multi map[int64]protocol.SDWANConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSDWANStore(path string) *sdwanStore {
|
func newSDWANStore(path string) *sdwanStore {
|
||||||
s := &sdwanStore{path: path}
|
s := &sdwanStore{path: path, multi: make(map[int64]protocol.SDWANConfig)}
|
||||||
_ = s.load()
|
_ = s.load()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,15 @@ func (s *sdwanStore) load() error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// try multi-tenant first
|
||||||
|
var m map[int64]protocol.SDWANConfig
|
||||||
|
if err := json.Unmarshal(b, &m); err == nil && len(m) > 0 {
|
||||||
|
for k, v := range m {
|
||||||
|
m[k] = normalizeSDWAN(v)
|
||||||
|
}
|
||||||
|
s.multi = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var c protocol.SDWANConfig
|
var c protocol.SDWANConfig
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
if err := json.Unmarshal(b, &c); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -57,12 +67,40 @@ func (s *sdwanStore) save(cfg protocol.SDWANConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *sdwanStore) saveTenant(tenantID int64, cfg protocol.SDWANConfig) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
cfg = normalizeSDWAN(cfg)
|
||||||
|
cfg.UpdatedAt = time.Now().Unix()
|
||||||
|
if s.multi == nil {
|
||||||
|
s.multi = make(map[int64]protocol.SDWANConfig)
|
||||||
|
}
|
||||||
|
s.multi[tenantID] = cfg
|
||||||
|
b, err := json.MarshalIndent(s.multi, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(s.path, b, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *sdwanStore) get() protocol.SDWANConfig {
|
func (s *sdwanStore) get() protocol.SDWANConfig {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
return s.cfg
|
return s.cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *sdwanStore) getTenant(tenantID int64) protocol.SDWANConfig {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if s.multi == nil {
|
||||||
|
return protocol.SDWANConfig{}
|
||||||
|
}
|
||||||
|
return s.multi[tenantID]
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
|
func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
|
||||||
if c.Mode == "" {
|
if c.Mode == "" {
|
||||||
c.Mode = "hub"
|
c.Mode = "hub"
|
||||||
@@ -83,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
|
|||||||
c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip})
|
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 })
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
@@ -11,6 +13,10 @@ func (s *Server) GetSDWAN() protocol.SDWANConfig {
|
|||||||
return s.sdwan.get()
|
return s.sdwan.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetSDWANTenant(tenantID int64) protocol.SDWANConfig {
|
||||||
|
return s.sdwan.getTenant(tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
|
func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
|
||||||
if err := s.sdwan.save(cfg); err != nil {
|
if err := s.sdwan.save(cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -19,6 +25,27 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
return nil
|
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) {
|
func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
|
||||||
if !cfg.Enabled || cfg.GatewayCIDR == "" {
|
if !cfg.Enabled || cfg.GatewayCIDR == "" {
|
||||||
return
|
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) {
|
func (s *Server) pushSDWANPeer(to *NodeInfo, peer protocol.SDWANPeer) {
|
||||||
if to == nil || !to.IsOnline() {
|
if to == nil || !to.IsOnline() {
|
||||||
return
|
return
|
||||||
@@ -48,7 +89,14 @@ func (s *Server) pushSDWANDel(to *NodeInfo, peer protocol.SDWANPeer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) announceSDWANNodeOnline(nodeName string) {
|
func (s *Server) announceSDWANNodeOnline(nodeName string) {
|
||||||
cfg := s.sdwan.get()
|
// pick tenant config by node
|
||||||
|
s.mu.RLock()
|
||||||
|
newNode := s.nodes[nodeName]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if newNode == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := s.sdwan.getTenant(newNode.TenantID)
|
||||||
if cfg.GatewayCIDR == "" {
|
if cfg.GatewayCIDR == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,7 +112,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
newNode := s.nodes[nodeName]
|
newNode = s.nodes[nodeName]
|
||||||
if newNode == nil || !newNode.IsOnline() {
|
if newNode == nil || !newNode.IsOnline() {
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return
|
return
|
||||||
@@ -74,7 +122,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
other := s.nodes[n.Node]
|
other := s.nodes[n.Node]
|
||||||
if other == nil || !other.IsOnline() {
|
if other == nil || !other.IsOnline() || other.TenantID != newNode.TenantID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// existing -> new
|
// existing -> new
|
||||||
@@ -86,7 +134,13 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) announceSDWANNodeOffline(nodeName string) {
|
func (s *Server) announceSDWANNodeOffline(nodeName string) {
|
||||||
cfg := s.sdwan.get()
|
s.mu.RLock()
|
||||||
|
old := s.nodes[nodeName]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if old == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := s.sdwan.getTenant(old.TenantID)
|
||||||
if cfg.GatewayCIDR == "" {
|
if cfg.GatewayCIDR == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,7 +154,7 @@ func (s *Server) announceSDWANNodeOffline(nodeName string) {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
for _, n := range s.nodes {
|
for _, n := range s.nodes {
|
||||||
if n.Name == nodeName || !n.IsOnline() {
|
if n.Name == nodeName || !n.IsOnline() || n.TenantID != old.TenantID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false})
|
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false})
|
||||||
@@ -112,7 +166,13 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
|
|||||||
if from == nil {
|
if from == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfg := s.sdwan.get()
|
// Use global config for untrusted nodes (TenantID=0), otherwise use tenant config
|
||||||
|
var cfg protocol.SDWANConfig
|
||||||
|
if from.TenantID == 0 {
|
||||||
|
cfg = s.sdwan.get()
|
||||||
|
} else {
|
||||||
|
cfg = s.sdwan.getTenant(from.TenantID)
|
||||||
|
}
|
||||||
if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
|
if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,20 +184,29 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
|
|||||||
toNode := ""
|
toNode := ""
|
||||||
for _, n := range cfg.Nodes {
|
for _, n := range cfg.Nodes {
|
||||||
if n.IP == pkt.DstIP {
|
if n.IP == pkt.DstIP {
|
||||||
|
candidate := s.GetNodeForUser(n.Node, from.Token)
|
||||||
|
if candidate != nil && candidate.TenantID == from.TenantID {
|
||||||
toNode = n.Node
|
toNode = n.Node
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
|
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
|
||||||
|
candidate := s.GetNodeForUser(n.Node, from.Token)
|
||||||
|
if candidate != nil && candidate.TenantID == from.TenantID {
|
||||||
toNode = n.Node
|
toNode = n.Node
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if toNode == "" || toNode == from.Name {
|
if toNode == "" || toNode == from.Name {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
to := s.nodes[toNode]
|
to := s.nodes[toNode]
|
||||||
|
if to != nil && to.TenantID != from.TenantID {
|
||||||
|
to = nil
|
||||||
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
if to == nil || !to.IsOnline() {
|
if to == nil || !to.IsOnline() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/openp2p-cn/inp2p/internal/store"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/auth"
|
"github.com/openp2p-cn/inp2p/pkg/auth"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/config"
|
"github.com/openp2p-cn/inp2p/pkg/config"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
||||||
@@ -17,26 +19,28 @@ import (
|
|||||||
|
|
||||||
// NodeInfo represents a connected client node.
|
// NodeInfo represents a connected client node.
|
||||||
type NodeInfo struct {
|
type NodeInfo struct {
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Token uint64
|
Token uint64 `json:"-"`
|
||||||
User string
|
TenantID int64 `json:"tenantId"`
|
||||||
Version string
|
User string `json:"user"`
|
||||||
NATType protocol.NATType
|
Version string `json:"version"`
|
||||||
PublicIP string
|
NATType protocol.NATType `json:"natType"`
|
||||||
PublicPort int
|
PublicIP string `json:"publicIP"`
|
||||||
LanIP string
|
PublicPort int `json:"publicPort"`
|
||||||
OS string
|
LanIP string `json:"lanIP"`
|
||||||
Mac string
|
OS string `json:"os"`
|
||||||
ShareBandwidth int
|
Mac string `json:"mac"`
|
||||||
RelayEnabled bool
|
ShareBandwidth int `json:"shareBandwidth"`
|
||||||
SuperRelay bool
|
RelayEnabled bool `json:"relayEnabled"`
|
||||||
HasIPv4 int
|
SuperRelay bool `json:"superRelay"`
|
||||||
IPv6 string
|
RelayOfficial bool `json:"relayOfficial"`
|
||||||
LoginTime time.Time
|
HasIPv4 int `json:"hasIPv4"`
|
||||||
LastHeartbeat time.Time
|
IPv6 string `json:"ipv6"`
|
||||||
Conn *signal.Conn
|
LoginTime time.Time `json:"loginTime"`
|
||||||
Apps []protocol.AppConfig
|
LastHeartbeat time.Time `json:"lastHeartbeat"`
|
||||||
mu sync.RWMutex
|
Conn *signal.Conn `json:"-"`
|
||||||
|
Apps []protocol.AppConfig `json:"apps"`
|
||||||
|
mu sync.RWMutex `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsOnline checks if node has sent heartbeat recently.
|
// IsOnline checks if node has sent heartbeat recently.
|
||||||
@@ -49,25 +53,52 @@ func (n *NodeInfo) IsOnline() bool {
|
|||||||
// Server is the INP2P signaling server.
|
// Server is the INP2P signaling server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.ServerConfig
|
cfg config.ServerConfig
|
||||||
nodes map[string]*NodeInfo // node name → info
|
nodes map[string]*NodeInfo
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
sdwanPath string
|
sdwanPath string
|
||||||
sdwan *sdwanStore
|
sdwan *sdwanStore
|
||||||
|
store *store.Store
|
||||||
|
tokens map[uint64]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) Store() *store.Store { return s.store }
|
||||||
|
|
||||||
// New creates a new server.
|
// New creates a new server.
|
||||||
func New(cfg config.ServerConfig) *Server {
|
func New(cfg config.ServerConfig) *Server {
|
||||||
// Use absolute path for sdwan config to avoid working directory issues
|
|
||||||
sdwanPath := "/root/.openclaw/workspace/inp2p/sdwan.json"
|
sdwanPath := "/root/.openclaw/workspace/inp2p/sdwan.json"
|
||||||
|
tokens := make(map[uint64]bool)
|
||||||
|
if cfg.Token != 0 {
|
||||||
|
tokens[cfg.Token] = true
|
||||||
|
}
|
||||||
|
for _, t := range cfg.Tokens {
|
||||||
|
tokens[t] = true
|
||||||
|
}
|
||||||
|
st, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[server] open store failed: %v", err)
|
||||||
|
} 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{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
nodes: make(map[string]*NodeInfo),
|
nodes: make(map[string]*NodeInfo),
|
||||||
sdwanPath: sdwanPath,
|
sdwanPath: sdwanPath,
|
||||||
sdwan: newSDWANStore(sdwanPath),
|
sdwan: newSDWANStore(sdwanPath),
|
||||||
|
store: st,
|
||||||
|
tokens: tokens,
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
},
|
},
|
||||||
quit: make(chan struct{}),
|
quit: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -93,8 +124,44 @@ func (s *Server) GetOnlineNodes() []*NodeInfo {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNodeForUser returns node if token matches (legacy) or tenant matches.
|
||||||
|
func (s *Server) GetNodeForUser(name string, token uint64) *NodeInfo {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
n := s.nodes[name]
|
||||||
|
if n == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n.Token != token && n.TenantID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetNodeForTenant(name string, tenantID int64) *NodeInfo {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
n := s.nodes[name]
|
||||||
|
if n == nil || n.TenantID != tenantID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var out []*NodeInfo
|
||||||
|
for _, n := range s.nodes {
|
||||||
|
if n.IsOnline() && n.TenantID == tenantID {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// GetRelayNodes returns nodes that can serve as relay.
|
// GetRelayNodes returns nodes that can serve as relay.
|
||||||
// Priority: same-user private relay → super relay
|
// Priority: same-user private relay → super relay (exclude official relays)
|
||||||
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
|
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
|
||||||
excludeSet := make(map[string]bool)
|
excludeSet := make(map[string]bool)
|
||||||
for _, n := range excludeNodes {
|
for _, n := range excludeNodes {
|
||||||
@@ -106,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
|
|||||||
|
|
||||||
var privateRelays, superRelays []*NodeInfo
|
var privateRelays, superRelays []*NodeInfo
|
||||||
for _, n := range s.nodes {
|
for _, n := range s.nodes {
|
||||||
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
|
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n.User == forUser {
|
if n.User == forUser {
|
||||||
@@ -119,6 +186,48 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
|
|||||||
return append(privateRelays, superRelays...)
|
return append(privateRelays, superRelays...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRelayNodesByTenant returns relay nodes within tenant.
|
||||||
|
func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) []*NodeInfo {
|
||||||
|
excludeSet := make(map[string]bool)
|
||||||
|
for _, n := range excludeNodes {
|
||||||
|
excludeSet[n] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var relays []*NodeInfo
|
||||||
|
for _, n := range s.nodes {
|
||||||
|
if !n.IsOnline() || excludeSet[n.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !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.
|
// HandleWS is the WebSocket handler for client connections.
|
||||||
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
ws, err := s.upgrader.Upgrade(w, r, nil)
|
ws, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
@@ -151,8 +260,26 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify token
|
// Verify token: master token OR tenant API key (DB) OR node_secret (DB)
|
||||||
if loginReq.Token != s.cfg.Token {
|
valid := s.tokens[loginReq.Token]
|
||||||
|
log.Printf("[server] login check: token=%d, cfg.Token=%d, valid=%v", loginReq.Token, s.cfg.Token, valid)
|
||||||
|
var tenantID int64
|
||||||
|
if !valid && s.store != nil {
|
||||||
|
// try api key (string) or node secret
|
||||||
|
if loginReq.NodeSecret != "" {
|
||||||
|
if ten, err := s.store.VerifyNodeSecret(loginReq.Node, loginReq.NodeSecret); err == nil && ten != nil {
|
||||||
|
valid = true
|
||||||
|
tenantID = ten.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
if ten, err := s.store.VerifyAPIKey(fmt.Sprintf("%d", loginReq.Token)); err == nil && ten != nil {
|
||||||
|
valid = true
|
||||||
|
tenantID = ten.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
|
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
|
||||||
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
|
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
|
||||||
Error: 1,
|
Error: 1,
|
||||||
@@ -174,12 +301,14 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
node := &NodeInfo{
|
node := &NodeInfo{
|
||||||
Name: loginReq.Node,
|
Name: loginReq.Node,
|
||||||
Token: loginReq.Token,
|
Token: loginReq.Token,
|
||||||
|
TenantID: tenantID,
|
||||||
User: loginReq.User,
|
User: loginReq.User,
|
||||||
Version: loginReq.Version,
|
Version: loginReq.Version,
|
||||||
NATType: loginReq.NATType,
|
NATType: loginReq.NATType,
|
||||||
ShareBandwidth: loginReq.ShareBandwidth,
|
ShareBandwidth: loginReq.ShareBandwidth,
|
||||||
RelayEnabled: loginReq.RelayEnabled,
|
RelayEnabled: loginReq.RelayEnabled,
|
||||||
SuperRelay: loginReq.SuperRelay,
|
SuperRelay: loginReq.SuperRelay,
|
||||||
|
RelayOfficial: loginReq.RelayOfficial,
|
||||||
PublicIP: loginReq.PublicIP,
|
PublicIP: loginReq.PublicIP,
|
||||||
PublicPort: loginReq.PublicPort,
|
PublicPort: loginReq.PublicPort,
|
||||||
LoginTime: time.Now(),
|
LoginTime: time.Now(),
|
||||||
@@ -211,6 +340,15 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.broadcastNodeOnline(loginReq.Node)
|
s.broadcastNodeOnline(loginReq.Node)
|
||||||
|
|
||||||
// Push current SDWAN config right after login (if exists and enabled)
|
// Push current SDWAN config right after login (if exists and enabled)
|
||||||
|
if node.TenantID > 0 {
|
||||||
|
if cfg := s.sdwan.getTenant(node.TenantID); cfg.Enabled && cfg.GatewayCIDR != "" {
|
||||||
|
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
|
||||||
|
log.Printf("[server] sdwan config push failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
|
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
|
||||||
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
|
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
|
||||||
log.Printf("[server] sdwan config push failed: %v", err)
|
log.Printf("[server] sdwan config push failed: %v", err)
|
||||||
@@ -218,6 +356,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
|
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Event-driven SDWAN peer notification
|
// Event-driven SDWAN peer notification
|
||||||
s.announceSDWANNodeOnline(loginReq.Node)
|
s.announceSDWANNodeOnline(loginReq.Node)
|
||||||
|
|
||||||
@@ -347,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
|
|||||||
|
|
||||||
// handleRelayNodeReq finds and returns the best relay node.
|
// handleRelayNodeReq finds and returns the best relay node.
|
||||||
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
|
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
|
||||||
relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
|
mode := "tenant"
|
||||||
|
if req.Mode == "official" {
|
||||||
if len(relays) == 0 {
|
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{
|
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
|
// Pick the first (best) relay
|
||||||
relay := relays[0]
|
relay := relays[0]
|
||||||
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
|
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)
|
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{
|
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.
|
// PushConnect sends a punch coordination message to a peer node.
|
||||||
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error {
|
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error {
|
||||||
toNode := s.GetNode(toNodeName)
|
toNode := s.GetNodeForUser(toNodeName, fromNode.Token)
|
||||||
if toNode == nil || !toNode.IsOnline() {
|
if toNode == nil || !toNode.IsOnline() {
|
||||||
return &NodeOfflineError{Node: toNodeName}
|
return &NodeOfflineError{Node: toNodeName}
|
||||||
}
|
}
|
||||||
|
if fromNode.TenantID != 0 && toNode.TenantID != fromNode.TenantID {
|
||||||
|
return &NodeOfflineError{Node: toNodeName}
|
||||||
|
}
|
||||||
|
|
||||||
// Push connect request to the destination
|
// Push connect request to the destination
|
||||||
req := protocol.ConnectReq{
|
req := protocol.ConnectReq{
|
||||||
@@ -390,8 +577,10 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
|
|||||||
FromIP: fromNode.PublicIP,
|
FromIP: fromNode.PublicIP,
|
||||||
Peer: protocol.PunchParams{
|
Peer: protocol.PunchParams{
|
||||||
IP: fromNode.PublicIP,
|
IP: fromNode.PublicIP,
|
||||||
|
Port: fromNode.PublicPort,
|
||||||
NATType: fromNode.NATType,
|
NATType: fromNode.NATType,
|
||||||
HasIPv4: fromNode.HasIPv4,
|
HasIPv4: fromNode.HasIPv4,
|
||||||
|
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
|
||||||
},
|
},
|
||||||
AppName: app.AppName,
|
AppName: app.AppName,
|
||||||
Protocol: app.Protocol,
|
Protocol: app.Protocol,
|
||||||
@@ -406,12 +595,19 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
|
|||||||
// broadcastNodeOnline notifies interested nodes that a peer came online.
|
// broadcastNodeOnline notifies interested nodes that a peer came online.
|
||||||
func (s *Server) broadcastNodeOnline(nodeName string) {
|
func (s *Server) broadcastNodeOnline(nodeName string) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
newNode := s.nodes[nodeName]
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
if newNode == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, n := range s.nodes {
|
for _, n := range s.nodes {
|
||||||
if n.Name == nodeName {
|
if n.Name == nodeName {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if n.Token != newNode.Token && (newNode.TenantID == 0 || n.TenantID != newNode.TenantID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Check if this node has any app targeting the new node
|
// Check if this node has any app targeting the new node
|
||||||
n.mu.RLock()
|
n.mu.RLock()
|
||||||
interested := false
|
interested := false
|
||||||
@@ -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() {
|
func (s *Server) StartCleanup() {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
@@ -448,6 +644,35 @@ func (s *Server) StartCleanup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
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:
|
case <-s.quit:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
319
internal/server/tenant_api.go
Normal file
319
internal/server/tenant_api.go
Normal 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
176
internal/server/user_api.go
Normal 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
940
internal/store/store.go
Normal 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
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version info (set via -ldflags)
|
// Version info (set via -ldflags)
|
||||||
@@ -47,6 +48,7 @@ type ServerConfig struct {
|
|||||||
KeyFile string `json:"keyFile"`
|
KeyFile string `json:"keyFile"`
|
||||||
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
|
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
|
||||||
Token uint64 `json:"token"` // master token for auth
|
Token uint64 `json:"token"` // master token for auth
|
||||||
|
Tokens []uint64 `json:"tokens"` // additional tenant tokens
|
||||||
JWTKey string `json:"jwtKey"` // auto-generated if empty
|
JWTKey string `json:"jwtKey"` // auto-generated if empty
|
||||||
|
|
||||||
AdminUser string `json:"adminUser"`
|
AdminUser string `json:"adminUser"`
|
||||||
@@ -82,6 +84,18 @@ func (c *ServerConfig) FillFromEnv() {
|
|||||||
if v := os.Getenv("INP2PS_TOKEN"); v != "" {
|
if v := os.Getenv("INP2PS_TOKEN"); v != "" {
|
||||||
c.Token, _ = strconv.ParseUint(v, 10, 64)
|
c.Token, _ = strconv.ParseUint(v, 10, 64)
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("INP2PS_TOKENS"); v != "" {
|
||||||
|
parts := strings.Split(v, ",")
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tv, err := strconv.ParseUint(p, 10, 64); err == nil {
|
||||||
|
c.Tokens = append(c.Tokens, tv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if v := os.Getenv("INP2PS_CERT"); v != "" {
|
if v := os.Getenv("INP2PS_CERT"); v != "" {
|
||||||
c.CertFile = v
|
c.CertFile = v
|
||||||
}
|
}
|
||||||
@@ -96,8 +110,8 @@ func (c *ServerConfig) FillFromEnv() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ServerConfig) Validate() error {
|
func (c *ServerConfig) Validate() error {
|
||||||
if c.Token == 0 {
|
if c.Token == 0 && len(c.Tokens) == 0 {
|
||||||
return fmt.Errorf("token is required (INP2PS_TOKEN or -token)")
|
return fmt.Errorf("token is required (INP2PS_TOKEN or INP2PS_TOKENS)")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -108,6 +122,7 @@ type ClientConfig struct {
|
|||||||
ServerPort int `json:"serverPort"`
|
ServerPort int `json:"serverPort"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Token uint64 `json:"token"`
|
Token uint64 `json:"token"`
|
||||||
|
NodeSecret string `json:"nodeSecret,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
Insecure bool `json:"insecure"` // skip TLS verify
|
Insecure bool `json:"insecure"` // skip TLS verify
|
||||||
|
|
||||||
@@ -119,6 +134,7 @@ type ClientConfig struct {
|
|||||||
|
|
||||||
RelayEnabled bool `json:"relayEnabled"` // --relay
|
RelayEnabled bool `json:"relayEnabled"` // --relay
|
||||||
SuperRelay bool `json:"superRelay"` // --super
|
SuperRelay bool `json:"superRelay"` // --super
|
||||||
|
RelayOfficial bool `json:"relayOfficial"` // official relay tag
|
||||||
RelayPort int `json:"relayPort"`
|
RelayPort int `json:"relayPort"`
|
||||||
MaxRelayLoad int `json:"maxRelayLoad"`
|
MaxRelayLoad int `json:"maxRelayLoad"`
|
||||||
|
|
||||||
@@ -148,6 +164,8 @@ func DefaultClientConfig() ClientConfig {
|
|||||||
ShareBandwidth: 10,
|
ShareBandwidth: 10,
|
||||||
RelayPort: DefaultRelayPort,
|
RelayPort: DefaultRelayPort,
|
||||||
MaxRelayLoad: DefaultMaxRelayLoad,
|
MaxRelayLoad: DefaultMaxRelayLoad,
|
||||||
|
RelayEnabled: true,
|
||||||
|
RelayOfficial: false,
|
||||||
LogLevel: 1,
|
LogLevel: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,8 +174,8 @@ func (c *ClientConfig) Validate() error {
|
|||||||
if c.ServerHost == "" {
|
if c.ServerHost == "" {
|
||||||
return fmt.Errorf("serverHost is required")
|
return fmt.Errorf("serverHost is required")
|
||||||
}
|
}
|
||||||
if c.Token == 0 {
|
if c.Token == 0 && c.NodeSecret == "" {
|
||||||
return fmt.Errorf("token is required")
|
return fmt.Errorf("token or nodeSecret is required")
|
||||||
}
|
}
|
||||||
if c.Node == "" {
|
if c.Node == "" {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const (
|
|||||||
SubPushSDWANConfig // push sdwan config to client
|
SubPushSDWANConfig // push sdwan config to client
|
||||||
SubPushSDWANPeer // push sdwan peer online/update
|
SubPushSDWANPeer // push sdwan peer online/update
|
||||||
SubPushSDWANDel // push sdwan peer offline/delete
|
SubPushSDWANDel // push sdwan peer offline/delete
|
||||||
|
SubPushConfig // generic remote config push
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sub types: MsgTunnel
|
// Sub types: MsgTunnel
|
||||||
@@ -191,12 +192,14 @@ func DecodePayload(data []byte, v interface{}) error {
|
|||||||
type LoginReq struct {
|
type LoginReq struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Token uint64 `json:"token"`
|
Token uint64 `json:"token"`
|
||||||
|
NodeSecret string `json:"nodeSecret,omitempty"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
NATType NATType `json:"natType"`
|
NATType NATType `json:"natType"`
|
||||||
ShareBandwidth int `json:"shareBandwidth"`
|
ShareBandwidth int `json:"shareBandwidth"`
|
||||||
RelayEnabled bool `json:"relayEnabled"` // --relay flag
|
RelayEnabled bool `json:"relayEnabled"` // --relay flag
|
||||||
SuperRelay bool `json:"superRelay"` // --super flag
|
SuperRelay bool `json:"superRelay"` // --super flag
|
||||||
|
RelayOfficial bool `json:"relayOfficial"` // official relay tag
|
||||||
PublicIP string `json:"publicIP,omitempty"`
|
PublicIP string `json:"publicIP,omitempty"`
|
||||||
PublicPort int `json:"publicPort,omitempty"`
|
PublicPort int `json:"publicPort,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -262,6 +265,7 @@ type ConnectRsp struct {
|
|||||||
// RelayNodeReq asks the server for a relay node.
|
// RelayNodeReq asks the server for a relay node.
|
||||||
type RelayNodeReq struct {
|
type RelayNodeReq struct {
|
||||||
PeerNode string `json:"peerNode"`
|
PeerNode string `json:"peerNode"`
|
||||||
|
Mode string `json:"mode,omitempty"` // "tenant" | "official"
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelayNodeRsp struct {
|
type RelayNodeRsp struct {
|
||||||
@@ -290,15 +294,23 @@ type SDWANNode struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubnetProxy struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
LocalCIDR string `json:"localCIDR"`
|
||||||
|
VirtualCIDR string `json:"virtualCIDR"`
|
||||||
|
}
|
||||||
|
|
||||||
type SDWANConfig struct {
|
type SDWANConfig struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
GatewayCIDR string `json:"gatewayCIDR"`
|
GatewayCIDR string `json:"gatewayCIDR"`
|
||||||
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
|
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
|
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
|
||||||
MTU int `json:"mtu,omitempty"`
|
MTU int `json:"mtu,omitempty"`
|
||||||
Routes []string `json:"routes,omitempty"`
|
Routes []string `json:"routes,omitempty"`
|
||||||
Nodes []SDWANNode `json:"nodes"`
|
Nodes []SDWANNode `json:"nodes"`
|
||||||
|
SubnetProxies []SubnetProxy `json:"subnetProxies,omitempty"`
|
||||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ type Manager struct {
|
|||||||
enabled bool
|
enabled bool
|
||||||
superRelay bool
|
superRelay bool
|
||||||
maxLoad int
|
maxLoad int
|
||||||
|
maxMbps int
|
||||||
token uint64 // this node's auth token
|
token uint64 // this node's auth token
|
||||||
port int
|
port int
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
@@ -92,11 +93,12 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a relay manager.
|
// 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{
|
return &Manager{
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
superRelay: superRelay,
|
superRelay: superRelay,
|
||||||
maxLoad: maxLoad,
|
maxLoad: maxLoad,
|
||||||
|
maxMbps: maxMbps,
|
||||||
token: token,
|
token: token,
|
||||||
port: port,
|
port: port,
|
||||||
pending: make(map[string]*pendingSession),
|
pending: make(map[string]*pendingSession),
|
||||||
@@ -296,14 +298,47 @@ func (m *Manager) bridge(ps *pendingSession) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
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() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
n, _ := io.Copy(sess.ConnB, sess.ConnA)
|
n := copyWithLimit(sess.ConnB, sess.ConnA)
|
||||||
atomic.AddInt64(&sess.BytesFwd, n)
|
atomic.AddInt64(&sess.BytesFwd, n)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
n, _ := io.Copy(sess.ConnA, sess.ConnB)
|
n := copyWithLimit(sess.ConnA, sess.ConnB)
|
||||||
atomic.AddInt64(&sess.BytesFwd, n)
|
atomic.AddInt64(&sess.BytesFwd, n)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
func TestRelayBridge(t *testing.T) {
|
func TestRelayBridge(t *testing.T) {
|
||||||
token := auth.MakeToken("test", "pass")
|
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 {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestRelayBridge(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelayLargeData(t *testing.T) {
|
func TestRelayLargeData(t *testing.T) {
|
||||||
token := auth.MakeToken("test", "pass")
|
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 {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestRelayLargeData(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelayAuthDenied(t *testing.T) {
|
func TestRelayAuthDenied(t *testing.T) {
|
||||||
token := auth.MakeToken("real", "token")
|
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 {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,33 @@ func (c *Conn) Request(mainType, subType uint16, payload interface{},
|
|||||||
|
|
||||||
// ReadLoop reads messages and dispatches to handlers. Blocks until error or Close().
|
// ReadLoop reads messages and dispatches to handlers. Blocks until error or Close().
|
||||||
func (c *Conn) ReadLoop() error {
|
func (c *Conn) ReadLoop() error {
|
||||||
|
// keepalive to avoid idle close (read deadline = 3x ping interval)
|
||||||
|
_ = c.ws.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
c.ws.SetPongHandler(func(string) error {
|
||||||
|
_ = c.ws.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send ping frames periodically to keep NAT/WSS alive
|
||||||
|
// Increased frequency to 10s for better resilience against network hiccups
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.quit:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.writeMu.Lock()
|
||||||
|
_ = c.ws.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
err := c.ws.WriteMessage(websocket.PingMessage, []byte(time.Now().Format("20060102150405")))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[signal] ping failed: %v, will reconnect", err)
|
||||||
|
}
|
||||||
|
c.writeMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
for {
|
for {
|
||||||
_, msg, err := c.ws.ReadMessage()
|
_, msg, err := c.ws.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
690
web/index.html
Normal file
690
web/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user