Harden outbound tag allocation

This commit is contained in:
ushan0v
2026-07-11 00:10:57 +03:00
parent 584f72f503
commit ef4bb76ad3
28 changed files with 1670 additions and 1177 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PODKOP PLUS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-10 05:05+0300\n"
"PO-Revision-Date: 2026-07-10 05:05+0300\n"
"POT-Creation-Date: 2026-07-10 15:25+0300\n"
"PO-Revision-Date: 2026-07-10 15:25+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -488,6 +488,9 @@ msgstr "Загружено"
msgid "Drag rows to change priority. The rule at the top is checked first."
msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым."
msgid "Duplicate JSON outbound tag"
msgstr "Такой тег JSON outbound уже используется"
msgid "Duplicate value"
msgstr "Дублирующееся значение"
+1
View File
@@ -21,6 +21,7 @@ export { bulkValidate } from './validators/bulkValidate';
export { validateOutboundJson } from './validators/validateOutboundJson';
export { validateProxyUrl } from './validators/validateProxyUrl';
export { parseValueList } from './helpers/parseValueList';
export { getProxyUrlName } from './helpers/getProxyUrlName';
export { injectGlobalStyles } from './helpers/injectGlobalStyles';
export { showToast } from './helpers/showToast';
export { getClashUIUrl } from './helpers/getClashApiUrl';
@@ -832,10 +832,13 @@ function getOutboundDisplayName(
entry: ClashProxyEntry | undefined,
link: string,
outboundMetadata?: Podkop.GetOutboundMetadata,
preferMetadata = false,
) {
const metadataName = outboundMetadata?.names?.[code];
return (
getProxyUrlName(link) ||
outboundMetadata?.names?.[code] ||
(preferMetadata ? metadataName : getProxyUrlName(link)) ||
(preferMetadata ? getProxyUrlName(link) : metadataName) ||
entry?.value?.name ||
code
);
@@ -888,6 +891,7 @@ function buildUrlTestInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode),
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || '',
@@ -928,6 +932,7 @@ function buildPriorityInfo({
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries,
}: {
config: PriorityConfig;
entry?: ClashProxyEntry;
@@ -937,6 +942,7 @@ function buildPriorityInfo({
cachedProxyLinks: Map<string, string>;
outboundMetadata?: Podkop.GetOutboundMetadata;
subscriptionCopyableCodes: Set<string>;
showDetectedCountries: boolean;
}): Podkop.PriorityInfo {
const selectedCode = entry?.value.now || '';
const cacheLevels = Array.isArray(groupCache?.levels)
@@ -1001,12 +1007,16 @@ function buildPriorityInfo({
childEntry,
link,
outboundMetadata,
subscriptionCopyableCodes.has(childCode),
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || '',
selected: selectedCode === childCode,
link,
canCopyLink,
country: showDetectedCountries
? outboundMetadata?.countries?.[childCode]
: undefined,
levelIndex,
levelName: level.displayName,
levelId: level.id,
@@ -1155,7 +1165,13 @@ function buildProxyGroupOutbounds(
const displayName =
priorityConfig?.displayName ||
urlTestConfig?.displayName ||
getOutboundDisplayName(code, item, link, outboundMetadata);
getOutboundDisplayName(
code,
item,
link,
outboundMetadata,
subscriptionCopyableCodes.has(code),
);
const isRuntimeUrlTest = isUrlTestProxyEntry(item);
return [
@@ -1197,6 +1213,7 @@ function buildProxyGroupOutbounds(
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes,
showDetectedCountries: priorityConfig.showDetectedCountries,
})
: undefined,
},
@@ -307,7 +307,11 @@ describe('getDashboardSections', () => {
mocks.getConfigSections.mockResolvedValue([
proxySection({ urltests: [], urltest_settings: undefined }),
priorityGroup('pg_main'),
priorityLevel('pl_de', { name: 'Germany', order: '0' }),
priorityLevel('pl_de', {
name: 'Germany',
order: '0',
detect_server_country: 'country_is',
}),
priorityLevel('pl_nl', {
name: 'Netherlands',
order: '1',
@@ -322,11 +326,7 @@ describe('getDashboardSections', () => {
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-priority-pg_main-out',
all: [
'main-1-out',
'main-2-out',
'main-priority-pg_main-out',
],
all: ['main-1-out', 'main-2-out', 'main-priority-pg_main-out'],
}),
'main-priority-pg_main-out': proxy('Selector', {
name: 'main-priority-pg_main-out',
@@ -343,7 +343,7 @@ describe('getDashboardSections', () => {
'main-1-out': 'First cached',
'main-2-out': 'Second cached',
},
countries: {},
countries: { 'main-2-out': 'DE' },
},
priorityGroups: {
'main-priority-pg_main-out': {
@@ -403,6 +403,7 @@ describe('getDashboardSections', () => {
levelIndex: item.levelIndex,
levelName: item.levelName,
selected: item.selected,
country: item.country,
})),
).toEqual([
{
@@ -410,12 +411,14 @@ describe('getDashboardSections', () => {
levelIndex: 0,
levelName: 'Germany',
selected: true,
country: 'DE',
},
{
code: 'main-1-out',
levelIndex: 1,
levelName: 'Netherlands',
selected: false,
country: undefined,
},
]);
});
@@ -434,11 +437,7 @@ describe('getDashboardSections', () => {
'main-out': proxy('Selector', {
name: 'main-out',
now: 'main-priority-pg_main-out',
all: [
'main-1-out',
'main-2-out',
'main-priority-pg_main-out',
],
all: ['main-1-out', 'main-2-out', 'main-priority-pg_main-out'],
}),
'main-priority-pg_main-out': proxy('Selector', {
name: 'main-priority-pg_main-out',
@@ -982,8 +981,13 @@ describe('getDashboardSections', () => {
]);
mocks.fsRead.mockResolvedValue(
JSON.stringify({
outboundMetadata: {
names: { 'main-2-out': 'Provider Subscription' },
countries: {},
},
links: {
'main-2-out': 'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription',
'main-2-out':
'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription',
},
linkRefs: {
'main-2-out': {
@@ -1004,6 +1008,7 @@ describe('getDashboardSections', () => {
expect(subscriptionOutbound?.link).toBe(
'vless://00000000-0000-4000-8000-000000000002@example.com:443#Subscription',
);
expect(subscriptionOutbound?.displayName).toBe('Provider Subscription');
expect(subscriptionOutbound?.canCopyLink).toBe(true);
});
+1
View File
@@ -10,6 +10,7 @@ const RESERVED_RUNTIME_TAGS = new Set([
'dns-in',
'service-mixed-in',
'direct-out',
'bypass-out',
]);
export function allocateRuntimeTag(base: string, postfix: string): string {
@@ -710,7 +710,18 @@ function getDetectedCountryFlag(country?: string) {
function renderDetailsMemberName(member: Podkop.UrlTestMember) {
const countryFlag = getDetectedCountryFlag(member.country);
return countryFlag ? `${countryFlag} ${member.displayName}` : member.displayName;
if (!countryFlag) {
return member.displayName;
}
return [
E(
'span',
{ class: 'pdk_dashboard-page__urltest-details__country-badge' },
countryFlag,
),
member.displayName,
];
}
function renderUrlTestSelectedValue(info: Podkop.UrlTestInfo) {
@@ -542,6 +542,17 @@ export const styles = `
font-weight: 400;
}
.pdk_dashboard-page__urltest-details__country-badge {
display: inline-flex;
align-items: center;
margin-right: 6px;
padding: 2px 4px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 4px;
background: rgba(128, 128, 128, 0.15);
line-height: 1;
}
.pdk_dashboard-page__urltest-details__priority-node {
color: var(--text-color-high, #fff);
font-weight: 600;
@@ -6,6 +6,7 @@ describe('runtimeTags', () => {
it('uses the same direct section collision format as the backend generator', () => {
expect(getOutboundTagBySection('proxy')).toBe('proxy-out');
expect(getOutboundTagBySection('direct')).toBe('direct-out-1');
expect(getOutboundTagBySection('bypass')).toBe('bypass-out-1');
expect(getOutboundTagBySection('direct-1')).toBe('direct-1-out');
});
});
@@ -56,6 +56,16 @@ describe('validateOutboundJson', () => {
);
});
it('rejects a tag already used by another JSON outbound', () => {
const result = validateOutboundJson('{"type":"direct","tag":"duplicate"}', [
'first',
'duplicate',
]);
expect(result.valid).toBe(false);
expect(result.message).toBe('Duplicate JSON outbound tag');
});
it('requires server fields for common server outbound types', () => {
const result = validateOutboundJson(
'{"type":"socks","tag":"proxy","server":"127.0.0.1"}',
@@ -40,7 +40,10 @@ function validateOutbounds(value: unknown): boolean {
);
}
export function validateOutboundJson(value: string): ValidationResult {
export function validateOutboundJson(
value: string,
usedTags: string[] = [],
): ValidationResult {
const normalized = `${value || ''}`.trim();
if (!normalized.length) {
@@ -67,6 +70,11 @@ export function validateOutboundJson(value: string): ValidationResult {
return invalid(_('JSON outbound must contain a non-empty tag field'));
}
const tag = parsed.tag.trim();
if (usedTags.some((usedTag) => `${usedTag || ''}`.trim() === tag)) {
return invalid(_('Duplicate JSON outbound tag'));
}
const type = parsed.type.trim().toLowerCase();
if (
@@ -272,7 +272,7 @@ function validateServerPort(value) {
function validateOutbounds(value) {
return Array.isArray(value) && value.length > 0 && value.every((item) => nonEmptyString(item));
}
function validateOutboundJson(value) {
function validateOutboundJson(value, usedTags = []) {
const normalized = `${value || ""}`.trim();
if (!normalized.length) {
return invalid(_("JSON outbound cannot be empty"));
@@ -292,6 +292,10 @@ function validateOutboundJson(value) {
if (!nonEmptyString(parsed.tag)) {
return invalid(_("JSON outbound must contain a non-empty tag field"));
}
const tag = parsed.tag.trim();
if (usedTags.some((usedTag) => `${usedTag || ""}`.trim() === tag)) {
return invalid(_("Duplicate JSON outbound tag"));
}
const type = parsed.type.trim().toLowerCase();
if ((type === "selector" || type === "urltest") && !validateOutbounds(parsed.outbounds)) {
return invalid(
@@ -951,6 +955,19 @@ function parseValueList(value) {
return value.split(/\n/).map((line) => line.split("//")[0].split("#")[0]).join(" ").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
}
// src/helpers/getProxyUrlName.ts
function getProxyUrlName(url) {
try {
const [_link, hash] = url.split("#");
if (!hash) {
return "";
}
return decodeURIComponent(hash);
} catch {
return "";
}
}
// src/helpers/downloadAsTxt.ts
function downloadAsTxt(text, filename) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
@@ -1136,19 +1153,6 @@ async function executeShellCommand({
}
}
// src/helpers/getProxyUrlName.ts
function getProxyUrlName(url) {
try {
const [_link, hash] = url.split("#");
if (!hash) {
return "";
}
return decodeURIComponent(hash);
} catch {
return "";
}
}
// src/helpers/onMount.ts
function getTarget(target) {
if (typeof target === "string") {
@@ -2456,7 +2460,8 @@ var RESERVED_RUNTIME_TAGS = /* @__PURE__ */ new Set([
"tproxy6-in",
"dns-in",
"service-mixed-in",
"direct-out"
"direct-out",
"bypass-out"
]);
function allocateRuntimeTag(base, postfix) {
let suffix = 1;
@@ -3666,8 +3671,9 @@ function getPriorityGroups(dashboardCache) {
}
return groups;
}
function getOutboundDisplayName(code, entry, link, outboundMetadata) {
return getProxyUrlName(link) || outboundMetadata?.names?.[code] || entry?.value?.name || code;
function getOutboundDisplayName(code, entry, link, outboundMetadata, preferMetadata = false) {
const metadataName = outboundMetadata?.names?.[code];
return (preferMetadata ? metadataName : getProxyUrlName(link)) || (preferMetadata ? getProxyUrlName(link) : metadataName) || entry?.value?.name || code;
}
function buildUrlTestInfo({
code,
@@ -3697,7 +3703,8 @@ function buildUrlTestInfo({
childCode,
childEntry,
link,
outboundMetadata
outboundMetadata,
subscriptionCopyableCodes.has(childCode)
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || "",
@@ -3731,7 +3738,8 @@ function buildPriorityInfo({
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes
subscriptionCopyableCodes,
showDetectedCountries
}) {
const selectedCode = entry?.value.now || "";
const cacheLevels = Array.isArray(groupCache?.levels) ? groupCache.levels : [];
@@ -3773,13 +3781,15 @@ function buildPriorityInfo({
childCode,
childEntry,
link,
outboundMetadata
outboundMetadata,
subscriptionCopyableCodes.has(childCode)
),
latency: childEntry?.value?.history?.[0]?.delay || 0,
type: childEntry?.value?.type || "",
selected: selectedCode === childCode,
link,
canCopyLink,
country: showDetectedCountries ? outboundMetadata?.countries?.[childCode] : void 0,
levelIndex,
levelName: level.displayName,
levelId: level.id
@@ -3879,7 +3889,13 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
}
const link = manualLinkByCode.get(code) || cachedProxyLinks.get(code) || "";
const canCopyLink = isCopyableProxyLink(link) || subscriptionCopyableCodes.has(code);
const displayName = priorityConfig?.displayName || urlTestConfig?.displayName || getOutboundDisplayName(code, item, link, outboundMetadata);
const displayName = priorityConfig?.displayName || urlTestConfig?.displayName || getOutboundDisplayName(
code,
item,
link,
outboundMetadata,
subscriptionCopyableCodes.has(code)
);
const isRuntimeUrlTest = isUrlTestProxyEntry(item);
return [
{
@@ -3912,7 +3928,8 @@ function buildProxyGroupOutbounds(section, proxies, outboundMetadata, urltestGro
manualLinkByCode,
cachedProxyLinks,
outboundMetadata,
subscriptionCopyableCodes
subscriptionCopyableCodes,
showDetectedCountries: priorityConfig.showDetectedCountries
}) : void 0
}
];
@@ -6043,7 +6060,17 @@ function getDetectedCountryFlag(country) {
}
function renderDetailsMemberName(member) {
const countryFlag = getDetectedCountryFlag(member.country);
return countryFlag ? `${countryFlag} ${member.displayName}` : member.displayName;
if (!countryFlag) {
return member.displayName;
}
return [
E(
"span",
{ class: "pdk_dashboard-page__urltest-details__country-badge" },
countryFlag
),
member.displayName
];
}
function renderUrlTestSelectedValue(info) {
const selectedMember = info.outbounds.find((member) => member.selected);
@@ -7396,6 +7423,17 @@ var styles = `
font-weight: 400;
}
.pdk_dashboard-page__urltest-details__country-badge {
display: inline-flex;
align-items: center;
margin-right: 6px;
padding: 2px 4px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 4px;
background: rgba(128, 128, 128, 0.15);
line-height: 1;
}
.pdk_dashboard-page__urltest-details__priority-node {
color: var(--text-color-high, #fff);
font-weight: 600;
@@ -13741,6 +13779,7 @@ return baseclass.extend({
bulkValidate,
coreService,
getClashUIUrl,
getProxyUrlName,
injectGlobalStyles,
parseValueList,
showToast,
@@ -376,6 +376,7 @@ let actionProvidersAvailabilityPromise = null;
let actionProvidersAvailabilityLoader = null;
const outboundNameChoicesCache = new Map();
const outboundNameChoicesInflight = new Map();
const outboundNameSourceOptions = new Map();
const SECTION_CACHE_DIR = "/var/run/podkop-plus/section-cache";
const COUNTRY_CODES =
"AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW XK".split(
@@ -1372,11 +1373,90 @@ function currentOutboundNameChoices(section_id, values) {
};
(outboundNameChoicesCache.get(section_id) || []).forEach(append);
currentDraftOutboundNames(section_id).forEach(append);
normalizeDynamicListItems(values).forEach(append);
return result.sort((a, b) => a.label.localeCompare(b.label));
}
function currentSourceOptionValues(section_id, optionName) {
const liveValues = currentLiveDynamicListValues(section_id, optionName);
if (liveValues != null) {
return liveValues;
}
const option = outboundNameSourceOptions.get(optionName);
if (option && typeof option.formvalue === "function") {
try {
const value = option.formvalue(section_id);
if (value != null) {
return normalizeDynamicListItems(value);
}
} catch (_error) {
// Fall back to the LuCI UCI cache when the section widget is not mounted.
}
}
return getConfigListValues(section_id, optionName);
}
function currentLiveDynamicListValues(section_id, optionName) {
if (typeof document === "undefined") {
return null;
}
const widget = document.getElementById(
`cbid.${UCI_PACKAGE}.${section_id}.${optionName}`,
);
if (!widget) {
return null;
}
const values = Array.from(
widget.querySelectorAll('.item > input[type="hidden"]'),
)
.map((input) => `${input.value || ""}`.trim())
.filter(Boolean);
const pendingInput = widget.querySelector('.add-item > input[type="text"]');
const pendingValue = `${(pendingInput && pendingInput.value) || ""}`.trim();
if (
pendingValue &&
!pendingInput.classList.contains("cbi-input-invalid") &&
!values.includes(pendingValue)
) {
values.push(pendingValue);
}
return values;
}
function currentDraftOutboundNames(section_id) {
const names = [];
currentSourceOptionValues(section_id, "selector_proxy_links").forEach(
(value, index) => {
const name = main.getProxyUrlName(`${value || ""}`);
names.push(name || `${section_id}-${index + 1}-out`);
},
);
currentSourceOptionValues(section_id, "interfaces").forEach((name) => {
const normalized = `${name || ""}`.trim();
if (normalized) {
names.push(normalized);
}
});
currentSourceOptionValues(section_id, "outbound_jsons").forEach((value) => {
const tag = outboundJsonDisplayTag(value);
if (tag) {
names.push(tag);
}
});
return names;
}
function isDownloadThroughTargetSection(section, currentSectionId) {
const sectionName = getUciSectionName(section);
const action = (section && section.action) || "";
@@ -3037,7 +3117,16 @@ function showOutboundJsonSettingsModal(
jsonOption.modalonly = true;
jsonOption.rmempty = false;
jsonOption.validate = function (_itemId, value) {
const validation = main.validateOutboundJson(`${value || ""}`);
const usedTags =
widget && widget.node
? Array.from(widget.node.querySelectorAll(".item"))
.filter((item) => item !== itemNode)
.map((item) =>
outboundJsonDisplayTag(dynamicListItemCurrentValue(item, "")),
)
.filter(Boolean)
: [];
const validation = main.validateOutboundJson(`${value || ""}`, usedTags);
return validation.valid ? true : validation.message;
};
configureTextareaOption(jsonOption);
@@ -3063,13 +3152,17 @@ function showOutboundJsonSettingsModal(
function validateOutboundJsonItemsBeforeSave(_section_id, values) {
const items = Array.isArray(values) ? values : values ? [values] : [];
const tags = [];
for (const value of items) {
const validation = main.validateOutboundJson(`${value || ""}`);
const validation = main.validateOutboundJson(`${value || ""}`, tags);
if (!validation.valid) {
const tag = outboundJsonDisplayTag(value);
return tag ? `${tag}: ${validation.message}` : validation.message;
}
const tag = outboundJsonDisplayTag(value);
tags.push(tag);
}
return true;
@@ -6368,6 +6461,7 @@ function createSectionContent(section) {
const validation = main.validateProxyUrl(value);
return validation.valid ? true : validation.message;
};
outboundNameSourceOptions.set("selector_proxy_links", o);
o = section.taboption(
"settings",
@@ -6428,6 +6522,7 @@ function createSectionContent(section) {
o.rmempty = true;
o.modalonly = true;
o.placeholder = _("Select a network interface");
outboundNameSourceOptions.set("interfaces", o);
o = section.taboption(
"settings",
@@ -6453,6 +6548,7 @@ function createSectionContent(section) {
const validation = main.validateOutboundJson(value);
return validation.valid ? true : validation.message;
};
outboundNameSourceOptions.set("outbound_jsons", o);
o = section.taboption(
"settings",
+5 -2
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PODKOP PLUS\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-10 05:05+0300\n"
"PO-Revision-Date: 2026-07-10 05:05+0300\n"
"POT-Creation-Date: 2026-07-10 15:25+0300\n"
"PO-Revision-Date: 2026-07-10 15:25+0300\n"
"Last-Translator: ushan0v\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -488,6 +488,9 @@ msgstr "Загружено"
msgid "Drag rows to change priority. The rule at the top is checked first."
msgstr "Перетаскивайте строки, чтобы изменить приоритет. Правило сверху проверяется первым."
msgid "Duplicate JSON outbound tag"
msgstr "Такой тег JSON outbound уже используется"
msgid "Duplicate value"
msgstr "Дублирующееся значение"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -78,7 +78,7 @@ config settings 'settings'
# option label 'JSON outbound'
# option enabled '1'
# option action 'connection'
# list outbound_jsons '{"type":"socks","server":"127.0.0.1","server_port":1080,"version":"5"}'
# list outbound_jsons '{"type":"socks","tag":"Local SOCKS","server":"127.0.0.1","server_port":1080,"version":"5"}'
# list community_lists 'russia_inside'
#config server 'server_json_inbound_example'
+19 -3
View File
@@ -1102,15 +1102,20 @@ function validate_common_rule_references(section, context) {
validate_plain_domain_ip_list_reference_value(value);
}
function valid_outbound_json(value) {
function outbound_json_object(value) {
try {
value = json(as_string(value));
}
catch (e) {
return false;
return null;
}
return type(value) == "object" && type(value.type) == "string";
return type(value) == "object" ? value : null;
}
function valid_outbound_json(value) {
value = outbound_json_object(value);
return value != null && type(value.type) == "string" && trim(as_string(value.type)) != "";
}
function validate_outbound_json_rule(section) {
@@ -1127,6 +1132,8 @@ function validate_outbound_json_rule(section) {
function validate_outbound_json_values(section) {
let name = section_name(section);
let values = connections.outbound_jsons(section);
let require_user_tag = length(list_option(section, "outbound_jsons")) > 0;
let tags = {};
for (let value in values) {
if (as_string(value) == "")
@@ -1134,6 +1141,15 @@ function validate_outbound_json_values(section) {
if (!valid_outbound_json(value))
fail_validation("Connection rule '" + name + "' must contain valid sing-box outbound JSON objects with a type field. Aborted.");
let outbound = outbound_json_object(value);
let tag_name = trim(as_string(outbound.tag || ""));
if (tag_name == "" && require_user_tag)
fail_validation("Connection rule '" + name + "' has JSON outbound without a non-empty tag. Aborted.");
if (tag_name != "" && tags[tag_name])
fail_validation("Connection rule '" + name + "' has duplicate JSON outbound tag '" + tag_name + "'. Aborted.");
if (tag_name != "")
tags[tag_name] = true;
}
}
+2
View File
@@ -9,6 +9,7 @@ const SB_FAKEIP_DNS_RULE_TAG = getenv("SB_FAKEIP_DNS_RULE_TAG") || "fakeip-dns-r
const SB_FAKEIP_RULESET_DNS_RULE_TAG = getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || "fakeip-ruleset-dns-rule-tag";
const SB_SERVICE_FAKEIP_DNS_RULE_TAG = getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || "service-fakeip-dns-rule-tag";
const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in";
const SB_TPROXY_INBOUND6_TAG = getenv("SB_TPROXY_INBOUND6_TAG") || "tproxy6-in";
const SB_DNS_INBOUND_TAG = getenv("SB_DNS_INBOUND_TAG") || "dns-in";
const SB_SERVICE_MIXED_INBOUND_TAG = getenv("SB_SERVICE_MIXED_INBOUND_TAG") || "service-mixed-in";
const SB_DIRECT_OUTBOUND_TAG = getenv("SB_DIRECT_OUTBOUND_TAG") || "direct-out";
@@ -528,6 +529,7 @@ function default_reserved_runtime_tags() {
SB_FAKEIP_RULESET_DNS_RULE_TAG,
SB_SERVICE_FAKEIP_DNS_RULE_TAG,
SB_TPROXY_INBOUND_TAG,
SB_TPROXY_INBOUND6_TAG,
SB_DNS_INBOUND_TAG,
SB_SERVICE_MIXED_INBOUND_TAG,
SB_DIRECT_OUTBOUND_TAG,
@@ -2,6 +2,7 @@
let fs = require("fs");
let uci_core = require("core.uci");
let runtime_constants = require("singbox.constants");
let validator_module = null;
const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || "podkop-plus";
@@ -18,18 +19,6 @@ const BYEDPI_RESPAWN_DELAY = getenv("BYEDPI_RESPAWN_DELAY") || "5";
const BYEDPI_OPEN_FILES_LIMIT = getenv("BYEDPI_OPEN_FILES_LIMIT") || "4096";
const BYEDPI_DEFAULT_CMD_OPTS = getenv("BYEDPI_DEFAULT_CMD_OPTS") || "-o 2 --auto=t,r,a,s -d 2";
const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in";
const RESERVED_RUNTIME_TAGS = [
getenv("SB_DNS_SERVER_TAG") || "dns-server",
getenv("SB_FAKEIP_DNS_SERVER_TAG") || "fakeip-server",
getenv("SB_BOOTSTRAP_SERVER_TAG") || "bootstrap-dns-server",
getenv("SB_FAKEIP_DNS_RULE_TAG") || "fakeip-dns-rule-tag",
getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || "fakeip-ruleset-dns-rule-tag",
getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || "service-fakeip-dns-rule-tag",
getenv("SB_TPROXY_INBOUND_TAG") || "tproxy-in",
getenv("SB_DNS_INBOUND_TAG") || "dns-in",
getenv("SB_SERVICE_MIXED_INBOUND_TAG") || "service-mixed-in",
getenv("SB_DIRECT_OUTBOUND_TAG") || "direct-out"
];
function as_string(value) {
return value == null ? "" : "" + value;
@@ -439,45 +428,8 @@ function restart_count() {
return count;
}
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function tag_is_reserved(tag) {
for (let value in RESERVED_RUNTIME_TAGS)
if (tag == as_string(value))
return true;
return false;
}
function runtime_tag(base, postfix) {
base = as_string(base);
postfix = as_string(postfix);
let candidate = base + "-" + postfix;
let suffix = 1;
if (match(base, /-[0-9]/) != null) {
let dash = str_last_index(base, "-");
let parent = dash >= 0 ? substr(base, 0, dash) : base;
if (tag_is_reserved(parent + "-" + postfix)) {
candidate = base + "-" + suffix + "-" + postfix;
suffix++;
}
}
while (tag_is_reserved(candidate)) {
candidate = base + "-" + suffix + "-" + postfix;
suffix++;
}
return candidate;
return runtime_constants.tag(base, postfix);
}
function read_json_file(path) {
@@ -3,23 +3,12 @@
let fs = require("fs");
let constants = require("core.constants");
let uci_core = require("core.uci");
let runtime_constants = require("singbox.constants");
const CONFIG_NAME = getenv("PODKOP_CONFIG_NAME") || constants.PODKOP_CONFIG_NAME || "podkop-plus";
const LIB_DIR = getenv("PODKOP_LIB") || "/usr/lib/podkop-plus";
const SB_TPROXY_INBOUND_TAG = getenv("SB_TPROXY_INBOUND_TAG") || constants.SB_TPROXY_INBOUND_TAG || "tproxy-in";
const NFT_TABLE_NAME = getenv("NFT_TABLE_NAME") || constants.NFT_TABLE_NAME || "PodkopPlusTable";
const RESERVED_RUNTIME_TAGS = [
getenv("SB_DNS_SERVER_TAG") || constants.SB_DNS_SERVER_TAG || "dns-server",
getenv("SB_FAKEIP_DNS_SERVER_TAG") || constants.SB_FAKEIP_DNS_SERVER_TAG || "fakeip-server",
getenv("SB_BOOTSTRAP_SERVER_TAG") || constants.SB_BOOTSTRAP_SERVER_TAG || "bootstrap-dns-server",
getenv("SB_FAKEIP_DNS_RULE_TAG") || constants.SB_FAKEIP_DNS_RULE_TAG || "fakeip-dns-rule-tag",
getenv("SB_FAKEIP_RULESET_DNS_RULE_TAG") || constants.SB_FAKEIP_RULESET_DNS_RULE_TAG || "fakeip-ruleset-dns-rule-tag",
getenv("SB_SERVICE_FAKEIP_DNS_RULE_TAG") || constants.SB_SERVICE_FAKEIP_DNS_RULE_TAG || "service-fakeip-dns-rule-tag",
SB_TPROXY_INBOUND_TAG,
getenv("SB_DNS_INBOUND_TAG") || constants.SB_DNS_INBOUND_TAG || "dns-in",
getenv("SB_SERVICE_MIXED_INBOUND_TAG") || constants.SB_SERVICE_MIXED_INBOUND_TAG || "service-mixed-in",
getenv("SB_DIRECT_OUTBOUND_TAG") || constants.SB_DIRECT_OUTBOUND_TAG || "direct-out"
];
function as_string(value) {
return value == null ? "" : "" + value;
@@ -502,45 +491,8 @@ function start_runtime(cfg) {
}
}
function str_last_index(value, needle) {
value = as_string(value);
needle = as_string(needle);
if (needle == "")
return length(value);
for (let i = length(value) - length(needle); i >= 0; i--)
if (substr(value, i, length(needle)) == needle)
return i;
return -1;
}
function tag_is_reserved(tag) {
for (let value in RESERVED_RUNTIME_TAGS)
if (tag == as_string(value))
return true;
return false;
}
function runtime_tag(base, postfix) {
base = as_string(base);
postfix = as_string(postfix);
let candidate = base + "-" + postfix;
let suffix = 1;
if (match(base, /-[0-9]/) != null) {
let dash = str_last_index(base, "-");
let parent = dash >= 0 ? substr(base, 0, dash) : base;
if (tag_is_reserved(parent + "-" + postfix)) {
candidate = base + "-" + suffix + "-" + postfix;
suffix++;
}
}
while (tag_is_reserved(candidate)) {
candidate = base + "-" + suffix + "-" + postfix;
suffix++;
}
return candidate;
return runtime_constants.tag(base, postfix);
}
function read_json_file(path) {
+112 -23
View File
@@ -521,9 +521,11 @@ function rewrite_subscription_outbound_references(outbounds, tag_map) {
if (type(outbound) != "object")
continue;
let detour = as_string(outbound.detour || "");
if (detour != "" && tag_map[detour])
outbound.detour = tag_map[detour];
for (let key in [ "detour", "default" ]) {
let reference = as_string(outbound[key] || "");
if (reference != "" && tag_map[reference])
outbound[key] = tag_map[reference];
}
if (type(outbound.outbounds) == "array") {
let rewritten = [];
@@ -570,6 +572,37 @@ function unique_tag(base, taken) {
return base + "-overflow";
}
function reserved_runtime_tag_set(outbounds) {
let result = {};
for (let tag_name in keys(object_or_empty(runtime_constants.RESERVED_TAGS)))
result[tag_name] = true;
for (let outbound in array_or_empty(outbounds)) {
if (type(outbound) != "object")
continue;
let tag_name = as_string(outbound.tag || "");
if (tag_name != "")
result[tag_name] = true;
}
return result;
}
function assert_unique_outbound_tags(config) {
let seen = {};
for (let outbound in array_or_empty(config.outbounds)) {
if (type(outbound) != "object")
runtime_generate_unsupported("generated sing-box outbound is not an object");
let tag_name = as_string(outbound.tag || "");
if (tag_name == "")
runtime_generate_unsupported("generated sing-box outbound has an empty tag");
if (seen[tag_name])
runtime_generate_unsupported("generated sing-box config has duplicate outbound tag '" + tag_name + "'");
seen[tag_name] = true;
}
}
function add_subscription_source_with_state(config, section, source_index, source_entry, taken, selector_tags, urltest_candidate_tags, state, show_metadata, include_urltest_groups, hide_urltest_group_outbounds, hide_detour_outbounds, node_prefix) {
let section_name = section[".name"];
let source_section = runtime_subscription.source_id(section_name, source_index);
@@ -1827,30 +1860,77 @@ function parse_outbound_json(value) {
return type(value) == "object" ? value : null;
}
function add_json_connection_outbound(config, state, section, json_index, outbound_json, taken, selector_tags, urltest_candidate_tags) {
let outbound = parse_outbound_json(outbound_json);
if (outbound == null)
runtime_generate_unsupported("JSON outbound is invalid");
function rewrite_json_outbound_references(outbounds, tag_map) {
for (let outbound in array_or_empty(outbounds)) {
if (type(outbound) != "object")
continue;
let display_name = as_string(outbound.remark || outbound.tag || ("JSON outbound " + json_index));
let tag_name = connection_item_tag(section[".name"], "json", json_index);
if (taken[tag_name])
tag_name = unique_tag(tag_name, taken);
taken[tag_name] = true;
for (let key in [ "detour", "default" ]) {
let reference = as_string(outbound[key] || "");
if (reference != "" && tag_map[reference])
outbound[key] = tag_map[reference];
}
outbound.tag = tag_name;
push(config.outbounds, outbound);
push(selector_tags, tag_name);
if (urltest_leaf_candidate_outbound(outbound))
push(urltest_candidate_tags, tag_name);
runtime_subscription.remember_outbound_metadata(state, tag_name, display_name, outbound);
runtime_subscription.remember_urltest_group(state, tag_name, display_name, outbound);
if (type(outbound.outbounds) == "array") {
let rewritten = [];
for (let tag_name in outbound.outbounds) {
tag_name = as_string(tag_name);
if (tag_name != "")
push(rewritten, as_string(tag_map[tag_name] || tag_name));
}
outbound.outbounds = rewritten;
}
}
}
function prepare_json_connection_outbounds(section, taken) {
let items = connections.outbound_jsons(section);
let prepared = [];
let outbounds = [];
let tag_map = {};
let legacy_tags = [];
for (let i = 0; i < length(items); i++) {
let outbound = parse_outbound_json(items[i]);
if (outbound == null)
runtime_generate_unsupported("JSON outbound is invalid");
let display_name = trim(as_string(outbound.tag || ""));
let legacy_tag = connection_item_tag(section[".name"], "json", i + 1);
let base = display_name != "" ? display_name : legacy_tag;
let tag_name = unique_tag(base, taken);
taken[tag_name] = true;
if (display_name != "" && !tag_map[display_name])
tag_map[display_name] = tag_name;
push(legacy_tags, [ legacy_tag, tag_name ]);
outbound.tag = tag_name;
push(outbounds, outbound);
push(prepared, {
outbound,
displayName: display_name != "" ? display_name : "JSON outbound " + (i + 1)
});
}
for (let entry in legacy_tags)
if (!tag_map[entry[0]])
tag_map[entry[0]] = entry[1];
rewrite_json_outbound_references(outbounds, tag_map);
return prepared;
}
function add_connection_json_outbounds(config, state, section, taken, selector_tags, urltest_candidate_tags) {
let items = connections.outbound_jsons(section);
for (let i = 0; i < length(items); i++)
add_json_connection_outbound(config, state, section, i + 1, items[i], taken, selector_tags, urltest_candidate_tags);
for (let item in prepare_json_connection_outbounds(section, taken)) {
let outbound = item.outbound;
let tag_name = outbound.tag;
push(config.outbounds, outbound);
push(selector_tags, tag_name);
if (urltest_leaf_candidate_outbound(outbound))
push(urltest_candidate_tags, tag_name);
runtime_subscription.remember_outbound_metadata(state, tag_name, item.displayName, outbound);
runtime_subscription.remember_urltest_group(state, tag_name, item.displayName, outbound);
}
}
function add_connections_outbound(config, section, taken) {
@@ -2365,6 +2445,14 @@ function reserve_section_outbound_tags(sections, taken) {
if (connections.is_connections_action(action) ||
action == "byedpi" || action == "zapret" || action == "zapret2")
taken[outbound_tag(section[".name"])] = true;
if (!connections.is_connections_action(action))
continue;
for (let urltest_id in connections.urltests(section))
taken[urltest_outbound_tag(section[".name"], urltest_id)] = true;
for (let group_id in connections.priority_groups(section))
taken[priority_outbound_tag(section[".name"], group_id)] = true;
}
}
@@ -2482,7 +2570,7 @@ function generate_config(output_path, service_address, mwan3_active) {
runtime_generate_unsupported("no enabled sections");
let config = base_config(settings, service_address, { mwan3_active: cli_bool(mwan3_active) });
let taken = { [runtime_constants.DIRECT_OUTBOUND_TAG]: true };
let taken = reserved_runtime_tag_set(config.outbounds);
reserve_section_outbound_tags(sections, taken);
for (let server in servers)
runtime_servers.add_server(config, server);
@@ -2496,6 +2584,7 @@ function generate_config(output_path, service_address, mwan3_active) {
for (let section in sections)
add_mixed_proxy_for_section(config, section, service_address);
assert_unique_outbound_tags(config);
strip_internal_fields(config);
if (!write_json_file(output_path, config)) {
warn("failed to write ", output_path, "\n");
+6 -25
View File
@@ -2180,12 +2180,8 @@ function xray_transport_from_stream(stream) {
return null;
}
function xray_display_name(original_tag, tag_counts, config_index) {
function xray_display_name(original_tag) {
original_tag = as_string(original_tag);
if (original_tag == "")
return "";
if (int(tag_counts[original_tag] || 0) > 1)
return original_tag + " #" + (config_index + 1);
return original_tag;
}
@@ -2488,21 +2484,7 @@ function xray_balancer_group_base(plan, display_name, config_index, config_count
return xray_tag_base("urltest", config_index, config_count);
}
function xray_tag_counts(configs) {
let counts = {};
for (let config in configs) {
for (let outbound in array_or_empty(config.outbounds)) {
if (type(outbound) != "object" || !xray_protocol_supported(outbound.protocol))
continue;
let tag = xray_source_tag(outbound, lc(as_string(outbound.protocol || "server")));
counts[tag] = (counts[tag] || 0) + 1;
}
}
return counts;
}
function xray_plan_config_outbounds(config, config_index, config_count, tag_counts, taken) {
function xray_plan_config_outbounds(config, config_index, config_count, taken) {
let source_outbounds = array_or_empty(config.outbounds);
let planned = [];
let tag_map = {};
@@ -2563,9 +2545,9 @@ function xray_add_converted_outbound(result, item, tag_map, visible, display_nam
push(result, converted);
}
function xray_config_outbounds(config, config_index, config_count, tag_counts, taken) {
function xray_config_outbounds(config, config_index, config_count, taken) {
let source_outbounds = array_or_empty(config.outbounds);
let plan = xray_plan_config_outbounds(config, config_index, config_count, tag_counts, taken);
let plan = xray_plan_config_outbounds(config, config_index, config_count, taken);
let planned = array_or_empty(plan.planned);
let tag_map = object_or_empty(plan.tag_map);
let display_name = xray_config_display_name(config, "");
@@ -2620,7 +2602,7 @@ function xray_config_outbounds(config, config_index, config_count, tag_counts, t
if (visible || dependency_tags[item.original_tag] || visible_source_tags[item.original_tag] === false) {
let item_display_name = visible && display_name != ""
? display_name
: xray_display_name(item.original_tag, tag_counts, config_index);
: xray_display_name(item.original_tag);
xray_add_converted_outbound(result, item, tag_map, !hidden, item_display_name);
}
}
@@ -2642,7 +2624,6 @@ function is_xray_config(value) {
function normalize_xray_configs(configs) {
configs = array_or_empty(configs);
let tag_counts = xray_tag_counts(configs);
let taken = {};
let outbounds = [];
@@ -2651,7 +2632,7 @@ function normalize_xray_configs(configs) {
if (!is_xray_config(config))
continue;
for (let outbound in xray_config_outbounds(config, i, length(configs), tag_counts, taken))
for (let outbound in xray_config_outbounds(config, i, length(configs), taken))
push(outbounds, outbound);
}
@@ -33,6 +33,10 @@ if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"' "$BYEDPI_RUNTIME_UC"
fi
grep -Fq 'require("core.uci")' "$BYEDPI_RUNTIME_UC" ||
fail "providers/byedpi/runtime.uc must use core.uci for runtime UCI access"
grep -Fq 'require("singbox.constants")' "$BYEDPI_RUNTIME_UC" ||
fail "providers/byedpi/runtime.uc must share the sing-box runtime tag allocator"
grep -Fq 'runtime_constants.tag(base, postfix)' "$BYEDPI_RUNTIME_UC" ||
fail "providers/byedpi/runtime.uc must allocate section tags through singbox.constants"
grep -Fq 'mode == "start-runtime"' "$BYEDPI_RUNTIME_UC" ||
fail "providers/byedpi/runtime.uc must own ByeDPI start"
@@ -126,8 +126,8 @@ assert(outbound("independent").detour == "hop-out", "independent subscription ou
assert(outbound("provider-group").detour == null, "subscription URLTest group must not receive Dial Fields");
assert(outbound("source-interface-1-out").bind_interface == "wg0", "interface binding was not preserved");
assert(outbound("source-interface-1-out").detour == null, "network interface must be excluded from section cascade");
assert(outbound("source-json-1-out").detour == null, "plain JSON outbound must be excluded from section cascade");
assert(outbound("source-json-2-out").detour == "direct-out", "explicit JSON detour was not preserved");
assert(outbound("JSON plain").detour == null, "plain JSON outbound must be excluded from section cascade");
assert(outbound("JSON explicit").detour == "direct-out", "explicit JSON detour was not preserved");
assert(outbound("source-out").detour == null, "section selector must not receive its own detour");
' "$WORK_DIR/output.json" || fail "section cascade generation"
+6
View File
@@ -47,6 +47,12 @@ assert_eq dns-server-out-1 \
assert_eq direct-out-1 \
"$(ucode "$HELPERS_UC" outbound-tag direct)" \
"reserved direct outbound tag"
assert_eq bypass-out-1 \
"$(ucode "$HELPERS_UC" outbound-tag bypass)" \
"reserved bypass outbound tag"
assert_eq tproxy6-in-1 \
"$(ucode "$HELPERS_UC" inbound-tag tproxy6)" \
"reserved IPv6 TProxy inbound tag"
assert_eq direct-1-out \
"$(ucode "$HELPERS_UC" outbound-tag direct-1)" \
"numbered direct outbound tag"
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PODKOP_LIB="$ROOT_DIR/podkop/files/usr/lib"
GENERATOR_UC="$PODKOP_LIB/singbox/generator.uc"
PARSER_UC="$PODKOP_LIB/subscription/parser.uc"
VALIDATOR_UC="$PODKOP_LIB/config/validator.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
mkdir -p "$WORK_DIR/subscriptions"
cat >"$WORK_DIR/subscriptions/proxy-subscription-1.json" <<'JSON'
{
"outbounds": [
{ "type": "socks", "tag": "germany", "server": "127.0.0.1", "server_port": 1101 },
{ "type": "socks", "tag": "germany", "server": "127.0.0.1", "server_port": 1102 },
{ "type": "socks", "tag": "bypass-out", "server": "127.0.0.1", "server_port": 1103 },
{ "type": "socks", "tag": "proxy-urltest-ut_name-out", "server": "127.0.0.1", "server_port": 1104 },
{ "type": "socks", "tag": "json-hop", "server": "127.0.0.1", "server_port": 1105 },
{ "type": "socks", "tag": "proxy-priority-pg_main-out", "server": "127.0.0.1", "server_port": 1106 },
{ "type": "socks", "tag": "dns-server", "server": "127.0.0.1", "server_port": 1107 }
]
}
JSON
printf '%s\n' 'https://duplicate.example/sub' >"$WORK_DIR/subscriptions/proxy-subscription-1.url"
: >"$WORK_DIR/subscriptions/proxy-subscription-1.user_agent"
cat >"$WORK_DIR/runtime-tags.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "log_level": "warn" },
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"subscription_urls": [ "https://duplicate.example/sub" ],
"interfaces": [ "wg0" ],
"outbound_jsons": [
"{\"type\":\"socks\",\"tag\":\"germany\",\"server\":\"127.0.0.1\",\"server_port\":1201}",
"{\"type\":\"socks\",\"tag\":\"germany\",\"server\":\"127.0.0.1\",\"server_port\":1203}",
"{\"type\":\"direct\",\"tag\":\"direct-out\"}",
"{\"type\":\"socks\",\"tag\":\"json-hop\",\"server\":\"127.0.0.1\",\"server_port\":1202}",
"{\"type\":\"selector\",\"tag\":\"json-group\",\"outbounds\":[\"proxy-json-4-out\",\"direct-out\"],\"default\":\"json-hop\"}",
"{\"type\":\"direct\",\"tag\":\"service-mixed-in\"}"
],
"domain_suffix": [ "example.org" ]
},
{
".name": "backup",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"type\":\"socks\",\"tag\":\"json-hop\",\"server\":\"127.0.0.1\",\"server_port\":1301}"
],
"domain_suffix": [ "backup.example.org" ]
}
],
"urltest": [
{
".name": "ut_name",
".type": "urltest",
"section": "proxy",
"name": "By name",
"filter_mode": "include",
"include_outbounds": [ "germany", "wg0", "json-hop" ]
},
{
".name": "ut_regex",
".type": "urltest",
"section": "proxy",
"name": "By regex",
"filter_mode": "include",
"include_regex": [ "^germany$" ]
}
],
"priority_group": [
{
".name": "pg_main",
".type": "priority_group",
"section": "proxy",
"name": "Priority"
}
],
"priority_level": [
{
".name": "pl_main",
".type": "priority_level",
"group": "pg_main",
"name": "All",
"order": "0",
"filter_mode": "disabled"
}
]
}
JSON
runtime_config="$WORK_DIR/runtime-config.json"
mkdir -p "$runtime_config.section-cache"
TMP_SUBSCRIPTION_FOLDER="$WORK_DIR/subscriptions" \
ucode -L "$PODKOP_LIB" "$GENERATOR_UC" generate-config-fixture \
"$WORK_DIR/runtime-tags.json" "$runtime_config" "127.0.0.1" "0"
ucode -e '
let fs = require("fs");
let config = json(fs.readfile(ARGV[0]));
let proxy_cache = json(fs.readfile(ARGV[1]));
let backup_cache = json(fs.readfile(ARGV[2]));
let by_tag = {};
let count = 0;
for (let outbound in config.outbounds || []) {
if (by_tag[outbound.tag])
die("duplicate generated outbound tag: " + outbound.tag + "\n");
by_tag[outbound.tag] = outbound;
count++;
}
if (length(keys(by_tag)) != count)
die("generated outbound tags are not globally unique\n");
for (let tag in [
"direct-out", "bypass-out", "proxy-out", "backup-out",
"germany", "germany-1", "germany-2", "germany-3",
"bypass-out-1", "dns-server-1", "service-mixed-in-1",
"proxy-urltest-ut_name-out-1", "proxy-priority-pg_main-out-1",
"proxy-interface-1-out", "direct-out-1", "json-hop", "json-hop-1", "json-hop-2",
"json-group", "proxy-urltest-ut_name-out", "proxy-urltest-ut_regex-out",
"proxy-priority-pg_main-out"
])
if (!by_tag[tag])
die("missing allocated outbound tag: " + tag + "\n");
let json_group = by_tag["json-group"];
if (length(json_group.outbounds || []) != 2 ||
json_group.outbounds[0] != "json-hop-1" || json_group.outbounds[1] != "direct-out-1")
die("JSON outbound references were not rewritten to allocated tags\n");
if (json_group.default != "json-hop-1")
die("JSON selector default was not rewritten to its allocated tag\n");
let name_group = by_tag["proxy-urltest-ut_name-out"];
let regex_group = by_tag["proxy-urltest-ut_regex-out"];
if (length(name_group.outbounds || []) != 7)
die("display-name filter did not select duplicate subscription, JSON and interface outbounds\n");
for (let tag in [ "germany", "germany-1", "germany-2", "germany-3", "proxy-interface-1-out", "json-hop", "json-hop-1" ])
if (index(name_group.outbounds, tag) < 0)
die("display-name filter is missing " + tag + "\n");
if (length(regex_group.outbounds || []) != 4)
die("display-name regex did not collapse runtime suffixes\n");
for (let tag in [ "germany", "germany-1", "germany-2", "germany-3" ])
if (index(regex_group.outbounds, tag) < 0)
die("display-name regex is missing " + tag + "\n");
let names = proxy_cache.outboundMetadata.names || {};
for (let tag in [ "germany", "germany-1", "germany-2", "germany-3" ])
if (names[tag] != "germany")
die("runtime suffix leaked into display name for " + tag + "\n");
if (names["proxy-interface-1-out"] != "wg0" ||
names["json-hop"] != "json-hop" || names["json-hop-1"] != "json-hop")
die("interface or JSON display tag metadata is missing\n");
if (names["bypass-out-1"] != "bypass-out" ||
names["dns-server-1"] != "dns-server" ||
names["service-mixed-in-1"] != "service-mixed-in" ||
names["proxy-urltest-ut_name-out-1"] != "proxy-urltest-ut_name-out" ||
names["proxy-priority-pg_main-out-1"] != "proxy-priority-pg_main-out")
die("system-tag collision suffix leaked into display metadata\n");
if ((backup_cache.outboundMetadata.names || {})["json-hop-2"] != "json-hop")
die("cross-section JSON tag allocation or display metadata is invalid\n");
' "$runtime_config" "$runtime_config.section-cache/proxy.json" \
"$runtime_config.section-cache/backup.json" || fail "runtime outbound tag allocation"
cat >"$WORK_DIR/duplicate-json-tags.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings" },
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [
"{\"type\":\"direct\",\"tag\":\"same\"}",
"{\"type\":\"direct\",\"tag\":\"same\"}"
],
"domain_suffix": [ "example.org" ]
}
]
}
JSON
cat >"$WORK_DIR/tagless-json.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings" },
"section": [
{
".name": "proxy",
".type": "section",
"enabled": "1",
"action": "connection",
"outbound_jsons": [ "{\"type\":\"direct\"}" ],
"domain_suffix": [ "example.org" ]
}
]
}
JSON
validate_rejects() {
local fixture="$1"
local expected="$2"
local output
if output="$(PODKOP_LIB="$PODKOP_LIB" ucode -L "$PODKOP_LIB" "$VALIDATOR_UC" \
validate-runtime-fixture "$fixture" '{}' 2>/dev/null)"; then
fail "validator accepted $fixture"
fi
printf '%s\n' "$output" | grep -Fq "$expected" ||
fail "validator message for $fixture does not contain: $expected"
}
validate_rejects "$WORK_DIR/duplicate-json-tags.json" "duplicate JSON outbound tag 'same'"
validate_rejects "$WORK_DIR/tagless-json.json" "JSON outbound without a non-empty tag"
cat >"$WORK_DIR/xray-duplicates.json" <<'JSON'
[
{
"outbounds": [
{
"protocol": "socks",
"tag": "germany",
"settings": { "servers": [ { "address": "127.0.0.1", "port": 1401 } ] }
}
]
},
{
"outbounds": [
{
"protocol": "socks",
"tag": "germany",
"settings": { "servers": [ { "address": "127.0.0.1", "port": 1402 } ] }
}
]
}
]
JSON
ucode -L "$PODKOP_LIB" "$PARSER_UC" normalize-content \
"$WORK_DIR/xray-duplicates.json" "$WORK_DIR/xray-normalized.json"
ucode -e '
let fs = require("fs");
let value = json(fs.readfile(ARGV[0]));
let outbounds = value.outbounds || [];
if (length(outbounds) != 2 || outbounds[0].tag != "germany" || outbounds[1].tag != "germany-1")
die("Xray duplicate runtime tags were not allocated\n");
if (outbounds[0].remark != "germany" || outbounds[1].remark != "germany")
die("Xray runtime suffix leaked into user-visible names\n");
' "$WORK_DIR/xray-normalized.json" || fail "Xray duplicate display names"
printf 'outbound tag regression checks passed\n'
@@ -86,6 +86,10 @@ if grep -n -E 'require\("uci"\)\.cursor|uci -q|"uci", "-q"|command_output\(comma
fi
grep -Fq 'require("core.uci")' "$NFQUEUE_RUNTIME" ||
fail "providers/nfqueue/runtime.uc must use core.uci for runtime UCI access"
grep -Fq 'require("singbox.constants")' "$NFQUEUE_RUNTIME" ||
fail "providers/nfqueue/runtime.uc must share the sing-box runtime tag allocator"
grep -Fq 'runtime_constants.tag(base, postfix)' "$NFQUEUE_RUNTIME" ||
fail "providers/nfqueue/runtime.uc must allocate section tags through singbox.constants"
for mode in \
'mode == "start-runtime"' \