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

198 lines
5.1 KiB
Go

package main
// Port of lib/auth.js: scrypt secret hashing, random tokens, cookie parsing
// and the in-memory session manager.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// HashSecret hashes secret with a fresh random 16-byte salt (hex string),
// matching JS hashSecret's default parameter.
func HashSecret(secret string) string {
var saltBytes [16]byte
if _, err := rand.Read(saltBytes[:]); err != nil {
panic(err)
}
return HashSecretWithSalt(secret, hex.EncodeToString(saltBytes[:]))
}
// HashSecretWithSalt returns `scrypt:<salt>:<hex hash>`. The salt string is
// fed to scrypt as its raw UTF-8 bytes, exactly like Node.
func HashSecretWithSalt(secret, salt string) string {
hash := ScryptHash(secret, salt)
return "scrypt:" + salt + ":" + hex.EncodeToString(hash)
}
// VerifySecret mirrors JS verifySecret: split on ':', destructure the first
// three parts, require kind == "scrypt", decode the expected hex and compare
// in constant time. Any malformed input returns false.
func VerifySecret(secret, encoded string) bool {
parts := strings.Split(encoded, ":")
if len(parts) < 3 {
return false
}
kind, salt, expectedHex := parts[0], parts[1], parts[2]
if kind != "scrypt" || salt == "" || expectedHex == "" {
return false
}
expected, err := hex.DecodeString(expectedHex)
if err != nil {
return false
}
actual := ScryptHash(secret, salt)
if len(actual) != len(expected) {
return false
}
return subtle.ConstantTimeCompare(actual, expected) == 1
}
// RandomToken mirrors JS randomToken: random bytes, base64url, no padding.
func RandomToken(nBytes int) string {
b := make([]byte, nBytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(b)
}
// TokenFingerprint is the SHA-256 hex digest of s.
func TokenFingerprint(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// ParseCookies mirrors JS parseCookies: split on ';', split each part on the
// first '=', trim both sides, decodeURIComponent key and value, then drop
// pairs with an empty key; later duplicates overwrite earlier ones.
//
// Deviation from JS: decodeURIComponent throws on a malformed escape, which
// aborts the whole JS parse; here a pair whose key or value fails to decode
// is skipped instead (and url.PathUnescape, unlike decodeURIComponent, does
// not reject invalid UTF-8 byte sequences produced by %XX escapes).
func ParseCookies(header string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(header, ";") {
at := strings.Index(part, "=")
if at < 0 {
continue
}
key, err := url.PathUnescape(strings.TrimSpace(part[:at]))
if err != nil {
continue
}
value, err := url.PathUnescape(strings.TrimSpace(part[at+1:]))
if err != nil {
continue
}
if key == "" {
continue
}
out[key] = value
}
return out
}
// Session is one login session. ExpiresAt is a time.Time where JS stores
// epoch milliseconds.
type Session struct {
ID string
UserID string
CSRF string
ExpiresAt time.Time
}
// SessionManager is the in-memory session store from lib/auth.js.
type SessionManager struct {
TTL time.Duration
mu sync.Mutex
sessions map[string]*Session
}
// NewSessionManager builds a manager whose sessions live for hours hours
// (JS: ttlMs = hours * 60 * 60 * 1000).
func NewSessionManager(hours float64) *SessionManager {
return &SessionManager{
TTL: time.Duration(hours * float64(time.Hour)),
sessions: map[string]*Session{},
}
}
// Create mints a session for userID: id = randomToken(32),
// csrf = randomToken(24), expiresAt = now + TTL.
func (m *SessionManager) Create(userID string) *Session {
session := &Session{
ID: RandomToken(32),
UserID: userID,
CSRF: RandomToken(24),
ExpiresAt: time.Now().Add(m.TTL),
}
m.mu.Lock()
defer m.mu.Unlock()
if m.sessions == nil {
m.sessions = map[string]*Session{}
}
m.sessions[session.ID] = session
return session
}
// Get returns the session named by the ng_session cookie, or nil. An expired
// session is deleted and reported as missing (JS: expiresAt <= Date.now()).
func (m *SessionManager) Get(r *http.Request) *Session {
id := sessionIDFromRequest(r)
if id == "" {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
session, ok := m.sessions[id]
if !ok {
return nil
}
if !session.ExpiresAt.After(time.Now()) {
delete(m.sessions, id)
return nil
}
return session
}
// Delete drops the session named by the ng_session cookie, if any.
func (m *SessionManager) Delete(r *http.Request) {
id := sessionIDFromRequest(r)
if id == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, id)
}
// Prune removes every expired session.
func (m *SessionManager) Prune() {
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for id, session := range m.sessions {
if !session.ExpiresAt.After(now) {
delete(m.sessions, id)
}
}
}
func sessionIDFromRequest(r *http.Request) string {
if r == nil {
return ""
}
return ParseCookies(r.Header.Get("Cookie"))["ng_session"]
}