DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,422 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
"longheng.io/server/internal/platform/audit"
|
||||
)
|
||||
|
||||
const AdminTokenHeader = "X-Admin-Token"
|
||||
const AdminActorHeader = "X-Admin-Actor"
|
||||
const AdminConfirmHeader = "X-Admin-Confirm"
|
||||
const AdminTraceHeader = "X-Admin-Trace-ID"
|
||||
const AdminIdempotencyHeader = "X-Admin-Idempotency-Key"
|
||||
const AdminOperationHeader = "X-Admin-Operation-ID"
|
||||
const AdminReceiptHeader = "X-Admin-Receipt-ID"
|
||||
|
||||
type AdminToken struct {
|
||||
Name string
|
||||
Token string
|
||||
Role string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
type AdminPolicy struct {
|
||||
Token string
|
||||
RBACEnabled bool
|
||||
RequireConfirmation bool
|
||||
Tokens []AdminToken
|
||||
Audit *audit.Logger
|
||||
}
|
||||
|
||||
type adminPrincipal struct {
|
||||
Name string
|
||||
Role string
|
||||
Scopes []string
|
||||
Root bool
|
||||
}
|
||||
|
||||
type adminPrincipalKey struct{}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func WrapAdmin(token string, logger *slog.Logger, scope string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return wrapAdminPolicy(func() AdminPolicy {
|
||||
return AdminPolicy{Token: token}
|
||||
}, logger, scope, "", false, next)
|
||||
}
|
||||
|
||||
func WrapAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return wrapAdminPolicy(policyProvider, logger, scope, "", false, next)
|
||||
}
|
||||
|
||||
func WrapDangerousAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope, operation string, next http.HandlerFunc) http.HandlerFunc {
|
||||
return wrapAdminPolicy(policyProvider, logger, scope, operation, true, next)
|
||||
}
|
||||
|
||||
func wrapAdminPolicy(policyProvider func() AdminPolicy, logger *slog.Logger, scope, operation string, dangerous bool, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
traceID := adminTraceID(r)
|
||||
w.Header().Set(AdminTraceHeader, traceID)
|
||||
writer := &statusWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
policy := AdminPolicy{}
|
||||
if policyProvider != nil {
|
||||
policy = policyProvider()
|
||||
}
|
||||
principal := adminPrincipal{Name: "unauthenticated", Role: "none"}
|
||||
requiredScope := adminRequiredScope(scope, r.Method)
|
||||
operationID := strings.TrimSpace(operation)
|
||||
if operationID == "" {
|
||||
operationID = AdminOperationID(r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set(AdminOperationHeader, operationID)
|
||||
receipt := admincmd.Receipt{}
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
if logger != nil {
|
||||
logger.Error("admin handler panic recovered", "scope", scope, "path", r.URL.Path, "trace_id", traceID, "panic", rec)
|
||||
}
|
||||
writer.status = http.StatusInternalServerError
|
||||
WriteJSON(writer, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
recordAdminAudit(r, policy, principal, scope, requiredScope, operationID, receipt, traceID, writer.status, time.Since(start))
|
||||
if logger != nil {
|
||||
logger.Info(
|
||||
"admin request",
|
||||
"scope", scope,
|
||||
"actor", principal.Name,
|
||||
"role", principal.Role,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", writer.status,
|
||||
"trace_id", traceID,
|
||||
"duration_ms", fmt.Sprintf("%.3f", float64(time.Since(start).Microseconds())/1000.0),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
if !adminPolicyOK(policy) {
|
||||
writer.status = http.StatusServiceUnavailable
|
||||
WriteJSON(writer, http.StatusServiceUnavailable, map[string]string{"error": "admin token not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var ok bool
|
||||
principal, ok = authenticateAdmin(policy, r.Header.Get(AdminTokenHeader), r.Header.Get(AdminActorHeader))
|
||||
if !ok {
|
||||
writer.status = http.StatusUnauthorized
|
||||
WriteJSON(writer, http.StatusUnauthorized, map[string]string{"error": "invalid admin token"})
|
||||
return
|
||||
}
|
||||
if policy.RBACEnabled && !principalAllows(principal, scope, requiredScope) {
|
||||
writer.status = http.StatusForbidden
|
||||
WriteJSON(writer, http.StatusForbidden, map[string]string{
|
||||
"error": "admin scope forbidden",
|
||||
"required_scope": requiredScope,
|
||||
})
|
||||
return
|
||||
}
|
||||
if policy.RequireConfirmation && adminMethodMutates(r.Method) {
|
||||
if r.Header.Get(AdminConfirmHeader) != operationID {
|
||||
writer.status = http.StatusPreconditionRequired
|
||||
WriteJSON(writer, http.StatusPreconditionRequired, map[string]string{
|
||||
"error": "dangerous admin operation requires confirmation",
|
||||
"header": AdminConfirmHeader,
|
||||
"operation_id": operationID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if dangerous && adminMethodMutates(r.Method) && strings.TrimSpace(r.Header.Get(AdminIdempotencyHeader)) == "" {
|
||||
writer.status = http.StatusPreconditionRequired
|
||||
WriteJSON(writer, http.StatusPreconditionRequired, map[string]string{
|
||||
"error": "dangerous admin operation requires idempotency key",
|
||||
"header": AdminIdempotencyHeader,
|
||||
"operation_id": operationID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if adminMethodMutates(r.Method) {
|
||||
receipt = adminCommandReceipt(r, principal, scope, operationID, start)
|
||||
if receipt.ID != "" {
|
||||
w.Header().Set(AdminReceiptHeader, receipt.ID)
|
||||
}
|
||||
}
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), adminPrincipalKey{}, principal.Name))
|
||||
next(writer, r)
|
||||
}
|
||||
}
|
||||
|
||||
func adminPolicyOK(policy AdminPolicy) bool {
|
||||
if strings.TrimSpace(policy.Token) != "" {
|
||||
return true
|
||||
}
|
||||
for _, token := range policy.Tokens {
|
||||
if strings.TrimSpace(token.Token) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func authenticateAdmin(policy AdminPolicy, headerToken, actorHeader string) (adminPrincipal, bool) {
|
||||
headerToken = strings.TrimSpace(headerToken)
|
||||
if headerToken == "" {
|
||||
return adminPrincipal{}, false
|
||||
}
|
||||
if !policy.RBACEnabled && policy.Token != "" && subtle.ConstantTimeCompare([]byte(headerToken), []byte(policy.Token)) == 1 {
|
||||
name := strings.TrimSpace(actorHeader)
|
||||
if name == "" {
|
||||
name = "root"
|
||||
}
|
||||
return adminPrincipal{Name: name, Role: "admin", Scopes: []string{"*"}, Root: true}, true
|
||||
}
|
||||
if !policy.RBACEnabled {
|
||||
return adminPrincipal{}, false
|
||||
}
|
||||
for _, token := range policy.Tokens {
|
||||
if token.Token == "" || subtle.ConstantTimeCompare([]byte(headerToken), []byte(token.Token)) != 1 {
|
||||
continue
|
||||
}
|
||||
// RBAC token 的名称就是已认证身份,不能再由请求头覆盖,否则审计和
|
||||
// 持久化管理命令的 actor 都可被 token 持有者任意伪造。
|
||||
name := strings.TrimSpace(token.Name)
|
||||
role := strings.ToLower(strings.TrimSpace(token.Role))
|
||||
if role == "" {
|
||||
role = "operator"
|
||||
}
|
||||
return adminPrincipal{
|
||||
Name: name,
|
||||
Role: role,
|
||||
Scopes: append([]string(nil), token.Scopes...),
|
||||
}, true
|
||||
}
|
||||
return adminPrincipal{}, false
|
||||
}
|
||||
|
||||
// AuthenticatedAdminActor 返回管理鉴权绑定的 actor。经过 WrapAdminPolicy 的
|
||||
// RBAC 请求始终取 token.Name;未经过 wrapper 的内部测试和非 RBAC 兼容入口才回退请求头。
|
||||
func AuthenticatedAdminActor(r *http.Request) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
if actor, ok := r.Context().Value(adminPrincipalKey{}).(string); ok {
|
||||
return strings.TrimSpace(actor)
|
||||
}
|
||||
return strings.TrimSpace(r.Header.Get(AdminActorHeader))
|
||||
}
|
||||
|
||||
func principalAllows(principal adminPrincipal, scope, requiredScope string) bool {
|
||||
if principal.Root || principal.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
if len(principal.Scopes) > 0 {
|
||||
return scopesAllow(principal.Scopes, scope, requiredScope)
|
||||
}
|
||||
switch principal.Role {
|
||||
case "operator":
|
||||
return true
|
||||
case "viewer", "auditor":
|
||||
return strings.HasSuffix(requiredScope, ":read")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scopesAllow(scopes []string, scope, requiredScope string) bool {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
requiredScope = strings.ToLower(strings.TrimSpace(requiredScope))
|
||||
for _, item := range scopes {
|
||||
item = strings.ToLower(strings.TrimSpace(item))
|
||||
switch item {
|
||||
case "*", requiredScope, scope + ":*":
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(requiredScope, ":read") && item == "*:read" {
|
||||
return true
|
||||
}
|
||||
if !strings.HasSuffix(requiredScope, ":read") && item == "*:write" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func adminRequiredScope(scope, method string) string {
|
||||
scope = strings.Trim(strings.ToLower(strings.TrimSpace(scope)), ":")
|
||||
if scope == "" {
|
||||
scope = "admin"
|
||||
}
|
||||
if adminMethodMutates(method) {
|
||||
return scope + ":write"
|
||||
}
|
||||
return scope + ":read"
|
||||
}
|
||||
|
||||
func adminMethodMutates(method string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(method)) {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func AdminOperationID(method, path string) string {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
path = strings.TrimSpace(path)
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return method + " " + path
|
||||
}
|
||||
|
||||
func adminTraceID(r *http.Request) string {
|
||||
if r != nil {
|
||||
if traceID := strings.TrimSpace(r.Header.Get(AdminTraceHeader)); traceID != "" {
|
||||
return traceID
|
||||
}
|
||||
if requestID := strings.TrimSpace(r.Header.Get("X-Request-ID")); requestID != "" {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
var raw [12]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return fmt.Sprintf("admin-%d", time.Now().UTC().UnixNano())
|
||||
}
|
||||
return "admin-" + hex.EncodeToString(raw[:])
|
||||
}
|
||||
|
||||
func adminCommandReceipt(r *http.Request, principal adminPrincipal, scope, operationID string, now time.Time) admincmd.Receipt {
|
||||
if r == nil {
|
||||
return admincmd.Receipt{}
|
||||
}
|
||||
receipt, err := admincmd.NewReceipt(admincmd.Command{
|
||||
Operation: operationID,
|
||||
Scope: scope,
|
||||
Target: r.URL.Path,
|
||||
Actor: principal.Name,
|
||||
IdempotencyKey: r.Header.Get(AdminIdempotencyHeader),
|
||||
Confirmation: r.Header.Get(AdminConfirmHeader),
|
||||
Params: map[string]any{
|
||||
"method": r.Method,
|
||||
"query": redactedAdminQuery(r.URL.RawQuery),
|
||||
},
|
||||
}, "accepted", now)
|
||||
if err != nil {
|
||||
return admincmd.Receipt{}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func recordAdminAudit(r *http.Request, policy AdminPolicy, principal adminPrincipal, scope, requiredScope, operationID string, receipt admincmd.Receipt, traceID string, status int, duration time.Duration) {
|
||||
if policy.Audit == nil {
|
||||
return
|
||||
}
|
||||
fields := map[string]string{
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"scope": scope,
|
||||
"required_scope": requiredScope,
|
||||
"operation_id": operationID,
|
||||
"status": fmt.Sprint(status),
|
||||
"duration_ms": fmt.Sprintf("%.3f", float64(duration.Microseconds())/1000.0),
|
||||
"role": principal.Role,
|
||||
}
|
||||
if receipt.ID != "" {
|
||||
fields["receipt_id"] = receipt.ID
|
||||
fields["receipt_status"] = receipt.Status
|
||||
}
|
||||
if receipt.ParamsHash != "" {
|
||||
fields["params_hash"] = receipt.ParamsHash
|
||||
}
|
||||
if receipt.IdempotencyKey != "" {
|
||||
fields["idempotency_key"] = receipt.IdempotencyKey
|
||||
}
|
||||
if r.URL.RawQuery != "" {
|
||||
fields["query"] = redactedAdminQuery(r.URL.RawQuery)
|
||||
}
|
||||
_ = policy.Audit.Record(r.Context(), audit.Event{
|
||||
Actor: principal.Name,
|
||||
Action: "admin.request",
|
||||
Target: r.URL.Path,
|
||||
TraceID: traceID,
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
|
||||
// redactedAdminQuery 在查询参数进入审计、回执或日志前统一清除凭证值。
|
||||
// 解析失败时宁可丢失原始查询,也不能把未知格式的密钥原样落盘。
|
||||
func redactedAdminQuery(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
return "[redacted-invalid-query]"
|
||||
}
|
||||
for key, items := range values {
|
||||
if !secretQueryKey(key) {
|
||||
continue
|
||||
}
|
||||
for idx := range items {
|
||||
items[idx] = "[redacted]"
|
||||
}
|
||||
values[key] = items
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func secretQueryKey(key string) bool {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
switch key {
|
||||
case "token", "password", "passwd", "authorization", "api_key", "apikey", "credential", "credentials":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "_password") ||
|
||||
strings.Contains(key, "secret") ||
|
||||
strings.Contains(key, "credential")
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, code int, payload any) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
code = http.StatusInternalServerError
|
||||
data = []byte(`{"error":"json encode failed"}`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write(append(data, '\n'))
|
||||
}
|
||||
Reference in new issue
Block a user