Introduce stable server IDs for subscription state
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-12 11:59:22 +03:00
parent 005c7a101b
commit 267afc5c7e
16 changed files with 323 additions and 145 deletions

View File

@@ -15,8 +15,8 @@
"userInfo": {} "userInfo": {}
}, },
"selection": { "selection": {
"desiredServerId": "Amsterdam", "desiredServerId": "srv_4d7c5d1bcd60d665",
"appliedServerId": "Amsterdam" "appliedServerId": "srv_4d7c5d1bcd60d665"
}, },
"connection": { "connection": {
"desired": "running", "desired": "running",
@@ -36,7 +36,15 @@
"startedAt": null, "startedAt": null,
"error": null "error": null
}, },
"servers": [] "servers": [
{
"id": "srv_4d7c5d1bcd60d665",
"label": "Amsterdam",
"host": "nl.example.net",
"port": 443,
"protocol": "vless"
}
]
} }
``` ```
@@ -56,7 +64,7 @@ Browser transport state lives beside, not inside, the domain snapshot. It record
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded. `selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.
Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008. Server IDs are deterministic from normalized protocol, host and port, while provider order and the human-readable `label` are separate. Duplicate labels remain separate servers; reorder and cosmetic rename keep the same ID. Ping results, React keys, persisted selection and apply commands use the ID. If the selected endpoint disappears, Harbor stops the active process, clears selection and requires an explicit new choice instead of silently switching traffic.
## Subscription import and refresh ## Subscription import and refresh
@@ -68,8 +76,8 @@ The existing background refresh remains every 15 minutes. Provider requests time
## Compatibility and migration ## Compatibility and migration
No path, volume or file is renamed. A legacy `state.json` without `revision`, `appliedTag` or `connectionDesired` is normalized on read: revision starts at `0`, the legacy `selectedTag` is treated as both desired and applied, and connection intent is inferred from the existing config. The next domain write stores the added fields. Existing unknown fields remain untouched. No path, volume or file is renamed. A legacy `state.json` without stable IDs is migrated to schema v4. A unique `selectedTag` is matched to its normalized endpoint and stored as `selectedServerId`/`appliedServerId`; an ambiguous or missing tag explicitly clears selection. The raw provider config remains unchanged in subscription cache and is normalized only in memory, so an older Harbor build can still use its original tags after rollback. Existing unknown fields remain untouched.
During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL. During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL.
Rollback is code-only: deploy the previous build. The added persisted fields are ignored by the previous implementation, so no data rewrite is needed. Revisions created by the newer build remain harmless integers in `state.json`. Rollback is code-only: deploy the previous build. The v4 state keeps `selectedTag`, `appliedTag` and server aliases for older builds, while subscription cache keeps raw provider tags. The added ID fields are ignored by the previous implementation.

View File

@@ -1,6 +1,6 @@
# Harbor state recovery # Harbor state recovery
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 3`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 adds the `enabled` state and migrates the former code-owned `.ru` exception into the first ordinary enabled rule. Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 4`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 added rule `enabled` state. Schema v4 adds stable server IDs and migrates an unambiguous legacy `selectedTag` to `selectedServerId`.
## Atomic writes ## Atomic writes
@@ -8,13 +8,13 @@ Persistent files are written to a unique temporary file in the same directory, f
## Migration ## Migration
On startup, a legacy `state.json` without `schemaVersion`, or any v1/v2 state, is normalized and migrated to the current schema. Existing custom rules are preserved, default to `enabled: true`, and follow the new ordinary `.ru` rule. A v3 state may keep, disable, or delete that rule without Harbor recreating it. Before replacement Harbor saves the original beside it: On startup, a legacy `state.json` without `schemaVersion`, or any v1-v3 state, is normalized and migrated to the current schema. Existing custom rules are preserved. Server identity is derived from protocol, host and port; a unique legacy tag keeps selection, while duplicate or missing matches require a new explicit choice. Before replacement Harbor saves the original beside it:
```text ```text
state.json.backup-v0-2026-07-11T12-00-00-000Z state.json.backup-v0-2026-07-11T12-00-00-000Z
``` ```
The migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. The backup is the safest rollback source because builds that only understand schema v2 do not know the per-rule `enabled` state. The migration preserves compatibility aliases, adds normalized revision, selection and server fields, and does not rename the volume. Subscription cache keeps the raw provider config so older builds can still use its original outbound tags. The backup remains the safest manual recovery source.
## Corrupt JSON ## Corrupt JSON

View File

@@ -23,7 +23,12 @@ import {
restoreSingboxConfig, restoreSingboxConfig,
writeSingboxConfig, writeSingboxConfig,
} from './singbox.js'; } from './singbox.js';
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js'; import {
fetchSubscription,
getHwid,
normalizeSubscriptionConfig,
selectRefreshedServer,
} from './subscription.js';
import { import {
createStateSnapshot, createStateSnapshot,
normalizeStoredState, normalizeStoredState,
@@ -53,7 +58,9 @@ function readSubscriptionCache() {
cacheRecoveryLogged = true; cacheRecoveryLogged = true;
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`); console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
} }
return cached; return cached?.config
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
: cached;
} }
const initialStoredState = stateStore.read(); const initialStoredState = stateStore.read();
@@ -195,10 +202,10 @@ function subscriptionHost(url) {
function buildActiveConfig( function buildActiveConfig(
subscriptionConfig, subscriptionConfig,
selectedTag, selectedServerId,
routeRules = stateStore.read().routeRules, routeRules = stateStore.read().routeRules,
) { ) {
return buildGatewayConfig(subscriptionConfig, selectedTag, { return buildGatewayConfig(subscriptionConfig, selectedServerId, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct', clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
routeRules, routeRules,
}); });
@@ -233,8 +240,8 @@ async function publicState() {
function writeCurrentConfig() { function writeCurrentConfig() {
const state = stateStore.read(); const state = stateStore.read();
const cached = readSubscriptionCache(); const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) return false; if (!state.selectedServerId || !cached?.config) return false;
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag)); writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
return true; return true;
} }
@@ -356,15 +363,15 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
return gatewayDiscoveryPromise; return gatewayDiscoveryPromise;
} }
async function applySelectedServer(selectedTag, { persist = true } = {}) { async function applySelectedServer(selectedServerId, { persist = true } = {}) {
const cached = readSubscriptionCache(); const cached = readSubscriptionCache();
if (!cached?.config) throw new HarborError('CONFIG_INVALID'); if (!cached?.config) throw new HarborError('CONFIG_INVALID');
const nextConfig = buildActiveConfig(cached.config, selectedTag); const nextConfig = buildActiveConfig(cached.config, selectedServerId);
if (persist) { if (persist) {
updateStoredState((state) => ({ updateStoredState((state) => ({
...state, ...state,
selectedTag, selectedServerId,
connectionDesired: 'running', connectionDesired: 'running',
})); }));
} }
@@ -383,7 +390,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
updateStoredState((state) => ({ updateStoredState((state) => ({
...state, ...state,
...(persist ? { ...(persist ? {
appliedTag: selectedTag, appliedServerId: selectedServerId,
appliedAt: new Date().toISOString(), appliedAt: new Date().toISOString(),
} : {}), } : {}),
appliedRouteRules: state.routeRules, appliedRouteRules: state.routeRules,
@@ -393,7 +400,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
async function applyRouteRules(routeRules) { async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read()); const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache(); const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) { if (!state.selectedServerId || !cached?.config) {
updateStoredState((current) => ({ updateStoredState((current) => ({
...current, ...current,
routeRules, routeRules,
@@ -407,7 +414,7 @@ async function applyRouteRules(routeRules) {
: null; : null;
const wasRunning = Boolean((await singboxRuntime.refresh()).running); const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try { try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules)); writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
if (wasRunning) await startSingbox(); if (wasRunning) await startSingbox();
updateStoredState((current) => ({ updateStoredState((current) => ({
...current, ...current,
@@ -438,11 +445,15 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
throw new HarborError('STATE_CONFLICT'); throw new HarborError('STATE_CONFLICT');
} }
const selectedTag = resetSelection const selectedServerId = resetSelection
? '' ? ''
: selectRefreshedServer(previousState.selectedTag, parsed.servers); : selectRefreshedServer(
const candidateConfig = selectedTag previousState.selectedServerId,
? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules) previousState.servers,
parsed.servers,
);
const candidateConfig = selectedServerId
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
: null; : null;
const previousCache = readSubscriptionCache(); const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath) const previousConfig = fs.existsSync(settings.configPath)
@@ -452,10 +463,16 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
const wasRunning = Boolean((await singboxRuntime.refresh()).running); const wasRunning = Boolean((await singboxRuntime.refresh()).running);
try { try {
if (resetSelection && wasRunning) await stopSingbox(); if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
if (candidateConfig) writeSingboxConfig(candidateConfig); if (candidateConfig) writeSingboxConfig(candidateConfig);
else removeSingboxConfig(); else removeSingboxConfig();
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed }); subscriptionCacheStore.write({
url: subscriptionUrl,
config: parsed.sourceConfig || parsed.config,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
if (!resetSelection && wasRunning && candidateConfig) await startSingbox(); if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
updateStoredState((state) => ({ updateStoredState((state) => ({
@@ -468,13 +485,14 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt, fetchedAt: parsed.fetchedAt,
selectedTag, selectedServerId,
appliedTag: selectedTag, appliedServerId: selectedServerId,
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
})); }));
if (resetSelection) gatewayAutoState = createGatewayAutoState(); if (resetSelection) gatewayAutoState = createGatewayAutoState();
} catch (error) { } catch (error) {
gatewayAutoState = previousGatewayAutoState; gatewayAutoState = previousGatewayAutoState;
if (previousCache) subscriptionCacheStore.write(previousCache); if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
else subscriptionCacheStore.remove(); else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig(); if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig); else restoreSingboxConfig(previousConfig);
@@ -495,7 +513,8 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt, fetchedAt: parsed.fetchedAt,
selectedTag, selectedServerId,
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
}; };
}); });
} }
@@ -559,8 +578,9 @@ async function handleApi(req, res) {
if (req.method === 'POST' && req.url === '/api/servers/ping-all') { if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
const state = stateStore.read(); const state = stateStore.read();
const results = await Promise.all((state.servers || []).map(async (server) => ({ const results = await Promise.all((state.servers || []).map(async (server) => ({
tag: String(server.tag || '').trim(), id: server.id,
...await tcpPing(server.server, server.server_port), tag: server.label,
...await tcpPing(server.host, server.port),
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
}))); })));
return sendState(res, { results }); return sendState(res, { results });
@@ -644,11 +664,20 @@ async function handleApi(req, res) {
} }
if (req.method === 'POST' && req.url === '/api/apply') { if (req.method === 'POST' && req.url === '/api/apply') {
const { selectedTag = '' } = await readBody(req); const { serverId = '', selectedTag = '' } = await readBody(req);
const tag = String(selectedTag).trim(); const state = normalizeStoredState(stateStore.read());
if (!tag) throw new HarborError('REQUEST_INVALID'); const id = String(serverId).trim() || (() => {
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag))); const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
return sendState(res, { selectedTag: tag }); return matches.length === 1 ? matches[0].id : '';
})();
if (!id || !state.servers.some((server) => server.id === id)) {
throw new HarborError('SERVER_NOT_FOUND');
}
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
return sendState(res, {
serverId: id,
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
});
} }
if (req.method === 'POST' && req.url === '/api/singbox/stop') { if (req.method === 'POST' && req.url === '/api/singbox/stop') {
@@ -667,7 +696,7 @@ async function handleApi(req, res) {
await singboxRuntime.restart(); await singboxRuntime.restart();
updateStoredState((state) => ({ updateStoredState((state) => ({
...state, ...state,
appliedTag: state.selectedTag, appliedServerId: state.selectedServerId,
connectionDesired: 'running', connectionDesired: 'running',
appliedRouteRules: state.routeRules, appliedRouteRules: state.routeRules,
})); }));

View File

@@ -4,7 +4,7 @@ import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js'; import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js'; import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 3; export const STATE_SCHEMA_VERSION = 4;
const clone = (value) => structuredClone(value); const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-'); const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');

View File

@@ -2,6 +2,11 @@ import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import { settings } from './config.js'; import { settings } from './config.js';
import { HarborError } from '../shared/errors.js'; import { HarborError } from '../shared/errors.js';
import {
createServerId,
normalizeServer,
serverIdentityKey,
} from '../shared/serverIdentity.js';
import { atomicWriteFile } from './services/stateStore.js'; import { atomicWriteFile } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
@@ -105,8 +110,27 @@ function maybeDecodeBase64(content) {
return content; return content;
} }
export function normalizeSubscriptionConfig(value) {
const parsedConfig = value && typeof value === 'object' ? value : {};
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
const servers = [];
const seen = new Set();
const normalizedOutbounds = outbounds.flatMap((outbound) => {
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
const id = createServerId(outbound);
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
if (seen.has(id)) return [];
seen.add(id);
servers.push(normalizeServer({ ...outbound, id }));
return [{ ...outbound, tag: id }];
});
if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID');
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
}
export function parseSubscriptionBody(body) { export function parseSubscriptionBody(body) {
let parsedConfig = null; let parsedConfig;
try { try {
parsedConfig = JSON.parse(body); parsedConfig = JSON.parse(body);
@@ -126,21 +150,7 @@ export function parseSubscriptionBody(body) {
}; };
} }
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : []; return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
const servers = outbounds
.filter((outbound) => PROXY_TYPES.has(outbound.type))
.map((outbound) => ({
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
type: outbound.type,
server: outbound.server || 'unknown',
server_port: outbound.server_port || 443,
}));
if (!servers.length) {
throw new HarborError('SUBSCRIPTION_INVALID');
}
return { config: parsedConfig, servers };
} }
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) { async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
@@ -173,13 +183,14 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
return response; return response;
} }
export function selectRefreshedServer(currentTag, servers) { export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
const selectedTag = String(currentTag || '').trim(); if (!currentServerId) return '';
if (!selectedTag) return ''; if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
const previous = currentServers.find((server) => server.id === currentServerId);
return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag if (!previous) return '';
|| servers[0]?.tag const identity = serverIdentityKey(previous);
|| ''; const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
return matches.length === 1 ? matches[0].id : '';
} }
export async function fetchSubscription(url, options) { export async function fetchSubscription(url, options) {

View File

@@ -1,4 +1,5 @@
import { normalizeRouteRules } from '../routingRules.js'; import { normalizeRouteRules } from '../routingRules.js';
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
const MODES = new Set(['client', 'gateway']); const MODES = new Set(['client', 'gateway']);
const CONNECTION_STATES = new Set(['running', 'stopped']); const CONNECTION_STATES = new Set(['running', 'stopped']);
@@ -12,13 +13,21 @@ const dateOrNull = (value) => (
export function normalizeStoredState(value) { export function normalizeStoredState(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const selectedTag = text(state.selectedTag); const servers = normalizeServers(state.servers);
const selectedServerId = resolveServerId(servers, state.selectedServerId, state.selectedTag);
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
? resolveServerId(servers, state.appliedServerId)
: resolveServerId(servers, '', state.appliedTag || state.selectedTag);
const selectedServer = servers.find((server) => server.id === selectedServerId);
const appliedServer = servers.find((server) => server.id === appliedServerId);
return { return {
...state, ...state,
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0, revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
selectedTag, selectedServerId,
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag, appliedServerId,
servers: Array.isArray(state.servers) ? state.servers : [], selectedTag: selectedServer?.label || '',
appliedTag: appliedServer?.label || '',
servers,
routeRules: normalizeRouteRules(state.routeRules), routeRules: normalizeRouteRules(state.routeRules),
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules), appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0 routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
@@ -43,22 +52,7 @@ export function createStateSnapshot({
const desired = CONNECTION_STATES.has(stored.connectionDesired) const desired = CONNECTION_STATES.has(stored.connectionDesired)
? stored.connectionDesired ? stored.connectionDesired
: configExists ? 'running' : 'stopped'; : configExists ? 'running' : 'stopped';
const servers = stored.servers.map((server) => { const servers = stored.servers;
const tag = text(server.tag);
return {
...server,
id: tag,
label: tag,
host: text(server.server),
port: Number(server.server_port) || 0,
protocol: text(server.type),
// v0 compatibility: the current UI and integrations still read these aliases.
tag,
server: text(server.server),
server_port: Number(server.server_port) || 0,
type: text(server.type),
};
});
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : []; const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
@@ -74,8 +68,8 @@ export function createStateSnapshot({
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {}, userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
}, },
selection: { selection: {
desiredServerId: stored.selectedTag, desiredServerId: stored.selectedServerId,
appliedServerId: stored.appliedTag, appliedServerId: stored.appliedServerId,
}, },
connection: { connection: {
desired, desired,
@@ -120,7 +114,7 @@ export function withStateV0Compatibility(snapshot, {
singboxStartedAt: snapshot.connection.startedAt, singboxStartedAt: snapshot.connection.startedAt,
subscriptionHost: snapshot.subscription.host, subscriptionHost: snapshot.subscription.host,
hasSubscription: snapshot.subscription.status === 'ready', hasSubscription: snapshot.subscription.status === 'ready',
selectedTag: snapshot.selection.desiredServerId, selectedTag: stored.selectedTag,
userInfo: snapshot.subscription.userInfo, userInfo: snapshot.subscription.userInfo,
fetchedAt: snapshot.subscription.fetchedAt, fetchedAt: snapshot.subscription.fetchedAt,
gatewayAuto: snapshot.mode === 'client' ? { gatewayAuto: snapshot.mode === 'client' ? {

View File

@@ -0,0 +1,67 @@
const text = (value) => String(value || '').trim();
function hash64(value) {
let hash = 0xcbf29ce484222325n;
for (let index = 0; index < value.length; index += 1) {
hash ^= BigInt(value.charCodeAt(index));
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
}
return hash.toString(16).padStart(16, '0');
}
export function serverIdentityKey(server) {
const protocol = text(server?.protocol || server?.type).toLowerCase();
const host = text(server?.host || server?.server).toLowerCase();
const port = Number(server?.port || server?.server_port) || 0;
return `${protocol}\u0000${host}\u0000${port}`;
}
export function createServerId(server) {
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
}
export function normalizeServer(server) {
const source = server && typeof server === 'object' ? server : {};
const protocol = text(source.protocol || source.type).toLowerCase();
const host = text(source.host || source.server);
const port = Number(source.port || source.server_port) || 0;
const id = text(source.id) || createServerId(source);
const label = text(source.label || source.tag) || host;
const metadata = Object.fromEntries([
['country', text(source.country)],
['city', text(source.city)],
['provider', text(source.provider)],
].filter(([, value]) => value));
return {
id,
label,
host,
port,
protocol,
...metadata,
// v0 aliases remain until old clients no longer consume this API.
tag: label,
server: host,
server_port: port,
type: protocol,
};
}
export function normalizeServers(servers) {
const seen = new Set();
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
const normalized = normalizeServer(server);
if (!normalized.id || seen.has(normalized.id)) return [];
seen.add(normalized.id);
return [normalized];
});
}
export function resolveServerId(servers, serverId, legacyTag = '') {
const id = text(serverId);
if (id) return servers.some((server) => server.id === id) ? id : '';
const tag = text(legacyTag);
const matches = tag ? servers.filter((server) => server.label === tag) : [];
return matches.length === 1 ? matches[0].id : '';
}

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.6.17', macClient: '0.7.0',
gatewayClient: '0.6.16', gatewayClient: '0.7.0',
gatewayBackend: '0.6.1', gatewayBackend: '0.7.0',
}); });
export function parseVersion(value) { export function parseVersion(value) {

View File

@@ -13,7 +13,7 @@ import { createOperationRegistry } from './state/operations.js';
function App() { function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready'); const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId: pendingTag, transport }, dispatch] = useReducer( const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
harborReducer, harborReducer,
initialHarborState, initialHarborState,
); );
@@ -27,7 +27,7 @@ function App() {
operationRegistry.current = createOperationRegistry(setOperations); operationRegistry.current = createOperationRegistry(setOperations);
} }
function setPendingTag(serverId) { function setPendingServerId(serverId) {
dispatch({ type: 'select-server', serverId }); dispatch({ type: 'select-server', serverId });
} }
@@ -144,19 +144,32 @@ function App() {
<div className="app-body client-mode"> <div className="app-body client-mode">
<main className="app-main"> <main className="app-main">
<ClientOverviewPage <ClientOverviewPage
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state} state={previewReady ? {
...state,
mode: 'client',
hasSubscription: true,
subscriptionHost: 'harbor.example',
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
proxyPort: 8082,
} : state}
versionInfo={versionInfo} versionInfo={versionInfo}
operations={operations} operations={operations}
error={error} error={error}
subscriptionUrl={subscriptionUrl} subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl} setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []} servers={previewReady ? [{
pendingTag={previewReady ? 'Amsterdam' : pendingTag} id: 'preview-amsterdam',
setPendingTag={setPendingTag} label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription} onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription} onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription} onForgetSubscription={forgetSubscription}
onApply={(tag) => run('serverApply', () => api.apply(tag), 'connection')} onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')} onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')} onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')} onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}

View File

@@ -57,9 +57,10 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }), refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }), forget: () => request('/api/subscription', { method: 'DELETE' }),
}, },
apply: (selectedTag) => request('/api/apply', { apply: (serverId) => request('/api/apply', {
method: 'POST', method: 'POST',
body: JSON.stringify({ selectedTag }), // selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}), }),
gatewayAuto: { gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', { setEnabled: (enabled) => request('/api/gateway-auto', {

View File

@@ -566,8 +566,8 @@ export function ClientOverviewPage({
subscriptionUrl, subscriptionUrl,
setSubscriptionUrl, setSubscriptionUrl,
servers, servers,
pendingTag, pendingServerId,
setPendingTag, setPendingServerId,
onFetchSubscription, onFetchSubscription,
onRefreshSubscription, onRefreshSubscription,
onForgetSubscription, onForgetSubscription,
@@ -583,9 +583,9 @@ export function ClientOverviewPage({
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available); const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
const connected = Boolean(state?.singboxRunning); const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription); const hasSubscription = Boolean(state?.hasSubscription);
const selectedTag = pendingTag || state?.selectedTag || ''; const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const showPower = hasSubscription && Boolean(selectedTag); const showPower = hasSubscription && Boolean(selectedServerId);
const canStart = Boolean(selectedTag || state?.configExists); const canStart = Boolean(selectedServerId || state?.configExists);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState(() => { const [durationMode, setDurationMode] = useState(() => {
try { try {
@@ -620,7 +620,7 @@ export function ClientOverviewPage({
const localRulesToggleRef = useRef(null); const localRulesToggleRef = useRef(null);
const localRulesBaselineRef = useRef('[]'); const localRulesBaselineRef = useRef('[]');
const previousHasSubscriptionRef = useRef(hasSubscription); const previousHasSubscriptionRef = useRef(hasSubscription);
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const serverKey = servers.map((server) => server.id).join('|');
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo); const usage = subscriptionUsage(state?.userInfo);
@@ -674,18 +674,18 @@ export function ClientOverviewPage({
} }
let cancelled = false; let cancelled = false;
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }]))); setPings(Object.fromEntries(servers.map((server) => [server.id, { checking: true }])));
api.servers.pingAll() api.servers.pingAll()
.then((data) => { .then((data) => {
if (cancelled) return; if (cancelled) return;
setPings(Object.fromEntries((data.results || []).map((ping) => [ setPings(Object.fromEntries((data.results || []).map((ping) => [
String(ping.tag || '').trim(), ping.id || ping.tag,
ping, ping,
]))); ])));
}) })
.catch(() => { .catch(() => {
if (!cancelled) { if (!cancelled) {
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }]))); setPings(Object.fromEntries(servers.map((server) => [server.id, { ok: false }])));
} }
}); });
@@ -822,15 +822,15 @@ export function ClientOverviewPage({
}, [instructionsOpen]); }, [instructionsOpen]);
async function toggleConnection() { async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists }); const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
if (action?.type === 'stop') return onStop(); if (action?.type === 'stop') return onStop();
if (action?.type === 'apply') return onApply(action.selectedTag); if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart(); if (action?.type === 'restart') return onRestart();
} }
function selectServer(tag) { function selectServer(serverId) {
setPendingTag(tag); setPendingServerId(serverId);
if (connected && tag) onApply(tag); if (connected && serverId) onApply(serverId);
} }
async function submitSubscription(event) { async function submitSubscription(event) {
@@ -1314,8 +1314,8 @@ export function ClientOverviewPage({
key={`${serverKey}:${serverRevealVersion}`} key={`${serverKey}:${serverRevealVersion}`}
> >
{servers.map((server, index) => { {servers.map((server, index) => {
const ping = pings[server.tag]; const ping = pings[server.id];
const selected = server.tag === selectedTag; const selected = server.id === selectedServerId;
const pingText = ping?.checking const pingText = ping?.checking
? 'Проверка…' ? 'Проверка…'
: ping?.ok ? `${ping.latency} ms` : 'Недоступен'; : ping?.ok ? `${ping.latency} ms` : 'Недоступен';
@@ -1327,13 +1327,14 @@ export function ClientOverviewPage({
<button <button
className={`client-server ${selected ? 'is-selected' : ''}`} className={`client-server ${selected ? 'is-selected' : ''}`}
type="button" type="button"
key={server.tag} key={server.id}
disabled={serverApplyBlocked} disabled={serverApplyBlocked}
aria-pressed={selected} aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}, ${pingText}`}
style={{ '--server-index': index }} style={{ '--server-index': index }}
onClick={() => selectServer(server.tag)} onClick={() => selectServer(server.id)}
> >
<strong>{server.tag}</strong> <strong>{server.label}</strong>
<small className={pingClass}>{pingText}</small> <small className={pingClass}>{pingText}</small>
</button> </button>
); );

View File

@@ -1,6 +1,6 @@
export function connectionAction({ connected, selectedTag, configExists }) { export function connectionAction({ connected, selectedServerId, configExists }) {
if (connected) return { type: 'stop' }; if (connected) return { type: 'stop' };
if (selectedTag) return { type: 'apply', selectedTag }; if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' }; if (configExists) return { type: 'restart' };
return null; return null;
} }

View File

@@ -11,6 +11,7 @@ import {
createStateSnapshot, createStateSnapshot,
normalizeStoredState, normalizeStoredState,
} from '../../src/shared/contracts/state.js'; } from '../../src/shared/contracts/state.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
import { HARBOR_VERSIONS } from '../../src/shared/versions.js'; import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
const root = path.resolve(import.meta.dirname, '../..'); const root = path.resolve(import.meta.dirname, '../..');
@@ -59,10 +60,12 @@ async function waitForState(port, child, stderr) {
} }
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => { test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
const legacyServerId = createServerId(legacyServer);
const stored = normalizeStoredState({ const stored = normalizeStoredState({
subscriptionUrl: 'https://provider.example/subscription/test', subscriptionUrl: 'https://provider.example/subscription/test',
selectedTag: ' legacy ', selectedTag: ' legacy ',
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }], servers: [legacyServer],
}); });
const snapshot = createStateSnapshot({ const snapshot = createStateSnapshot({
storedState: stored, storedState: stored,
@@ -76,8 +79,8 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
assert.equal(snapshot.apiVersion, 1); assert.equal(snapshot.apiVersion, 1);
assert.deepEqual(snapshot.selection, { assert.deepEqual(snapshot.selection, {
desiredServerId: 'legacy', desiredServerId: legacyServerId,
appliedServerId: 'legacy', appliedServerId: legacyServerId,
}); });
assert.equal(snapshot.connection.process, 'running'); assert.equal(snapshot.connection.process, 'running');
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false); assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
@@ -100,6 +103,7 @@ test('GET and domain mutations return one state shape with monotonic revisions',
tls: { enabled: true }, tls: { enabled: true },
}], }],
}; };
const testServerId = createServerId(config.outbounds[0]);
fs.mkdirSync(binDir); fs.mkdirSync(binDir);
const singboxPath = path.join(binDir, 'sing-box'); const singboxPath = path.join(binDir, 'sing-box');
const workingSingbox = `#!/usr/bin/env node const workingSingbox = `#!/usr/bin/env node
@@ -190,7 +194,7 @@ setInterval(() => {}, 60_000);
runtime: { singBox: '1.12.13' }, runtime: { singBox: '1.12.13' },
}); });
assertStateSnapshot(initial); assertStateSnapshot(initial);
assert.equal(initial.selection.appliedServerId, 'test-vpn'); assert.equal(initial.selection.appliedServerId, testServerId);
assert.deepEqual(initial.route.localRules, [ assert.deepEqual(initial.route.localRules, [
{ type: 'domain_suffix', value: 'ru', enabled: true }, { type: 'domain_suffix', value: 'ru', enabled: true },
]); ]);
@@ -266,7 +270,7 @@ setInterval(() => {}, 60_000);
); );
assert.equal(missingServer.response.status, 404); assert.equal(missingServer.response.status, 404);
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND'); assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn'); assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
async function stateResponse(pathname, method = 'POST', body) { async function stateResponse(pathname, method = 'POST', body) {
const result = await request(port, pathname, method, body); const result = await request(port, pathname, method, body);
@@ -285,10 +289,14 @@ setInterval(() => {}, 60_000);
const fetchesBeforeImport = providerFetchCount; const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl }); await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1); assert.equal(providerFetchCount, fetchesBeforeImport + 1);
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' }); assert.equal(
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
'test-vpn',
);
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
assert.deepEqual(applied.state.selection, { assert.deepEqual(applied.state.selection, {
desiredServerId: 'test-vpn', desiredServerId: testServerId,
appliedServerId: 'test-vpn', appliedServerId: testServerId,
}); });
assert.equal(applied.state.connection.process, 'running'); assert.equal(applied.state.connection.process, 'running');
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'); const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
@@ -323,7 +331,7 @@ setInterval(() => {}, 60_000);
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
{ domain: ['example.com'], outbound: 'direct' }, { domain: ['example.com'], outbound: 'direct' },
{ domain_suffix: ['example.org'], outbound: 'direct' }, { domain_suffix: ['example.org'], outbound: 'direct' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' }, { inbound: ['mixed-in'], outbound: testServerId },
]); ]);
await mutation('/api/singbox/stop'); await mutation('/api/singbox/stop');

View File

@@ -9,6 +9,7 @@ import {
createStateStore, createStateStore,
STATE_SCHEMA_VERSION, STATE_SCHEMA_VERSION,
} from '../../src/server/services/stateStore.js'; } from '../../src/server/services/stateStore.js';
import { createServerId } from '../../src/shared/serverIdentity.js';
const fixture = (t) => { const fixture = (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
@@ -39,7 +40,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
schemaVersion: 2, schemaVersion: 2,
revision: 7, revision: 7,
selectedTag: 'nl', selectedTag: 'nl',
servers: [{ tag: 'nl' }], servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
}; };
fs.writeFileSync(filePath, JSON.stringify(legacy)); fs.writeFileSync(filePath, JSON.stringify(legacy));
@@ -53,11 +54,31 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
{ type: 'domain_suffix', value: 'ru', enabled: true }, { type: 'domain_suffix', value: 'ru', enabled: true },
]); ]);
assert.equal(migrated.appliedTag, 'nl'); assert.equal(migrated.appliedTag, 'nl');
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
assert.equal(store.migration.fromVersion, 2); assert.equal(store.migration.fromVersion, 2);
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy); assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION); assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
}); });
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
const filePath = fixture(t);
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 3,
selectedTag: 'Amsterdam',
servers: [
{ tag: 'Amsterdam', type: 'vless', server: 'nl-1.example', server_port: 443 },
{ tag: 'Amsterdam', type: 'vless', server: 'nl-2.example', server_port: 443 },
],
}));
const migrated = createStateStore(filePath).read();
assert.equal(migrated.selectedServerId, '');
assert.equal(migrated.appliedServerId, '');
assert.equal(migrated.servers.length, 2);
});
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => { test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
const filePath = fixture(t); const filePath = fixture(t);
fs.writeFileSync(filePath, '{broken'); fs.writeFileSync(filePath, '{broken');

View File

@@ -6,26 +6,51 @@ import {
selectRefreshedServer, selectRefreshedServer,
} from '../../src/server/subscription.js'; } from '../../src/server/subscription.js';
test('subscription server tags are trimmed for selection', () => { const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
const { servers } = parseSubscriptionBody(JSON.stringify({ const outbound = (tag, server, server_port = 443) => ({
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }], type: 'vless',
})); tag,
server,
assert.equal(servers[0].tag, 'de-frankfurt'); server_port,
}); });
test('refreshed subscription keeps the selected server when its tag still exists', () => { test('duplicate labels remain independently addressable by stable ID', () => {
const servers = [{ tag: 'de' }, { tag: 'nl' }]; const { config, servers } = parse([
outbound('Amsterdam', 'nl-1.example'),
outbound('Amsterdam', 'nl-2.example'),
]);
assert.equal(selectRefreshedServer('nl', servers), 'nl'); assert.equal(servers.length, 2);
assert.equal(servers[0].label, 'Amsterdam');
assert.equal(servers[1].label, 'Amsterdam');
assert.notEqual(servers[0].id, servers[1].id);
assert.deepEqual(config.outbounds.map((item) => item.tag), servers.map((server) => server.id));
}); });
test('refreshed subscription selects the first server when the old tag disappeared', () => { test('provider reorder does not change server IDs or selection', () => {
const servers = [{ tag: 'de' }, { tag: 'nl' }]; const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
const after = parse([outbound('NL', 'nl.example'), outbound('DE', 'de.example')]).servers;
const selectedId = before[1].id;
assert.equal(selectRefreshedServer('old-name', servers), 'de'); assert.deepEqual(
new Map(after.map((server) => [server.host, server.id])),
new Map(before.map((server) => [server.host, server.id])),
);
assert.equal(selectRefreshedServer(selectedId, before, after), selectedId);
}); });
test('refreshed subscription does not select a server before the first user choice', () => { test('cosmetic rename keeps selection for the same endpoint', () => {
assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), ''); const before = parse([outbound('Old name', 'nl.example')]).servers;
const after = parse([outbound('New name', 'nl.example')]).servers;
assert.equal(after[0].id, before[0].id);
assert.equal(selectRefreshedServer(before[0].id, before, after), before[0].id);
});
test('removed selected server requires an explicit new choice', () => {
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
const after = parse([outbound('DE', 'de.example')]).servers;
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
assert.equal(selectRefreshedServer('', before, after), '');
}); });

View File

@@ -16,9 +16,9 @@ import { instructionBlocks } from '../../src/web/instructions.js';
test('connection button chooses the only valid client action', () => { test('connection button chooses the only valid client action', () => {
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' }); assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), { assert.deepEqual(connectionAction({ selectedServerId: 'srv_nl' }), {
type: 'apply', type: 'apply',
selectedTag: 'nl-amsterdam', serverId: 'srv_nl',
}); });
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' }); assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
assert.equal(connectionAction({}), null); assert.equal(connectionAction({}), null);