feat: restrict user permissions and multi-node IP constraints

- Non-admin users cannot set speedId or inPort on forward create/update
- Multi-entrance tunnels disable custom listen IP for forwards
- Multi-exit tunnels disable custom connect IP
- Multi-node hop chains disable custom connect IP per hop
- Remove tunnel-first-IP fallback in forward ingress resolution
- Add contract tests for non-admin permission restrictions

Entire-Checkpoint: 133693290660
This commit is contained in:
sagitchu
2026-03-04 14:03:48 +08:00
parent 2e3d5c9249
commit 2b224e4e9c
7 changed files with 623 additions and 149 deletions
+68 -5
View File
@@ -456,6 +456,10 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
name := asString(req["name"])
if name == "" {
response.WriteJSON(w, response.ErrDefault("隧道名称不能为空"))
@@ -663,6 +667,10 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
if err := validateTunnelConnectIPConstraints(req); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
id := asInt64(req["id"], 0)
if id <= 0 {
response.WriteJSON(w, response.ErrDefault("隧道ID不能为空"))
@@ -1145,6 +1153,16 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("转发名称和目标地址不能为空"))
return
}
if roleID != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置自定义端口"))
return
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
@@ -1159,6 +1177,11 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
port = 10000
}
entryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
inIp := strings.TrimSpace(asString(req["inIp"]))
if inIp != "" && len(entryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range entryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -1175,7 +1198,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" {
userName = "user"
}
inIp := strings.TrimSpace(asString(req["inIp"]))
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -1251,6 +1273,16 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if strategy == "" {
strategy = forward.Strategy
}
if actorRole != 0 {
if _, ok := req["speedId"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则"))
return
}
if _, ok := req["inPort"]; ok {
response.WriteJSON(w, response.Err(-1, "普通用户无法修改自定义端口"))
return
}
}
speedID := asAnyToInt64Ptr(req["speedId"])
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
@@ -1281,6 +1313,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
inIp = asString(rawInIP)
}
fwdEntryNodes, _ := h.tunnelEntryNodeIDs(tunnelID)
if hasInIP && strings.TrimSpace(inIp) != "" && len(fwdEntryNodes) > 1 {
response.WriteJSON(w, response.ErrDefault("多入口隧道的转发不支持自定义监听IP"))
return
}
for _, nodeID := range fwdEntryNodes {
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
@@ -2166,6 +2202,32 @@ func buildTunnelInIP(inNodes []tunnelRuntimeNode, nodes map[int64]*nodeRecord, i
return strings.Join(ordered, ",")
}
func validateTunnelConnectIPConstraints(req map[string]interface{}) error {
outNodes := asMapSlice(req["outNodeId"])
if len(outNodes) > 1 {
for _, item := range outNodes {
if strings.TrimSpace(asString(item["connectIp"])) != "" {
return fmt.Errorf("多出口隧道不支持设置自定义连接IP")
}
}
}
for hopIdx, hopRaw := range asAnySlice(req["chainNodes"]) {
hopNodes := asMapSlice(hopRaw)
if len(hopNodes) <= 1 {
continue
}
for _, item := range hopNodes {
if strings.TrimSpace(asString(item["connectIp"])) != "" {
return fmt.Errorf("转发链第%d跳有多个节点时不支持设置自定义连接IP", hopIdx+1)
}
}
}
return nil
}
func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateState) {
if req == nil || state == nil {
return
@@ -3108,10 +3170,11 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return fmt.Errorf("userId or tunnelId missing")
}
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, err :=
existingID, currentFlow, currentNum, currentExpTime, currentFlowReset, currentSpeedID, currentStatus, lookupErr :=
h.repo.GetExistingUserTunnel(userID, tunnelID)
speedID := asAnyToInt64Ptr(req["speedId"])
var err error
speedID, err = h.normalizeSpeedLimitReference(speedID)
if err != nil {
return err
@@ -3123,7 +3186,7 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
reqFlowReset := asInt64(req["flowResetTime"], -1)
reqStatus := asInt(req["status"], -1)
if err == sql.ErrNoRows {
if lookupErr == sql.ErrNoRows {
if reqFlow < 0 || reqNum < 0 || reqExpTime < 0 || reqFlowReset < 0 {
uFlow, uNum, uExp, uReset, uErr := h.repo.GetUserDefaultsForTunnel(userID)
if uErr == nil {
@@ -3176,8 +3239,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error {
return nil
}
if err != nil {
return err
if lookupErr != nil {
return lookupErr
}
newFlow := currentFlow
@@ -2753,18 +2753,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
seenPorts := make(map[int64]struct{})
seenPairs := make(map[string]struct{})
var tunnelFirstIP string
if tunnelInIP.Valid && strings.TrimSpace(tunnelInIP.String) != "" {
tunnelIPs := strings.Split(tunnelInIP.String, ",")
for _, ip := range tunnelIPs {
ip = strings.TrimSpace(ip)
if ip != "" {
tunnelFirstIP = ip
break
}
}
}
for _, row := range fpRows {
if !row.Port.Valid {
continue
@@ -2777,8 +2765,6 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
var ip string
if row.InIP.Valid && strings.TrimSpace(row.InIP.String) != "" {
ip = strings.TrimSpace(row.InIP.String)
} else if tunnelFirstIP != "" {
ip = tunnelFirstIP
} else if row.ServerIP.Valid && strings.TrimSpace(row.ServerIP.String) != "" {
ip = strings.TrimSpace(row.ServerIP.String)
}
@@ -917,3 +917,184 @@ func TestForwardCreateThenPauseResumeContract(t *testing.T) {
func jsonNumber(v int64) string {
return strconv.FormatInt(v, 10)
}
func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) {
secret := "contract-jwt-secret-perm"
router, repo := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
VALUES(2, 'normal_user_perm', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
`, now, now).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "perm-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "perm-node", "perm-secret", "10.0.0.20", "10.0.0.20", "", "30000-30010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
entryNodeID := mustLastInsertID(t, repo, "perm-node")
if err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 30001, 'round', 1, 'tls')
`, tunnelID, entryNodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(?, ?, NULL, 10, 99999, 0, 0, 1, 2727251700000, 1)
`, 2, tunnelID).Error; err != nil {
t.Fatalf("insert user_tunnel: %v", err)
}
if err := repo.DB().Exec(`
INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status)
VALUES(?, ?, ?, ?, ?, ?, 1)
`, "perm-speed-limit", 2048, tunnelID, "perm-tunnel", now, now).Error; err != nil {
t.Fatalf("insert speed limit: %v", err)
}
speedID := mustLastInsertID(t, repo, "perm-speed-limit")
userToken, err := auth.GenerateToken(2, "normal_user_perm", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
stopNode := startMockNodeSession(t, server.URL, "perm-secret")
defer stopNode()
t.Run("non-admin cannot set speedId on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-speed",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"speedId": speedID,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置限速规则")
})
t.Run("non-admin cannot set inPort on create", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-port",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
"inPort": 12345,
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法设置自定义端口")
})
t.Run("non-admin can create without speedId and inPort", func(t *testing.T) {
createPayload := map[string]interface{}{
"name": "perm-forward-ok",
"tunnelId": tunnelID,
"remoteAddr": "1.2.3.4:443",
"strategy": "fifo",
}
createBody, err := json.Marshal(createPayload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
forwardID := mustLastInsertID(t, repo, "perm-forward-ok")
t.Run("non-admin cannot update speedId", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"speedId": speedID,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改限速规则")
})
t.Run("non-admin cannot update inPort", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated2",
"tunnelId": tunnelID,
"remoteAddr": "5.6.7.8:443",
"inPort": 54321,
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCodeMsg(t, res, -1, "普通用户无法修改自定义端口")
})
t.Run("non-admin can update without speedId and inPort", func(t *testing.T) {
updatePayload := map[string]interface{}{
"id": forwardID,
"name": "perm-forward-updated-ok",
"tunnelId": tunnelID,
"remoteAddr": "9.10.11.12:443",
}
updateBody, err := json.Marshal(updatePayload)
if err != nil {
t.Fatalf("marshal update payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody))
req.Header.Set("Authorization", userToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
assertCode(t, res, 0)
})
}