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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -431,55 +431,39 @@ async function applyRouteRules(routeRules) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSavedSubscription() {
|
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
||||||
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);
|
|
||||||
return serializeControl(async () => {
|
return serializeControl(async () => {
|
||||||
const currentState = stateStore.read();
|
const previousState = normalizeStoredState(stateStore.read());
|
||||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
||||||
throw new HarborError('STATE_CONFLICT');
|
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 previousCache = readSubscriptionCache();
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
: null;
|
: null;
|
||||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
const previousGatewayAutoState = gatewayAutoState;
|
||||||
!previousCache?.config || !isDeepStrictEqual(
|
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||||
buildActiveConfig(previousCache.config, currentState.selectedTag),
|
|
||||||
buildActiveConfig(parsed.config, selectedTag),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (singboxRuntime.running && activeConfigChanged) {
|
if (resetSelection && wasRunning) await stopSingbox();
|
||||||
await applySelectedServer(selectedTag, { persist: false });
|
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
||||||
}
|
else removeSingboxConfig();
|
||||||
else if (selectedTag) {
|
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||||
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
|
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
||||||
} 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,6 +471,24 @@ 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,
|
||||||
@@ -496,6 +498,21 @@ function refreshSavedSubscription() {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user