feat: Go port of the panel in panel-go/ — drop-in replacement for server.js
Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
package main
|
||||
|
||||
// HTTP plumbing + JS value-semantics helpers: the Go port of lib/http.js
|
||||
// plus the small coercion helpers server.js relies on (cleanText,
|
||||
// requiredText, optionalIso, nonNegativeNumber, asIds).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ---------- lib/http.js ----------
|
||||
|
||||
// SendJSON is sendJson: compact JSON, HTML escaping off (via marshalJSON),
|
||||
// content-type/length set first, caller headers override.
|
||||
func SendJSON(w http.ResponseWriter, status int, v any, headers map[string]string) {
|
||||
body, err := marshalJSON(v)
|
||||
if err != nil {
|
||||
status = 500
|
||||
body = []byte(`{"error":"internal_error","message":"服务器内部错误"}`)
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "application/json; charset=utf-8")
|
||||
h.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
for k, val := range headers {
|
||||
h.Set(k, val)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// SendError is sendError: {"error":code,"message":message}.
|
||||
func SendError(w http.ResponseWriter, status int, message, code string) {
|
||||
SendJSON(w, status, map[string]any{"error": code, "message": message}, nil)
|
||||
}
|
||||
|
||||
const defaultMaxBody = 1024 * 1024
|
||||
|
||||
// ReadJSON is readJson: empty body -> empty map; over maxBytes -> 413;
|
||||
// invalid JSON -> 400. Numbers decode as json.Number (UseNumber).
|
||||
func ReadJSON(r *http.Request, maxBytes int64) (map[string]any, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > maxBytes {
|
||||
return nil, httpErr(413, "Request body is too large")
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
|
||||
return nil, httpErr(400, "Invalid JSON body")
|
||||
}
|
||||
if out == nil { // body was `null`
|
||||
out = map[string]any{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RoutePattern is lib/http.js route(): ":param" matches a single non-empty
|
||||
// path segment; a single trailing slash on the pathname is optional.
|
||||
// Captured values are URI-decoded like decodeURIComponent (on a malformed
|
||||
// escape the raw segment is kept).
|
||||
func RoutePattern(pattern, pathname string) (map[string]string, bool) {
|
||||
p := pathname
|
||||
if len(p) > 1 && strings.HasSuffix(p, "/") {
|
||||
p = p[:len(p)-1]
|
||||
}
|
||||
patSegs := strings.Split(strings.TrimPrefix(pattern, "/"), "/")
|
||||
pathSegs := strings.Split(strings.TrimPrefix(p, "/"), "/")
|
||||
if len(patSegs) != len(pathSegs) {
|
||||
return nil, false
|
||||
}
|
||||
params := map[string]string{}
|
||||
for i, seg := range patSegs {
|
||||
if strings.HasPrefix(seg, ":") {
|
||||
if pathSegs[i] == "" {
|
||||
return nil, false
|
||||
}
|
||||
v := pathSegs[i]
|
||||
if dec, err := url.PathUnescape(v); err == nil {
|
||||
v = dec
|
||||
}
|
||||
params[seg[1:]] = v
|
||||
continue
|
||||
}
|
||||
if seg != pathSegs[i] {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return params, true
|
||||
}
|
||||
|
||||
// securityHeaders mirrors server.js securityHeaders().
|
||||
func securityHeaders(w http.ResponseWriter) {
|
||||
h := w.Header()
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
}
|
||||
|
||||
// ---------- dispatch ----------
|
||||
|
||||
// trackingWriter records whether a response has been committed, mirroring
|
||||
// the JS `if (!res.headersSent) ... else res.end()` error path.
|
||||
type trackingWriter struct {
|
||||
http.ResponseWriter
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (t *trackingWriter) WriteHeader(status int) {
|
||||
t.wrote = true
|
||||
t.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (t *trackingWriter) Write(b []byte) (int, error) {
|
||||
t.wrote = true
|
||||
return t.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// ServeHTTP is requestHandler: security headers first, then healthz, auth,
|
||||
// agent, subscription, admin, static, SPA fallback, 404 — in that order.
|
||||
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
tw := &trackingWriter{ResponseWriter: w}
|
||||
securityHeaders(tw)
|
||||
pathname := r.URL.EscapedPath()
|
||||
if err := a.dispatch(tw, r, pathname); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[%s] %v\n", NowISO(), err)
|
||||
if !tw.wrote {
|
||||
var he *HTTPError
|
||||
if errors.As(err, &he) {
|
||||
SendError(tw, he.Status, he.Message, "request_error")
|
||||
} else {
|
||||
SendError(tw, 500, "服务器内部错误", "internal_error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dispatch(w http.ResponseWriter, r *http.Request, pathname string) error {
|
||||
if pathname == "/healthz" {
|
||||
SendJSON(w, 200, map[string]any{"ok": true, "version": VERSION, "time": NowISO()}, nil)
|
||||
return nil
|
||||
}
|
||||
if handled, err := a.handleAuth(w, r, pathname); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if handled, err := a.handleAgent(w, r, pathname); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if handled, err := a.handleSubscription(w, r, pathname); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if handled, err := a.handleAdminApi(w, r, pathname); err != nil || handled {
|
||||
return err
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
served, err := ServeStatic(w, pathname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if served {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodGet && !strings.HasPrefix(pathname, "/api/") {
|
||||
_, err := ServeStatic(w, "/")
|
||||
return err
|
||||
}
|
||||
SendError(w, 404, "接口不存在", "not_found")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- JS value semantics ----------
|
||||
|
||||
// JSString approximates JS String(value) for the shapes JSON bodies carry:
|
||||
// nil -> "", string as-is, json.Number -> its literal, bool -> "true"/"false".
|
||||
// Objects/arrays yield "" (JS would produce "[object Object]"/joined text;
|
||||
// no panel code path depends on those).
|
||||
func JSString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case bool:
|
||||
if t {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'g', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// secretString is String(value) as used by verifySecret/hashSecret inputs:
|
||||
// strings pass through, everything else goes through JSString.
|
||||
func secretString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return JSString(v)
|
||||
}
|
||||
|
||||
// jsTruthy is JS Boolean(value) / `value || fallback` truthiness.
|
||||
func jsTruthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return t != ""
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return err == nil && f != 0 && !math.IsNaN(f)
|
||||
case float64:
|
||||
return t != 0 && !math.IsNaN(t)
|
||||
default:
|
||||
return true // arrays and objects are always truthy in JS
|
||||
}
|
||||
}
|
||||
|
||||
// jsNumber approximates JS Number(value); ok=false is NaN.
|
||||
func jsNumber(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return 0, false // Number(null) is 0 in JS, but panel paths treat
|
||||
// null like undefined via `value == null` guards before calling
|
||||
// Number; the remaining direct uses (ports) want NaN semantics.
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case float64:
|
||||
return t, true
|
||||
case bool:
|
||||
if t {
|
||||
return 1, true
|
||||
}
|
||||
return 0, true
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, true
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case []any:
|
||||
if len(t) == 0 {
|
||||
return 0, true
|
||||
}
|
||||
if len(t) == 1 {
|
||||
return jsNumber(t[0])
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// truncateRunes slices by rune count. JS slice() counts UTF-16 code units,
|
||||
// so astral-plane characters count once here but twice in JS.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// CleanTextStr is cleanText for an already-string value.
|
||||
func CleanTextStr(s string, max int) string {
|
||||
return truncateRunes(strings.TrimSpace(s), max)
|
||||
}
|
||||
|
||||
// CleanText is String(value ?? ”).trim().slice(0, max).
|
||||
func CleanText(v any, max int) string {
|
||||
return CleanTextStr(JSString(v), max)
|
||||
}
|
||||
|
||||
// RequiredText is requiredText: CleanText, or 400 `<label>不能为空`.
|
||||
func RequiredText(v any, label string, max int) (string, error) {
|
||||
result := CleanText(v, max)
|
||||
if result == "" {
|
||||
return "", httpErr(400, "%s不能为空", label)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OptionalISO is optionalIso: null/” -> nil; a parseable date -> canonical
|
||||
// ISO string; otherwise 400 `<label>格式无效`. A JSON number is treated as
|
||||
// epoch milliseconds, like JS `new Date(number)`.
|
||||
func OptionalISO(v any, label string) (*string, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, ok := ParseISO(s)
|
||||
if !ok {
|
||||
return nil, httpErr(400, "%s格式无效", label)
|
||||
}
|
||||
out := ISOFromTime(t)
|
||||
return &out, nil
|
||||
}
|
||||
if n, ok := v.(json.Number); ok {
|
||||
ms, err := n.Int64()
|
||||
if err != nil {
|
||||
f, ferr := n.Float64()
|
||||
if ferr != nil {
|
||||
return nil, httpErr(400, "%s格式无效", label)
|
||||
}
|
||||
ms = int64(f)
|
||||
}
|
||||
out := ISOFromTime(unixMs(ms))
|
||||
return &out, nil
|
||||
}
|
||||
return nil, httpErr(400, "%s格式无效", label)
|
||||
}
|
||||
|
||||
// NonNegativeNumber is nonNegativeNumber: Number(null/”) is 0; the value
|
||||
// must be finite, >= 0, and integral when integer is set.
|
||||
func NonNegativeNumber(v any, label string, integer bool) (float64, error) {
|
||||
fail := func() (float64, error) {
|
||||
kind := "数字"
|
||||
if integer {
|
||||
kind = "整数"
|
||||
}
|
||||
return 0, httpErr(400, "%s必须是非负%s", label, kind)
|
||||
}
|
||||
var n float64
|
||||
if v == nil {
|
||||
n = 0
|
||||
} else if s, ok := v.(string); ok && s == "" {
|
||||
n = 0
|
||||
} else {
|
||||
f, ok := jsNumber(v)
|
||||
if !ok {
|
||||
return fail()
|
||||
}
|
||||
n = f
|
||||
}
|
||||
if math.IsNaN(n) || math.IsInf(n, 0) || n < 0 || (integer && n != math.Trunc(n)) {
|
||||
return fail()
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// asIds is asIds: array -> String(each), drop empties, dedupe in order.
|
||||
func asIds(v any) []string {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, item := range arr {
|
||||
s := JSString(item)
|
||||
if s == "" || seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceStrings(v []string, max int) []string {
|
||||
if len(v) > max {
|
||||
return v[:max]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func strVal(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func unixMs(ms int64) time.Time { return time.UnixMilli(ms).UTC() }
|
||||
|
||||
// marshalIndentJSON is JSON.stringify(v, null, 2) (no trailing newline).
|
||||
func marshalIndentJSON(v any) ([]byte, error) {
|
||||
raw, err := marshalJSON(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// recordToMap decodes a record through its MarshalJSON (so Extra fields
|
||||
// survive) into a generic map for response shaping.
|
||||
func recordToMap(v any) map[string]any {
|
||||
raw, err := marshalJSON(v)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := unmarshalJSONUseNumber(raw, &out); err != nil || out == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user