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,156 @@
|
||||
package main
|
||||
|
||||
// Port of lib/access.js: subscription client identity, active-client and
|
||||
// observed-IP windows, and access-log pruning.
|
||||
//
|
||||
// JS Date.parse failures yield NaN and every comparison against NaN is
|
||||
// false; ParseISOMs reports those as ok=false and the filters below exclude
|
||||
// such records, preserving the JS semantics.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Window constants from lib/access.js, in milliseconds.
|
||||
const (
|
||||
ClientWindowMs = int64(24 * 60 * 60 * 1000)
|
||||
LogRetentionMs = int64(30 * 24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
// ClientIdentityResult is the JS clientIdentity return shape
|
||||
// { userAgent, kind, key }.
|
||||
type ClientIdentityResult struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// truncRunes truncates to at most n characters. JS slice() counts UTF-16
|
||||
// code units; counting runes matches for all BMP text.
|
||||
func truncRunes(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > n {
|
||||
runes = runes[:n]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// ClientIdentity mirrors JS clientIdentity(headers, secret): the declared
|
||||
// identity is x-device-id, falling back to x-client-id, trimmed and cut to
|
||||
// 128 chars; the user agent is trimmed and cut to 200 chars; kind is
|
||||
// "header" when a declared identity exists, else "user-agent" when a user
|
||||
// agent exists, else "unknown". Key is HMAC-SHA256(secret, "kind:identity")
|
||||
// as hex.
|
||||
func ClientIdentity(headers http.Header, secret string) ClientIdentityResult {
|
||||
declared := headers.Get("X-Device-Id")
|
||||
if declared == "" {
|
||||
declared = headers.Get("X-Client-Id")
|
||||
}
|
||||
declared = truncRunes(strings.TrimSpace(declared), 128)
|
||||
userAgent := truncRunes(strings.TrimSpace(headers.Get("User-Agent")), 200)
|
||||
kind := "unknown"
|
||||
if declared != "" {
|
||||
kind = "header"
|
||||
} else if userAgent != "" {
|
||||
kind = "user-agent"
|
||||
}
|
||||
identity := declared
|
||||
if identity == "" {
|
||||
identity = userAgent
|
||||
}
|
||||
if identity == "" {
|
||||
identity = "unknown"
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(kind + ":" + identity))
|
||||
return ClientIdentityResult{
|
||||
UserAgent: userAgent,
|
||||
Kind: kind,
|
||||
Key: hex.EncodeToString(mac.Sum(nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func observationTtlMinutes(d *Data) float64 {
|
||||
ttl := d.Settings.ObservationTtlMinutes
|
||||
if ttl == 0 {
|
||||
// JS: settings.observationTtlMinutes || 10 — 0 (and NaN) fall back.
|
||||
ttl = 10
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
// ActiveClients returns the clientKeys of the customer's subscription
|
||||
// clients seen within the last 24h, in stored order.
|
||||
func ActiveClients(d *Data, customerID string, now time.Time) []string {
|
||||
cutoff := now.UnixMilli() - ClientWindowMs
|
||||
out := []string{}
|
||||
for _, item := range d.SubscriptionClients {
|
||||
if item.CustomerID != customerID {
|
||||
continue
|
||||
}
|
||||
ms, ok := ParseISOMs(item.LastSeenAt)
|
||||
if ok && ms > cutoff {
|
||||
out = append(out, item.ClientKey)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ObservedIPs returns the customer's observations fresher than the
|
||||
// configured observation TTL (settings.observationTtlMinutes || 10).
|
||||
func ObservedIPs(d *Data, customerID string, now time.Time) []*Observation {
|
||||
cutoff := now.UnixMilli() - int64(observationTtlMinutes(d)*60000)
|
||||
out := []*Observation{}
|
||||
for _, item := range d.Observations {
|
||||
if item.CustomerID != customerID {
|
||||
continue
|
||||
}
|
||||
ms, ok := ParseISOMs(item.LastSeenAt)
|
||||
if ok && ms > cutoff {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PruneAccess mirrors JS pruneAccess: subscriptionAccess keeps the last
|
||||
// 10000 entries fresher than 30 days, subscriptionClients keeps entries
|
||||
// fresher than 24h, observations keep entries fresher than the TTL.
|
||||
func PruneAccess(d *Data, now time.Time) {
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
retentionCutoff := nowMs - LogRetentionMs
|
||||
access := []*SubscriptionAccess{}
|
||||
for _, item := range d.SubscriptionAccess {
|
||||
if ms, ok := ParseISOMs(item.At); ok && ms > retentionCutoff {
|
||||
access = append(access, item)
|
||||
}
|
||||
}
|
||||
if len(access) > 10000 { // JS: .slice(-10000)
|
||||
access = access[len(access)-10000:]
|
||||
}
|
||||
d.SubscriptionAccess = access
|
||||
|
||||
clientCutoff := nowMs - ClientWindowMs
|
||||
clients := []*SubscriptionClient{}
|
||||
for _, item := range d.SubscriptionClients {
|
||||
if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > clientCutoff {
|
||||
clients = append(clients, item)
|
||||
}
|
||||
}
|
||||
d.SubscriptionClients = clients
|
||||
|
||||
observationCutoff := nowMs - int64(observationTtlMinutes(d)*60000)
|
||||
observations := []*Observation{}
|
||||
for _, item := range d.Observations {
|
||||
if ms, ok := ParseISOMs(item.LastSeenAt); ok && ms > observationCutoff {
|
||||
observations = append(observations, item)
|
||||
}
|
||||
}
|
||||
d.Observations = observations
|
||||
}
|
||||
Reference in New Issue
Block a user