Assign/unassign, customer-form alignment, suspend/resume and credential rotation no longer rebuild (and restart the node's sing-box) per change. They mark the node rebuild-pending; a 1s background flusher applies exactly one rebuild with the final state when the FIXED window — 10s from the first pending change, never extended by later changes — elapses. Manual deploy, repair, node edits and server reconciles stay immediate and consume the mark. Removal clears it, and the flusher discards marks on deleted / removing / draft nodes, so a pending apply can never resurrect a node. The mark is persisted in the data file, so a panel restart inside the window cannot strand a node: the first post-boot pass flushes whatever is due.
431 lines
11 KiB
Go
431 lines
11 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()
|
|
// System-status sampling + due external-source refreshes (outside the
|
|
// transaction; fetches must not hold the store lock).
|
|
a.monitor().Sample(a.store.Snapshot(), false)
|
|
a.RefreshDueSources()
|
|
}
|
|
|
|
// ---------- 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)
|
|
}
|
|
}
|
|
|
|
// Node sort order migration (idempotent): unless the stored
|
|
// sortIndex values are exactly the permutation 0..n-1, renumber by
|
|
// storage order. Mirrors the Node implementation's boot migration.
|
|
{
|
|
nodes := store.Snapshot().Nodes
|
|
seen := map[int]bool{}
|
|
valid := true
|
|
for _, node := range nodes {
|
|
if node.SortIndex < 0 || node.SortIndex >= len(nodes) || seen[node.SortIndex] {
|
|
valid = false
|
|
break
|
|
}
|
|
seen[node.SortIndex] = true
|
|
}
|
|
if !valid {
|
|
if _, err := store.Transaction(func(d *Data) (any, error) {
|
|
for index, node := range d.Nodes {
|
|
node.SortIndex = index
|
|
}
|
|
return nil, nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}()
|
|
// Coalesced rebuild flusher (round-3 item 23): applies pending
|
|
// binding-class rebuilds once their fixed window elapses. Pending
|
|
// marks are persisted, so this also drains whatever a previous
|
|
// process left mid-window.
|
|
go func() {
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
_, _ = app.orchestrator.FlushPendingRebuilds(time.Now())
|
|
}
|
|
}()
|
|
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)
|
|
}
|
|
}
|