feat(panel): reskin backend — node sortIndex, presence/speed, /api/system, credentials, subscription aggregation (Node+Go parity)
This commit is contained in:
@@ -280,7 +280,7 @@ export async function fetchAllSubscriptions(client, token) {
|
||||
// Learning normalizer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc'];
|
||||
const ID_PREFIXES = ['usr', 'srv', 'agt', 'enr', 'cus', 'nod', 'ncl', 'res', 'job', 'evt', 'obs', 'acc', 'src', 'lnk'];
|
||||
const PREFIXED_ID_RE = new RegExp(`\\b(${ID_PREFIXES.join('|')})_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`, 'g');
|
||||
const BARE_UUID_RE = /(?<![0-9A-Za-z_-])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9A-Za-z_-])/g;
|
||||
const ISO_TS_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})/g;
|
||||
@@ -424,6 +424,10 @@ export class Normalizer {
|
||||
return out;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
// Speeds are derived from adjacent usage reports over wall-clock
|
||||
// time (dt differs per side by construction), so only their shape
|
||||
// is comparable: positive vs zero. See UI-RESKIN-REPORT.md.
|
||||
if (keyName === 'speedUpBps' || keyName === 'speedDownBps') return value > 0 ? '<speed+>' : 0;
|
||||
if (keyName && PORT_FIELDS.has(keyName) && this.ports.has(value)) return this.ports.get(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
// executed and the first differing JSON path is reported per step.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
import {
|
||||
ADMIN_USERNAME, DEFAULT_GO_BIN, DIFFTEST_DIR, Normalizer, PanelClient,
|
||||
Reporter, UA_DEFAULT, completeJob, fetchAllSubscriptions, firstDiff,
|
||||
@@ -284,6 +287,201 @@ async function runPhase2(runner, { seedLog, seedExpect }) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3: UI-reskin features — node reorder, presence/speed, /api/system,
|
||||
// customer-form node alignment, credentials, subscription aggregation.
|
||||
// Time-derived values (speeds, /api/system numbers) are compared by shape
|
||||
// only; everything else is compared exactly like phase 2.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AGG_FIXTURE_BODY = Buffer.from([
|
||||
'ss://YWVzLTI1Ni1nY206ZXh0LXB3@ext.example.com:8388#ExtOne',
|
||||
'vless://22222222-2222-2222-2222-222222222222@ext2.example.com:443?security=none#ExtTwo',
|
||||
'trojan://x@bad.example.com:443#Dropped',
|
||||
'',
|
||||
].join('\n')).toString('base64');
|
||||
const AGG_MANUAL_LINK = 'ss://YWVzLTI1Ni1nY206bWFudWFsLXB3@manual.example.com:8388#ManualOne';
|
||||
|
||||
async function runPhase3(runner) {
|
||||
// --- node reorder + subscription order ----------------------------------
|
||||
await runner.step('p3/reorder-nodes', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/nodes/reorder', { ids: [side.ids.n2, side.ids.n1] });
|
||||
const list = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
reorder: view(res),
|
||||
order: list.json.nodes.map((n) => n.name),
|
||||
sortIndexes: list.json.nodes.map((n) => n.sortIndex),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/alice-sub-order-after-reorder', async (side) => {
|
||||
const res = await side.client.getSubscription(side.subTokens.alice, 'raw', UA_DEFAULT);
|
||||
const names = res.body.split('\n').filter(Boolean).map((line) => {
|
||||
const idx = line.lastIndexOf('#');
|
||||
return idx >= 0 ? decodeURIComponent(line.slice(idx + 1)) : line;
|
||||
});
|
||||
return { status: res.status, names };
|
||||
});
|
||||
|
||||
// --- presence & speed: two usage reports ~6s apart -----------------------
|
||||
await runner.step('p3/usage-report-1', async (side) =>
|
||||
view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 1500, downlink: 2100 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 5000, downlink: 6000 },
|
||||
],
|
||||
})));
|
||||
await runner.step('p3/usage-report-2-after-6s', async (side) => {
|
||||
await sleep(6000);
|
||||
return view(await side.client.agentPost('/api/agent/usage', side.agentKeys.entry, {
|
||||
epoch: 'diff-phase2-epoch-1',
|
||||
samples: [
|
||||
{ kind: 'user', id: side.ids.alice, uplink: 4500, downlink: 6300 },
|
||||
{ kind: 'inbound', id: side.ids.n1, uplink: 8000, downlink: 9000 },
|
||||
],
|
||||
}));
|
||||
});
|
||||
await runner.step('p3/presence-projection', async (side) => {
|
||||
const customers = await side.client.admin('GET', '/api/customers');
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const nameOf = (id) => { const c = customers.json.customers.find((x) => x.id === id); return c ? c.name : '?'; };
|
||||
return {
|
||||
customers: customers.json.customers.map((c) => ({
|
||||
name: c.name, online: c.online,
|
||||
speedPositive: (c.speedUpBps || 0) > 0 && (c.speedDownBps || 0) > 0,
|
||||
hasLastActiveAt: Boolean(c.lastActiveAt),
|
||||
})),
|
||||
nodes: nodes.json.nodes.map((n) => ({
|
||||
name: n.name, clientCount: n.clientCount, onlineCount: n.onlineCount,
|
||||
onlineNames: (n.onlineCustomerIds || []).map(nameOf),
|
||||
speedPositive: (n.speedUpBps || 0) > 0,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/system-shape', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/system');
|
||||
const s = res.json || {};
|
||||
return {
|
||||
status: res.status, version: s.version,
|
||||
hostKeys: s.host ? Object.keys(s.host).sort() : null,
|
||||
networkKeys: s.network ? Object.keys(s.network).sort() : null,
|
||||
todayKeys: s.today ? Object.keys(s.today).sort() : null,
|
||||
ipsKeys: s.ips ? Object.keys(s.ips).sort() : null,
|
||||
historyIsArray: Array.isArray(s.history),
|
||||
connectionsIsNumber: Boolean(s.network) && typeof s.network.connections === 'number',
|
||||
panelMemPositive: s.panelMemBytes > 0,
|
||||
startedAtPresent: typeof s.startedAt === 'string',
|
||||
};
|
||||
});
|
||||
|
||||
// --- customer-form node alignment (on Carol: no golden subscriptions) ----
|
||||
await runner.step('p3/align-carol-add-n1', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [side.ids.n1] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
const mine = nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol);
|
||||
if (mine.length) side.ids.carolNc = mine[0].id;
|
||||
return {
|
||||
status: res.status, count: mine.length,
|
||||
nodeNames: mine.map((nc) => { const n = nodes.json.nodes.find((x) => x.id === nc.nodeId); return n ? n.name : '?'; }),
|
||||
statuses: mine.map((nc) => nc.status),
|
||||
};
|
||||
});
|
||||
|
||||
// --- credentials ----------------------------------------------------------
|
||||
await runner.step('p3/credentials-get', async (side) => {
|
||||
const res = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
return {
|
||||
status: res.status,
|
||||
hasToken: typeof res.json.subscriptionToken === 'string' && res.json.subscriptionToken.length > 10,
|
||||
items: (res.json.items || []).map((entry) => ({
|
||||
nodeName: entry.nodeName, protocol: entry.protocol, status: entry.status,
|
||||
hasClientId: Boolean(entry.clientId), hasPassword: Boolean(entry.password),
|
||||
hasShortId: Boolean(entry.shortId), username: entry.username,
|
||||
})),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/credentials-regenerate', async (side) => {
|
||||
const before = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const regen = await side.client.admin('POST', `/api/customers/${side.ids.carol}/credentials/${side.ids.carolNc}/regenerate`, {});
|
||||
const after = await side.client.admin('GET', `/api/customers/${side.ids.carol}/credentials`);
|
||||
const b = (before.json.items || [])[0] || {};
|
||||
const a = (after.json.items || [])[0] || {};
|
||||
return {
|
||||
status: regen.status,
|
||||
changed: b.clientId !== a.clientId || b.password !== a.password,
|
||||
itemNodeName: regen.json && regen.json.item ? regen.json.item.nodeName : null,
|
||||
itemHasClientId: Boolean(regen.json && regen.json.item && regen.json.item.clientId),
|
||||
};
|
||||
});
|
||||
await runner.step('p3/align-carol-remove-all', async (side) => {
|
||||
const res = await side.client.admin('PATCH', `/api/customers/${side.ids.carol}`, { nodeIds: [] });
|
||||
const nodes = await side.client.admin('GET', '/api/nodes');
|
||||
return {
|
||||
status: res.status,
|
||||
bindingsForCarol: nodes.json.nodeClients.filter((nc) => nc.customerId === side.ids.carol).length,
|
||||
};
|
||||
});
|
||||
|
||||
// --- subscription aggregation ---------------------------------------------
|
||||
// Both panels must allow private subscription sources to reach the
|
||||
// loopback fixture: flip the setting in each side's data file and
|
||||
// restart (there is deliberately no settings API).
|
||||
const fixture = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
res.end(AGG_FIXTURE_BODY);
|
||||
});
|
||||
await new Promise((resolve) => fixture.listen(0, '127.0.0.1', resolve));
|
||||
const fixtureUrl = `http://127.0.0.1:${fixture.address().port}/sub`;
|
||||
try {
|
||||
for (const side of [runner.sideA, runner.sideB]) {
|
||||
await stopPanel(side.proc);
|
||||
const db = JSON.parse(fs.readFileSync(side.dataFile, 'utf8'));
|
||||
db.settings = db.settings || {};
|
||||
db.settings.allowPrivateSubSources = true;
|
||||
fs.writeFileSync(side.dataFile, JSON.stringify(db));
|
||||
await restartSide(side);
|
||||
}
|
||||
await runner.step('p3/agg-relogin', async (side) => view(await side.client.login()));
|
||||
await runner.step('p3/agg-create-dave', async (side) => {
|
||||
const res = await side.client.admin('POST', '/api/customers', {
|
||||
name: 'Dave',
|
||||
manualLinks: [{ uri: AGG_MANUAL_LINK, remark: '手动一号', enabled: true }],
|
||||
externalSources: [{ url: fixtureUrl, remark: '机场', enabled: true }],
|
||||
});
|
||||
if (res.json && res.json.customer) {
|
||||
side.ids.dave = res.json.customer.id;
|
||||
side.subTokens.dave = res.json.customer.subscriptionToken;
|
||||
const source = (res.json.customer.externalSources || [])[0];
|
||||
if (source) side.ids.daveSrc = source.id;
|
||||
}
|
||||
return {
|
||||
status: res.status,
|
||||
manualCount: res.json && res.json.customer ? (res.json.customer.manualLinks || []).length : null,
|
||||
sourceCount: res.json && res.json.customer ? (res.json.customer.externalSources || []).length : null,
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-refresh', async (side) =>
|
||||
view(await side.client.admin('POST', `/api/customers/${side.ids.dave}/external-sources/${side.ids.daveSrc}/refresh`, {})));
|
||||
await runner.step('p3/agg-customer-status', async (side) => {
|
||||
const res = await side.client.admin('GET', '/api/customers');
|
||||
const dave = res.json.customers.find((c) => c.id === side.ids.dave);
|
||||
const status = dave && dave.externalStatus ? Object.values(dave.externalStatus)[0] : null;
|
||||
return {
|
||||
hasExternalStatus: Boolean(dave && dave.externalStatus),
|
||||
count: status ? status.count : null,
|
||||
dropped: status ? status.droppedCount : null,
|
||||
failCount: status ? status.failCount : null,
|
||||
hasOkAt: Boolean(status && status.okAt),
|
||||
lastError: status ? status.lastError : 'missing',
|
||||
};
|
||||
});
|
||||
await runner.step('p3/agg-subscriptions', async (side) =>
|
||||
fetchAllSubscriptions(side.client, side.subTokens.dave));
|
||||
} finally {
|
||||
fixture.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB interoperability: swap the two panels' DB files and keep working.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -360,6 +558,7 @@ async function runPair({ reporter, kindA, kindB, bin, seedPath, seedLog, seedExp
|
||||
try {
|
||||
const runner = new StepRunner(reporter, sideA, sideB);
|
||||
await runPhase2(runner, { seedLog, seedExpect });
|
||||
await runPhase3(runner);
|
||||
await runInterop(reporter, sideA, sideB, { seedExpect });
|
||||
} finally {
|
||||
await destroySide(sideA, keep);
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
package main
|
||||
|
||||
// External subscription aggregation — mirrors lib/extsub.js (design:
|
||||
// SUB-AGGREGATION-DESIGN.md). SSRF guard, pinned-IP dialing, redirect
|
||||
// re-validation, 10s timeout, 1MiB cap, 200 items per source.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var LinkSchemes = map[string]bool{
|
||||
"vless:": true, "ss:": true, "hysteria2:": true, "anytls:": true, "socks5:": true,
|
||||
}
|
||||
|
||||
const maxSubBodyBytes = 1024 * 1024
|
||||
const maxSubItems = 200
|
||||
const fetchTimeout = 10 * time.Second
|
||||
const defaultSubUA = "v2rayN/6.55"
|
||||
|
||||
func ValidateLinkUri(uri string) bool {
|
||||
if uri == "" || len(uri) > 2048 {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return LinkSchemes[parsed.Scheme+":"] && parsed.Hostname() != ""
|
||||
}
|
||||
|
||||
var base64CharsetRe = regexp.MustCompile(`^[A-Za-z0-9+/=_-]+$`)
|
||||
|
||||
// ParseLinkList mirrors parseLinkList in lib/extsub.js.
|
||||
func ParseLinkList(body string) ([]SubCacheItem, int) {
|
||||
text := body
|
||||
compact := strings.Join(strings.Fields(text), "")
|
||||
if len(compact) >= 16 && base64CharsetRe.MatchString(compact) {
|
||||
normalized := strings.NewReplacer("-", "+", "_", "/").Replace(compact)
|
||||
if pad := len(normalized) % 4; pad != 0 {
|
||||
normalized += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(normalized); err == nil && strings.Contains(string(decoded), "://") {
|
||||
text = string(decoded)
|
||||
}
|
||||
}
|
||||
items := []SubCacheItem{}
|
||||
dropped := 0
|
||||
for _, rawLine := range strings.Split(text, "\n") {
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if len(items) >= maxSubItems {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
if !ValidateLinkUri(line) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
if idx := strings.LastIndex(line, "#"); idx >= 0 {
|
||||
rawName := line[idx+1:]
|
||||
if decoded, err := url.PathUnescape(rawName); err == nil {
|
||||
name = decoded
|
||||
} else {
|
||||
name = rawName
|
||||
}
|
||||
}
|
||||
items = append(items, SubCacheItem{URI: line, Name: truncateRunes(name, 120)})
|
||||
}
|
||||
return items, dropped
|
||||
}
|
||||
|
||||
func isGlobalIPString(ip string) bool {
|
||||
parsed := net.ParseIP(strings.Trim(ip, "[]"))
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
if parsed.To4() != nil {
|
||||
return isGlobalIPv4(parsed)
|
||||
}
|
||||
return isGlobalIPv6(parsed)
|
||||
}
|
||||
|
||||
func resolveCheckedIP(ctx context.Context, hostname string, allowPrivate bool) (string, error) {
|
||||
var addresses []string
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
addresses = []string{hostname}
|
||||
} else {
|
||||
records, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return "", errors.New("域名解析无结果")
|
||||
}
|
||||
for _, record := range records {
|
||||
addresses = append(addresses, record.IP.String())
|
||||
}
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return "", errors.New("域名解析无结果")
|
||||
}
|
||||
if !allowPrivate {
|
||||
for _, ip := range addresses {
|
||||
if !isGlobalIPString(ip) {
|
||||
return "", errors.New("订阅源指向非公网地址,已拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses[0], nil
|
||||
}
|
||||
|
||||
func fetchSourceBody(source ExtSource, settings *Settings) (string, error) {
|
||||
parsed, err := url.Parse(source.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", errors.New("仅支持 http/https 订阅源")
|
||||
}
|
||||
allowPrivate := settings != nil && settings.AllowPrivateSubSources
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
pin := func(ctx context.Context, addr string) (string, string, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ip, err := resolveCheckedIP(ctx, host, allowPrivate)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return ip, port, nil
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ip, port, err := pin(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
|
||||
},
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
ip, port, err := pin(ctx, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsDialer := &tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}
|
||||
return tlsDialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
|
||||
},
|
||||
ResponseHeaderTimeout: fetchTimeout,
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: fetchTimeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return errors.New("订阅源重定向次数过多")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ua := source.UserAgent
|
||||
if ua == "" {
|
||||
ua = defaultSubUA
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, source.URL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
req.Header.Set("Accept", "*/*")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", errors.New("订阅源请求超时")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("订阅源返回 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSubBodyBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(body) > maxSubBodyBytes {
|
||||
return "", errors.New("订阅源响应超过 1MiB")
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
type FetchResult struct {
|
||||
OK bool
|
||||
Items []SubCacheItem
|
||||
Dropped int
|
||||
Error string
|
||||
}
|
||||
|
||||
// FetchExternalSource mirrors fetchExternalSource: never fails hard.
|
||||
func FetchExternalSource(source ExtSource, settings *Settings) FetchResult {
|
||||
body, err := fetchSourceBody(source, settings)
|
||||
if err != nil {
|
||||
message := err.Error()
|
||||
if len([]rune(message)) > 240 {
|
||||
message = truncateRunes(message, 240)
|
||||
}
|
||||
return FetchResult{OK: false, Items: []SubCacheItem{}, Error: message}
|
||||
}
|
||||
items, dropped := ParseLinkList(body)
|
||||
return FetchResult{OK: true, Items: items, Dropped: dropped}
|
||||
}
|
||||
|
||||
func RefreshMinutes(settings *Settings) float64 {
|
||||
if settings == nil || settings.ExternalSubRefreshMinutes <= 0 {
|
||||
return 60
|
||||
}
|
||||
return math.Min(720, math.Max(15, settings.ExternalSubRefreshMinutes))
|
||||
}
|
||||
|
||||
func jitterFactor(sourceID string) float64 {
|
||||
hash := sha256.Sum256([]byte(sourceID))
|
||||
return 1 + float64(int(hash[0]%21)-10)/100
|
||||
}
|
||||
|
||||
type dueSource struct {
|
||||
CustomerID string
|
||||
Source ExtSource
|
||||
}
|
||||
|
||||
// DueSources mirrors dueSources in lib/extsub.js.
|
||||
func DueSources(d *Data, nowMs int64, limit int) []dueSource {
|
||||
out := []dueSource{}
|
||||
for _, customer := range d.Customers {
|
||||
for _, source := range customer.ExternalSources {
|
||||
if !source.Enabled {
|
||||
continue
|
||||
}
|
||||
entry := d.SubSourceCache[source.ID]
|
||||
if entry == nil || entry.FetchedAt == nil {
|
||||
out = append(out, dueSource{customer.ID, source})
|
||||
continue
|
||||
}
|
||||
base := RefreshMinutes(&d.Settings) * 60000
|
||||
failCount := entry.FailCount
|
||||
if failCount > 10 {
|
||||
failCount = 10
|
||||
}
|
||||
backoff := math.Min(math.Pow(2, float64(failCount))*base, 24*3600*1000)
|
||||
fetchedMs, _ := ParseISOMs(*entry.FetchedAt)
|
||||
if float64(nowMs) >= float64(fetchedMs)+backoff*jitterFactor(source.ID) {
|
||||
out = append(out, dueSource{customer.ID, source})
|
||||
}
|
||||
if len(out) >= limit {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyFetchResult mirrors applyFetchResult in lib/extsub.js.
|
||||
func ApplyFetchResult(d *Data, sourceID string, result FetchResult, nowISO string) *SubSourceCacheEntry {
|
||||
if d.SubSourceCache == nil {
|
||||
d.SubSourceCache = map[string]*SubSourceCacheEntry{}
|
||||
}
|
||||
entry := d.SubSourceCache[sourceID]
|
||||
if entry == nil {
|
||||
entry = &SubSourceCacheEntry{Items: []SubCacheItem{}}
|
||||
}
|
||||
entry.FetchedAt = StrPtr(nowISO)
|
||||
if result.OK {
|
||||
entry.OkAt = StrPtr(nowISO)
|
||||
entry.FailCount = 0
|
||||
entry.LastError = nil
|
||||
entry.DroppedCount = result.Dropped
|
||||
entry.Items = result.Items
|
||||
if entry.Items == nil {
|
||||
entry.Items = []SubCacheItem{}
|
||||
}
|
||||
} else {
|
||||
entry.FailCount++
|
||||
entry.LastError = StrPtr(result.Error)
|
||||
}
|
||||
d.SubSourceCache[sourceID] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
// ---------- save-time normalization ----------
|
||||
|
||||
func NormalizeExternalSources(value any) ([]ExtSource, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, httpErr(400, "外部订阅格式无效")
|
||||
}
|
||||
if len(arr) > 5 {
|
||||
return nil, httpErr(400, "每个客户最多 5 个外部订阅")
|
||||
}
|
||||
out := []ExtSource{}
|
||||
for _, raw := range arr {
|
||||
item, _ := raw.(map[string]any)
|
||||
rawURL := CleanText(item["url"], 2048)
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
return nil, httpErr(400, "外部订阅地址无效")
|
||||
}
|
||||
idValue := ""
|
||||
if s, isStr := item["id"].(string); isStr && s != "" {
|
||||
idValue = truncateRunes(s, 80)
|
||||
} else {
|
||||
idValue = NewID("src")
|
||||
}
|
||||
next := ExtSource{
|
||||
ID: idValue,
|
||||
URL: rawURL,
|
||||
Remark: CleanText(item["remark"], 80),
|
||||
Enabled: true,
|
||||
}
|
||||
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
|
||||
next.Enabled = false
|
||||
}
|
||||
if ua := CleanText(item["userAgent"], 120); ua != "" {
|
||||
next.UserAgent = ua
|
||||
}
|
||||
out = append(out, next)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func NormalizeManualLinks(value any) ([]ManualLink, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
arr, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, httpErr(400, "外部链接格式无效")
|
||||
}
|
||||
if len(arr) > 50 {
|
||||
return nil, httpErr(400, "每个客户最多 50 条外部链接")
|
||||
}
|
||||
out := []ManualLink{}
|
||||
for _, raw := range arr {
|
||||
item, _ := raw.(map[string]any)
|
||||
uri := CleanText(item["uri"], 2048)
|
||||
if !ValidateLinkUri(uri) {
|
||||
return nil, httpErr(400, "外部链接无效或协议不受支持")
|
||||
}
|
||||
idValue := ""
|
||||
if s, isStr := item["id"].(string); isStr && s != "" {
|
||||
idValue = truncateRunes(s, 80)
|
||||
} else {
|
||||
idValue = NewID("lnk")
|
||||
}
|
||||
next := ManualLink{
|
||||
ID: idValue,
|
||||
URI: uri,
|
||||
Remark: CleanText(item["remark"], 80),
|
||||
Enabled: true,
|
||||
}
|
||||
if enabled, isBool := item["enabled"].(bool); isBool && !enabled {
|
||||
next.Enabled = false
|
||||
}
|
||||
out = append(out, next)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- merge helpers (used by subscriptions.go) ----------
|
||||
|
||||
func DedupKey(uri string) string {
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
username, password := "", ""
|
||||
if parsed.User != nil {
|
||||
username = parsed.User.Username()
|
||||
password, _ = parsed.User.Password()
|
||||
}
|
||||
hash := sha256.Sum256([]byte(username + ":" + password))
|
||||
return fmt.Sprintf("%s//%s:%s#%s", parsed.Scheme+":", strings.ToLower(parsed.Hostname()), parsed.Port(), hex.EncodeToString(hash[:])[:16])
|
||||
}
|
||||
|
||||
func WithName(uri, name string) string {
|
||||
base := uri
|
||||
if idx := strings.LastIndex(uri, "#"); idx >= 0 {
|
||||
base = uri[:idx]
|
||||
}
|
||||
return base + "#" + encodeURIComponent(name)
|
||||
}
|
||||
|
||||
func NameFromURI(uri string) string {
|
||||
idx := strings.LastIndex(uri, "#")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
raw := uri[idx+1:]
|
||||
if decoded, err := url.PathUnescape(raw); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func strOrNil(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- App-level refresh machinery ----------
|
||||
|
||||
// RefreshOneSource fetches one source now and persists the outcome.
|
||||
func (a *App) RefreshOneSource(customerID, sourceID string) *FetchResult {
|
||||
data := a.store.Snapshot()
|
||||
customer := findCustomer(data, customerID)
|
||||
if customer == nil {
|
||||
return nil
|
||||
}
|
||||
var source *ExtSource
|
||||
for i := range customer.ExternalSources {
|
||||
if customer.ExternalSources[i].ID == sourceID {
|
||||
source = &customer.ExternalSources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
settings := data.Settings
|
||||
result := FetchExternalSource(*source, &settings)
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
ApplyFetchResult(d, sourceID, result, NowISO())
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &result
|
||||
}
|
||||
|
||||
func (a *App) RefreshSourcesInBackground(customerID string, sourceIDs []string) {
|
||||
for _, sourceID := range sourceIDs {
|
||||
go func(id string) {
|
||||
_ = a.RefreshOneSource(customerID, id)
|
||||
}(sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RefreshDueSources() {
|
||||
data := a.store.Snapshot()
|
||||
for _, due := range DueSources(data, time.Now().UnixMilli(), 3) {
|
||||
_ = a.RefreshOneSource(due.CustomerID, due.Source.ID)
|
||||
}
|
||||
}
|
||||
+343
-3
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -36,6 +37,25 @@ type App struct {
|
||||
|
||||
loginMu sync.Mutex
|
||||
loginAttempts map[string]*loginAttempt
|
||||
|
||||
sysmonMu sync.Mutex
|
||||
sysmon *SystemMonitor
|
||||
}
|
||||
|
||||
// monitor lazily creates the system monitor (data dir = store file dir).
|
||||
func (a *App) monitor() *SystemMonitor {
|
||||
a.sysmonMu.Lock()
|
||||
defer a.sysmonMu.Unlock()
|
||||
if a.sysmon == nil {
|
||||
dir := "."
|
||||
if a.store != nil && a.store.File != "" {
|
||||
if idx := strings.LastIndex(a.store.File, "/"); idx >= 0 {
|
||||
dir = a.store.File[:idx]
|
||||
}
|
||||
}
|
||||
a.sysmon = NewSystemMonitor(dir)
|
||||
}
|
||||
return a.sysmon
|
||||
}
|
||||
|
||||
func NewApp(store *Store, sessions *SessionManager, orch *Orchestrator) *App {
|
||||
@@ -158,6 +178,16 @@ func NormalizeServer(body map[string]any, current *Server) (*Server, error) {
|
||||
if next.TLSDomain != "" && !validTLSDomain(next.TLSDomain) {
|
||||
return nil, httpErr(400, "TLS 域名必须是有效的公网域名")
|
||||
}
|
||||
if _, ok := body["enabled"]; ok {
|
||||
enabled := true
|
||||
if b, isBool := body["enabled"].(bool); isBool && !b {
|
||||
enabled = false
|
||||
}
|
||||
next.Enabled = &enabled
|
||||
} else if next.Enabled == nil {
|
||||
enabled := true
|
||||
next.Enabled = &enabled
|
||||
}
|
||||
return &next, nil
|
||||
}
|
||||
|
||||
@@ -413,6 +443,7 @@ func PublicServer(item *Server, data *Data) map[string]any {
|
||||
}
|
||||
}
|
||||
out["pendingCleanup"] = pending
|
||||
out["enabled"] = item.Enabled == nil || *item.Enabled
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -421,9 +452,76 @@ func PublicCustomer(item *Customer, data *Data) map[string]any {
|
||||
out := recordToMap(item)
|
||||
out["observedIpCount"] = len(ObservedIPs(data, item.ID, time.Now()))
|
||||
out["subscriptionClientCount"] = len(ActiveClients(data, item.ID, time.Now()))
|
||||
if len(item.ExternalSources) > 0 {
|
||||
status := map[string]any{}
|
||||
for _, source := range item.ExternalSources {
|
||||
entry := data.SubSourceCache[source.ID]
|
||||
summary := map[string]any{
|
||||
"fetchedAt": nil, "okAt": nil, "failCount": 0,
|
||||
"lastError": nil, "droppedCount": 0, "count": 0,
|
||||
}
|
||||
if entry != nil {
|
||||
if entry.FetchedAt != nil {
|
||||
summary["fetchedAt"] = *entry.FetchedAt
|
||||
}
|
||||
if entry.OkAt != nil {
|
||||
summary["okAt"] = *entry.OkAt
|
||||
}
|
||||
summary["failCount"] = entry.FailCount
|
||||
if entry.LastError != nil {
|
||||
summary["lastError"] = *entry.LastError
|
||||
}
|
||||
summary["droppedCount"] = entry.DroppedCount
|
||||
summary["count"] = len(entry.Items)
|
||||
}
|
||||
status[source.ID] = summary
|
||||
}
|
||||
out["externalStatus"] = status
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// credentialItems mirrors credentialItems in server.js.
|
||||
func credentialItems(d *Data, customer *Customer) []map[string]any {
|
||||
items := []map[string]any{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customer.ID {
|
||||
continue
|
||||
}
|
||||
node := findNode(d, nc.NodeID)
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
username := ""
|
||||
if node.Protocol == "socks5-auth" {
|
||||
username = NodeEmail(customer.ID)
|
||||
}
|
||||
creds := nc.Credentials
|
||||
var clientID, password, shortID any
|
||||
if creds.ClientID != "" {
|
||||
clientID = creds.ClientID
|
||||
}
|
||||
if creds.RelayPassword != "" {
|
||||
password = creds.RelayPassword
|
||||
}
|
||||
if creds.ShortID != "" {
|
||||
shortID = creds.ShortID
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"nodeClientId": nc.ID,
|
||||
"nodeId": node.ID,
|
||||
"nodeName": node.Name,
|
||||
"protocol": node.Protocol,
|
||||
"status": nc.Status,
|
||||
"clientId": clientID,
|
||||
"password": password,
|
||||
"shortId": shortID,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// CustomerBlockReason is customerBlockReason: "expired" / "traffic_limit" /
|
||||
// "ip_limit", or "" for none (JS null).
|
||||
func CustomerBlockReason(data *Data, customer *Customer) string {
|
||||
@@ -1074,6 +1172,9 @@ func (a *App) handleAgent(w http.ResponseWriter, r *http.Request, pathname strin
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
if customer := findCustomer(d, customerID); customer != nil {
|
||||
customer.LastOnlineAt = StrPtr(now)
|
||||
}
|
||||
if !affectedSet[customerID] {
|
||||
affectedSet[customerID] = true
|
||||
affected = append(affected, customerID)
|
||||
@@ -1241,6 +1342,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/system" {
|
||||
data := a.store.Snapshot()
|
||||
SendJSON(w, 200, a.monitor().Status(data, VERSION), nil)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/protocols" {
|
||||
SendJSON(w, 200, map[string]any{"profiles": ProfileCatalog, "realityPresets": RealityPresets}, nil)
|
||||
return true, nil
|
||||
@@ -1558,9 +1665,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/customers" {
|
||||
data := a.store.Snapshot()
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
customers := []map[string]any{}
|
||||
for _, item := range data.Customers {
|
||||
customers = append(customers, PublicCustomer(item, data))
|
||||
out := PublicCustomer(item, data)
|
||||
out["online"] = CustomerOnline(item, nowMs)
|
||||
if item.LastOnlineAt != nil {
|
||||
out["lastActiveAt"] = *item.LastOnlineAt
|
||||
} else {
|
||||
out["lastActiveAt"] = nil
|
||||
}
|
||||
up, down := CustomerSpeedBps(item, nowMs)
|
||||
out["speedUpBps"] = up
|
||||
out["speedDownBps"] = down
|
||||
customers = append(customers, out)
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"customers": customers}, nil)
|
||||
return true, nil
|
||||
@@ -1631,6 +1750,16 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
externalSources, err := NormalizeExternalSources(body["externalSources"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
manualLinks, err := NormalizeManualLinks(body["manualLinks"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
var createdID string
|
||||
var refreshIDs []string
|
||||
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
name, err := RequiredText(body["name"], "客户名称", 120)
|
||||
if err != nil {
|
||||
@@ -1678,13 +1807,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
// Arm the monthly-reset schedule: it starts with the next
|
||||
// reset instant, it never fires retroactively at creation.
|
||||
ArmTrafficReset(next, time.Now())
|
||||
if len(externalSources) > 0 {
|
||||
next.ExternalSources = externalSources
|
||||
}
|
||||
if len(manualLinks) > 0 {
|
||||
next.ManualLinks = manualLinks
|
||||
}
|
||||
d.Customers = append(d.Customers, next)
|
||||
if body["nodeIds"] != nil {
|
||||
if err := AlignCustomerNodes(d, next, asIds(body["nodeIds"]), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "create_customer", next.ID, map[string]any{"name": next.Name})
|
||||
createdID = next.ID
|
||||
for _, source := range externalSources {
|
||||
refreshIDs = append(refreshIDs, source.ID)
|
||||
}
|
||||
return cloneJSONValue(next), nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if len(refreshIDs) > 0 {
|
||||
a.RefreshSourcesInBackground(createdID, refreshIDs)
|
||||
}
|
||||
SendJSON(w, 201, map[string]any{"customer": customerAny}, nil)
|
||||
return true, nil
|
||||
}
|
||||
@@ -1693,6 +1840,21 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
var patchSources []ExtSource
|
||||
if body["externalSources"] != nil {
|
||||
patchSources, err = NormalizeExternalSources(body["externalSources"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
var patchLinks []ManualLink
|
||||
if body["manualLinks"] != nil {
|
||||
patchLinks, err = NormalizeManualLinks(body["manualLinks"])
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
var patchRefreshIDs []string
|
||||
customerAny, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
item := findCustomer(d, params["id"])
|
||||
if item == nil {
|
||||
@@ -1700,6 +1862,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
}
|
||||
wasActive := item.Status == "active"
|
||||
previousReason := strVal(item.SuspendReason)
|
||||
previousSources := append([]ExtSource{}, item.ExternalSources...)
|
||||
if _, ok := body["name"]; ok {
|
||||
name, err := RequiredText(body["name"], "客户名称", 120)
|
||||
if err != nil {
|
||||
@@ -1778,6 +1941,41 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
item.SuspendReason = nil
|
||||
item.SuspendReasonPresent = true
|
||||
}
|
||||
if body["externalSources"] != nil {
|
||||
if len(patchSources) > 0 {
|
||||
item.ExternalSources = patchSources
|
||||
} else {
|
||||
item.ExternalSources = nil
|
||||
}
|
||||
keptIDs := map[string]bool{}
|
||||
for _, source := range patchSources {
|
||||
keptIDs[source.ID] = true
|
||||
}
|
||||
for _, old := range previousSources {
|
||||
if !keptIDs[old.ID] && d.SubSourceCache != nil {
|
||||
delete(d.SubSourceCache, old.ID)
|
||||
}
|
||||
}
|
||||
for _, source := range patchSources {
|
||||
found := false
|
||||
for _, old := range previousSources {
|
||||
if old.ID == source.ID && old.URL == source.URL {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
patchRefreshIDs = append(patchRefreshIDs, source.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if body["manualLinks"] != nil {
|
||||
if len(patchLinks) > 0 {
|
||||
item.ManualLinks = patchLinks
|
||||
} else {
|
||||
item.ManualLinks = nil
|
||||
}
|
||||
}
|
||||
if wasActive && item.Status == "suspended" {
|
||||
_, _ = SuspendCustomerResources(d, item, actor)
|
||||
}
|
||||
@@ -1786,6 +1984,11 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if body["nodeIds"] != nil {
|
||||
if err := AlignCustomerNodes(d, item, asIds(body["nodeIds"]), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
item.UpdatedAt = NowISO()
|
||||
Audit(d, actor, "update_customer", item.ID, nil)
|
||||
return cloneJSONValue(item), nil
|
||||
@@ -1793,9 +1996,78 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if len(patchRefreshIDs) > 0 {
|
||||
a.RefreshSourcesInBackground(params["id"], patchRefreshIDs)
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"customer": customerAny}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/credentials", pathname); ok && r.Method == http.MethodGet {
|
||||
data := a.store.Snapshot()
|
||||
customer := findCustomer(data, params["id"])
|
||||
if customer == nil {
|
||||
return true, httpErr(404, "客户不存在")
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{
|
||||
"customerId": customer.ID,
|
||||
"subscriptionToken": customer.SubscriptionToken,
|
||||
"items": credentialItems(data, customer),
|
||||
}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/credentials/:ncid/regenerate", pathname); ok && r.Method == http.MethodPost {
|
||||
var responseItem map[string]any
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
customer := findCustomer(d, params["id"])
|
||||
if customer == nil {
|
||||
return nil, httpErr(404, "客户不存在")
|
||||
}
|
||||
var binding *NodeClient
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.ID == params["ncid"] && nc.CustomerID == customer.ID {
|
||||
binding = nc
|
||||
break
|
||||
}
|
||||
}
|
||||
if binding == nil {
|
||||
return nil, httpErr(404, "凭据不存在")
|
||||
}
|
||||
node := findNode(d, binding.NodeID)
|
||||
if node == nil {
|
||||
return nil, httpErr(404, "节点不存在")
|
||||
}
|
||||
binding.Credentials = NewNodeClientCredentials(node.Protocol)
|
||||
binding.UpdatedAt = NowISO()
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "regenerate_credentials", customer.ID, map[string]any{"nodeId": node.ID, "node": node.Name})
|
||||
for _, entry := range credentialItems(d, customer) {
|
||||
if entry["nodeClientId"] == binding.ID {
|
||||
responseItem = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"item": responseItem}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/external-sources/:sid/refresh", pathname); ok && r.Method == http.MethodPost {
|
||||
result := a.RefreshOneSource(params["id"], params["sid"])
|
||||
if result == nil {
|
||||
return true, httpErr(404, "订阅源不存在")
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{
|
||||
"ok": result.OK, "count": len(result.Items), "dropped": result.Dropped, "error": strOrNil(result.Error),
|
||||
}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if params, ok := RoutePattern("/api/customers/:id/reset-usage", pathname); ok && r.Method == http.MethodPost {
|
||||
_, err := a.store.Transaction(func(d *Data) (any, error) {
|
||||
item := findCustomer(d, params["id"])
|
||||
@@ -1860,6 +2132,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
return nil, httpErr(409, "客户仍有节点分配。请先移除全部分配")
|
||||
}
|
||||
}
|
||||
removedCustomer := d.Customers[index]
|
||||
for _, source := range removedCustomer.ExternalSources {
|
||||
if d.SubSourceCache != nil {
|
||||
delete(d.SubSourceCache, source.ID)
|
||||
}
|
||||
}
|
||||
d.Customers = append(d.Customers[:index], d.Customers[index+1:]...)
|
||||
access := d.SubscriptionAccess[:0]
|
||||
for _, item := range d.SubscriptionAccess {
|
||||
@@ -1894,14 +2172,31 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
|
||||
if r.Method == http.MethodGet && pathname == "/api/nodes" {
|
||||
data := a.store.Snapshot()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
sortedNodes := append([]*Node{}, data.Nodes...)
|
||||
sort.SliceStable(sortedNodes, func(i, j int) bool { return sortedNodes[i].SortIndex < sortedNodes[j].SortIndex })
|
||||
nodes := []map[string]any{}
|
||||
visibleIDs := map[string]bool{}
|
||||
for _, item := range data.Nodes {
|
||||
for _, item := range sortedNodes {
|
||||
if item.PendingDelete {
|
||||
continue
|
||||
}
|
||||
visibleIDs[item.ID] = true
|
||||
nodes = append(nodes, PublicNode(item))
|
||||
out := PublicNode(item)
|
||||
clientCount := 0
|
||||
for _, nc := range data.NodeClients {
|
||||
if nc.NodeID == item.ID {
|
||||
clientCount++
|
||||
}
|
||||
}
|
||||
onlineIDs := NodeOnlineCustomerIds(data, item, nowMs)
|
||||
out["clientCount"] = clientCount
|
||||
out["onlineCount"] = len(onlineIDs)
|
||||
out["onlineCustomerIds"] = onlineIDs
|
||||
up, down := NodeSpeedBps(item, nowMs)
|
||||
out["speedUpBps"] = up
|
||||
out["speedDownBps"] = down
|
||||
nodes = append(nodes, out)
|
||||
}
|
||||
nodeClients := []map[string]any{}
|
||||
for _, nc := range data.NodeClients {
|
||||
@@ -1921,6 +2216,44 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
SendJSON(w, 200, map[string]any{"nodes": nodes, "nodeClients": nodeClients}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if r.Method == http.MethodPost && pathname == "/api/nodes/reorder" {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
_, err = a.store.Transaction(func(d *Data) (any, error) {
|
||||
ids := asIds(body["ids"])
|
||||
byID := map[string]*Node{}
|
||||
for _, item := range d.Nodes {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
ordered := []*Node{}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if node := byID[id]; node != nil && !seen[id] {
|
||||
seen[id] = true
|
||||
ordered = append(ordered, node)
|
||||
}
|
||||
}
|
||||
rest := append([]*Node{}, d.Nodes...)
|
||||
sort.SliceStable(rest, func(i, j int) bool { return rest[i].SortIndex < rest[j].SortIndex })
|
||||
for _, item := range rest {
|
||||
if !seen[item.ID] {
|
||||
ordered = append(ordered, item)
|
||||
}
|
||||
}
|
||||
for index, item := range ordered {
|
||||
item.SortIndex = index
|
||||
}
|
||||
Audit(d, actor, "reorder_nodes", "nodes", map[string]any{"count": len(ordered)})
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
SendJSON(w, 200, map[string]any{"ok": true}, nil)
|
||||
return true, nil
|
||||
}
|
||||
if r.Method == http.MethodPost && pathname == "/api/nodes" {
|
||||
body, err := ReadJSON(r, defaultMaxBody)
|
||||
if err != nil {
|
||||
@@ -1948,6 +2281,13 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
||||
next.CreatedAt = NowISO()
|
||||
next.UpdatedAt = NowISO()
|
||||
EnsureNodeSecrets(next)
|
||||
maxIndex := -1
|
||||
for _, item := range d.Nodes {
|
||||
if item.SortIndex > maxIndex {
|
||||
maxIndex = item.SortIndex
|
||||
}
|
||||
}
|
||||
next.SortIndex = maxIndex + 1
|
||||
d.Nodes = append(d.Nodes, next)
|
||||
Audit(d, actor, "create_node", next.ID, map[string]any{"name": next.Name})
|
||||
return cloneJSONValue(next), nil
|
||||
|
||||
@@ -272,6 +272,10 @@ func (a *App) housekeeping() {
|
||||
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 ----------
|
||||
@@ -361,6 +365,32 @@ func run() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()))
|
||||
|
||||
|
||||
+222
-70
@@ -612,6 +612,15 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
||||
return orchNotFound("出口服务器")
|
||||
}
|
||||
}
|
||||
targets := []*Server{server}
|
||||
if !direct {
|
||||
targets = append(targets, exitServer)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.Enabled != nil && !*target.Enabled {
|
||||
return httpErr(409, "设备 %s 已停用", target.Name)
|
||||
}
|
||||
}
|
||||
if len(activeNodeClients(d, node)) == 0 {
|
||||
return httpErr(400, "请先给节点分配至少一个客户再部署")
|
||||
}
|
||||
@@ -720,6 +729,10 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
|
||||
if server == nil {
|
||||
return 0, orchNotFound("服务器")
|
||||
}
|
||||
// A disabled device receives no new deployments, including reconciles.
|
||||
if server.Enabled != nil && !*server.Enabled {
|
||||
return 0, nil
|
||||
}
|
||||
failInFlight := true
|
||||
if opts.FailInFlight != nil {
|
||||
failInFlight = *opts.FailInFlight
|
||||
@@ -935,61 +948,178 @@ func (o *Orchestrator) RepairNode(nodeID, actor string) (bool, error) {
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// assignCustomerData is the transaction-free core of AssignCustomer,
|
||||
// shared with the customer form's node alignment (AlignCustomerNodes).
|
||||
func assignCustomerData(d *Data, nodeID, customerID, actor string) (*NodeClient, error) {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return nil, orchNotFound("节点")
|
||||
}
|
||||
customer := orchFindCustomer(d, customerID)
|
||||
if customer == nil {
|
||||
return nil, orchNotFound("客户")
|
||||
}
|
||||
if customer.Status != "active" {
|
||||
return nil, httpErr(409, "只能给状态正常的客户分配节点")
|
||||
}
|
||||
var nc *NodeClient
|
||||
for _, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
nc = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if nc != nil && nc.Status == "active" {
|
||||
return nil, httpErr(409, "该客户已分配到此节点")
|
||||
}
|
||||
if nc != nil {
|
||||
nc.Status = "active"
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
// Record the assignment only.
|
||||
} else {
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||
return nc, nil
|
||||
}
|
||||
|
||||
func unassignCustomerData(d *Data, nodeID, customerID, actor string) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
}
|
||||
index := -1
|
||||
for i, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return httpErr(404, "该客户未分配到此节点")
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// AlignCustomerNodes mirrors alignCustomerNodes in lib/orchestrator.js:
|
||||
// full-set alignment of one customer's node bindings (customer form's
|
||||
// 关联入站 multi-select). Suspended customers get suspended bindings.
|
||||
func AlignCustomerNodes(d *Data, customer *Customer, desiredIds []string, actor string) error {
|
||||
desired := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, nodeID := range desiredIds {
|
||||
if nodeID == "" || seen[nodeID] {
|
||||
continue
|
||||
}
|
||||
seen[nodeID] = true
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil || node.PendingDelete {
|
||||
return httpErr(400, "节点不存在")
|
||||
}
|
||||
desired = append(desired, nodeID)
|
||||
}
|
||||
rebuild := map[string]bool{}
|
||||
kept := []*NodeClient{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customer.ID {
|
||||
kept = append(kept, nc)
|
||||
continue
|
||||
}
|
||||
if seen[nc.NodeID] {
|
||||
kept = append(kept, nc)
|
||||
continue
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", nc.NodeID, map[string]any{"customerId": customer.ID})
|
||||
rebuild[nc.NodeID] = true
|
||||
}
|
||||
d.NodeClients = kept
|
||||
for _, nodeID := range desired {
|
||||
exists := false
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID == customer.ID && nc.NodeID == nodeID {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
node := orchFindNode(d, nodeID)
|
||||
status := "suspended"
|
||||
if customer.Status == "active" {
|
||||
status = "active"
|
||||
}
|
||||
now := NowISO()
|
||||
d.NodeClients = append(d.NodeClients, &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customer.ID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customer.ID, "customer": customer.Name})
|
||||
if status == "active" {
|
||||
rebuild[nodeID] = true
|
||||
}
|
||||
}
|
||||
for nodeID := range rebuild {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
continue
|
||||
}
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) AssignCustomer(nodeID, customerID, actor string) (*NodeClient, error) {
|
||||
if actor == "" {
|
||||
actor = "admin"
|
||||
}
|
||||
var result *NodeClient
|
||||
err := o.tx(func(d *Data) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
nc, err := assignCustomerData(d, nodeID, customerID, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
customer := orchFindCustomer(d, customerID)
|
||||
if customer == nil {
|
||||
return orchNotFound("客户")
|
||||
}
|
||||
if customer.Status != "active" {
|
||||
return httpErr(409, "只能给状态正常的客户分配节点")
|
||||
}
|
||||
var nc *NodeClient
|
||||
for _, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
nc = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
if nc != nil && nc.Status == "active" {
|
||||
return httpErr(409, "该客户已分配到此节点")
|
||||
}
|
||||
if nc != nil {
|
||||
nc.Status = "active"
|
||||
nc.UpdatedAt = NowISO()
|
||||
} else {
|
||||
now := NowISO()
|
||||
nc = &NodeClient{
|
||||
ID: NewID("ncl"),
|
||||
NodeID: nodeID,
|
||||
CustomerID: customerID,
|
||||
Credentials: NewNodeClientCredentials(node.Protocol),
|
||||
Status: "active",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients, nc)
|
||||
}
|
||||
if (node.Status == "removing" || node.Status == "draft") && !boolVal(node.AutoRemoving) {
|
||||
// Record the assignment only.
|
||||
} else {
|
||||
if node.Status == "removing" && boolVal(node.AutoRemoving) {
|
||||
cancelQueuedDeletes(d, node.ID, "已取消:节点恢复服务")
|
||||
node.AutoRemoving = BoolPtr(false)
|
||||
}
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "assign_customer", node.ID, map[string]any{"customerId": customerID, "customer": customer.Name})
|
||||
result = cloneNodeClientRecord(nc)
|
||||
return nil
|
||||
})
|
||||
@@ -1001,28 +1131,7 @@ func (o *Orchestrator) UnassignCustomer(nodeID, customerID, actor string) (bool,
|
||||
actor = "admin"
|
||||
}
|
||||
err := o.tx(func(d *Data) error {
|
||||
node := orchFindNode(d, nodeID)
|
||||
if node == nil {
|
||||
return orchNotFound("节点")
|
||||
}
|
||||
index := -1
|
||||
for i, entry := range d.NodeClients {
|
||||
if entry.NodeID == nodeID && entry.CustomerID == customerID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return httpErr(404, "该客户未分配到此节点")
|
||||
}
|
||||
d.NodeClients = append(d.NodeClients[:index], d.NodeClients[index+1:]...)
|
||||
if node.Status != "draft" && node.Status != "removing" {
|
||||
if err := RebuildNode(d, node, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
Audit(d, actor, "unassign_customer", node.ID, map[string]any{"customerId": customerID})
|
||||
return nil
|
||||
return unassignCustomerData(d, nodeID, customerID, actor)
|
||||
})
|
||||
return err == nil, err
|
||||
}
|
||||
@@ -1298,6 +1407,9 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
slot = &UsageSlot{}
|
||||
customer.ServerUsage[key] = slot
|
||||
}
|
||||
prevCounters := slot.UsageCounters
|
||||
prevSampleISO := slot.LastSampleAt
|
||||
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
|
||||
delta := ApplyDelta(SlotHolder(slot), uplink, downlink, sampleEpoch)
|
||||
if delta == nil {
|
||||
continue
|
||||
@@ -1307,11 +1419,33 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
customer.UsedBytes += delta.UpDelta + delta.DownDelta
|
||||
customer.LastUsageAt = StrPtr(reportedAt)
|
||||
customer.UpdatedAt = reportedAt
|
||||
slot.LastSampleAt = StrPtr(reportedAt)
|
||||
if delta.UpDelta > 0 || delta.DownDelta > 0 {
|
||||
slot.LastActiveAt = StrPtr(reportedAt)
|
||||
customer.LastOnlineAt = StrPtr(reportedAt)
|
||||
}
|
||||
if !restarted && prevCounters != nil && prevSampleISO != nil {
|
||||
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
|
||||
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
|
||||
dt := float64(nowMs-prevMs) / 1000
|
||||
if dt >= 5 {
|
||||
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
|
||||
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
|
||||
slot.SpeedUpBps = &up
|
||||
slot.SpeedDownBps = &down
|
||||
slot.SpeedAt = StrPtr(reportedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "inbound":
|
||||
node := orchFindNode(d, sample.ID)
|
||||
if node == nil || node.ServerID != serverID {
|
||||
continue
|
||||
}
|
||||
prevCounters := node.UsageCounters
|
||||
prevSampleISO := node.LastSampleAt
|
||||
restarted := prevCounters != nil && prevCounters.Epoch != nil && sampleEpoch != nil && *sampleEpoch != *prevCounters.Epoch
|
||||
delta := ApplyDelta(NodeHolder(node), uplink, downlink, sampleEpoch)
|
||||
if delta == nil {
|
||||
continue
|
||||
@@ -1323,6 +1457,24 @@ func (o *Orchestrator) RecordUsage(serverID string, samples []UsageSample, errTe
|
||||
}
|
||||
node.MeteredTraffic = &MeteredTraffic{Uplink: mu + delta.UpDelta, Downlink: md + delta.DownDelta}
|
||||
node.LastUsageAt = StrPtr(reportedAt)
|
||||
node.LastSampleAt = StrPtr(reportedAt)
|
||||
if delta.UpDelta > 0 || delta.DownDelta > 0 {
|
||||
node.LastActiveAt = StrPtr(reportedAt)
|
||||
}
|
||||
if !restarted && prevCounters != nil && prevSampleISO != nil {
|
||||
if prevMs, ok1 := ParseISOMs(*prevSampleISO); ok1 {
|
||||
if nowMs, ok2 := ParseISOMs(reportedAt); ok2 {
|
||||
dt := float64(nowMs-prevMs) / 1000
|
||||
if dt >= 5 {
|
||||
up := float64(int64(math.Round(float64(uplink-prevCounters.Uplink) / dt)))
|
||||
down := float64(int64(math.Round(float64(downlink-prevCounters.Downlink) / dt)))
|
||||
node.SpeedUpBps = &up
|
||||
node.SpeedDownBps = &down
|
||||
node.SpeedAt = StrPtr(reportedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// Presence & speed derivation — mirrors lib/presence.js. See that file
|
||||
// for the signal model (per-server user counters, per-node inbound
|
||||
// counters, AnyTLS observations; 3-minute window; server-level activity
|
||||
// attributed to the customer's nodes on that server).
|
||||
|
||||
import "time"
|
||||
|
||||
const PresenceWindowMs = 180000
|
||||
const SpeedStaleMs = 150000
|
||||
|
||||
func parseISOMsOr0(value *string) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
ms, ok := ParseISOMs(*value)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
func CustomerOnline(customer *Customer, nowMs int64) bool {
|
||||
ms := parseISOMsOr0(customer.LastOnlineAt)
|
||||
return ms > 0 && nowMs-ms <= PresenceWindowMs
|
||||
}
|
||||
|
||||
func CustomerSpeedBps(customer *Customer, nowMs int64) (float64, float64) {
|
||||
var up, down float64
|
||||
for _, slot := range customer.ServerUsage {
|
||||
if slot == nil || slot.SpeedAt == nil {
|
||||
continue
|
||||
}
|
||||
if nowMs-parseISOMsOr0(slot.SpeedAt) > SpeedStaleMs {
|
||||
continue
|
||||
}
|
||||
if slot.SpeedUpBps != nil {
|
||||
up += *slot.SpeedUpBps
|
||||
}
|
||||
if slot.SpeedDownBps != nil {
|
||||
down += *slot.SpeedDownBps
|
||||
}
|
||||
}
|
||||
return up, down
|
||||
}
|
||||
|
||||
func NodeSpeedBps(node *Node, nowMs int64) (float64, float64) {
|
||||
if node.SpeedAt == nil || nowMs-parseISOMsOr0(node.SpeedAt) > SpeedStaleMs {
|
||||
return 0, 0
|
||||
}
|
||||
var up, down float64
|
||||
if node.SpeedUpBps != nil {
|
||||
up = *node.SpeedUpBps
|
||||
}
|
||||
if node.SpeedDownBps != nil {
|
||||
down = *node.SpeedDownBps
|
||||
}
|
||||
return up, down
|
||||
}
|
||||
|
||||
// NodeOnlineCustomerIds mirrors nodeOnlineCustomerIds in lib/presence.js.
|
||||
func NodeOnlineCustomerIds(d *Data, node *Node, nowMs int64) []string {
|
||||
out := []string{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.NodeID != node.ID || nc.Status != "active" {
|
||||
continue
|
||||
}
|
||||
customer := findCustomer(d, nc.CustomerID)
|
||||
if customer == nil {
|
||||
continue
|
||||
}
|
||||
var ms int64
|
||||
if slot := customer.ServerUsage["srv:"+node.ServerID]; slot != nil && slot.LastActiveAt != nil {
|
||||
ms = parseISOMsOr0(slot.LastActiveAt)
|
||||
} else {
|
||||
ms = parseISOMsOr0(customer.LastOnlineAt)
|
||||
}
|
||||
if ms > 0 && nowMs-ms <= PresenceWindowMs {
|
||||
out = append(out, customer.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func CountOnlineCustomers(d *Data, now time.Time) int {
|
||||
count := 0
|
||||
for _, customer := range d.Customers {
|
||||
if CustomerOnline(customer, now.UnixMilli()) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ func EmptyData() *Data {
|
||||
SchemaVersion: 1,
|
||||
CreatedAt: NowISO(),
|
||||
Settings: Settings{
|
||||
BrandName: "NexusGate",
|
||||
BrandName: "amo",
|
||||
SessionHours: 12,
|
||||
ObservationTtlMinutes: 10,
|
||||
ActivityRetentionDays: 30,
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestInitCreatesMissingFile(t *testing.T) {
|
||||
if s.Data.SchemaVersion != 1 {
|
||||
t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion)
|
||||
}
|
||||
if s.Data.Settings.BrandName != "NexusGate" || s.Data.Settings.SessionHours != 12 ||
|
||||
if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 ||
|
||||
s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 ||
|
||||
s.Data.Settings.CompletedJobRetentionDays != 7 {
|
||||
t.Errorf("settings defaults = %+v", s.Data.Settings)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -129,13 +130,29 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
|
||||
return uri
|
||||
}
|
||||
|
||||
// sortIndexOf mirrors sortIndexOf in lib/subscriptions.js.
|
||||
func sortIndexOf(d *Data, nodeID string) int {
|
||||
for _, item := range d.Nodes {
|
||||
if item.ID == nodeID {
|
||||
return item.SortIndex
|
||||
}
|
||||
}
|
||||
return 1 << 30
|
||||
}
|
||||
|
||||
func Entries(d *Data, customerID string) []SubEntry {
|
||||
seen := map[string]int{}
|
||||
items := []SubEntry{}
|
||||
bindings := []*NodeClient{}
|
||||
for _, nc := range d.NodeClients {
|
||||
if nc.CustomerID != customerID || nc.Status != "active" {
|
||||
continue
|
||||
if nc.CustomerID == customerID && nc.Status == "active" {
|
||||
bindings = append(bindings, nc)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(bindings, func(i, j int) bool {
|
||||
return sortIndexOf(d, bindings[i].NodeID) < sortIndexOf(d, bindings[j].NodeID)
|
||||
})
|
||||
for _, nc := range bindings {
|
||||
uri := NodeClientURI(d, nc)
|
||||
if uri == "" {
|
||||
continue
|
||||
@@ -663,8 +680,78 @@ func MakeSingBox(items []SubEntry) string {
|
||||
|
||||
var clashUARe = regexp.MustCompile(`(?i)clash|mihomo`)
|
||||
|
||||
// AggregateEntries mirrors aggregateEntries in lib/subscriptions.js:
|
||||
// own entries (sortIndex order) → manual links → external sources,
|
||||
// deduped by scheme+host+port+credential fingerprint, first wins.
|
||||
func AggregateEntries(d *Data, customer *Customer) []SubEntry {
|
||||
own := Entries(d, customer.ID)
|
||||
out := []SubEntry{}
|
||||
seenKeys := map[string]bool{}
|
||||
seenNames := map[string]int{}
|
||||
push := func(uri, name string) {
|
||||
if uri == "" {
|
||||
return
|
||||
}
|
||||
key := DedupKey(uri)
|
||||
if key != "" && seenKeys[key] {
|
||||
return
|
||||
}
|
||||
if key != "" {
|
||||
seenKeys[key] = true
|
||||
}
|
||||
index := seenNames[name] + 1
|
||||
seenNames[name] = index
|
||||
finalName := name
|
||||
if index != 1 {
|
||||
finalName = fmt.Sprintf("%s (%d)", name, index)
|
||||
}
|
||||
out = append(out, SubEntry{ClientURI: WithName(uri, finalName)})
|
||||
}
|
||||
for _, entry := range own {
|
||||
key := DedupKey(entry.ClientURI)
|
||||
if key != "" {
|
||||
seenKeys[key] = true
|
||||
}
|
||||
seenNames[NameFromURI(entry.ClientURI)]++
|
||||
out = append(out, entry)
|
||||
}
|
||||
for i, link := range customer.ManualLinks {
|
||||
if !link.Enabled {
|
||||
continue
|
||||
}
|
||||
name := link.Remark
|
||||
if name == "" {
|
||||
name = NameFromURI(link.URI)
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("手动节点 %d", i+1)
|
||||
}
|
||||
push(link.URI, name)
|
||||
}
|
||||
for _, source := range customer.ExternalSources {
|
||||
if !source.Enabled {
|
||||
continue
|
||||
}
|
||||
entry := d.SubSourceCache[source.ID]
|
||||
if entry == nil || entry.OkAt == nil {
|
||||
continue
|
||||
}
|
||||
for _, item := range entry.Items {
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = "未命名节点"
|
||||
}
|
||||
if source.Remark != "" {
|
||||
name = "[" + source.Remark + "] " + name
|
||||
}
|
||||
push(item.URI, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FormatSubscription(d *Data, customer *Customer, format, userAgent string) (*SubResult, bool) {
|
||||
items := Entries(d, customer.ID)
|
||||
items := AggregateEntries(d, customer)
|
||||
if len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
// Host/system sampling for the system-status page — mirrors
|
||||
// lib/sysstat.js. Memory-only ring history (240 samples), ~30s minimum
|
||||
// sampling interval, "today" traffic baseline kept in memory (a panel
|
||||
// restart restarts the day counter from traffic seen since boot).
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const historyCap = 240
|
||||
const sampleMinIntervalMs = 25000
|
||||
|
||||
type sysSample struct {
|
||||
At string `json:"at"`
|
||||
CPU *float64 `json:"cpu"`
|
||||
Mem *float64 `json:"mem"`
|
||||
UpBps float64 `json:"upBps"`
|
||||
DownBps float64 `json:"downBps"`
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
|
||||
type sysCurrent struct {
|
||||
CPUPct *float64
|
||||
MemTotal int64
|
||||
MemUsed int64
|
||||
DiskTotal int64
|
||||
DiskUsed int64
|
||||
UpBps float64
|
||||
DownBps float64
|
||||
Conns int
|
||||
TotalUp int64
|
||||
TotalDown int64
|
||||
}
|
||||
|
||||
type SystemMonitor struct {
|
||||
mu sync.Mutex
|
||||
dataDir string
|
||||
startedAt time.Time
|
||||
prevIdle int64
|
||||
prevTotal int64
|
||||
hasPrev bool
|
||||
cpuPct *float64
|
||||
history []sysSample
|
||||
lastSample time.Time
|
||||
dayKey string
|
||||
dayBaseUp int64
|
||||
dayBaseDown int64
|
||||
todayUp int64
|
||||
todayDown int64
|
||||
current *sysCurrent
|
||||
}
|
||||
|
||||
func NewSystemMonitor(dataDir string) *SystemMonitor {
|
||||
return &SystemMonitor{dataDir: dataDir, startedAt: time.Now()}
|
||||
}
|
||||
|
||||
func readCPUTimes() (idle, total int64, ok bool) {
|
||||
raw, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
line := strings.SplitN(string(raw), "\n", 2)[0]
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 6 {
|
||||
return 0, 0, false
|
||||
}
|
||||
var values []int64
|
||||
for _, f := range fields[1:] {
|
||||
n, err := strconv.ParseInt(f, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
values = append(values, n)
|
||||
}
|
||||
idle = values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
return idle, total, true
|
||||
}
|
||||
|
||||
func readMemInfo() (total, used int64) {
|
||||
raw, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
grab := func(key string) int64 {
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, key+":") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
total = grab("MemTotal")
|
||||
available := grab("MemAvailable")
|
||||
if total > 0 {
|
||||
used = total - available
|
||||
}
|
||||
return total, used
|
||||
}
|
||||
|
||||
func isGlobalIPv4(ip net.IP) bool {
|
||||
v4 := ip.To4()
|
||||
if v4 == nil {
|
||||
return false
|
||||
}
|
||||
a, b := v4[0], v4[1]
|
||||
switch {
|
||||
case a == 0 || a == 10 || a == 127:
|
||||
return false
|
||||
case a == 172 && b >= 16 && b <= 31:
|
||||
return false
|
||||
case a == 192 && b == 168:
|
||||
return false
|
||||
case a == 100 && b >= 64 && b <= 127:
|
||||
return false
|
||||
case a == 169 && b == 254:
|
||||
return false
|
||||
case a >= 224:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isGlobalIPv6(ip net.IP) bool {
|
||||
if ip.To4() != nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
b := ip.To16()
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
if b[0] == 0xfc || b[0] == 0xfd { // ULA fc00::/7
|
||||
return false
|
||||
}
|
||||
if b[0] == 0xfe && b[1]&0xc0 == 0x80 { // link-local fe80::/10
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HostIPs picks the host's global interface addresses (no external
|
||||
// lookup of any kind), mirroring hostIPs in lib/sysstat.js.
|
||||
func HostIPs() (ipv4, ipv6 *string) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch a := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = a.IP
|
||||
case *net.IPAddr:
|
||||
ip = a.IP
|
||||
}
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
if ipv4 == nil && isGlobalIPv4(ip) {
|
||||
s := ip.String()
|
||||
ipv4 = &s
|
||||
}
|
||||
if ipv6 == nil && isGlobalIPv6(ip) {
|
||||
s := ip.String()
|
||||
ipv6 = &s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
func round1(v float64) float64 {
|
||||
return float64(int64(v*10+0.5)) / 10
|
||||
}
|
||||
|
||||
func (m *SystemMonitor) Sample(d *Data, force bool) *sysCurrent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := time.Now()
|
||||
if !force && m.current != nil && now.Sub(m.lastSample).Milliseconds() < sampleMinIntervalMs {
|
||||
return m.current
|
||||
}
|
||||
m.lastSample = now
|
||||
if idle, total, ok := readCPUTimes(); ok {
|
||||
if m.hasPrev {
|
||||
totalDelta := total - m.prevTotal
|
||||
idleDelta := idle - m.prevIdle
|
||||
if totalDelta > 0 {
|
||||
pct := (1 - float64(idleDelta)/float64(totalDelta)) * 100
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
m.cpuPct = &pct
|
||||
}
|
||||
}
|
||||
m.prevIdle, m.prevTotal, m.hasPrev = idle, total, true
|
||||
}
|
||||
memTotal, memUsed := readMemInfo()
|
||||
var diskTotal, diskUsed int64
|
||||
var statfs syscall.Statfs_t
|
||||
if err := syscall.Statfs(m.dataDir, &statfs); err == nil {
|
||||
diskTotal = int64(statfs.Blocks) * int64(statfs.Bsize)
|
||||
diskUsed = (int64(statfs.Blocks) - int64(statfs.Bfree)) * int64(statfs.Bsize)
|
||||
}
|
||||
var totalUp, totalDown int64
|
||||
var upBps, downBps float64
|
||||
nowMs := now.UnixMilli()
|
||||
for _, node := range d.Nodes {
|
||||
if node.MeteredTraffic != nil {
|
||||
totalUp += node.MeteredTraffic.Uplink
|
||||
totalDown += node.MeteredTraffic.Downlink
|
||||
}
|
||||
up, down := NodeSpeedBps(node, nowMs)
|
||||
upBps += up
|
||||
downBps += down
|
||||
}
|
||||
dayKey := now.Format("2006-01-02")
|
||||
if m.dayKey != dayKey {
|
||||
m.dayKey = dayKey
|
||||
m.dayBaseUp = totalUp
|
||||
m.dayBaseDown = totalDown
|
||||
}
|
||||
m.todayUp = totalUp - m.dayBaseUp
|
||||
if m.todayUp < 0 {
|
||||
m.todayUp = 0
|
||||
}
|
||||
m.todayDown = totalDown - m.dayBaseDown
|
||||
if m.todayDown < 0 {
|
||||
m.todayDown = 0
|
||||
}
|
||||
conns := CountOnlineCustomers(d, now)
|
||||
var cpuVal, memVal *float64
|
||||
if m.cpuPct != nil {
|
||||
v := round1(*m.cpuPct)
|
||||
cpuVal = &v
|
||||
}
|
||||
if memTotal > 0 {
|
||||
v := round1(float64(memUsed) / float64(memTotal) * 100)
|
||||
memVal = &v
|
||||
}
|
||||
m.history = append(m.history, sysSample{
|
||||
At: ISOFromTime(now), CPU: cpuVal, Mem: memVal,
|
||||
UpBps: upBps, DownBps: downBps, Connections: conns,
|
||||
})
|
||||
if len(m.history) > historyCap {
|
||||
m.history = m.history[len(m.history)-historyCap:]
|
||||
}
|
||||
m.current = &sysCurrent{
|
||||
CPUPct: cpuVal, MemTotal: memTotal, MemUsed: memUsed,
|
||||
DiskTotal: diskTotal, DiskUsed: diskUsed,
|
||||
UpBps: upBps, DownBps: downBps, Conns: conns,
|
||||
TotalUp: totalUp, TotalDown: totalDown,
|
||||
}
|
||||
return m.current
|
||||
}
|
||||
|
||||
func panelMemBytes() int64 {
|
||||
raw, err := os.ReadFile("/proc/self/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(line, "VmRSS:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if n, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hostUptimeSec() int64 {
|
||||
raw, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
return int64(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *SystemMonitor) Status(d *Data, version string) map[string]any {
|
||||
current := m.Sample(d, false)
|
||||
m.mu.Lock()
|
||||
history := make([]sysSample, len(m.history))
|
||||
copy(history, m.history)
|
||||
todayUp, todayDown := m.todayUp, m.todayDown
|
||||
startedAt := m.startedAt
|
||||
m.mu.Unlock()
|
||||
if current == nil {
|
||||
current = &sysCurrent{}
|
||||
}
|
||||
memTotal, memUsed := current.MemTotal, current.MemUsed
|
||||
var memPct, diskPct *float64
|
||||
if memTotal > 0 {
|
||||
v := round1(float64(memUsed) / float64(memTotal) * 100)
|
||||
memPct = &v
|
||||
}
|
||||
if current.DiskTotal > 0 {
|
||||
v := round1(float64(current.DiskUsed) / float64(current.DiskTotal) * 100)
|
||||
diskPct = &v
|
||||
}
|
||||
onlineNodes := 0
|
||||
for _, node := range d.Nodes {
|
||||
if node.Status == "active" {
|
||||
onlineNodes++
|
||||
}
|
||||
}
|
||||
ipv4, ipv6 := HostIPs()
|
||||
load1 := float64(0)
|
||||
// load average from /proc/loadavg
|
||||
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) > 0 {
|
||||
load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"version": version,
|
||||
"startedAt": ISOFromTime(startedAt),
|
||||
"panelUptimeSec": int64(time.Since(startedAt).Seconds()),
|
||||
"hostUptimeSec": hostUptimeSec(),
|
||||
"panelMemBytes": panelMemBytes(),
|
||||
"host": map[string]any{
|
||||
"cpuPercent": current.CPUPct,
|
||||
"cpuCount": runtime.NumCPU(),
|
||||
"load1": load1,
|
||||
"memTotal": memTotal, "memUsed": memUsed, "memPercent": memPct,
|
||||
"diskTotal": current.DiskTotal, "diskUsed": current.DiskUsed, "diskPercent": diskPct,
|
||||
},
|
||||
"network": map[string]any{
|
||||
"upBps": current.UpBps, "downBps": current.DownBps,
|
||||
"connections": current.Conns, "onlineCustomers": current.Conns,
|
||||
"onlineNodes": onlineNodes, "nodesTotal": len(d.Nodes), "customersTotal": len(d.Customers),
|
||||
},
|
||||
"today": map[string]any{"upBytes": todayUp, "downBytes": todayDown},
|
||||
"totals": map[string]any{"upBytes": current.TotalUp, "downBytes": current.TotalDown},
|
||||
"ips": map[string]any{"ipv4": ipv4, "ipv6": ipv6},
|
||||
"history": history,
|
||||
}
|
||||
}
|
||||
+69
-17
@@ -217,23 +217,24 @@ func recordJSON[T any](raw []byte, dst *T, extra *extraFields) error {
|
||||
// ---------- 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"`
|
||||
Extra extraFields `json:"-"`
|
||||
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 {
|
||||
@@ -272,6 +273,8 @@ type Settings struct {
|
||||
ObservationTtlMinutes float64 `json:"observationTtlMinutes"`
|
||||
ActivityRetentionDays float64 `json:"activityRetentionDays"`
|
||||
CompletedJobRetentionDays float64 `json:"completedJobRetentionDays"`
|
||||
ExternalSubRefreshMinutes float64 `json:"externalSubRefreshMinutes,omitempty"`
|
||||
AllowPrivateSubSources bool `json:"allowPrivateSubSources,omitempty"`
|
||||
Extra extraFields `json:"-"`
|
||||
}
|
||||
|
||||
@@ -379,6 +382,7 @@ type Server struct {
|
||||
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"`
|
||||
@@ -501,6 +505,11 @@ func (c UsageCounters) MarshalJSON() ([]byte, error) {
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
@@ -545,9 +554,46 @@ type Customer struct {
|
||||
ServerUsage map[string]*UsageSlot `json:"serverUsage,omitempty"`
|
||||
LastUsageAt *string `json:"lastUsageAt,omitempty"`
|
||||
LastOnlineAt *string `json:"lastOnlineAt,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
|
||||
@@ -678,6 +724,12 @@ type Node struct {
|
||||
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:"-"`
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLinkListBase64AndPlain(t *testing.T) {
|
||||
plain := "vless://11111111-1111-1111-1111-111111111111@a.example.com:443?security=reality#One\n" +
|
||||
"trojan://x@b.example.com:443#Bad\n" +
|
||||
"ss://YWVzLTI1Ni1nY206cGFzcw@b.example.com:8388#Two\n"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
items, dropped := ParseLinkList(encoded)
|
||||
if len(items) != 2 || dropped != 1 {
|
||||
t.Fatalf("base64 list: items=%d dropped=%d", len(items), dropped)
|
||||
}
|
||||
if items[0].Name != "One" || items[1].Name != "Two" {
|
||||
t.Fatalf("names: %+v", items)
|
||||
}
|
||||
items2, dropped2 := ParseLinkList(plain)
|
||||
if len(items2) != 2 || dropped2 != 1 {
|
||||
t.Fatalf("plain list: items=%d dropped=%d", len(items2), dropped2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchExternalSourceGuards(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("vless://11111111-1111-1111-1111-111111111111@a.example.com:443#X\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
source := ExtSource{ID: "src_t", URL: server.URL, Enabled: true}
|
||||
settings := &Settings{}
|
||||
blocked := FetchExternalSource(source, settings)
|
||||
if blocked.OK {
|
||||
t.Fatal("loopback source must be rejected by default")
|
||||
}
|
||||
settings.AllowPrivateSubSources = true
|
||||
allowed := FetchExternalSource(source, settings)
|
||||
if !allowed.OK || len(allowed.Items) != 1 {
|
||||
t.Fatalf("allowPrivate fetch: %+v", allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateEntriesOrderAndDedup(t *testing.T) {
|
||||
d := &Data{
|
||||
Nodes: []*Node{
|
||||
{ID: "nod_b", Name: "B", SortIndex: 1, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20003},
|
||||
{ID: "nod_a", Name: "A", SortIndex: 0, ServerID: "srv_1", Protocol: "shadowsocks-aes256-gcm", Status: "active", Port: 20001},
|
||||
},
|
||||
Servers: []*Server{{ID: "srv_1", Name: "S", PublicAddress: "s.example.com"}},
|
||||
NodeClients: []*NodeClient{
|
||||
{ID: "ncl_b", NodeID: "nod_b", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-b"}},
|
||||
{ID: "ncl_a", NodeID: "nod_a", CustomerID: "cus_1", Status: "active", Credentials: Credentials{RelayPassword: "pw-a"}},
|
||||
},
|
||||
SubSourceCache: map[string]*SubSourceCacheEntry{
|
||||
"src_1": {Items: []SubCacheItem{{URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Dup", Name: "Dup"}}},
|
||||
},
|
||||
}
|
||||
okAt := NowISO()
|
||||
d.SubSourceCache["src_1"].OkAt = &okAt
|
||||
customer := &Customer{
|
||||
ID: "cus_1",
|
||||
ManualLinks: []ManualLink{
|
||||
{ID: "lnk_1", URI: "ss://YWVzLTI1Ni1nY206cHctYQ@s.example.com:20002#Whatever", Remark: "手动一号", Enabled: true},
|
||||
},
|
||||
ExternalSources: []ExtSource{{ID: "src_1", URL: "https://example.com/sub", Remark: "机场", Enabled: true}},
|
||||
}
|
||||
entries := AggregateEntries(d, customer)
|
||||
names := []string{}
|
||||
for _, entry := range entries {
|
||||
names = append(names, NameFromURI(entry.ClientURI))
|
||||
}
|
||||
// Own entries first in sortIndex order (A before B although B's
|
||||
// binding is stored first); the manual link follows; the external
|
||||
// item duplicates the manual link (same host/port/credential) and
|
||||
// is dropped.
|
||||
joined := strings.Join(names, ",")
|
||||
if joined != "A,B,手动一号" {
|
||||
t.Fatalf("aggregate order/dedup: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWindow(t *testing.T) {
|
||||
now := time.Now()
|
||||
recent := ISOFromTime(now.Add(-60 * time.Second))
|
||||
customer := &Customer{LastOnlineAt: &recent}
|
||||
if !CustomerOnline(customer, now.UnixMilli()) {
|
||||
t.Fatal("customer active 60s ago should be online")
|
||||
}
|
||||
old := ISOFromTime(now.Add(-10 * time.Minute))
|
||||
customer.LastOnlineAt = &old
|
||||
if CustomerOnline(customer, now.UnixMilli()) {
|
||||
t.Fatal("customer active 10m ago should be offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostIPsNoPanic(t *testing.T) {
|
||||
HostIPs() // just must not panic on any host
|
||||
}
|
||||
Reference in New Issue
Block a user