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'))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var DefaultClientIPHeaders = []string{"X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"}
|
||||
|
||||
type ClientIPOptions struct {
|
||||
Headers []string
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
type IPInfo struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type IPLocator interface {
|
||||
LookupIP(context.Context, string) (IPInfo, bool, error)
|
||||
}
|
||||
|
||||
type StaticIPRange struct {
|
||||
CIDR string `json:"cidr"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type StaticIPLocator struct {
|
||||
ranges []staticIPRange
|
||||
}
|
||||
|
||||
type staticIPRange struct {
|
||||
prefix netip.Prefix
|
||||
info IPInfo
|
||||
}
|
||||
|
||||
func NewStaticIPLocator(ranges []StaticIPRange) (*StaticIPLocator, error) {
|
||||
out := make([]staticIPRange, 0, len(ranges))
|
||||
for _, r := range ranges {
|
||||
cidr := strings.TrimSpace(r.CIDR)
|
||||
if cidr == "" {
|
||||
continue
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, staticIPRange{
|
||||
prefix: prefix,
|
||||
info: IPInfo{
|
||||
Country: strings.TrimSpace(r.Country),
|
||||
Region: strings.TrimSpace(r.Region),
|
||||
City: strings.TrimSpace(r.City),
|
||||
ASN: strings.TrimSpace(r.ASN),
|
||||
Source: strings.TrimSpace(r.Source),
|
||||
},
|
||||
})
|
||||
}
|
||||
return &StaticIPLocator{ranges: out}, nil
|
||||
}
|
||||
|
||||
func (l *StaticIPLocator) LookupIP(ctx context.Context, ip string) (IPInfo, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return IPInfo{}, false, err
|
||||
}
|
||||
addr, ok := parseIPAddr(ip)
|
||||
if !ok || l == nil {
|
||||
return IPInfo{}, false, nil
|
||||
}
|
||||
for _, r := range l.ranges {
|
||||
if !r.prefix.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
info := r.info
|
||||
info.IP = addr.String()
|
||||
return info, true, nil
|
||||
}
|
||||
return IPInfo{}, false, nil
|
||||
}
|
||||
|
||||
func ClientIP(r *http.Request, options ClientIPOptions) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
remote := RemoteAddrIP(r.RemoteAddr)
|
||||
if remote == "" {
|
||||
return ""
|
||||
}
|
||||
if !remoteTrusted(remote, options.TrustedProxies) {
|
||||
return remote
|
||||
}
|
||||
for _, header := range clientIPHeaders(options.Headers) {
|
||||
for _, candidate := range strings.Split(r.Header.Get(header), ",") {
|
||||
if ip := normalizeIP(candidate); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
func RemoteAddrIP(remoteAddr string) string {
|
||||
remoteAddr = strings.TrimSpace(remoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return ""
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
return normalizeIP(host)
|
||||
}
|
||||
return normalizeIP(remoteAddr)
|
||||
}
|
||||
|
||||
func clientIPHeaders(headers []string) []string {
|
||||
if len(headers) == 0 {
|
||||
return DefaultClientIPHeaders
|
||||
}
|
||||
out := make([]string, 0, len(headers))
|
||||
for _, header := range headers {
|
||||
header = strings.TrimSpace(header)
|
||||
if header != "" {
|
||||
out = append(out, header)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return DefaultClientIPHeaders
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteTrusted(remote string, cidrs []string) bool {
|
||||
if len(cidrs) == 0 {
|
||||
return false
|
||||
}
|
||||
addr, ok := parseIPAddr(remote)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeIP(value string) string {
|
||||
addr, ok := parseIPAddr(value)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return addr.String()
|
||||
}
|
||||
|
||||
func parseIPAddr(value string) (netip.Addr, bool) {
|
||||
value = strings.Trim(strings.TrimSpace(value), "[]")
|
||||
if value == "" {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// DecodeStrictJSON 读取单个 JSON 请求体,统一限制大小、拒绝未知字段并检查尾部多余 JSON。
|
||||
func DecodeStrictJSON(w http.ResponseWriter, r *http.Request, maxBytes int64, out any) error {
|
||||
if r == nil || r.Body == nil {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
body := http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
defer func() {
|
||||
_ = body.Close()
|
||||
}()
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra json.RawMessage
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("request body must contain a single JSON object: %w", err)
|
||||
}
|
||||
return errors.New("request body must contain a single JSON object")
|
||||
}
|
||||
|
||||
// DecodeStrictJSONReader 从任意 reader 读取单个 JSON 对象;适合已经自行处理空 body 的 handler helper。
|
||||
func DecodeStrictJSONReader(reader io.Reader, maxBytes int64, out any) error {
|
||||
if reader == nil {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return fmt.Errorf("request body exceeds %d bytes", maxBytes)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra json.RawMessage
|
||||
if err := decoder.Decode(&extra); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("request body must contain a single JSON object: %w", err)
|
||||
}
|
||||
return errors.New("request body must contain a single JSON object")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
name string
|
||||
addr string
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
opts Timeouts
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
type Timeouts struct {
|
||||
ReadTimeout time.Duration
|
||||
ReadHeaderTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
func New(name, addr string, logger *slog.Logger, opts Timeouts) *Server {
|
||||
if addr == "" {
|
||||
addr = ":18080"
|
||||
}
|
||||
return &Server{
|
||||
name: name,
|
||||
addr: addr,
|
||||
logger: logger,
|
||||
mux: http.NewServeMux(),
|
||||
opts: normalizeTimeouts(opts),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Name() string {
|
||||
return s.name
|
||||
}
|
||||
|
||||
func (s *Server) Mux() *http.ServeMux {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
func (s *Server) Start(_ context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.listener != nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
ln, err := net.Listen("tcp", s.addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.listener = ln
|
||||
s.server = NewHTTPServer(s.mux, s.opts)
|
||||
server := s.server
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
err := server.Serve(ln)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) && s.logger != nil {
|
||||
s.logger.Error("admin http serve failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Info("admin http listening", "addr", ln.Addr().String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewHTTPServer(handler http.Handler, opts Timeouts) *http.Server {
|
||||
opts = normalizeTimeouts(opts)
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
ReadTimeout: opts.ReadTimeout,
|
||||
ReadHeaderTimeout: opts.ReadHeaderTimeout,
|
||||
WriteTimeout: opts.WriteTimeout,
|
||||
IdleTimeout: opts.IdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimeouts(opts Timeouts) Timeouts {
|
||||
if opts.ReadTimeout <= 0 {
|
||||
opts.ReadTimeout = 10 * time.Second
|
||||
}
|
||||
if opts.ReadHeaderTimeout <= 0 {
|
||||
opts.ReadHeaderTimeout = 5 * time.Second
|
||||
}
|
||||
if opts.WriteTimeout <= 0 {
|
||||
opts.WriteTimeout = 15 * time.Second
|
||||
}
|
||||
if opts.IdleTimeout <= 0 {
|
||||
opts.IdleTimeout = 60 * time.Second
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
s.mu.Lock()
|
||||
server := s.server
|
||||
s.server = nil
|
||||
s.listener = nil
|
||||
s.mu.Unlock()
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.Info("stopping admin http", "addr", s.addr)
|
||||
}
|
||||
return server.Shutdown(ctx)
|
||||
}
|
||||
Reference in new issue
Block a user