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.
157 lines
4.5 KiB
Go
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
|
|
}
|