Files
NexusGate/panel-go/main.go
T
aaaamoooo c6f6b9dbea feat(panel-go): monthly traffic reset for customers
- trafficResetDay/Period/At on the shared customer record (omitempty,
  inert for the Node panel, which preserves unknown fields)
- housekeeping applies due resets before quota evaluation: period-key
  idempotency, short-month clamping (31st fires Feb 28), missed-period
  catch-up after panel downtime, traffic suspensions auto-resume
- first sight of a schedule only arms the period marker, never zeroes;
  API create/PATCH accept trafficResetDay (0=off) and re-arm on change
- UI: reset-day input + last-reset display in the customer form
- tests: calendar math, apply pass, API lifecycle, persistence;
  full Go/Node suites green, difftest formal 50/50
2026-10-21 03:16:30 +08:00

390 lines
10 KiB
Go

package main
// Program entry point: environment, store bootstrap, startup secret
// scrubbing, admin bootstrap, HTTP server and the 60s housekeeping loop —
// mirroring server.js main() and housekeeping().
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
// VERSION is the panel version; do not change (agents and the AnyTLS gate
// parse it numerically).
const VERSION = "0.6.8"
// cookieSecure mirrors NG_COOKIE_SECURE !== 'false'; set in main().
var cookieSecure = true
// ---------- startup scrub helpers ----------
func extraStringValue(ex extraFields, key string) (string, bool) {
raw, ok := ex[key]
if !ok {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false
}
return s, true
}
func setExtraString(ex extraFields, key, value string) extraFields {
if ex == nil {
ex = extraFields{}
}
raw, _ := json.Marshal(value)
ex[key] = raw
return ex
}
// extraEngineDetail reads engine.detail when a record carries its engine
// state as an unknown (Extra) field rather than the typed field.
func extraEngineDetail(ex extraFields) (string, bool) {
raw, ok := ex["engine"]
if !ok {
return "", false
}
var eng map[string]any
if err := json.Unmarshal(raw, &eng); err != nil {
return "", false
}
s, ok := eng["detail"].(string)
return s, ok
}
func setExtraEngineDetail(ex extraFields, value string) {
raw, ok := ex["engine"]
if !ok {
return
}
var eng map[string]any
if err := json.Unmarshal(raw, &eng); err != nil {
return
}
eng["detail"] = value
out, err := json.Marshal(eng)
if err == nil {
ex["engine"] = out
}
}
func needsScrub(s string) bool {
return s != "" && s != RedactSecrets(s)
}
// startupNeedsCleaning reports whether any persisted job/node/server still
// holds an un-redacted secret in error/lastError/engine.detail.
func startupNeedsCleaning(d *Data) bool {
checkExtra := func(ex extraFields) bool {
for _, key := range []string{"error", "lastError"} {
if s, ok := extraStringValue(ex, key); ok && needsScrub(s) {
return true
}
}
if s, ok := extraEngineDetail(ex); ok && needsScrub(s) {
return true
}
return false
}
for _, job := range d.Jobs {
if job.Error != nil && needsScrub(*job.Error) || checkExtra(job.Extra) {
return true
}
}
for _, node := range d.Nodes {
if node.Error != nil && needsScrub(*node.Error) || checkExtra(node.Extra) {
return true
}
}
for _, server := range d.Servers {
if server.Engine != nil && needsScrub(server.Engine.Detail) || checkExtra(server.Extra) {
return true
}
}
return false
}
func scrubExtra(ex extraFields) {
for _, key := range []string{"error", "lastError"} {
if s, ok := extraStringValue(ex, key); ok && s != "" {
setExtraString(ex, key, RedactSecrets(s))
}
}
if s, ok := extraEngineDetail(ex); ok && s != "" {
setExtraEngineDetail(ex, RedactSecrets(s))
}
}
func scrubRecordSecrets(d *Data) {
for _, job := range d.Jobs {
if job.Error != nil && *job.Error != "" {
redacted := RedactSecrets(*job.Error)
job.Error = &redacted
}
scrubExtra(job.Extra)
}
for _, node := range d.Nodes {
if node.Error != nil && *node.Error != "" {
redacted := RedactSecrets(*node.Error)
node.Error = &redacted
}
scrubExtra(node.Extra)
}
for _, server := range d.Servers {
if server.Engine != nil && server.Engine.Detail != "" {
server.Engine.Detail = RedactSecrets(server.Engine.Detail)
}
scrubExtra(server.Extra)
}
}
// ---------- housekeeping ----------
// housekeeping is server.js housekeeping(): offline servers, expired job
// leases, quota suspensions/resumes, retention pruning, access pruning and
// session pruning, every 60 seconds. The transaction result is never SKIP,
// so a successful pass persists.
func (a *App) housekeeping() {
_, err := a.store.Transaction(func(d *Data) (any, error) {
now := time.Now().UnixMilli()
nowISO := NowISO()
for _, server := range d.Servers {
if server.LastSeenAt == nil {
continue
}
if ms, ok := ParseISOMs(*server.LastSeenAt); ok && now-ms > 180000 {
server.Status = "offline"
}
}
for _, job := range d.Jobs {
if job.Status != "running" || job.LeaseUntil == nil {
continue
}
ms, ok := ParseISOMs(*job.LeaseUntil)
if !ok || ms > now {
continue
}
job.LeaseUntil = nil
job.UpdatedAt = nowISO
if job.Attempts < 3 {
job.Status = "queued"
job.Error = StrPtr("Agent 未在租约内确认,任务已自动重试")
} else {
job.Status = "failed"
job.Error = StrPtr("Agent 连续 3 次未在租约内确认")
if node := findNode(d, job.NodeID); node != nil {
kind := ""
if job.Payload != nil {
kind = job.Payload.KindOf()
}
if kind == "node-exit" {
node.Status = "degraded"
} else {
node.Status = "failed"
}
node.Error = job.Error
node.UpdatedAt = nowISO
}
}
}
// Monthly traffic resets run before the quota evaluation below,
// so a customer whose new period just started is judged on the
// zeroed counters (and a reset can lift a traffic suspension in
// the same pass). Due-ness derives from the persisted period
// marker, so this also catches up resets missed while the panel
// was down — at the first pass after it is back (<=60s).
if err := ApplyMonthlyTrafficResets(d, time.Now()); err != nil {
return nil, err
}
for _, customer := range d.Customers {
expired := false
if customer.ExpiresAt != nil {
if ms, ok := ParseISOMs(*customer.ExpiresAt); ok && ms <= now {
expired = true
}
}
exhausted := customer.TrafficLimitBytes > 0 && float64(customer.UsedBytes) >= customer.TrafficLimitBytes
if (expired || exhausted) && customer.Status == "active" {
customer.Status = "suspended"
if expired {
customer.SuspendReason = StrPtr("expired")
customer.SuspendReasonPresent = true
} else {
customer.SuspendReason = StrPtr("traffic_limit")
customer.SuspendReasonPresent = true
}
customer.UpdatedAt = nowISO
if _, err := SuspendCustomerResources(d, customer, "system:quota"); err != nil {
return nil, err
}
} else if customer.Status == "suspended" && strVal(customer.SuspendReason) == "ip_limit" && CustomerBlockReason(d, customer) == "" {
customer.Status = "active"
customer.SuspendReason = nil
customer.SuspendReasonPresent = true
customer.UpdatedAt = nowISO
if _, err := ResumeCustomerResources(d, customer, "system:ip_window"); err != nil {
return nil, err
}
}
}
jobRetention := d.Settings.CompletedJobRetentionDays
if jobRetention == 0 {
jobRetention = 7
}
jobCutoff := now - int64(jobRetention*86400000)
jobs := d.Jobs[:0]
for _, job := range d.Jobs {
if job.Status == "completed" || job.Status == "failed" {
if ms, ok := ParseISOMs(job.UpdatedAt); !ok || ms <= jobCutoff {
continue
}
}
jobs = append(jobs, job)
}
d.Jobs = jobs
activityRetention := d.Settings.ActivityRetentionDays
if activityRetention == 0 {
activityRetention = 30
}
activityCutoff := now - int64(activityRetention*86400000)
activity := d.Activity[:0]
for _, event := range d.Activity {
if ms, ok := ParseISOMs(event.At); ok && ms > activityCutoff {
activity = append(activity, event)
}
}
if len(activity) > 2000 {
activity = activity[:2000]
}
d.Activity = activity
PruneAccess(d, time.Now())
return nil, nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Housekeeping failed: %v\n", err)
return
}
a.getSessions().Prune()
}
// ---------- main ----------
func defaultDataFile() string {
exe, err := os.Executable()
if err != nil {
return filepath.Join("data", "nexusgate.json")
}
return filepath.Join(filepath.Dir(exe), "data", "nexusgate.json")
}
func run() error {
dataFile := os.Getenv("NG_DATA_FILE")
if dataFile == "" {
dataFile = defaultDataFile()
}
host := os.Getenv("NG_HOST")
if host == "" {
host = "127.0.0.1"
}
port := 8787
if p := os.Getenv("NG_PORT"); p != "" {
if n, err := strconv.Atoi(p); err == nil {
port = n
}
}
cookieSecure = os.Getenv("NG_COOKIE_SECURE") != "false"
store := NewStore(dataFile)
if err := store.Init(); err != nil {
return err
}
if startupNeedsCleaning(store.Snapshot()) {
if _, err := store.Transaction(func(d *Data) (any, error) {
scrubRecordSecrets(d)
return nil, nil
}); err != nil {
return err
}
}
missingToken := false
for _, customer := range store.Snapshot().Customers {
if customer.SubscriptionToken == "" {
missingToken = true
break
}
}
if missingToken {
if _, err := store.Transaction(func(d *Data) (any, error) {
for _, customer := range d.Customers {
if customer.SubscriptionToken == "" {
customer.SubscriptionToken = RandomToken(32)
}
}
return nil, nil
}); err != nil {
return err
}
}
if len(store.Snapshot().Users) == 0 {
password := os.Getenv("NG_ADMIN_PASSWORD")
generated := false
if password == "" {
password = RandomToken(15)
generated = true
}
username := os.Getenv("NG_ADMIN_USERNAME")
if username == "" {
username = "admin"
}
if _, err := store.Transaction(func(d *Data) (any, error) {
d.Users = append(d.Users, &User{
ID: NewID("usr"),
Username: username,
PasswordHash: HashSecret(password),
Role: "owner",
Status: "active",
CreatedAt: NowISO(),
})
return nil, nil
}); err != nil {
return err
}
if generated {
fmt.Printf("BOOTSTRAP_PASSWORD=%s\n", password)
}
}
app := NewApp(store, nil, NewOrchestrator(store))
app.replaceSessions(NewSessionManager(app.sessionHours()))
server := &http.Server{
Addr: host + ":" + strconv.Itoa(port),
Handler: app,
ReadHeaderTimeout: 15 * time.Second,
ReadTimeout: 30 * time.Second,
}
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
app.housekeeping()
}
}()
fmt.Printf("NexusGate v%s listening on http://%s:%d\n", VERSION, host, port)
return server.ListenAndServe()
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}