Refine VPN client instructions and subscription refresh flow
This commit is contained in:
@@ -11,14 +11,17 @@ import {
|
||||
removeSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js';
|
||||
import { fetchSubscription, selectRefreshedServer } from './subscription.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
let singboxProcess = null;
|
||||
let singboxStartedAt = null;
|
||||
let subscriptionRefreshPromise = null;
|
||||
let subscriptionRefreshTimer = null;
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
@@ -195,6 +198,69 @@ async function applySelectedServer(selectedTag) {
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = readJson(settings.statePath, {});
|
||||
if (!initialState.subscriptionUrl) {
|
||||
const error = new Error('Подписка не настроена');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new Error('Подписка была изменена во время обновления');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const previousCache = readJson(settings.subscriptionCachePath, null);
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxProcess && selectedTag) await applySelectedServer(selectedTag);
|
||||
else if (selectedTag) {
|
||||
writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
|
||||
} else {
|
||||
removeSingboxConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
|
||||
else fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
};
|
||||
})().finally(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
|
||||
return subscriptionRefreshPromise;
|
||||
}
|
||||
|
||||
async function handleApi(req, res) {
|
||||
if (req.method === 'GET' && req.url === '/api/state') {
|
||||
return sendJson(res, 200, publicState());
|
||||
@@ -236,16 +302,8 @@ async function handleApi(req, res) {
|
||||
return sendJson(res, 200, { success: true, ...parsed });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') {
|
||||
const state = readJson(settings.statePath, {});
|
||||
if (!state.subscriptionUrl) {
|
||||
return sendJson(res, 400, { success: false, error: 'Подписка не настроена' });
|
||||
}
|
||||
const info = await fetchSubscriptionInfo(state.subscriptionUrl);
|
||||
writeJson(settings.statePath, { ...state, ...info });
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||
return sendJson(res, 200, { success: true, ...info });
|
||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||
return sendJson(res, 200, await refreshSavedSubscription());
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
@@ -319,6 +377,7 @@ const server = http.createServer(async (req, res) => {
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(subscriptionRefreshTimer);
|
||||
await stopSingbox();
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -336,3 +395,10 @@ await startSingbox().catch((error) => console.warn(`[control] sing-box не за
|
||||
server.listen(settings.port, '0.0.0.0', () => {
|
||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||
});
|
||||
|
||||
subscriptionRefreshTimer = setInterval(() => {
|
||||
if (!readJson(settings.statePath, {}).subscriptionUrl) return;
|
||||
refreshSavedSubscription()
|
||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||
subscriptionRefreshTimer.unref();
|
||||
|
||||
@@ -160,13 +160,13 @@ async function requestSubscription(url) {
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionInfo(url) {
|
||||
const response = await requestSubscription(url);
|
||||
await response.body?.cancel();
|
||||
return {
|
||||
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
export function selectRefreshedServer(currentTag, servers) {
|
||||
const selectedTag = String(currentTag || '').trim();
|
||||
if (!selectedTag) return '';
|
||||
|
||||
return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag
|
||||
|| servers[0]?.tag
|
||||
|| '';
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
|
||||
Reference in New Issue
Block a user