Compare commits

...

10 Commits

28 changed files with 2200 additions and 314 deletions

3
.gitignore vendored
View File

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

220
ARCH_V1_USER_NETWORK.md Normal file
View File

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

View File

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

View File

@@ -12,16 +12,58 @@ import (
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/openp2p-cn/inp2p/internal/server"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/nat"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
type rateLimiter struct {
mu sync.Mutex
m map[string]*rateEntry
max int
window time.Duration
}
type rateEntry struct {
count int
reset time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{m: make(map[string]*rateEntry), max: max, window: window}
}
func (r *rateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.m[key]
now := time.Now()
if !ok || now.After(e.reset) {
r.m[key] = &rateEntry{count: 1, reset: now.Add(r.window)}
return true
}
if e.count >= r.max {
return false
}
e.count++
return true
}
func clientIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func main() {
cfg := config.DefaultServerConfig()
@@ -38,6 +80,8 @@ func main() {
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
user := flag.String("user", "", "Username for token generation (requires -password)")
pass := flag.String("password", "", "Password for token generation")
bootstrapAdmin := flag.String("bootstrap-admin", "", "Bootstrap system admin username (letters only, >=6)")
bootstrapPass := flag.String("bootstrap-password", "", "Bootstrap system admin password")
version := flag.Bool("version", false, "Print version and exit")
flag.Parse()
@@ -47,6 +91,46 @@ func main() {
os.Exit(0)
}
// Bootstrap system admin (optional)
if *bootstrapAdmin != "" {
if !server.IsValidGlobalUsername(*bootstrapAdmin) {
log.Fatalf("[main] invalid bootstrap-admin username (letters only, >=6)")
}
if *bootstrapPass == "" {
log.Fatalf("[main] bootstrap-password required")
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("[main] open store failed: %v", err)
}
_ = st.SetSetting("bootstrapped_admin", "1")
// ensure default tenant exists
if _, gErr := st.GetTenantByID(1); gErr != nil {
_, _, _, _ = st.CreateTenantWithUsers("default", "admin", "admin")
}
// update/create admin user in tenant 1
users, _ := st.ListUsers(1)
var adminID int64
for _, u := range users {
if u.Role == "admin" {
adminID = u.ID
break
}
}
if adminID > 0 {
_ = st.UpdateUserEmail(adminID, *bootstrapAdmin)
_ = st.UpdateUserPassword(adminID, *bootstrapPass)
log.Printf("[main] bootstrapped admin updated: %s", *bootstrapAdmin)
} else {
_, err = st.CreateUser(1, "admin", *bootstrapAdmin, *bootstrapPass, 1)
if err != nil {
log.Fatalf("[main] bootstrap admin create failed: %v", err)
}
log.Printf("[main] bootstrapped admin created: %s", *bootstrapAdmin)
}
os.Exit(0)
}
// Token: either direct value or generated from user+password
if *token > 0 {
cfg.Token = *token
@@ -91,78 +175,93 @@ func main() {
srv := server.New(cfg)
srv.StartCleanup()
// Admin-only Middleware
// 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
}
authHeader := r.Header.Get("Authorization")
valid := authHeader == fmt.Sprintf("Bearer %d", cfg.Token)
if !valid {
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
}
// RBAC: admin only
if srv.Store() != nil {
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 || u.Role != "admin" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
next(w, r)
}
}
// Tenant or Admin Middleware
// 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
}
authHeader := r.Header.Get("Authorization")
if authHeader == fmt.Sprintf("Bearer %d", cfg.Token) {
next(w, r)
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
}
// check API key + RBAC
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
// role check if user exists
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
if u.Role == "operator" {
path := r.URL.Path
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
// 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
}
next(w, r)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
out = append(out, item)
}
return out
}
mux := http.NewServeMux()
@@ -177,105 +276,96 @@ func main() {
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)
mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume)
mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume)
// 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
}
// Support two modes:
// 1) token login: {"token":"xxxx"} (admin/master only, backward compatible)
// 2) user login: {"tenant":1,"username":"admin","password":"pass"}
var reqTok struct {
Token string `json:"token"`
}
// single mode: username/password login
var reqUser struct {
TenantID int64 `json:"tenant"`
Username string `json:"username"`
Password string `json:"password"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &reqTok)
_ = json.Unmarshal(body, &reqUser)
// --- user login ---
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`)
return
}
u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password)
if err != nil || u == nil || u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
// issue API key for this tenant and return subnet
key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", key, u.Role, u.Status, ""}
if ten != nil {
resp.Subnet = ten.Subnet
}
b, _ := json.Marshal(resp)
if reqUser.Username == "" || reqUser.Password == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":1,"message":"username and password required"}`)
return
}
// --- token login (legacy/admin) ---
valid := false
role := "admin"
status := 1
if reqTok.Token != "" {
// support numeric token as string
if reqTok.Token == fmt.Sprintf("%d", cfg.Token) {
valid = true
} else {
for _, t := range cfg.Tokens {
if reqTok.Token == fmt.Sprintf("%d", t) {
valid = true
break
}
}
}
if !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 !valid {
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 token"}`)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
if srv.Store() != nil {
if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil {
if u.Role != "" {
role = u.Role
}
status = u.Status
}
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")
fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status)
w.WriteHeader(http.StatusOK)
w.Write(b)
})
mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
@@ -289,20 +379,15 @@ func main() {
return
}
w.Header().Set("Content-Type", "application/json")
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
nodes := srv.GetOnlineNodesByTenant(tenantID)
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, tenantID)})
return
}
nodes := srv.GetOnlineNodes()
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, 0)})
}))
mux.HandleFunc("/api/v1/sdwans", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
@@ -311,13 +396,8 @@ func main() {
return
}
w.Header().Set("Content-Type", "application/json")
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
_ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID))
return
@@ -335,15 +415,39 @@ func main() {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
if 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 {
if err := srv.SetSDWANTenant(tenantID, req); err != nil {
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
}
@@ -378,13 +482,8 @@ func main() {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
@@ -413,13 +512,8 @@ func main() {
http.Error(w, "node not found or offline", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 && node.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
@@ -451,13 +545,8 @@ func main() {
http.Error(w, "source node offline", http.StatusNotFound)
return
}
// tenant filter by API key
tenantID := int64(0)
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
tenantID = ten.ID
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 && fromNode.TenantID != tenantID {
http.Error(w, "node not found", http.StatusNotFound)
return
@@ -529,10 +618,10 @@ func main() {
// Enable TCP keepalive at server level
httpSrv := &http.Server{
Handler: mux,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {

5
go.mod
View File

@@ -6,7 +6,10 @@ toolchain go1.24.4
require github.com/gorilla/websocket v1.5.3
require golang.org/x/sys v0.41.0
require (
golang.org/x/crypto v0.23.0
golang.org/x/sys v0.41.0
)
require modernc.org/sqlite v1.29.0

2
go.sum
View File

@@ -18,6 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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=

BIN
inp2pc

Binary file not shown.

BIN
inp2ps

Binary file not shown.

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
)
// GET /api/v1/admin/audit?tenant=3&limit=50&offset=0
func (s *Server) HandleAdminAudit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 && i <= 500 {
limit = i
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i >= 0 {
offset = i
}
}
tenantID := int64(0)
if v := r.URL.Query().Get("tenant"); v != "" {
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
tenantID = i
}
}
logs, err := s.store.ListAuditLogs(tenantID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "logs": logs})
}

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

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

View File

@@ -3,6 +3,7 @@ package server
import (
"fmt"
"log"
"os"
"time"
"github.com/openp2p-cn/inp2p/pkg/auth"
@@ -68,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
return nil
}
// Debug: force relay path if explicit env set
if os.Getenv("INP2P_FORCE_RELAY") == "1" {
log.Printf("[coord] %s → %s: force relay requested", from.Name, to.Name)
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 0,
From: to.Name,
To: from.Name,
Peer: toParams,
Detail: "punch-failed",
})
return nil
}
// Push PunchStart to BOTH sides simultaneously
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())

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

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

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -121,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip})
}
sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Node < c.Nodes[j].Node })
// de-dup subnet proxies by node+cidr
if len(c.SubnetProxies) > 0 {
m2 := make(map[string]protocol.SubnetProxy)
for _, sp := range c.SubnetProxies {
if sp.Node == "" || sp.VirtualCIDR == "" || sp.LocalCIDR == "" {
continue
}
key := sp.Node + "|" + sp.VirtualCIDR + "|" + sp.LocalCIDR
m2[key] = sp
}
c.SubnetProxies = c.SubnetProxies[:0]
for _, sp := range m2 {
c.SubnetProxies = append(c.SubnetProxies, sp)
}
}
return c
}

View File

@@ -1,6 +1,8 @@
package server
import (
"errors"
"fmt"
"log"
"net/netip"
@@ -23,10 +25,23 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil
}
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error {
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
}

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal"
@@ -33,6 +33,7 @@ type NodeInfo struct {
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"`
SuperRelay bool `json:"superRelay"`
RelayOfficial bool `json:"relayOfficial"`
HasIPv4 int `json:"hasIPv4"`
IPv6 string `json:"ipv6"`
LoginTime time.Time `json:"loginTime"`
@@ -77,6 +78,15 @@ func New(cfg config.ServerConfig) *Server {
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Printf("[server] open store failed: %v", err)
} else {
// bootstrap default tenant if missing
if _, gErr := st.GetTenantByID(1); gErr != nil {
if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil {
log.Printf("[server] bootstrap default tenant failed: %v", cErr)
} else {
log.Printf("[server] bootstrap default tenant created (tenant=1)")
}
}
}
return &Server{
cfg: cfg,
@@ -86,7 +96,7 @@ func New(cfg config.ServerConfig) *Server {
store: st,
tokens: tokens,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
},
@@ -151,7 +161,7 @@ func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
}
// GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay
// Priority: same-user private relay → super relay (exclude official relays)
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
@@ -163,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
var privateRelays, superRelays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial {
continue
}
if n.User == forUser {
@@ -191,13 +201,33 @@ func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) [
if !n.IsOnline() || excludeSet[n.Name] {
continue
}
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) {
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !n.RelayOfficial {
relays = append(relays, n)
}
}
return relays
}
// GetOfficialRelays returns official relay nodes (global pool)
func (s *Server) GetOfficialRelays(excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
excludeSet[n] = true
}
s.mu.RLock()
defer s.mu.RUnlock()
var relays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || !n.RelayOfficial {
continue
}
relays = append(relays, n)
}
return relays
}
// HandleWS is the WebSocket handler for client connections.
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
@@ -278,6 +308,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ShareBandwidth: loginReq.ShareBandwidth,
RelayEnabled: loginReq.RelayEnabled,
SuperRelay: loginReq.SuperRelay,
RelayOfficial: loginReq.RelayOfficial,
PublicIP: loginReq.PublicIP,
PublicPort: loginReq.PublicPort,
LoginTime: time.Now(),
@@ -455,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
// handleRelayNodeReq finds and returns the best relay node.
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
mode := "tenant"
if req.Mode == "official" {
mode = "official"
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relay := official[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode)
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
Error: 1,
RelayName: relay.Name,
RelayIP: relay.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: mode,
Error: 0,
})
}
// prefer hub relay if sdwan mode=hub
if requester.TenantID > 0 && s.sdwan != nil {
cfg := s.sdwan.getTenant(requester.TenantID)
if cfg.Mode == "hub" && cfg.HubNode != "" && cfg.HubNode != requester.Name && cfg.HubNode != req.PeerNode {
hub := s.GetNode(cfg.HubNode)
if hub != nil && hub.IsOnline() && hub.TenantID == requester.TenantID && hub.RelayEnabled {
log.Printf("[server] relay selected: %s (hub) for %s → %s", hub.Name, requester.Name, req.PeerNode)
totp := auth.GenTOTP(hub.Token, time.Now().Unix())
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
RelayName: hub.Name,
RelayIP: hub.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: "private",
Error: 0,
})
}
}
}
// prefer same-tenant relays, exclude requester and peer
relays := s.GetRelayNodesByTenant(requester.TenantID, requester.Name, req.PeerNode)
if len(relays) == 0 {
// fallback to same-user (private) then super
relays = s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
// final fallback: official relays
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relays = official
mode = "official"
} else if relays[0].User != requester.User {
mode = "super"
} else {
mode = "private"
}
}
// Pick the first (best) relay
relay := relays[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
mode := "private"
if relay.User != requester.User {
mode = "super"
}
log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode)
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
@@ -501,6 +577,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
FromIP: fromNode.PublicIP,
Peer: protocol.PunchParams{
IP: fromNode.PublicIP,
Port: fromNode.PublicPort,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
@@ -550,7 +627,7 @@ func (s *Server) broadcastNodeOnline(nodeName string) {
}
}
// StartCleanup periodically removes stale nodes.
// StartCleanup periodically removes stale nodes and checks SDWAN hub health.
func (s *Server) StartCleanup() {
go func() {
ticker := time.NewTicker(30 * time.Second)
@@ -567,6 +644,35 @@ func (s *Server) StartCleanup() {
}
}
s.mu.Unlock()
// hub offline -> auto mesh (tenant configs)
if s.sdwan != nil {
sd := s.sdwan
sd.mu.RLock()
m := make(map[int64]protocol.SDWANConfig, len(sd.multi))
for k, v := range sd.multi {
m[k] = v
}
sd.mu.RUnlock()
for tid, cfg := range m {
if cfg.Mode != "hub" || cfg.HubNode == "" {
continue
}
hub := s.GetNode(cfg.HubNode)
if hub != nil && hub.IsOnline() && hub.TenantID == tid {
continue
}
// auto fallback to mesh
cfg.Mode = "mesh"
cfg.HubNode = ""
_ = s.sdwan.saveTenant(tid, cfg)
if s.store != nil {
_ = s.store.AddAuditLog("system", "0", "sdwan_update", "tenant", fmt.Sprintf("%d", tid), "hub->mesh (hub offline)", "")
}
s.broadcastSDWANTenant(tid, cfg)
log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid)
}
}
case <-s.quit:
return
}

View File

@@ -34,12 +34,50 @@ func writeJSON(w http.ResponseWriter, status int, body string) {
}
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"`
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"}`)
@@ -49,23 +87,39 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ten, err := s.store.CreateTenant(req.Name)
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"`
}{0, "ok", ten.ID, ten.Subnet}
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 {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
@@ -87,6 +141,40 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
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
@@ -109,24 +197,47 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
}{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 {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// tenant auth by API key
// tenant auth by session/apikey
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
tok := BearerToken(r)
ten, err := s.store.VerifyAPIKey(tok)
if err != nil || ten == nil {
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"}`)
@@ -147,6 +258,24 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
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"`
@@ -171,12 +300,17 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID}
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))
}

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

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

View File

@@ -8,8 +8,10 @@ import (
"errors"
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
@@ -18,27 +20,57 @@ type Store struct {
}
type Tenant struct {
ID int64
Name string
Status int
Subnet string
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
Expires *time.Time
Status int
ID int64
TenantID int64
Hash string
Scope string
ExpiresAt *int64
Status int
CreatedAt int64
Plain string
}
type NodeCredential struct {
NodeID int64
NodeName string
Secret string
VirtualIP string
TenantID int64
NodeID int64
NodeUUID string
NodeName string
Alias string
Secret string
VirtualIP string
TenantID int64
Status int
CreatedAt int64
LastSeen *int64
}
type EnrollToken struct {
@@ -50,6 +82,18 @@ type EnrollToken struct {
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) {
@@ -70,6 +114,12 @@ func Open(dbPath string) (*Store, error) {
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
}
@@ -105,12 +155,15 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
node_uuid TEXT,
node_name TEXT NOT NULL,
alias TEXT DEFAULT '',
node_pubkey TEXT,
node_secret_hash TEXT,
virtual_ip TEXT,
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 (
@@ -147,6 +200,11 @@ func (s *Store) migrate() error {
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,
@@ -154,12 +212,33 @@ func (s *Store) migrate() error {
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
}
@@ -215,35 +294,167 @@ func (s *Store) CreateTenant(name string) (*Tenant, error) {
}
id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil
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)
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h)
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, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil
return &NodeCredential{NodeID: id, NodeUUID: nodeUUID, NodeName: nodeName, Alias: "", Secret: secret, VirtualIP: vip, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h)
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); err != nil {
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 FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
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); err != nil {
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
@@ -274,10 +485,10 @@ func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
h := hashTokenString(code)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h)
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); err != nil {
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 {
@@ -306,6 +517,179 @@ func (s *Store) IncEnrollAttempt(code string) {
_, _ = 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++ {
@@ -320,14 +704,178 @@ func hashTokenString(token string) string {
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
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); err != nil {
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[:])
@@ -339,5 +887,54 @@ func randToken() string {
return hex.EncodeToString(b)
}
func randUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
func (s *Store) backfillNodeIdentity() error {
rows, err := s.DB.Query(`SELECT id,tenant_id,node_uuid,virtual_ip FROM nodes ORDER BY id`)
if err != nil {
return err
}
defer rows.Close()
type rowNode struct {
id int64
tenantID int64
uuid string
vip string
}
var list []rowNode
for rows.Next() {
var r rowNode
if err := rows.Scan(&r.id, &r.tenantID, &r.uuid, &r.vip); err == nil {
list = append(list, r)
}
}
for _, n := range list {
if n.uuid == "" {
if _, err := s.DB.Exec(`UPDATE nodes SET node_uuid=? WHERE id=?`, randUUID(), n.id); err != nil {
return err
}
}
if strings.TrimSpace(n.vip) == "" {
vip, err := s.AllocateNodeIP(n.tenantID)
if err == nil && vip != "" {
_, _ = s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE id=?`, vip, n.id)
}
}
}
return nil
}
// helper to avoid unused import (net)
var _ = net.IPv4len

View File

@@ -36,17 +36,17 @@ const (
// ServerConfig holds inp2ps configuration.
type ServerConfig struct {
WSPort int `json:"wsPort"`
STUNUDP1 int `json:"stunUDP1"`
STUNUDP2 int `json:"stunUDP2"`
STUNTCP1 int `json:"stunTCP1"`
STUNTCP2 int `json:"stunTCP2"`
WebPort int `json:"webPort"`
APIPort int `json:"apiPort"`
DBPath string `json:"dbPath"`
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
WSPort int `json:"wsPort"`
STUNUDP1 int `json:"stunUDP1"`
STUNUDP2 int `json:"stunUDP2"`
STUNTCP1 int `json:"stunTCP1"`
STUNTCP2 int `json:"stunTCP2"`
WebPort int `json:"webPort"`
APIPort int `json:"apiPort"`
DBPath string `json:"dbPath"`
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
Token uint64 `json:"token"` // master token for auth
Tokens []uint64 `json:"tokens"` // additional tenant tokens
JWTKey string `json:"jwtKey"` // auto-generated if empty
@@ -132,10 +132,11 @@ type ClientConfig struct {
STUNTCP1 int `json:"stunTCP1,omitempty"`
STUNTCP2 int `json:"stunTCP2,omitempty"`
RelayEnabled bool `json:"relayEnabled"` // --relay
SuperRelay bool `json:"superRelay"` // --super
RelayPort int `json:"relayPort"`
MaxRelayLoad int `json:"maxRelayLoad"`
RelayEnabled bool `json:"relayEnabled"` // --relay
SuperRelay bool `json:"superRelay"` // --super
RelayOfficial bool `json:"relayOfficial"` // official relay tag
RelayPort int `json:"relayPort"`
MaxRelayLoad int `json:"maxRelayLoad"`
ShareBandwidth int `json:"shareBandwidth"` // Mbps
LogLevel int `json:"logLevel"`
@@ -163,6 +164,8 @@ func DefaultClientConfig() ClientConfig {
ShareBandwidth: 10,
RelayPort: DefaultRelayPort,
MaxRelayLoad: DefaultMaxRelayLoad,
RelayEnabled: true,
RelayOfficial: false,
LogLevel: 1,
}
}

View File

@@ -197,8 +197,9 @@ type LoginReq struct {
Version string `json:"version"`
NATType NATType `json:"natType"`
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"` // --relay flag
SuperRelay bool `json:"superRelay"` // --super flag
RelayEnabled bool `json:"relayEnabled"` // --relay flag
SuperRelay bool `json:"superRelay"` // --super flag
RelayOfficial bool `json:"relayOfficial"` // official relay tag
PublicIP string `json:"publicIP,omitempty"`
PublicPort int `json:"publicPort,omitempty"`
}
@@ -264,6 +265,7 @@ type ConnectRsp struct {
// RelayNodeReq asks the server for a relay node.
type RelayNodeReq struct {
PeerNode string `json:"peerNode"`
Mode string `json:"mode,omitempty"` // "tenant" | "official"
}
type RelayNodeRsp struct {
@@ -292,16 +294,24 @@ type SDWANNode struct {
IP string `json:"ip"`
}
type SubnetProxy struct {
Node string `json:"node"`
LocalCIDR string `json:"localCIDR"`
VirtualCIDR string `json:"virtualCIDR"`
}
type SDWANConfig struct {
Enabled bool `json:"enabled,omitempty"`
Name string `json:"name,omitempty"`
GatewayCIDR string `json:"gatewayCIDR"`
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
MTU int `json:"mtu,omitempty"`
Routes []string `json:"routes,omitempty"`
Nodes []SDWANNode `json:"nodes"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Name string `json:"name,omitempty"`
GatewayCIDR string `json:"gatewayCIDR"`
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
HubNode string `json:"hubNode,omitempty"`
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
MTU int `json:"mtu,omitempty"`
Routes []string `json:"routes,omitempty"`
Nodes []SDWANNode `json:"nodes"`
SubnetProxies []SubnetProxy `json:"subnetProxies,omitempty"`
UpdatedAt int64 `json:"updatedAt,omitempty"`
}
type SDWANPeer struct {

View File

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

View File

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

View File

@@ -31,11 +31,9 @@
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
<div class="space-y-3">
<input v-model="loginTenant" class="ipt" placeholder="Tenant ID用户登录" @keyup.enter="login">
<input v-model="loginUser" class="ipt" placeholder="用户名(如 admin" @keyup.enter="login">
<input v-model="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">或使用主 Token 登录(管理员)</div>
<input v-model="loginToken" class="ipt" type="password" placeholder="Master Token" @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>
@@ -86,25 +84,32 @@
<div class="overflow-auto">
<table class="w-full text-sm min-w-[900px]">
<thead class="text-slate-400"><tr>
<th class="p-3 text-left">节点</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</th>
<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.name" class="border-t border-white/5">
<td class="p-3">{{ n.name }}</td>
<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">
<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="7">暂无节点</td></tr>
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="9">暂无节点</td></tr>
</tbody>
</table>
</div>
@@ -117,21 +122,30 @@
<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="font-bold mb-3">节点映射</div>
<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.name }}</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>
@@ -139,6 +153,26 @@
</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">
@@ -168,6 +202,44 @@
</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>
@@ -270,16 +342,17 @@ createApp({
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:'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 loginTenant = ref('1'), loginUser = ref('admin'), loginPass = ref('admin'), loginToken = ref(''), loginErr = ref('');
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', mtu:1420, nodes:[], routes:['10.10.0.0/24'] });
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([]);
@@ -287,13 +360,20 @@ createApp({
const keyForm = ref({ scope:'all', ttl:0 });
const userForm = ref({ role:'operator', email:'', password:'' });
const isAdmin = computed(() => role.value === 'admin' && localStorage.getItem('t') === localStorage.getItem('master_t'));
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
const 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') || '') });
@@ -328,18 +408,14 @@ createApp({
loginErr.value = '';
busy.value = true;
try {
let d;
if ((loginToken.value || '').trim()) {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token: loginToken.value.trim() }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || 'token 登录失败');
localStorage.setItem('master_t', d.token || '');
} else {
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tenant: Number(loginTenant.value || 1), username: loginUser.value, password: loginPass.value }) }).then(r=>r.json());
if (d.error) throw new Error(d.message || '用户名密码登录失败');
}
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();
@@ -356,6 +432,7 @@ createApp({
localStorage.removeItem('master_t');
loggedIn.value = false;
role.value = '';
tokenType.value = '';
stopTimer();
};
@@ -367,6 +444,10 @@ createApp({
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 {
@@ -383,6 +464,8 @@ createApp({
};
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])); });
@@ -404,6 +487,28 @@ createApp({
catch(e){ toast(e.message, 'error'); }
};
const renameNode = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextAlias = prompt('输入新昵称(留空则清除)', node.alias || '');
if (nextAlias === null) return;
try {
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: nextAlias }) });
toast('昵称已更新');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const changeNodeIP = async (node) => {
if (!node.nodeUUID) return toast('该节点尚无UUID稍后重连后再试', 'error');
const nextIP = prompt('输入新的虚拟IP必须在本网络CIDR内', node.virtualIP || '');
if (!nextIP) return;
try {
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: nextIP }) });
toast('IP已更新节点将按新IP重连');
refreshAll();
} catch(e){ toast(e.message, 'error'); }
};
const appPushNode = ref('');
const appPushRaw = ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
const openAppManager = (node) => { appPushNode.value = node; toast(`已选中 ${node},请在控制台执行推配置`); tab.value = 'p2p'; };
@@ -496,6 +601,27 @@ createApp({
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:'{}' });
@@ -532,22 +658,29 @@ createApp({
else stopTimer();
});
// keep session token in localStorage; do not force logout on load
onMounted(() => {
logout();
if (localStorage.getItem('t')) {
loggedIn.value = true;
refreshAll();
}
});
return {
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status,
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
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, autoAssignIPs,
kickNode, openAppManager, pushAppConfigs, openConnect, doConnect,
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
};
}