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.
440 lines
11 KiB
Go
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
|
|
}
|