Refine subscription import and refresh flow
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-07-12 11:18:33 +03:00
parent ba15a25c89
commit 005c7a101b
15 changed files with 190 additions and 218 deletions

View File

@@ -58,6 +58,14 @@ Browser transport state lives beside, not inside, the domain snapshot. It record
Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008. Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008.
## Subscription import and refresh
The browser validates only the shape and `http`/`https` protocol of a subscription URL. The provider is contacted once, after explicit submit. The backend fetches and parses the complete response before entering the serialized commit.
Import and refresh share one commit path. It prepares the candidate server list and sing-box config first, then updates cache, config, runtime and canonical state. If provider fetch, parsing, config validation or runtime apply fails, the previous subscription cache, selected server, config and running process remain active. Refreshes for the saved URL share one in-flight Promise; a refresh that finishes after another import is rejected with `STATE_CONFLICT` instead of overwriting the newer subscription.
The existing background refresh remains every 15 minutes. Provider requests time out after 15 seconds by default (`SUBSCRIPTION_TIMEOUT_MS` may override it). A failed background refresh logs a redacted warning and keeps the last successful subscription snapshot.
## 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 `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.

View File

@@ -11,6 +11,6 @@ Each entry is `{ status: "running", startedAt }`. A repeated operation key recei
The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure. The registry only disables controls that can mutate the same domain state. Copy actions, instruction navigation and local tabs remain available during subscription refresh. Progress is announced with `role="status"`; the structured error from TASK-004 remains `role="alert"` after failure.
Subscription URL validation uses a latest-request runner. Starting a new validation aborts the previous signal and ignores its result even if the underlying request resolves late. Subscription URL validation is local and accepts only well-formed `http` and `https` URLs. It does not contact the provider; the explicit import operation performs the single provider request and reports provider failures through the structured subscription error.
The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run. The registry is local transport/UI state. It does not replace backend `snapshot.operation`, change revisions or persist data. Rollback is frontend-only. A `diagnostics` key is intentionally deferred until TASK-016 adds a diagnostics operation to run.

View File

@@ -29,6 +29,7 @@ export const settings = {
hostNetworkStatePath: hostNetworkStatePath:
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json", process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456), gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
hwidPath: path.join(dataDir, "hwid"), hwidPath: path.join(dataDir, "hwid"),
logLevel: process.env.LOG_LEVEL || "info", logLevel: process.env.LOG_LEVEL || "info",
appName: "VPN Proxy Gateway", appName: "VPN Proxy Gateway",

View File

@@ -431,55 +431,39 @@ async function applyRouteRules(routeRules) {
} }
} }
function refreshSavedSubscription() { async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise; return serializeControl(async () => {
const previousState = normalizeStoredState(stateStore.read());
subscriptionRefreshPromise = (async () => { if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
const initialState = stateStore.read(); throw new HarborError('STATE_CONFLICT');
if (!initialState.subscriptionUrl) {
throw new HarborError('SUBSCRIPTION_INVALID');
} }
const subscriptionUrl = initialState.subscriptionUrl; const selectedTag = resetSelection
const parsed = await fetchSubscription(subscriptionUrl); ? ''
return serializeControl(async () => { : selectRefreshedServer(previousState.selectedTag, parsed.servers);
const currentState = stateStore.read(); const candidateConfig = selectedTag
if (currentState.subscriptionUrl !== subscriptionUrl) { ? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules)
throw new HarborError('STATE_CONFLICT'); : null;
} const previousCache = readSubscriptionCache();
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const previousGatewayAutoState = gatewayAutoState;
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); try {
const previousCache = readSubscriptionCache(); if (resetSelection && wasRunning) await stopSingbox();
const previousConfig = fs.existsSync(settings.configPath) if (candidateConfig) writeSingboxConfig(candidateConfig);
? fs.readFileSync(settings.configPath, 'utf8') else removeSingboxConfig();
: null;
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
!previousCache?.config || !isDeepStrictEqual(
buildActiveConfig(previousCache.config, currentState.selectedTag),
buildActiveConfig(parsed.config, selectedTag),
)
);
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed }); subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
try {
if (singboxRuntime.running && activeConfigChanged) {
await applySelectedServer(selectedTag, { persist: false });
}
else if (selectedTag) {
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
} else {
removeSingboxConfig();
}
} catch (error) {
if (previousCache) subscriptionCacheStore.write(previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
throw error;
}
updateStoredState((state) => ({ updateStoredState((state) => ({
...state, ...(resetSelection ? {
routeRules: state.routeRules,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
connectionDesired: 'stopped',
} : state),
subscriptionUrl, subscriptionUrl,
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
@@ -487,15 +471,48 @@ function refreshSavedSubscription() {
selectedTag, selectedTag,
appliedTag: selectedTag, appliedTag: selectedTag,
})); }));
if (resetSelection) gatewayAutoState = createGatewayAutoState();
} catch (error) {
gatewayAutoState = previousGatewayAutoState;
if (previousCache) subscriptionCacheStore.write(previousCache);
else subscriptionCacheStore.remove();
if (previousConfig === null) removeSingboxConfig();
else restoreSingboxConfig(previousConfig);
if (wasRunning) {
try {
await startSingbox();
} catch (rollbackError) {
throw new HarborError('PROCESS_START_FAILED', {
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
});
}
}
throw error;
}
return { return {
success: true, success: true,
servers: parsed.servers, servers: parsed.servers,
userInfo: parsed.userInfo, userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt, fetchedAt: parsed.fetchedAt,
selectedTag, selectedTag,
}; };
}); });
}
async function importSubscription(subscriptionUrl) {
const parsed = await fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
}
function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
subscriptionRefreshPromise = (async () => {
const subscriptionUrl = stateStore.read().subscriptionUrl;
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
const parsed = await fetchSubscription(subscriptionUrl);
return commitSubscription(subscriptionUrl, parsed);
})().finally(() => { })().finally(() => {
subscriptionRefreshPromise = null; subscriptionRefreshPromise = null;
}); });
@@ -553,25 +570,7 @@ async function handleApi(req, res) {
const { url = '' } = await readBody(req); const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim(); const normalizedUrl = String(url).trim();
const parsed = await withOperation('subscription-import', async () => { const parsed = await withOperation('subscription-import', async () => {
const result = await fetchSubscription(normalizedUrl); return importSubscription(normalizedUrl);
await serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
updateStoredState((state) => ({
routeRules: state.routeRules,
subscriptionUrl: normalizedUrl,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
servers: result.servers,
userInfo: result.userInfo,
fetchedAt: result.fetchedAt,
selectedTag: '',
appliedTag: '',
connectionDesired: 'stopped',
}));
gatewayAutoState = createGatewayAutoState();
});
return result;
}); });
return sendState(res, parsed); return sendState(res, parsed);
} }

View File

@@ -143,7 +143,7 @@ export function parseSubscriptionBody(body) {
return { config: parsedConfig, servers }; return { config: parsedConfig, servers };
} }
async function requestSubscription(url) { async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
let parsedUrl; let parsedUrl;
try { try {
parsedUrl = new URL(url); parsedUrl = new URL(url);
@@ -157,9 +157,10 @@ async function requestSubscription(url) {
let response; let response;
try { try {
response = await fetch(parsedUrl, { response = await fetchImpl(parsedUrl, {
headers: subscriptionHeaders(), headers: subscriptionHeaders(),
redirect: 'follow', redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
}); });
} catch (cause) { } catch (cause) {
throw new HarborError('PROVIDER_UNAVAILABLE', { cause }); throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
@@ -181,8 +182,8 @@ export function selectRefreshedServer(currentTag, servers) {
|| ''; || '';
} }
export async function fetchSubscription(url) { export async function fetchSubscription(url, options) {
const response = await requestSubscription(url); const response = await requestSubscription(url, options);
const body = await response.text(); const body = await response.text();
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo')); const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));

View File

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

View File

@@ -16,10 +16,6 @@ export class HarborApiError extends Error {
} }
} }
export function validationStatusForError(error) {
return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable';
}
export async function request(url, options = {}, fetchImpl = fetch) { export async function request(url, options = {}, fetchImpl = fetch) {
let response; let response;
try { try {
@@ -54,11 +50,6 @@ export const api = {
state: () => request('/api/state'), state: () => request('/api/state'),
version: () => request('/api/version'), version: () => request('/api/version'),
subscription: { subscription: {
validate: (url, signal) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', { fetch: (url) => request('/api/subscription/fetch', {
method: 'POST', method: 'POST',
body: JSON.stringify({ url }), body: JSON.stringify({ url }),

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { api, validationStatusForError } from '../api.js'; import { api } from '../api.js';
import { import {
connectionAction, connectionAction,
connectionDurationParts, connectionDurationParts,
copyText, copyText,
isSubscriptionUrlValid,
localProxyUrls, localProxyUrls,
subscriptionDomain, subscriptionDomain,
subscriptionDaysLeft, subscriptionDaysLeft,
@@ -12,7 +13,7 @@ import {
} from '../utils/clientControls.js'; } from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js'; import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js'; import { instructionBlocks } from '../instructions.js';
import { createLatestRequest, operationBlocked } from '../state/operations.js'; import { operationBlocked } from '../state/operations.js';
import { ConfirmationPopup } from './ConfirmationPopup.jsx'; import { ConfirmationPopup } from './ConfirmationPopup.jsx';
import { canAppendRouteRule } from '../../shared/routingRules.js'; import { canAppendRouteRule } from '../../shared/routingRules.js';
import { import {
@@ -594,8 +595,6 @@ export function ClientOverviewPage({
} }
}); });
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [validationAttempt, setValidationAttempt] = useState(0);
const [showIntro, setShowIntro] = useState(!hasSubscription); const [showIntro, setShowIntro] = useState(!hasSubscription);
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription); const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
const [pings, setPings] = useState({}); const [pings, setPings] = useState({});
@@ -621,8 +620,6 @@ 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 validationRequests = useRef(null);
if (!validationRequests.current) validationRequests.current = createLatestRequest();
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).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);
@@ -646,12 +643,9 @@ export function ClientOverviewPage({
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)] ? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
: instructionGuides; : instructionGuides;
const normalizedSubscriptionUrl = subscriptionUrl.trim(); const normalizedSubscriptionUrl = subscriptionUrl.trim();
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl const subscriptionValidationStatus = !normalizedSubscriptionUrl
? subscriptionValidation.status ? 'idle'
: normalizedSubscriptionUrl ? 'checking' : 'idle'; : isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
? subscriptionValidation.error
: null;
const subscriptionWaiting = hasSubscription && !subscriptionContentReady; const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
const connectionBlocked = operationBlocked(operations, 'connection'); const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply'); const serverApplyBlocked = operationBlocked(operations, 'serverApply');
@@ -729,54 +723,6 @@ export function ClientOverviewPage({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [hasSubscription]); }, [hasSubscription]);
useEffect(() => {
validationRequests.current.cancel();
if (!normalizedSubscriptionUrl) {
setSubscriptionValidation({ url: '', status: 'idle' });
return undefined;
}
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
const timer = setTimeout(() => {
validationRequests.current
.run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal))
.then((result) => {
if (!result) return;
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'valid',
error: null,
});
})
.catch((validationError) => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: validationStatusForError(validationError),
error: {
context: 'subscription',
message: validationError.message,
correlationId: validationError.correlationId,
retry: validationError.retryable
? () => {
setSubscriptionValidation({
url: normalizedSubscriptionUrl,
status: 'checking',
error: null,
});
setValidationAttempt((attempt) => attempt + 1);
}
: null,
},
});
});
}, 350);
return () => {
clearTimeout(timer);
validationRequests.current.cancel();
};
}, [normalizedSubscriptionUrl, validationAttempt]);
useEffect(() => { useEffect(() => {
if (!hasSubscription) { if (!hasSubscription) {
setEditingSubscription(true); setEditingSubscription(true);
@@ -1317,22 +1263,16 @@ export function ClientOverviewPage({
type="submit" type="submit"
aria-label={subscriptionValidationStatus === 'valid' aria-label={subscriptionValidationStatus === 'valid'
? 'Сохранить подписку' ? 'Сохранить подписку'
: subscriptionValidationStatus === 'invalid' : 'Ссылка подписки не распознана'}
? 'Ссылка подписки не распознана'
: subscriptionValidationStatus === 'unavailable'
? 'Проверка подписки временно недоступна'
: 'Проверяем ссылку подписки'}
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'} disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
> >
{subscriptionValidationStatus === 'valid' {subscriptionValidationStatus === 'valid'
? '✓' ? '✓'
: subscriptionValidationStatus === 'invalid' : '×'}
? '×'
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
</button> </button>
)} )}
</form> </form>
<InlineError error={subscriptionError || error} context="subscription" /> <InlineError error={error} context="subscription" />
<InlineProgress operations={operations} context="subscription" /> <InlineProgress operations={operations} context="subscription" />
</div> </div>

View File

@@ -40,27 +40,3 @@ export function createOperationRegistry(onChange = () => {}, now = () => new Dat
return { run, getSnapshot: () => operations }; return { run, getSnapshot: () => operations };
} }
export function createLatestRequest() {
let controller = null;
return {
run(action) {
controller?.abort();
controller = new AbortController();
const current = controller;
return Promise.resolve()
.then(() => action(current.signal))
.then(
(value) => current.signal.aborted ? undefined : value,
(error) => {
if (current.signal.aborted) return undefined;
throw error;
},
);
},
cancel() {
controller?.abort();
},
};
}

View File

@@ -2361,20 +2361,11 @@ p {
cursor: default; cursor: default;
} }
.client-subscription-edit.is-checking .client-subscription-submit {
color: var(--client-muted);
animation: client-validation-pulse 900ms ease-in-out infinite alternate;
}
.client-subscription-edit.is-invalid .client-subscription-submit { .client-subscription-edit.is-invalid .client-subscription-submit {
color: oklch(0.68 0.15 28); color: oklch(0.68 0.15 28);
opacity: 0.9; opacity: 0.9;
} }
@keyframes client-validation-pulse {
to { opacity: 0.35; }
}
.client-subscription-heading { .client-subscription-heading {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -61,6 +61,14 @@ export function subscriptionDomain(subscriptionHost) {
} }
} }
export function isSubscriptionUrlValid(value) {
try {
return ['http:', 'https:'].includes(new URL(String(value).trim()).protocol);
} catch {
return false;
}
}
export function localProxyUrls(port = 8082, host = '127.0.0.1') { export function localProxyUrls(port = 8082, host = '127.0.0.1') {
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return { return {

View File

@@ -114,11 +114,32 @@ setInterval(() => {}, 60_000);
fs.writeFileSync(singboxPath, workingSingbox); fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755); fs.chmodSync(singboxPath, 0o755);
const subscriptionServer = http.createServer((req, res) => { let providerFetchCount = 0;
let delayedPath = '';
let invalidNextPath = '';
let delayedRequestStarted = null;
let releaseDelayedRequest = null;
const subscriptionServer = http.createServer(async (req, res) => {
providerFetchCount += 1;
if (req.url === '/timeout') return;
if (req.url === '/unavailable') { if (req.url === '/unavailable') {
res.writeHead(503); res.writeHead(503);
return res.end('unavailable'); return res.end('unavailable');
} }
if (req.url === '/invalid') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === invalidNextPath) {
invalidNextPath = '';
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('not a subscription');
}
if (req.url === delayedPath) {
delayedRequestStarted?.();
await new Promise((resolve) => { releaseDelayedRequest = resolve; });
delayedPath = '';
}
res.writeHead(200, { res.writeHead(200, {
'content-type': 'application/json', 'content-type': 'application/json',
'subscription-userinfo': 'upload=10; download=20; total=100', 'subscription-userinfo': 'upload=10; download=20; total=100',
@@ -147,6 +168,7 @@ setInterval(() => {}, 60_000);
PORT: String(port), PORT: String(port),
PATH: `${binDir}:${process.env.PATH}`, PATH: `${binDir}:${process.env.PATH}`,
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'), HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
SUBSCRIPTION_TIMEOUT_MS: '50',
}, },
stdio: ['ignore', 'ignore', 'pipe'], stdio: ['ignore', 'ignore', 'pipe'],
}); });
@@ -206,6 +228,36 @@ setInterval(() => {}, 60_000);
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE'); assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
assert.equal(providerUnavailable.payload.error.retryable, true); assert.equal(providerUnavailable.payload.error.retryable, true);
const preservedSubscription = {
state: JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')),
cache: fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
config: fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
};
for (const [pathname, expectedCode] of [
['/timeout', 'PROVIDER_UNAVAILABLE'],
['/invalid', 'SUBSCRIPTION_INVALID'],
]) {
const failedImport = await rawRequest(
port,
'/api/subscription/fetch',
'POST',
{ url: `http://127.0.0.1:${subscriptionPort}${pathname}` },
);
assert.equal(failedImport.payload.error.code, expectedCode);
const storedAfterFailure = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8'));
assert.equal(storedAfterFailure.subscriptionUrl, preservedSubscription.state.subscriptionUrl);
assert.equal(storedAfterFailure.selectedTag, preservedSubscription.state.selectedTag);
assert.deepEqual(storedAfterFailure.servers, preservedSubscription.state.servers);
assert.equal(
fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'),
preservedSubscription.cache,
);
assert.equal(
fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'),
preservedSubscription.config,
);
}
const missingServer = await rawRequest( const missingServer = await rawRequest(
port, port,
'/api/apply', '/api/apply',
@@ -230,14 +282,25 @@ setInterval(() => {}, 60_000);
return result; return result;
} }
await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl }); const fetchesBeforeImport = providerFetchCount;
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl }); await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' }); const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
assert.deepEqual(applied.state.selection, { assert.deepEqual(applied.state.selection, {
desiredServerId: 'test-vpn', desiredServerId: 'test-vpn',
appliedServerId: 'test-vpn', appliedServerId: 'test-vpn',
}); });
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 configBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8');
invalidNextPath = '/subscription/test';
const failedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
assert.equal(failedRefresh.response.status, 400);
assert.equal(failedRefresh.payload.error.code, 'SUBSCRIPTION_INVALID');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).selectedTag, 'test-vpn');
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), cacheBeforeFailedRefresh);
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), configBeforeFailedRefresh);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped'); assert.equal((await mutation('/api/singbox/stop')).state.connection.desired, 'stopped');
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
@@ -341,7 +404,19 @@ if (process.argv[2] === 'check') {
fs.writeFileSync(singboxPath, workingSingbox); fs.writeFileSync(singboxPath, workingSingbox);
fs.chmodSync(singboxPath, 0o755); fs.chmodSync(singboxPath, 0o755);
await mutation('/api/subscription/refresh'); const delayedRequest = new Promise((resolve) => { delayedRequestStarted = resolve; });
delayedPath = '/subscription/test';
const staleRefresh = rawRequest(port, '/api/subscription/refresh', 'POST');
await delayedRequest;
const replacementUrl = `http://127.0.0.1:${subscriptionPort}/subscription/replacement`;
await mutation('/api/subscription/fetch', 'POST', { url: replacementUrl });
releaseDelayedRequest();
const staleRefreshResult = await staleRefresh;
assert.equal(staleRefreshResult.response.status, 409);
assert.equal(staleRefreshResult.payload.error.code, 'STATE_CONFLICT');
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')).subscriptionUrl, replacementUrl);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8')).url, replacementUrl);
revision = (await request(port, '/api/state')).revision;
assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false); assert.equal((await mutation('/api/gateway-auto', 'POST', { enabled: false })).state.gatewayAuto.enabled, false);
const forgotten = await mutation('/api/subscription', 'DELETE'); const forgotten = await mutation('/api/subscription', 'DELETE');
assert.equal(forgotten.state.subscription.status, 'missing'); assert.equal(forgotten.state.subscription.status, 'missing');

View File

@@ -4,7 +4,6 @@ import test from 'node:test';
import { import {
HarborApiError, HarborApiError,
request, request,
validationStatusForError,
} from '../../src/web/api.js'; } from '../../src/web/api.js';
const response = (status, error) => ({ const response = (status, error) => ({
@@ -54,8 +53,3 @@ test('local unknown errors get a safe message and diagnostic reference', () => {
assert.equal(typeof error.correlationId, 'string'); assert.equal(typeof error.correlationId, 'string');
assert.ok(error.correlationId.length >= 8); assert.ok(error.correlationId.length >= 8);
}); });
test('subscription validation distinguishes bad input from provider outage', () => {
assert.equal(validationStatusForError({ code: 'SUBSCRIPTION_INVALID' }), 'invalid');
assert.equal(validationStatusForError({ code: 'PROVIDER_UNAVAILABLE' }), 'unavailable');
});

View File

@@ -6,6 +6,7 @@ import {
copyText, copyText,
formatConnectionDuration, formatConnectionDuration,
formatConnectionDurationWords, formatConnectionDurationWords,
isSubscriptionUrlValid,
localProxyUrls, localProxyUrls,
subscriptionDomain, subscriptionDomain,
subscriptionDaysLeft, subscriptionDaysLeft,
@@ -44,6 +45,13 @@ test('saved subscription is reduced to its public domain', () => {
assert.equal(subscriptionDomain(''), ''); assert.equal(subscriptionDomain(''), '');
}); });
test('subscription URL is validated locally by shape', () => {
assert.equal(isSubscriptionUrlValid(' https://sub.example/token '), true);
assert.equal(isSubscriptionUrlValid('http://127.0.0.1/subscription'), true);
assert.equal(isSubscriptionUrlValid('ftp://sub.example/token'), false);
assert.equal(isSubscriptionUrlValid('not-a-url'), false);
});
test('local proxy exposes both supported URLs', () => { test('local proxy exposes both supported URLs', () => {
assert.deepEqual(localProxyUrls(18080), { assert.deepEqual(localProxyUrls(18080), {
socks5: 'socks5://127.0.0.1:18080', socks5: 'socks5://127.0.0.1:18080',

View File

@@ -3,7 +3,6 @@ import http from 'node:http';
import test from 'node:test'; import test from 'node:test';
import { import {
createLatestRequest,
createOperationRegistry, createOperationRegistry,
OPERATION_CONFLICTS, OPERATION_CONFLICTS,
operationBlocked, operationBlocked,
@@ -72,22 +71,3 @@ test('a conflicting operation is rejected before its action starts', async () =>
connection.resolve(true); connection.resolve(true);
await running; await running;
}); });
test('latest request cancels and ignores a stale validation result', async () => {
const latest = createLatestRequest();
const oldResult = deferred();
const newResult = deferred();
let oldSignal;
const oldRequest = latest.run((signal) => {
oldSignal = signal;
return oldResult.promise;
});
const newRequest = latest.run(() => newResult.promise);
oldResult.resolve('old');
newResult.resolve('new');
assert.equal(await oldRequest, undefined);
assert.equal(await newRequest, 'new');
assert.equal(oldSignal.aborted, true);
});