Files
NexusGate/panel-go/access.go
T
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

157 lines
4.5 KiB
Go

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
}