Refine subscription import and refresh flow
This commit is contained in:
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,7 @@ export const settings = {
|
||||
hostNetworkStatePath:
|
||||
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
|
||||
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
|
||||
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
|
||||
@@ -431,55 +431,39 @@ async function applyRouteRules(routeRules) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = stateStore.read();
|
||||
if (!initialState.subscriptionUrl) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
||||
return serializeControl(async () => {
|
||||
const currentState = stateStore.read();
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
const previousState = normalizeStoredState(stateStore.read());
|
||||
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const selectedTag = resetSelection
|
||||
? ''
|
||||
: selectRefreshedServer(previousState.selectedTag, parsed.servers);
|
||||
const candidateConfig = selectedTag
|
||||
? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||
!previousCache?.config || !isDeepStrictEqual(
|
||||
buildActiveConfig(previousCache.config, currentState.selectedTag),
|
||||
buildActiveConfig(parsed.config, selectedTag),
|
||||
)
|
||||
);
|
||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||
const previousGatewayAutoState = gatewayAutoState;
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
|
||||
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;
|
||||
}
|
||||
if (resetSelection && wasRunning) await stopSingbox();
|
||||
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
||||
else removeSingboxConfig();
|
||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
||||
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
...(resetSelection ? {
|
||||
routeRules: state.routeRules,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
connectionDesired: 'stopped',
|
||||
} : state),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
@@ -487,6 +471,24 @@ function refreshSavedSubscription() {
|
||||
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 {
|
||||
success: true,
|
||||
@@ -496,6 +498,21 @@ function refreshSavedSubscription() {
|
||||
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(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
@@ -553,25 +570,7 @@ async function handleApi(req, res) {
|
||||
const { url = '' } = await readBody(req);
|
||||
const normalizedUrl = String(url).trim();
|
||||
const parsed = await withOperation('subscription-import', async () => {
|
||||
const result = await fetchSubscription(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 importSubscription(normalizedUrl);
|
||||
});
|
||||
return sendState(res, parsed);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function parseSubscriptionBody(body) {
|
||||
return { config: parsedConfig, servers };
|
||||
}
|
||||
|
||||
async function requestSubscription(url) {
|
||||
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
@@ -157,9 +157,10 @@ async function requestSubscription(url) {
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(parsedUrl, {
|
||||
response = await fetchImpl(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
|
||||
@@ -181,8 +182,8 @@ export function selectRefreshedServer(currentTag, servers) {
|
||||
|| '';
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
const response = await requestSubscription(url);
|
||||
export async function fetchSubscription(url, options) {
|
||||
const response = await requestSubscription(url, options);
|
||||
|
||||
const body = await response.text();
|
||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.6.16',
|
||||
gatewayClient: '0.6.15',
|
||||
gatewayBackend: '0.6.0',
|
||||
macClient: '0.6.17',
|
||||
gatewayClient: '0.6.16',
|
||||
gatewayBackend: '0.6.1',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
|
||||
@@ -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) {
|
||||
let response;
|
||||
try {
|
||||
@@ -54,11 +50,6 @@ export const api = {
|
||||
state: () => request('/api/state'),
|
||||
version: () => request('/api/version'),
|
||||
subscription: {
|
||||
validate: (url, signal) => request('/api/subscription/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
}),
|
||||
fetch: (url) => request('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { api, validationStatusForError } from '../api.js';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
connectionAction,
|
||||
connectionDurationParts,
|
||||
copyText,
|
||||
isSubscriptionUrlValid,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.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 { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||
import {
|
||||
@@ -594,8 +595,6 @@ export function ClientOverviewPage({
|
||||
}
|
||||
});
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||||
const [validationAttempt, setValidationAttempt] = useState(0);
|
||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||
const [pings, setPings] = useState({});
|
||||
@@ -621,8 +620,6 @@ export function ClientOverviewPage({
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
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 gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||
@@ -646,12 +643,9 @@ export function ClientOverviewPage({
|
||||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||
: instructionGuides;
|
||||
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||||
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||
? subscriptionValidation.status
|
||||
: normalizedSubscriptionUrl ? 'checking' : 'idle';
|
||||
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||
? subscriptionValidation.error
|
||||
: null;
|
||||
const subscriptionValidationStatus = !normalizedSubscriptionUrl
|
||||
? 'idle'
|
||||
: isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
|
||||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||||
const connectionBlocked = operationBlocked(operations, 'connection');
|
||||
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||||
@@ -729,54 +723,6 @@ export function ClientOverviewPage({
|
||||
return () => clearTimeout(timer);
|
||||
}, [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(() => {
|
||||
if (!hasSubscription) {
|
||||
setEditingSubscription(true);
|
||||
@@ -1317,22 +1263,16 @@ export function ClientOverviewPage({
|
||||
type="submit"
|
||||
aria-label={subscriptionValidationStatus === 'valid'
|
||||
? 'Сохранить подписку'
|
||||
: subscriptionValidationStatus === 'invalid'
|
||||
? 'Ссылка подписки не распознана'
|
||||
: subscriptionValidationStatus === 'unavailable'
|
||||
? 'Проверка подписки временно недоступна'
|
||||
: 'Проверяем ссылку подписки'}
|
||||
: 'Ссылка подписки не распознана'}
|
||||
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
|
||||
>
|
||||
{subscriptionValidationStatus === 'valid'
|
||||
? '✓'
|
||||
: subscriptionValidationStatus === 'invalid'
|
||||
? '×'
|
||||
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
|
||||
: '×'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<InlineError error={subscriptionError || error} context="subscription" />
|
||||
<InlineError error={error} context="subscription" />
|
||||
<InlineProgress operations={operations} context="subscription" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,27 +40,3 @@ export function createOperationRegistry(onChange = () => {}, now = () => new Dat
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2361,20 +2361,11 @@ p {
|
||||
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 {
|
||||
color: oklch(0.68 0.15 28);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes client-validation-pulse {
|
||||
to { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.client-subscription-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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') {
|
||||
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||
return {
|
||||
|
||||
@@ -114,11 +114,32 @@ setInterval(() => {}, 60_000);
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
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') {
|
||||
res.writeHead(503);
|
||||
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, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=10; download=20; total=100',
|
||||
@@ -147,6 +168,7 @@ setInterval(() => {}, 60_000);
|
||||
PORT: String(port),
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
|
||||
SUBSCRIPTION_TIMEOUT_MS: '50',
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
@@ -206,6 +228,36 @@ setInterval(() => {}, 60_000);
|
||||
assert.equal(providerUnavailable.payload.error.code, 'PROVIDER_UNAVAILABLE');
|
||||
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(
|
||||
port,
|
||||
'/api/apply',
|
||||
@@ -230,14 +282,25 @@ setInterval(() => {}, 60_000);
|
||||
return result;
|
||||
}
|
||||
|
||||
await stateResponse('/api/subscription/validate', 'POST', { url: subscriptionUrl });
|
||||
const fetchesBeforeImport = providerFetchCount;
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
|
||||
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
|
||||
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
|
||||
assert.deepEqual(applied.state.selection, {
|
||||
desiredServerId: 'test-vpn',
|
||||
appliedServerId: 'test-vpn',
|
||||
});
|
||||
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/restart')).state.connection.desired, 'running');
|
||||
|
||||
@@ -341,7 +404,19 @@ if (process.argv[2] === 'check') {
|
||||
fs.writeFileSync(singboxPath, workingSingbox);
|
||||
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);
|
||||
const forgotten = await mutation('/api/subscription', 'DELETE');
|
||||
assert.equal(forgotten.state.subscription.status, 'missing');
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from 'node:test';
|
||||
import {
|
||||
HarborApiError,
|
||||
request,
|
||||
validationStatusForError,
|
||||
} from '../../src/web/api.js';
|
||||
|
||||
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.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');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
copyText,
|
||||
formatConnectionDuration,
|
||||
formatConnectionDurationWords,
|
||||
isSubscriptionUrlValid,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
@@ -44,6 +45,13 @@ test('saved subscription is reduced to its public domain', () => {
|
||||
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', () => {
|
||||
assert.deepEqual(localProxyUrls(18080), {
|
||||
socks5: 'socks5://127.0.0.1:18080',
|
||||
|
||||
@@ -3,7 +3,6 @@ import http from 'node:http';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createLatestRequest,
|
||||
createOperationRegistry,
|
||||
OPERATION_CONFLICTS,
|
||||
operationBlocked,
|
||||
@@ -72,22 +71,3 @@ test('a conflicting operation is rejected before its action starts', async () =>
|
||||
connection.resolve(true);
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user