Add local routing rules to Harbor
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 17s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-11 21:52:03 +03:00
parent 306a9b8ced
commit a0c66edb02
17 changed files with 823 additions and 26 deletions

View File

@@ -30,6 +30,7 @@ import {
withStateV0Compatibility,
} from '../shared/contracts/state.js';
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { buildVersionInfo } from './version.js';
@@ -192,9 +193,14 @@ function subscriptionHost(url) {
}
}
function buildActiveConfig(subscriptionConfig, selectedTag) {
function buildActiveConfig(
subscriptionConfig,
selectedTag,
routeRules = stateStore.read().routeRules,
) {
return buildGatewayConfig(subscriptionConfig, selectedTag, {
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
routeRules,
});
}
@@ -383,6 +389,38 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
}
}
async function applyRouteRules(routeRules) {
const state = normalizeStoredState(stateStore.read());
const cached = readSubscriptionCache();
if (!state.selectedTag || !cached?.config) {
updateStoredState((current) => ({ ...current, routeRules }));
return;
}
const previousConfig = fs.existsSync(settings.configPath)
? fs.readFileSync(settings.configPath, 'utf8')
: null;
const wasRunning = singboxRuntime.running;
try {
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
if (wasRunning) await startSingbox();
updateStoredState((current) => ({ ...current, routeRules }));
} catch (error) {
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], 'Route rules rollback failed'),
});
}
}
throw error;
}
}
function refreshSavedSubscription() {
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
@@ -515,6 +553,7 @@ async function handleApi(req, res) {
removeSingboxConfig();
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
updateStoredState((state) => ({
routeRules: state.routeRules,
subscriptionUrl: normalizedUrl,
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
servers: result.servers,
@@ -564,12 +603,32 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, gatewayAuto: state.gatewayAuto, state });
}
if (req.method === 'PUT' && req.url === '/api/route-rules') {
const { rules, expectedRevision } = await readBody(req);
let routeRules;
try {
routeRules = normalizeRouteRules(rules, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
throw new HarborError('REQUEST_INVALID');
}
await serializeControl(async () => {
const current = normalizeStoredState(stateStore.read());
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await withOperation('route-rules', () => applyRouteRules(routeRules));
});
return sendState(res);
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
await withOperation('subscription-forget', () => serializeControl(async () => {
await stopSingbox();
removeSingboxConfig();
subscriptionCacheStore.remove();
updateStoredState(() => ({}));
updateStoredState((state) => ({ routeRules: state.routeRules }));
gatewayAutoState = createGatewayAutoState();
}));
return sendState(res);