Preserve VLESS WebSocket variants during subscription refresh
This commit is contained in:
+42
-13
@@ -545,12 +545,43 @@ function buildActiveConfig(
|
||||
const stopSingbox = () => singboxRuntime.stop();
|
||||
const startSingbox = () => singboxRuntime.apply();
|
||||
|
||||
function writeCurrentConfig() {
|
||||
const state = stateStore.read();
|
||||
function writeCurrentConfig(onlyIfSelectionChanged = false) {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedServerId || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||
return true;
|
||||
const cachedServers = cached.servers as StoredState['servers'];
|
||||
const selectedServerId = selectRefreshedServer(
|
||||
state.selectedServerId,
|
||||
state.servers,
|
||||
cachedServers,
|
||||
);
|
||||
if (onlyIfSelectionChanged && selectedServerId === state.selectedServerId) return false;
|
||||
const activeConfig = selectedServerId
|
||||
? buildActiveConfig(cached.config, selectedServerId)
|
||||
: null;
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
try {
|
||||
if (activeConfig) writeSingboxConfig(activeConfig);
|
||||
else removeSingboxConfig();
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
servers: cachedServers,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([error, rollbackError], 'Current config rollback failed');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return Boolean(selectedServerId);
|
||||
}
|
||||
|
||||
async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||
@@ -623,15 +654,13 @@ process.on('SIGINT', shutdown);
|
||||
|
||||
await gatewayAutoService.refresh({ reconfigure: false })
|
||||
.catch((error: unknown) => console.warn(`[control] Gateway не определён: ${errorMessage(error)}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
try {
|
||||
writeCurrentConfig();
|
||||
} catch (error) {
|
||||
const candidate = record(error);
|
||||
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||
}
|
||||
try {
|
||||
writeCurrentConfig(settings.appMode !== 'client' && fs.existsSync(settings.configPath));
|
||||
} catch (error) {
|
||||
const candidate = record(error);
|
||||
if (!String(candidate.code || '').startsWith('SUBSCRIPTION_')) throw error;
|
||||
console.warn(`[storage] сохранённая подписка отклонена: ${errorMessage(error)}; возврат к первичной настройке`);
|
||||
await subscriptionService.resetSavedSubscription({ stopRuntime: false });
|
||||
}
|
||||
await startSingbox()
|
||||
.then(() => {
|
||||
|
||||
@@ -36,6 +36,16 @@ function outboundRecord(value: unknown): SubscriptionOutbound {
|
||||
return record(value) as SubscriptionOutbound;
|
||||
}
|
||||
|
||||
function connectionVariantKey(value: unknown) {
|
||||
const outbound = record(value);
|
||||
const transport = record(outbound.transport);
|
||||
if (transport.type !== 'ws') return '';
|
||||
const headers = record(transport.headers);
|
||||
const tls = record(outbound.tls);
|
||||
// ponytail: WS routing distinguishes current same-endpoint variants; extend when another real transport needs it.
|
||||
return ['ws', transport.path || '/', headers.Host || headers.host || '', tls.server_name || ''].join('\u0000');
|
||||
}
|
||||
|
||||
function usableProxyOutbound(value: unknown) {
|
||||
const outbound = outboundRecord(value);
|
||||
const host = String(outbound.server || '').trim().toLowerCase();
|
||||
@@ -107,11 +117,52 @@ export function parseVlessUrl(rawUrl: string) {
|
||||
const serverName = parsed.searchParams.get('sni') || server;
|
||||
const fingerprint = parsed.searchParams.get('fp') || 'chrome';
|
||||
const flow = parsed.searchParams.get('flow') || '';
|
||||
const security = parsed.searchParams.get('security') || '';
|
||||
const transportType = parsed.searchParams.get('type') || 'tcp';
|
||||
const encryption = parsed.searchParams.get('encryption') || '';
|
||||
|
||||
if (!uuid || !server || !serverPort) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
if (security === 'tls' && transportType === 'ws') {
|
||||
if (encryption && encryption !== 'none') {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
const websocketHost = parsed.searchParams.get('host') || '';
|
||||
const alpn = (parsed.searchParams.get('alpn') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
type: 'vless',
|
||||
tag,
|
||||
server,
|
||||
server_port: serverPort,
|
||||
uuid,
|
||||
flow,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: serverName,
|
||||
...(alpn.length ? { alpn } : {}),
|
||||
utls: {
|
||||
enabled: true,
|
||||
fingerprint,
|
||||
},
|
||||
},
|
||||
transport: {
|
||||
type: 'ws',
|
||||
path: parsed.searchParams.get('path') || '/',
|
||||
...(websocketHost ? { headers: { Host: websocketHost } } : {}),
|
||||
},
|
||||
packet_encoding: 'xudp',
|
||||
};
|
||||
}
|
||||
|
||||
if ((security && security !== 'reality') || !['tcp', 'raw'].includes(transportType)) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
if (!publicKey || !shortId) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
@@ -169,8 +220,12 @@ export function normalizeSubscriptionConfig(value: unknown) {
|
||||
rejectedOutbounds.push(outbound);
|
||||
return [];
|
||||
}
|
||||
const id = createServerId(outbound);
|
||||
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
|
||||
const endpointId = createServerId(outbound);
|
||||
const variant = connectionVariantKey(outbound);
|
||||
const id = variant
|
||||
? `srv_${crypto.createHash('sha256').update(`${endpointId}\u0000${variant}`).digest('hex').slice(0, 16)}`
|
||||
: endpointId;
|
||||
// ponytail: endpoint plus WS route deduplicates indistinguishable entries; include provider IDs if a real feed needs more.
|
||||
if (seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
servers.push(normalizeServer({ ...outbound, id }));
|
||||
@@ -244,11 +299,17 @@ export function selectRefreshedServer(
|
||||
nextServers: readonly HarborServer[],
|
||||
) {
|
||||
if (!currentServerId) return '';
|
||||
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
||||
const previous = currentServers.find((server) => server.id === currentServerId);
|
||||
if (!previous) return '';
|
||||
const identity = serverIdentityKey(previous);
|
||||
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
|
||||
if (matches.some((server) => server.id === currentServerId)) {
|
||||
return currentServerId === createServerId(previous) && matches.length > 1 ? '' : currentServerId;
|
||||
}
|
||||
if (
|
||||
currentServerId !== createServerId(previous) ||
|
||||
currentServers.filter((server) => serverIdentityKey(server) === identity).length !== 1
|
||||
) return '';
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user