Files
NexusGate/panel-go/httpd.go
aaaamoooo 3951134940
CI / test (push) Canceled after 0s
CI / anytls-smoke (push) Canceled after 0s
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.
2026-10-20 08:21:54 +00:00

440 lines
11 KiB
Go

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
}