Files
NexusGate/panel-go/types.go
T
aaaamoooo 408b03ee68 feat(agent-auth): session tokens replace per-request scrypt (item 25)
Panel: after one scrypt-verified key auth, issue a random 24h
session token; store only its SHA-256 hash + expiry on the agent
record. authenticateAgent tries the token path first (constant-time
hash compare, active + unexpired), then the original fingerprint /
legacy VerifySecret path, and hands fresh tokens out via the
X-Agent-Session-Token response headers. Revoke() now clears token
material at all three revocation sites.

Agent: persist the token in the config dir (0600, temp+rename like
keys.json), present it as bearer, and on any 401 clear it and retry
once with the key (which re-issues). Old panels never send the
headers, so key-only operation is unchanged; old agents ignore the
headers. Version stays 0.6.8.

Tests: panel — issuance, hash-only storage, scrypt-free token path
(sabotaged KeyHash still authenticates), expiry fallback + rotation,
hash-mismatch fallback, revocation, per-record binding, key-only
rotation. Agent — capture/persist/reuse against a fake panel, 401
fallback and re-issue, old-panel key-only, client-side expiry,
double rejection surfacing without retry loops.
2026-10-21 13:12:19 +00:00

1147 lines
35 KiB
Go

package main
// Shared data model + JSON plumbing for the Go port of the NexusGate panel.
//
// Compatibility contract with the Node implementation:
// - The database is one JSON object (see Data). Every record type mirrors
// the JS object shapes field by field; fields JS may leave absent use
// pointer+omitempty only where "absent" and "null" are semantically
// identical in the JS code (all reads are truthiness-based). Where the
// JS create paths write an explicit null, the Go field has NO omitempty
// so a nil pointer re-marshals as null.
// - Unknown fields (written by other panel versions) are captured per
// record in Extra and re-emitted verbatim on marshal, so a load/save
// round trip never drops data.
// - All JSON output goes through marshalJSON (HTML escaping disabled) to
// match JSON.stringify.
import (
"bytes"
"crypto/rand"
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"time"
)
// ---------- errors ----------
// HTTPError mirrors a JS Error carrying statusCode.
type HTTPError struct {
Status int
Message string
}
func (e *HTTPError) Error() string { return e.Message }
func httpErr(status int, format string, args ...any) *HTTPError {
return &HTTPError{Status: status, Message: fmt.Sprintf(format, args...)}
}
// ---------- time ----------
// NowISO is JS `new Date().toISOString()`: UTC, millisecond precision, Z.
func NowISO() string { return ISOFromTime(time.Now()) }
func ISOFromTime(t time.Time) string {
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
// ParseISO accepts the forms JS `new Date(value)` accepts for the inputs
// this panel produces/consumes: RFC3339 (with optional fractional seconds
// and offsets) and date-only "2006-01-02" (interpreted as UTC midnight).
func ParseISO(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.000Z0700",
"2006-01-02T15:04:05Z0700",
"2006-01-02T15:04Z0700",
"2006-01-02",
} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
}
// ParseISOMs is JS Date.parse: milliseconds since epoch; ok=false is NaN
// (comparisons against NaN are always false in the JS code paths).
func ParseISOMs(s string) (int64, bool) {
t, ok := ParseISO(s)
if !ok {
return 0, false
}
return t.UnixMilli(), true
}
// ---------- ids ----------
func NewUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// NewID mirrors JS `id(prefix)` => `${prefix}_${crypto.randomUUID()}`.
func NewID(prefix string) string { return prefix + "_" + NewUUID() }
func StrPtr(s string) *string { return &s }
func BoolPtr(b bool) *bool { return &b }
// boolVal is JS truthiness for an optional boolean field.
func boolVal(p *bool) bool { return p != nil && *p }
func IntPtr(v int) *int { return &v }
// ---------- JSON helpers ----------
// marshalJSON is JSON.stringify: compact, no HTML escaping.
func marshalJSON(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
// unmarshalJSONUseNumber decodes with UseNumber so generic `any` fields
// (activity detail, job payloads from agents) keep integer literals exact.
func unmarshalJSONUseNumber(b []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
return dec.Decode(v)
}
// cloneJSONValue is structuredClone for JSON-shaped values.
func cloneJSONValue[T any](v T) T {
raw, err := marshalJSON(v)
if err != nil {
panic(err)
}
var out T
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return out
}
// ---------- unknown-field passthrough ----------
type extraFields map[string]json.RawMessage
var knownKeyCache sync.Map // reflect.Type -> map[string]struct{}
func knownJSONKeys(t reflect.Type) map[string]struct{} {
if cached, ok := knownKeyCache.Load(t); ok {
return cached.(map[string]struct{})
}
keys := map[string]struct{}{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.PkgPath != "" { // unexported
continue
}
tag := f.Tag.Get("json")
if tag == "-" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" {
name = f.Name
}
keys[name] = struct{}{}
}
knownKeyCache.Store(t, keys)
return keys
}
func captureExtra(raw []byte, sample any) extraFields {
var all map[string]json.RawMessage
if err := json.Unmarshal(raw, &all); err != nil || len(all) == 0 {
return nil
}
known := knownJSONKeys(reflect.TypeOf(sample))
var extra extraFields
for k, v := range all {
if _, ok := known[k]; !ok {
if extra == nil {
extra = extraFields{}
}
extra[k] = v
}
}
return extra
}
// mergeMarshal marshals a record and re-attaches its unknown fields.
func mergeMarshal(sample any, extra extraFields) ([]byte, error) {
base, err := marshalJSON(sample)
if err != nil || len(extra) == 0 {
return base, err
}
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
for k, v := range extra {
if _, ok := merged[k]; !ok {
merged[k] = v
}
}
return marshalJSON(merged)
}
// recordJSON implements the UnmarshalJSON half shared by every record type.
func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
var plain T
if err := unmarshalJSONUseNumber(raw, &plain); err != nil {
return err
}
*dst = plain
*extra = captureExtra(raw, plain)
return nil
}
// ---------- database ----------
type Data struct {
SchemaVersion int `json:"schemaVersion"`
CreatedAt string `json:"createdAt"`
Settings Settings `json:"settings"`
Users []*User `json:"users"`
Servers []*Server `json:"servers"`
Agents []*Agent `json:"agents"`
EnrollmentTokens []*EnrollmentToken `json:"enrollmentTokens"`
Customers []*Customer `json:"customers"`
Nodes []*Node `json:"nodes"`
NodeClients []*NodeClient `json:"nodeClients"`
Jobs []*Job `json:"jobs"`
Activity []*Activity `json:"activity"`
Observations []*Observation `json:"observations"`
SubscriptionAccess []*SubscriptionAccess `json:"subscriptionAccess"`
SubscriptionClients []*SubscriptionClient `json:"subscriptionClients"`
KeyStore map[string]*KeyEntry `json:"keyStore"`
SubSourceCache map[string]*SubSourceCacheEntry `json:"subSourceCache,omitempty"`
Extra extraFields `json:"-"`
}
func (d *Data) UnmarshalJSON(raw []byte) error {
type plain Data
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*d = Data(p)
d.Extra = extra
return nil
}
func (d Data) MarshalJSON() ([]byte, error) {
type plain Data
return mergeMarshal(plain(d), d.Extra)
}
// Clone is structuredClone(this.data). Since round-3 item 22 it is a
// reflective deep copy: the old JSON marshal/unmarshal round trip cost
// a full serialize + parse of the whole database (with a per-record
// map dance for unknown fields) on EVERY store read — twice per admin
// request — and dominated panel latency on the router's CPU. The data
// graph is plain exported structs/slices/maps/strings/numbers, which
// reflection copies exactly; TestCloneEquivalence proves byte-identical
// output against cloneViaJSON on the real seed database.
func (d *Data) Clone() *Data {
if d == nil {
return nil
}
return cloneReflectValue(reflect.ValueOf(d)).Interface().(*Data)
}
// cloneReflectValue deep-copies v. Structs are shallow-copied first (so
// any unexported field keeps its value) and every exported field is
// then replaced by its deep copy; []byte (incl. json.RawMessage) is
// copied verbatim; scalars pass through.
func cloneReflectValue(v reflect.Value) reflect.Value {
if !v.IsValid() {
return v
}
switch v.Kind() {
case reflect.Pointer:
if v.IsNil() {
return v
}
out := reflect.New(v.Type().Elem())
out.Elem().Set(cloneReflectValue(v.Elem()))
return out
case reflect.Interface:
if v.IsNil() {
return v
}
return reflect.ValueOf(cloneReflectValue(v.Elem()).Interface())
case reflect.Slice:
if v.IsNil() {
return v
}
if v.Type().Elem().Kind() == reflect.Uint8 {
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
reflect.Copy(out, v)
return out
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(cloneReflectValue(v.Index(i)))
}
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
iter := v.MapRange()
for iter.Next() {
out.SetMapIndex(cloneReflectValue(iter.Key()), cloneReflectValue(iter.Value()))
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).PkgPath != "" {
continue // unexported: kept from the shallow copy
}
out.Field(i).Set(cloneReflectValue(v.Field(i)))
}
return out
default:
return v
}
}
// cloneViaJSON is the original Clone implementation: a full JSON
// marshal/unmarshal round trip. Kept for the equivalence test and the
// item-22 benchmarks that quantify what the reflective Clone replaced.
func (d *Data) cloneViaJSON() *Data {
raw, err := marshalJSON(d)
if err != nil {
panic(err)
}
var out Data
if err := unmarshalJSONUseNumber(raw, &out); err != nil {
panic(err)
}
return &out
}
type Settings struct {
BrandName string `json:"brandName"`
SessionHours float64 `json:"sessionHours"`
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
ActivityRetentionDays float64 `json:"activityRetentionDays"`
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
// Depleting-soon thresholds for the customers summary, aligned with
// 3x-ui's expireDiff/trafficDiff settings (same names, units and
// factory default of 0 = nobody counts as depleting soon).
ExpireDiff float64 `json:"expireDiff,omitempty"`
TrafficDiff float64 `json:"trafficDiff,omitempty"`
Extra extraFields `json:"-"`
}
func (s *Settings) UnmarshalJSON(raw []byte) error {
type plain Settings
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = Settings(p)
s.Extra = extra
return nil
}
func (s Settings) MarshalJSON() ([]byte, error) {
type plain Settings
return mergeMarshal(plain(s), s.Extra)
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt *string `json:"updatedAt,omitempty"`
Extra extraFields `json:"-"`
}
func (u *User) UnmarshalJSON(raw []byte) error {
type plain User
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*u = User(p)
u.Extra = extra
return nil
}
func (u User) MarshalJSON() ([]byte, error) {
type plain User
return mergeMarshal(plain(u), u.Extra)
}
type EngineState struct {
Status string `json:"status"`
Detail string `json:"detail"`
SingBoxInstalled bool `json:"singBoxInstalled"`
Certificates []string `json:"certificates"`
SingBoxStatus string `json:"singBoxStatus"`
SingBoxVersion string `json:"singBoxVersion,omitempty"`
At string `json:"at"`
Extra extraFields `json:"-"`
}
func (e *EngineState) UnmarshalJSON(raw []byte) error {
type plain EngineState
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*e = EngineState(p)
e.Extra = extra
return nil
}
func (e EngineState) MarshalJSON() ([]byte, error) {
type plain EngineState
return mergeMarshal(plain(e), e.Extra)
}
type ServerUsage struct {
LastReportAt string `json:"lastReportAt"`
SampleCount int `json:"sampleCount"`
Error *string `json:"error"`
Extra extraFields `json:"-"`
}
func (u *ServerUsage) UnmarshalJSON(raw []byte) error {
type plain ServerUsage
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*u = ServerUsage(p)
u.Extra = extra
return nil
}
func (u ServerUsage) MarshalJSON() ([]byte, error) {
type plain ServerUsage
return mergeMarshal(plain(u), u.Extra)
}
type Server struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Region string `json:"region"`
PublicAddress string `json:"publicAddress"`
PublicAddressV6 string `json:"publicAddressV6"`
TLSDomain string `json:"tlsDomain"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
Labels []string `json:"labels"`
Status string `json:"status"`
Enabled *bool `json:"enabled,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
LastSeenAt *string `json:"lastSeenAt,omitempty"`
System json.RawMessage `json:"system,omitempty"`
Engine *EngineState `json:"engine,omitempty"`
EnginePresent bool `json:"-"`
Usage *ServerUsage `json:"usage,omitempty"`
Extra extraFields `json:"-"`
}
func (s *Server) UnmarshalJSON(raw []byte) error {
type plain Server
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = Server(p)
s.Extra = extra
var probe map[string]json.RawMessage
if json.Unmarshal(raw, &probe) == nil {
_, s.EnginePresent = probe["engine"]
}
return nil
}
func (s Server) MarshalJSON() ([]byte, error) {
type plain Server
base, err := mergeMarshal(plain(s), s.Extra)
if err != nil || s.Engine != nil || !s.EnginePresent {
return base, err
}
// JS assigns server.engine = ... || null on heartbeat: once the key
// exists it stays present with value null.
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
merged["engine"] = json.RawMessage("null")
return marshalJSON(merged)
}
type Agent struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
KeyHash string `json:"keyHash"`
KeyFingerprint *string `json:"keyFingerprint,omitempty"`
// SessionTokenHash is the SHA-256 hex of the short-lived agent session
// token (item 25): after one scrypt-verified key auth the panel issues
// a random token and stores only its hash here, so steady-state agent
// requests authenticate with a constant-time hash compare instead of
// a scrypt run. The plaintext token is never persisted panel-side.
SessionTokenHash *string `json:"sessionTokenHash,omitempty"`
SessionTokenExpiresAt *string `json:"sessionTokenExpiresAt,omitempty"`
Status string `json:"status"`
Hostname string `json:"hostname"`
Version string `json:"version"`
CreatedAt string `json:"createdAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (a *Agent) UnmarshalJSON(raw []byte) error {
type plain Agent
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = Agent(p)
a.Extra = extra
return nil
}
func (a Agent) MarshalJSON() ([]byte, error) {
type plain Agent
return mergeMarshal(plain(a), a.Extra)
}
// Revoke marks the agent revoked and drops any issued session token
// (item 25), so the token dies with the revocation even if the record's
// status were flipped back later.
func (a *Agent) Revoke() {
a.Status = "revoked"
a.SessionTokenHash = nil
a.SessionTokenExpiresAt = nil
}
type EnrollmentToken struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt"`
ExpiresAt string `json:"expiresAt"`
UsedAt *string `json:"usedAt"`
Extra extraFields `json:"-"`
}
func (t *EnrollmentToken) UnmarshalJSON(raw []byte) error {
type plain EnrollmentToken
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*t = EnrollmentToken(p)
t.Extra = extra
return nil
}
func (t EnrollmentToken) MarshalJSON() ([]byte, error) {
type plain EnrollmentToken
return mergeMarshal(plain(t), t.Extra)
}
type UsageCounters struct {
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
Epoch *string `json:"epoch"`
Extra extraFields `json:"-"`
}
func (c *UsageCounters) UnmarshalJSON(raw []byte) error {
type plain UsageCounters
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = UsageCounters(p)
c.Extra = extra
return nil
}
func (c UsageCounters) MarshalJSON() ([]byte, error) {
type plain UsageCounters
return mergeMarshal(plain(c), c.Extra)
}
type UsageSlot struct {
UsageCounters *UsageCounters `json:"usageCounters,omitempty"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
Extra extraFields `json:"-"`
}
func (s *UsageSlot) UnmarshalJSON(raw []byte) error {
type plain UsageSlot
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*s = UsageSlot(p)
s.Extra = extra
return nil
}
func (s UsageSlot) MarshalJSON() ([]byte, error) {
type plain UsageSlot
return mergeMarshal(plain(s), s.Extra)
}
type Customer struct {
ID string `json:"id"`
SubscriptionToken string `json:"subscriptionToken"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"`
SuspendReason *string `json:"suspendReason,omitempty"`
SuspendReasonPresent bool `json:"-"`
TrafficLimitBytes float64 `json:"trafficLimitBytes"`
UsedBytes int64 `json:"usedBytes"`
UsedUplinkBytes int64 `json:"usedUplinkBytes"`
UsedDownlinkBytes int64 `json:"usedDownlinkBytes"`
TrafficResetDay int `json:"trafficResetDay,omitempty"`
TrafficResetPeriod string `json:"trafficResetPeriod,omitempty"`
TrafficResetAt *string `json:"trafficResetAt,omitempty"`
ExpiresAt *string `json:"expiresAt"`
IPLimit int64 `json:"ipLimit"`
DeviceLimit int64 `json:"deviceLimit"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
LastUsageAt *string `json:"lastUsageAt,omitempty"`
LastOnlineAt *string `json:"lastOnlineAt,omitempty"`
// The customer's single credential set, shared by all of their node
// bindings (round-2 item 2). Populated by the store-load migration for
// legacy records and at creation for new ones.
Credentials *Credentials `json:"credentials,omitempty"`
ExternalSources []ExtSource `json:"externalSources,omitempty"`
ManualLinks []ManualLink `json:"manualLinks,omitempty"`
Extra extraFields `json:"-"`
}
// ExtSource is one external subscription source on a customer
// (SUB-AGGREGATION-DESIGN.md). UserAgent is optional.
type ExtSource struct {
ID string `json:"id"`
URL string `json:"url"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
UserAgent string `json:"userAgent,omitempty"`
}
// ManualLink is one hand-added node link on a customer.
type ManualLink struct {
ID string `json:"id"`
URI string `json:"uri"`
Remark string `json:"remark"`
Enabled bool `json:"enabled"`
}
// SubCacheItem is one parsed entry of a fetched external source.
type SubCacheItem struct {
URI string `json:"uri"`
Name string `json:"name"`
}
// SubSourceCacheEntry is the fetch cache for one external source,
// stored top-level in data.subSourceCache keyed by source id.
type SubSourceCacheEntry struct {
FetchedAt *string `json:"fetchedAt"`
OkAt *string `json:"okAt"`
FailCount int `json:"failCount"`
LastError *string `json:"lastError"`
DroppedCount int `json:"droppedCount"`
Items []SubCacheItem `json:"items"`
}
func (c *Customer) UnmarshalJSON(raw []byte) error {
type plain Customer
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = Customer(p)
c.Extra = extra
var probe map[string]json.RawMessage
if json.Unmarshal(raw, &probe) == nil {
_, c.SuspendReasonPresent = probe["suspendReason"]
}
return nil
}
func (c Customer) MarshalJSON() ([]byte, error) {
type plain Customer
base, err := mergeMarshal(plain(c), c.Extra)
if err != nil || c.SuspendReason != nil || !c.SuspendReasonPresent {
return base, err
}
// JS writes suspendReason: null explicitly on resume paths.
var merged map[string]json.RawMessage
if err := json.Unmarshal(base, &merged); err != nil {
return nil, err
}
merged["suspendReason"] = json.RawMessage("null")
return marshalJSON(merged)
}
type RealityConfig struct {
KeyID string `json:"keyId"`
ServerName string `json:"serverName"`
DestPort int `json:"destPort"`
Extra extraFields `json:"-"`
}
func (r *RealityConfig) UnmarshalJSON(raw []byte) error {
type plain RealityConfig
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*r = RealityConfig(p)
r.Extra = extra
return nil
}
func (r RealityConfig) MarshalJSON() ([]byte, error) {
type plain RealityConfig
return mergeMarshal(plain(r), r.Extra)
}
type ExitCredentials struct {
ClientID string `json:"clientId"`
Password string `json:"password"`
SocksUser string `json:"socksUser"`
Extra extraFields `json:"-"`
}
func (c *ExitCredentials) UnmarshalJSON(raw []byte) error {
type plain ExitCredentials
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = ExitCredentials(p)
c.Extra = extra
return nil
}
func (c ExitCredentials) MarshalJSON() ([]byte, error) {
type plain ExitCredentials
return mergeMarshal(plain(c), c.Extra)
}
type MeteredTraffic struct {
Uplink int64 `json:"uplink"`
Downlink int64 `json:"downlink"`
Extra extraFields `json:"-"`
}
func (m *MeteredTraffic) UnmarshalJSON(raw []byte) error {
type plain MeteredTraffic
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*m = MeteredTraffic(p)
m.Extra = extra
return nil
}
func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
type plain MeteredTraffic
return mergeMarshal(plain(m), m.Extra)
}
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
ServerID string `json:"serverId"`
ExitServerID *string `json:"exitServerId"`
Protocol string `json:"protocol"`
Topology string `json:"topology"`
ExitProtocol *string `json:"exitProtocol"`
// ExitMode selects the forward exit target: "" / "managed" is a panel
// server (ExitServerID); "manual" is an unmanaged landing endpoint
// described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials
// (round-2 item 8). Manual exits have no exit resource and are always
// ExitReady — the panel only builds the entry-side outbound.
ExitMode string `json:"exitMode,omitempty"`
ExitAddress string `json:"exitAddress,omitempty"`
NetworkMode string `json:"networkMode"`
RealityServerName string `json:"realityServerName"`
RealityDestPort int `json:"realityDestPort"`
PortMode string `json:"portMode"`
Port int `json:"port"`
ExitPortMode *string `json:"exitPortMode"`
ExitPort *int `json:"exitPort"`
Status string `json:"status"`
EntryReady bool `json:"entryReady"`
ExitReady bool `json:"exitReady"`
ResourceID string `json:"resourceId"`
ExitResourceID string `json:"exitResourceId"`
Artifacts map[string]json.RawMessage `json:"artifacts"`
Error *string `json:"error"`
PendingDelete bool `json:"pendingDelete"`
AutoRemoving *bool `json:"autoRemoving,omitempty"`
WSPath *string `json:"wsPath,omitempty"`
SSServerKey *string `json:"ssServerKey,omitempty"`
// SocksUser/SocksPassword are the node-level fixed account of a
// socks5-auth inbound (round-3 item 21, the 3x-ui inbound model):
// the inbound's only user, carried by every subscriber's link for
// this node. Empty on legacy records until the store-load migration
// (MigrateSocksNodeCredentials) mints a pair.
SocksUser string `json:"socksUser,omitempty"`
SocksPassword string `json:"socksPassword,omitempty"`
// RebuildPending marks a node whose binding-class changes are being
// coalesced (round-3 item 23): the flusher applies ONE rebuild with
// the final state once the fixed window (from RebuildPendingAt, the
// FIRST pending change) elapses. Persisted so a panel restart inside
// the window cannot strand the node un-deployed.
RebuildPending bool `json:"rebuildPending,omitempty"`
RebuildPendingAt string `json:"rebuildPendingAt,omitempty"`
RebuildPendingExit bool `json:"rebuildPendingExit,omitempty"`
Reality *RealityConfig `json:"reality,omitempty"`
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
UsageCounters *UsageCounters `json:"usageCounters"`
RetiredUsageEpochs []string `json:"retiredUsageEpochs"`
MeteredTraffic *MeteredTraffic `json:"meteredTraffic"`
LastUsageAt *string `json:"lastUsageAt"`
SortIndex int `json:"sortIndex"`
SpeedUpBps *float64 `json:"speedUpBps,omitempty"`
SpeedDownBps *float64 `json:"speedDownBps,omitempty"`
SpeedAt *string `json:"speedAt,omitempty"`
LastActiveAt *string `json:"lastActiveAt,omitempty"`
LastSampleAt *string `json:"lastSampleAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (n *Node) UnmarshalJSON(raw []byte) error {
type plain Node
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*n = Node(p)
n.Extra = extra
return nil
}
func (n Node) MarshalJSON() ([]byte, error) {
type plain Node
return mergeMarshal(plain(n), n.Extra)
}
type Credentials struct {
ClientID string `json:"clientId"`
RelayPassword string `json:"relayPassword"`
ShortID string `json:"shortId"`
Extra extraFields `json:"-"`
}
func (c *Credentials) UnmarshalJSON(raw []byte) error {
type plain Credentials
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = Credentials(p)
c.Extra = extra
return nil
}
func (c Credentials) MarshalJSON() ([]byte, error) {
type plain Credentials
return mergeMarshal(plain(c), c.Extra)
}
type NodeClient struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
CustomerID string `json:"customerId"`
// Deprecated: credentials live on the Customer (round-2 item 2). This
// field is only a migration input — the store-load migration converges
// it into Customer.Credentials and clears it, so it disappears from
// disk on the next save. Builders receive resolved copies instead.
Credentials *Credentials `json:"credentials,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (n *NodeClient) UnmarshalJSON(raw []byte) error {
type plain NodeClient
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*n = NodeClient(p)
n.Extra = extra
return nil
}
func (n NodeClient) MarshalJSON() ([]byte, error) {
type plain NodeClient
return mergeMarshal(plain(n), n.Extra)
}
// KeyPair is a Reality keypair as shipped in job payload keyMaterial.
type KeyPair struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
}
// KeyEntry is a keyStore record (KeyPair + updatedAt).
type KeyEntry struct {
PrivateKey string `json:"privateKey"`
PublicKey string `json:"publicKey"`
UpdatedAt string `json:"updatedAt"`
Extra extraFields `json:"-"`
}
func (k *KeyEntry) UnmarshalJSON(raw []byte) error {
type plain KeyEntry
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*k = KeyEntry(p)
k.Extra = extra
return nil
}
func (k KeyEntry) MarshalJSON() ([]byte, error) {
type plain KeyEntry
return mergeMarshal(plain(k), k.Extra)
}
type JobPayload struct {
Resource json.RawMessage `json:"resource,omitempty"`
Kind string `json:"kind,omitempty"`
KeyMaterial map[string]KeyPair `json:"keyMaterial,omitempty"`
ResourceID string `json:"resourceId,omitempty"`
Extra extraFields `json:"-"`
}
func (p *JobPayload) UnmarshalJSON(raw []byte) error {
type plain JobPayload
var q plain
var extra extraFields
if err := recordJSON(raw, &q, &extra); err != nil {
return err
}
*p = JobPayload(q)
p.Extra = extra
return nil
}
func (p JobPayload) MarshalJSON() ([]byte, error) {
type plain JobPayload
return mergeMarshal(plain(p), p.Extra)
}
// KindOf mirrors JS `payload.kind || payload.resource?.meta?.kind`.
func (p *JobPayload) KindOf() string {
if p == nil {
return ""
}
if p.Kind != "" {
return p.Kind
}
if len(p.Resource) > 0 {
var probe struct {
Meta struct {
Kind string `json:"kind"`
} `json:"meta"`
}
if err := json.Unmarshal(p.Resource, &probe); err == nil {
return probe.Meta.Kind
}
}
return ""
}
type Job struct {
ID string `json:"id"`
ServerID string `json:"serverId"`
NodeID string `json:"nodeId"`
Action string `json:"action"`
Payload *JobPayload `json:"payload"`
Status string `json:"status"`
Attempts int `json:"attempts"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Error *string `json:"error"`
LeaseUntil *string `json:"leaseUntil"`
StartedAt *string `json:"startedAt,omitempty"`
CompletedAt *string `json:"completedAt,omitempty"`
Extra extraFields `json:"-"`
}
func (j *Job) UnmarshalJSON(raw []byte) error {
type plain Job
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*j = Job(p)
j.Extra = extra
return nil
}
func (j Job) MarshalJSON() ([]byte, error) {
type plain Job
return mergeMarshal(plain(j), j.Extra)
}
type Activity struct {
ID string `json:"id"`
At string `json:"at"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Detail map[string]any `json:"detail"`
Extra extraFields `json:"-"`
}
func (a *Activity) UnmarshalJSON(raw []byte) error {
type plain Activity
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = Activity(p)
a.Extra = extra
return nil
}
func (a Activity) MarshalJSON() ([]byte, error) {
type plain Activity
return mergeMarshal(plain(a), a.Extra)
}
type Observation struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
IP string `json:"ip"`
FirstSeen string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (o *Observation) UnmarshalJSON(raw []byte) error {
type plain Observation
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*o = Observation(p)
o.Extra = extra
return nil
}
func (o Observation) MarshalJSON() ([]byte, error) {
type plain Observation
return mergeMarshal(plain(o), o.Extra)
}
type SubscriptionAccess struct {
ID string `json:"id"`
CustomerID string `json:"customerId"`
At string `json:"at"`
IP string `json:"ip"`
UserAgent string `json:"userAgent"`
IdentityType string `json:"identityType"`
ClientKey string `json:"clientKey"`
Format string `json:"format"`
Status int `json:"status"`
Reason string `json:"reason"`
Bytes int64 `json:"bytes"`
Extra extraFields `json:"-"`
}
func (a *SubscriptionAccess) UnmarshalJSON(raw []byte) error {
type plain SubscriptionAccess
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*a = SubscriptionAccess(p)
a.Extra = extra
return nil
}
func (a SubscriptionAccess) MarshalJSON() ([]byte, error) {
type plain SubscriptionAccess
return mergeMarshal(plain(a), a.Extra)
}
type SubscriptionClient struct {
CustomerID string `json:"customerId"`
ClientKey string `json:"clientKey"`
IdentityType string `json:"identityType"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
Extra extraFields `json:"-"`
}
func (c *SubscriptionClient) UnmarshalJSON(raw []byte) error {
type plain SubscriptionClient
var p plain
var extra extraFields
if err := recordJSON(raw, &p, &extra); err != nil {
return err
}
*c = SubscriptionClient(p)
c.Extra = extra
return nil
}
func (c SubscriptionClient) MarshalJSON() ([]byte, error) {
type plain SubscriptionClient
return mergeMarshal(plain(c), c.Extra)
}