feat: карточки сервисов на дашборде + секция Hosts в правилах + скрытие вкладки Conditions для Hosts

Дашборд:
- Добавлены карточки статуса Zapret, Zapret2, ByeDpi на панель мониторинга
- Показывают статус (работает/остановлен), количество перезапусков, предупреждения
- Баг: API возвращает boolean а не число — исправлено сравнение

Секция Hosts:
- Перенесены опции hosts из глобальных настроек в отдельный тип секции (action=hosts)
- Опции: пользовательские DNS записи, удалённые списки хостов, автообновление, интервал
- Миграция: автоматический перенос глобальных настроек hosts в секцию
- Вкладка «Условия» скрыта для секций типа Hosts (hosts записи применяются глобально)
- Возвращена кнопка «Import Hosts File» с модальным окном загрузки из файла
- Убраны неиспользуемые опции из settings.js (~280 строк)

Локализация:
- Переведены 6 новых строк для превью импорта hosts
This commit is contained in:
Dushnilin
2026-08-06 14:16:18 +03:00
parent 488bbfefd5
commit 7125e1a48e
23 changed files with 1222 additions and 793 deletions
@@ -372,6 +372,12 @@ function isConnectionAction(action?: string) {
);
}
function isServiceAction(action?: string) {
return Boolean(
action && ['zapret', 'zapret2', 'byedpi'].includes(action),
);
}
function hasSubscriptionSources(section: Tachyon.ConfigSection) {
return getSubscriptionSourceCount(section) > 0;
}
@@ -1384,7 +1390,9 @@ export async function getDashboardSections(
configSections
.filter(
(section) =>
section.enabled !== '0' && isConnectionAction(section.action),
section.enabled !== '0' &&
(isConnectionAction(section.action) ||
isServiceAction(section.action)),
)
.map(async (section) => {
const displayName = getDisplayName(section);
@@ -1497,6 +1505,98 @@ export async function getDashboardSections(
};
}
if (isServiceAction(sectionAction)) {
const serviceType = sectionAction as
| 'zapret'
| 'zapret2'
| 'byedpi';
let serviceStatus: Tachyon.ServiceStatus | undefined;
try {
if (serviceType === 'zapret') {
const result = await TachyonShellMethods.getZapretStatus();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: 'zapret',
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: s.restart_count,
unstable: Boolean(s.runtime_unstable),
statusMessage: s.status_message,
};
}
} else if (serviceType === 'zapret2') {
const result = await TachyonShellMethods.getZapret2Status();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: 'zapret2',
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: 0,
unstable: false,
statusMessage: s.status_message,
};
}
} else if (serviceType === 'byedpi') {
const result = await TachyonShellMethods.getByedpiStatus();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: 'byedpi',
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: s.restart_count,
unstable: Boolean(s.runtime_unstable),
statusMessage: s.status_message,
};
}
}
} catch (_error) {
// Status fetch failed — show section without status
}
const statusLabel = serviceStatus
? serviceStatus.ready
? _('Running')
: serviceStatus.conflict
? _('Conflict')
: serviceStatus.configured
? _('Stopped')
: _('Not configured')
: _('Unknown');
return {
withTagSelect: false,
code: sectionName,
sectionName,
displayName,
action: sectionAction,
serviceStatus,
outbounds: [
{
code: sectionName,
displayName: statusLabel,
latency: 0,
type: serviceType.toUpperCase(),
selected: true,
canCopyLink: false,
},
],
};
}
return {
withTagSelect: false,
code: sectionName,
@@ -995,7 +995,7 @@ describe('getDashboardSections', () => {
});
});
it('does not expose ByeDPI sections on the dashboard', async () => {
it('exposes ByeDPI sections on the dashboard as service cards', async () => {
mocks.getConfigSections.mockResolvedValue([
proxySection(),
{
@@ -1021,7 +1021,10 @@ describe('getDashboardSections', () => {
const result = await getDashboardSections();
expect(result.success).toBe(true);
expect(result.data.map((section) => section.sectionName)).toEqual(['main']);
expect(result.data.map((section) => section.sectionName)).toEqual([
'main',
'dpi',
]);
});
it('fetches Clash API proxies directly in the browser to avoid rpcd output limits', async () => {
@@ -270,6 +270,9 @@ function renderDefaultState({
const isConnectionNode = ['vpn', 'awg', 'warp'].includes(
section.action || '',
);
const isServiceNode = ['zapret', 'zapret2', 'byedpi'].includes(
section.action || '',
);
function testLatency() {
if (section.withTagSelect) {
@@ -565,6 +568,139 @@ function renderDefaultState({
);
}
if (isServiceNode && section.serviceStatus) {
const ss = section.serviceStatus;
const statusColor = ss.ready
? 'var(--success-color-medium, green)'
: ss.conflict
? 'var(--error-color-medium, red)'
: ss.configured
? 'var(--warn-color-medium, orange)'
: 'var(--primary-color-low, lightgray)';
const statusText = ss.ready
? _('Running')
: ss.conflict
? _('Conflict')
: ss.configured
? _('Stopped')
: _('Not configured');
const typeLabel =
ss.serviceType === 'zapret'
? 'Zapret'
: ss.serviceType === 'zapret2'
? 'Zapret2'
: 'ByeDPI';
return E(
'div',
{ class: 'tachyon_dashboard-page__outbound-section' },
[
E(
'div',
{
class: 'tachyon_dashboard-page__outbound-section__title-section',
style: 'cursor: default;',
},
[
E(
'div',
{
class:
'tachyon_dashboard-page__outbound-section__title-section__title',
style: 'display: flex; align-items: center; gap: 8px;',
},
[
E('span', {}, section.displayName),
E(
'span',
{
style:
'font-size: 12px; opacity: 0.6; font-weight: normal;',
},
typeLabel,
),
],
),
],
),
E(
'div',
{
style:
'display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 16px 12px;',
},
[
E(
'div',
{ style: 'display: flex; align-items: center; gap: 6px;' },
[
E(
'span',
{ style: 'opacity: 0.7; font-size: 13px;' },
_('Status') + ':',
),
E(
'span',
{
style: `font-size: 13px; font-weight: 500; color: ${statusColor};`,
},
statusText,
),
],
),
ss.restartCount > 0
? E(
'div',
{ style: 'display: flex; align-items: center; gap: 6px;' },
[
E(
'span',
{ style: 'opacity: 0.7; font-size: 13px;' },
_('Restarts') + ':',
),
E(
'span',
{
style:
'font-size: 13px; font-weight: 500; color: var(--warn-color-medium, orange);',
},
`${ss.restartCount}`,
),
],
)
: '',
ss.unstable
? E(
'div',
{ style: 'display: flex; align-items: center; gap: 6px;' },
[
E(
'span',
{
style:
'font-size: 13px; font-weight: 500; color: var(--error-color-medium, red);',
},
_('Unstable'),
),
],
)
: '',
],
),
ss.statusMessage
? E(
'div',
{
style:
'padding: 0 16px 8px; font-size: 12px; opacity: 0.6; word-break: break-word;',
},
ss.statusMessage,
)
: '',
],
);
}
const metadataNodes = (section.subscriptionMetadata || [])
.map((metadata) => renderSubscriptionMetadata(metadata))
.filter(Boolean) as HTMLElement[];
+16 -1
View File
@@ -163,6 +163,18 @@ export namespace Tachyon {
outbounds: PriorityMember[];
}
export interface ServiceStatus {
serviceType: 'zapret' | 'zapret2' | 'byedpi';
configured: boolean;
ready: boolean;
conflict: boolean;
runningProcesses: number;
expectedProcesses: number;
restartCount: number;
unstable: boolean;
statusMessage: string;
}
export interface OutboundGroup {
withTagSelect: boolean;
code: string;
@@ -175,6 +187,7 @@ export namespace Tachyon {
proxyConfigType?: ProxyConfigType;
subscriptionSourceCount?: number;
subscriptionMetadata?: SubscriptionMetadata[];
serviceStatus?: ServiceStatus;
outbounds: Outbound[];
}
@@ -219,7 +232,9 @@ export namespace Tachyon {
| 'zapret2'
| 'byedpi'
| 'awg'
| 'warp';
| 'warp'
| 'dns'
| 'hosts';
type LegacyConnectionType = 'proxy' | 'vpn' | 'block' | 'exclusion';
type ProxyConfigType =
| 'urltest'
@@ -2129,6 +2129,9 @@ function renderDefaultState({
const isConnectionNode = ["vpn", "awg", "warp"].includes(
section.action || ""
);
const isServiceNode = ["zapret", "zapret2", "byedpi"].includes(
section.action || ""
);
function testLatency() {
if (section.withTagSelect) {
return onTestLatency(
@@ -2362,6 +2365,108 @@ function renderDefaultState({
]
);
}
if (isServiceNode && section.serviceStatus) {
const ss = section.serviceStatus;
const statusColor = ss.ready ? "var(--success-color-medium, green)" : ss.conflict ? "var(--error-color-medium, red)" : ss.configured ? "var(--warn-color-medium, orange)" : "var(--primary-color-low, lightgray)";
const statusText = ss.ready ? _("Running") : ss.conflict ? _("Conflict") : ss.configured ? _("Stopped") : _("Not configured");
const typeLabel = ss.serviceType === "zapret" ? "Zapret" : ss.serviceType === "zapret2" ? "Zapret2" : "ByeDPI";
return E(
"div",
{ class: "tachyon_dashboard-page__outbound-section" },
[
E(
"div",
{
class: "tachyon_dashboard-page__outbound-section__title-section",
style: "cursor: default;"
},
[
E(
"div",
{
class: "tachyon_dashboard-page__outbound-section__title-section__title",
style: "display: flex; align-items: center; gap: 8px;"
},
[
E("span", {}, section.displayName),
E(
"span",
{
style: "font-size: 12px; opacity: 0.6; font-weight: normal;"
},
typeLabel
)
]
)
]
),
E(
"div",
{
style: "display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 16px 12px;"
},
[
E(
"div",
{ style: "display: flex; align-items: center; gap: 6px;" },
[
E(
"span",
{ style: "opacity: 0.7; font-size: 13px;" },
_("Status") + ":"
),
E(
"span",
{
style: `font-size: 13px; font-weight: 500; color: ${statusColor};`
},
statusText
)
]
),
ss.restartCount > 0 ? E(
"div",
{ style: "display: flex; align-items: center; gap: 6px;" },
[
E(
"span",
{ style: "opacity: 0.7; font-size: 13px;" },
_("Restarts") + ":"
),
E(
"span",
{
style: "font-size: 13px; font-weight: 500; color: var(--warn-color-medium, orange);"
},
`${ss.restartCount}`
)
]
) : "",
ss.unstable ? E(
"div",
{ style: "display: flex; align-items: center; gap: 6px;" },
[
E(
"span",
{
style: "font-size: 13px; font-weight: 500; color: var(--error-color-medium, red);"
},
_("Unstable")
)
]
) : ""
]
),
ss.statusMessage ? E(
"div",
{
style: "padding: 0 16px 8px; font-size: 12px; opacity: 0.6; word-break: break-word;"
},
ss.statusMessage
) : ""
]
);
}
const metadataNodes = (section.subscriptionMetadata || []).map((metadata) => renderSubscriptionMetadata(metadata)).filter(Boolean);
const subscriptionUpdateAction = renderSubscriptionUpdateAction(
section,
@@ -3789,6 +3894,11 @@ function isConnectionAction(action) {
)
);
}
function isServiceAction(action) {
return Boolean(
action && ["zapret", "zapret2", "byedpi"].includes(action)
);
}
function hasSubscriptionSources(section) {
return getSubscriptionSourceCount(section) > 0;
}
@@ -4471,7 +4581,7 @@ async function getDashboardSections(options = {}) {
);
const data = await Promise.all(
configSections.filter(
(section) => section.enabled !== "0" && isConnectionAction(section.action)
(section) => section.enabled !== "0" && (isConnectionAction(section.action) || isServiceAction(section.action))
).map(async (section) => {
const displayName = getDisplayName(section);
const sectionName = section[".name"];
@@ -4555,6 +4665,81 @@ async function getDashboardSections(options = {}) {
]
};
}
if (isServiceAction(sectionAction)) {
const serviceType = sectionAction;
let serviceStatus;
try {
if (serviceType === "zapret") {
const result = await TachyonShellMethods.getZapretStatus();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: "zapret",
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: s.restart_count,
unstable: Boolean(s.runtime_unstable),
statusMessage: s.status_message
};
}
} else if (serviceType === "zapret2") {
const result = await TachyonShellMethods.getZapret2Status();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: "zapret2",
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: 0,
unstable: false,
statusMessage: s.status_message
};
}
} else if (serviceType === "byedpi") {
const result = await TachyonShellMethods.getByedpiStatus();
if (result.success) {
const s = result.data;
serviceStatus = {
serviceType: "byedpi",
configured: Boolean(s.configured),
ready: Boolean(s.ready),
conflict: Boolean(s.conflict),
runningProcesses: s.running_process_count,
expectedProcesses: s.expected_process_count,
restartCount: s.restart_count,
unstable: Boolean(s.runtime_unstable),
statusMessage: s.status_message
};
}
}
} catch (_error) {
}
const statusLabel = serviceStatus ? serviceStatus.ready ? _("Running") : serviceStatus.conflict ? _("Conflict") : serviceStatus.configured ? _("Stopped") : _("Not configured") : _("Unknown");
return {
withTagSelect: false,
code: sectionName,
sectionName,
displayName,
action: sectionAction,
serviceStatus,
outbounds: [
{
code: sectionName,
displayName: statusLabel,
latency: 0,
type: serviceType.toUpperCase(),
selected: true,
canCopyLink: false
}
]
};
}
return {
withTagSelect: false,
code: sectionName,
@@ -4212,6 +4212,8 @@ function getActionOptionLabel(action) {
return "MASQUE";
case "openvpn":
return "OpenVPN";
case "hosts":
return _("Hosts");
case "outbound":
return _("JSON outbound");
case "proxy":
@@ -4301,6 +4303,7 @@ function populateActionOptionValues(option) {
if (isByedpiInstalledForUi()) {
option.value("byedpi", getActionOptionLabel("byedpi"));
}
option.value("hosts", getActionOptionLabel("hosts"));
}
@@ -7416,6 +7419,537 @@ function createSectionContent(section) {
configureTextareaOption(o, analyzeByedpiStrategy);
// ─── Hosts Import Helpers ──────────────────────────────────────────────
function parseHostsFile(text, options) {
options = options || {};
const skipLoopback = options.skipLoopback !== false;
const lines = (text || "").split(/\r?\n/);
const entries = [];
let skippedComments = 0;
let skippedLoopback = 0;
let skippedInvalid = 0;
const loopbackIPs = new Set([
"127.0.0.1", "0.0.0.0", "::1",
"0:0:0:0:0:0:0:1", "0000:0000:0000:0000:0000:0000:0000:0001",
]);
const loopbackHosts = new Set([
"localhost", "localhost.localdomain", "local", "broadcasthost",
]);
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
const hashIdx = line.indexOf("#");
if (hashIdx !== -1) {
if (hashIdx === 0) { skippedComments++; continue; }
line = line.substring(0, hashIdx).trim();
if (!line) { skippedComments++; continue; }
}
const parts = line.split(/\s+/);
if (parts.length < 2) { skippedInvalid++; continue; }
const ip = parts[0].trim();
const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
const isIPv6 = ip.indexOf(":") !== -1 && /^[0-9a-fA-F:]+$/.test(ip);
if (!isIPv4 && !isIPv6) { skippedInvalid++; continue; }
const isIpLoopback = loopbackIPs.has(ip);
for (let j = 1; j < parts.length; j++) {
const domain = parts[j].toLowerCase().trim();
if (!domain) continue;
if (skipLoopback && (isIpLoopback || loopbackHosts.has(domain))) {
skippedLoopback++; continue;
}
if (domain.length > 253 || /^[^a-z0-9_.-]/i.test(domain)) {
skippedInvalid++; continue;
}
entries.push({ domain: domain, ip: ip });
}
}
return { entries, skippedComments, skippedLoopback, skippedInvalid, totalLines: lines.length };
}
function showImportHostsModal(section_id, dnsHostsOptRef) {
const fileInput = E("input", { type: "file", accept: ".txt,.hosts,hosts,*", style: "display:none" });
const fileSelectBtn = E("button", {
class: "cbi-button cbi-button-neutral", type: "button",
click: function () { fileInput.click(); },
}, _("Choose Hosts File..."));
const loadCurrentBtn = E("button", {
class: "cbi-button cbi-button-action", type: "button",
click: function () {
let existingEntries = [];
const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l);
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : [];
}
textArea.value = existingEntries.join("\n");
fileStatus.textContent = _("Loaded %d current record(s)").format(existingEntries.length);
updatePreview();
},
}, _("Load Current Records"));
const fileStatus = E("span", {
style: "margin-left:10px;font-size:0.85rem;color:var(--text-color-medium,#888);",
}, _("No file selected"));
const textArea = E("textarea", {
class: "cbi-input-textarea", rows: 8,
style: "width:100%;font-family:monospace;font-size:0.85rem;margin-top:8px;resize:vertical;",
placeholder: "127.0.0.1 localhost\n192.168.1.100 server.local\n10.0.0.5 example.com alias.com",
});
const skipLoopbackCb = E("input", {
type: "checkbox", id: "hosts-skip-loopback", checked: true,
style: "margin-right:6px;vertical-align:middle;",
});
const overwriteCb = E("input", {
type: "checkbox", id: "hosts-overwrite", checked: false,
style: "margin-right:6px;vertical-align:middle;",
});
const previewBox = E("div", {
style: "margin-top:12px;padding:10px 14px;background:var(--background-color-secondary,#f8f9fa);border:1px solid var(--border-color-medium,#e0e0e0);border-radius:4px;font-size:0.85rem;line-height:1.5;",
}, [E("em", {}, _("Paste content or upload a file to preview imported DNS records."))]);
const importBtn = E("button", {
class: "cbi-button cbi-button-action", type: "button", disabled: true,
}, _("Import"));
let currentParsedResult = null;
function updatePreview() {
const text = textArea.value;
const skipLoopback = skipLoopbackCb.checked;
const overwrite = overwriteCb.checked;
if (!text.trim()) {
previewBox.innerHTML = "";
previewBox.appendChild(E("em", {}, _("Paste content or upload a file to preview imported DNS records.")));
importBtn.disabled = true;
importBtn.textContent = _("Import");
currentParsedResult = null;
return;
}
const result = parseHostsFile(text, { skipLoopback: skipLoopback });
currentParsedResult = result;
let existingEntries = [];
const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2) domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
let newCount = 0, updatedCount = 0;
for (const item of result.entries) {
if (domainMap.has(item.domain.toLowerCase())) updatedCount++; else newCount++;
domainMap.set(item.domain.toLowerCase(), item.ip);
}
previewBox.innerHTML = "";
previewBox.appendChild(E("div", {
style: "display:grid;grid-template-columns:1fr 1fr;gap:6px;row-gap:4px;",
}, [
E("div", {}, [E("strong", {}, _("Total lines: ")), `${result.totalLines}`]),
E("div", {}, [E("strong", {}, _("Parsed records: ")), `${result.entries.length}`]),
E("div", {}, [E("strong", {}, _("Skipped loopback: ")), `${result.skippedLoopback}`]),
E("div", {}, [E("strong", {}, _("Skipped comments/invalid: ")), `${result.skippedComments + result.skippedInvalid}`]),
E("div", {}, [E("strong", {}, _("New domains: ")), `${newCount}`]),
E("div", {}, [E("strong", {}, _("Updated/duplicates: ")), `${updatedCount}`]),
]));
if (result.entries.length > 0) {
importBtn.disabled = false;
importBtn.textContent = _("Import (%d records)").format(result.entries.length);
} else {
importBtn.disabled = true;
importBtn.textContent = _("Import");
}
}
fileInput.addEventListener("change", function (ev) {
const file = ev.target.files[0];
if (!file) return;
fileStatus.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
const reader = new FileReader();
reader.onload = function (e) { textArea.value = e.target.result || ""; updatePreview(); };
reader.readAsText(file);
});
textArea.addEventListener("input", updatePreview);
skipLoopbackCb.addEventListener("change", updatePreview);
overwriteCb.addEventListener("change", updatePreview);
importBtn.addEventListener("click", function () {
if (!currentParsedResult || !currentParsedResult.entries.length) return;
const overwrite = overwriteCb.checked;
const uiEl = dnsHostsOptRef ? dnsHostsOptRef.getUIElement(section_id) : null;
let existingEntries = [];
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (typeof v === "string" && v) existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal) ? uciVal : uciVal ? [uciVal] : [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2) domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
for (const item of currentParsedResult.entries) domainMap.set(item.domain.toLowerCase(), item.ip);
const finalEntries = [];
for (const [domain, ip] of domainMap.entries()) finalEntries.push(`${domain} ${ip}`);
uci.set(UCI_PACKAGE, section_id, "dns_hosts", finalEntries.join("\n"));
if (uiEl && typeof uiEl.setValue === "function") uiEl.setValue(finalEntries.join("\n"));
ui.hideModal();
ui.addNotification(null, _("Successfully imported %d DNS record(s)").format(finalEntries.length), "info");
});
ui.showModal(_("Import Hosts File"), [
E("div", { class: "cbi-map" }, [
E("div", { style: "margin-bottom:12px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;" }, [
fileInput, fileSelectBtn, loadCurrentBtn, fileStatus,
]),
E("label", { style: "display:block;margin-bottom:4px;font-weight:bold;" }, _("Or paste hosts file content:")),
textArea,
E("div", { style: "margin-top:10px;" }, [
E("label", { style: "display:flex;align-items:center;cursor:pointer;margin-bottom:6px;" }, [
skipLoopbackCb,
E("span", {}, _("Skip loopback & local entries (127.0.0.1, ::1, 0.0.0.0, localhost)")),
]),
E("label", { style: "display:flex;align-items:center;cursor:pointer;" }, [
overwriteCb,
E("span", {}, _("Overwrite existing records (if unchecked, merge with existing records)")),
]),
]),
previewBox,
]),
E("div", { class: "button-row", style: "margin-top:16px;" }, [
importBtn,
E("button", { class: "btn cbi-button cbi-button-neutral", click: ui.createHandlerFn(this, function () { ui.hideModal(); }) }, _("Cancel")),
]),
]);
}
// ─── Hosts Section Options ──────────────────────────────────────────────
o = section.taboption("settings", form.TextValue, "dns_hosts", _("Custom DNS Records (Hosts)"),
_("One record per line: <code>example.com 192.168.1.100</code> or <code>192.168.1.100 example.com</code>. Lines starting with <code>#</code> are ignored."));
o.depends("action", "hosts");
o.rows = 8;
o.wrap = "soft";
o.textarea = true;
o.modalonly = true;
o.cfgvalue = function (section_id) {
const val = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
if (!val) return "";
if (Array.isArray(val)) return val.join("\n");
return String(val || "");
};
o.write = function (section_id, value) {
const lines = String(value || "").split("\n")
.map(l => l.trim())
.filter(l => l && !l.startsWith("#"));
if (lines.length === 0) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
} else {
uci.set(UCI_PACKAGE, section_id, "dns_hosts", lines.join("\n"));
}
};
o.remove = function (section_id) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
};
o.validate = function (_section_id, value) {
if (!value) return true;
const lines = value.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 2) {
return _('Invalid line: "%s". Use: domain ip or ip domain').format(
trimmed.length > 40 ? trimmed.substring(0, 40) + "\u2026" : trimmed,
);
}
}
return true;
};
const _origDnsHostsRenderWidget = o.renderWidget;
o.renderWidget = function (section_id, option_index, cfgvalue) {
const node = _origDnsHostsRenderWidget.call(this, section_id, option_index, cfgvalue);
const ta = node.tagName === "TEXTAREA" ? node : node.querySelector("textarea");
if (ta) {
ta.style.fontFamily = "monospace";
ta.style.fontSize = "0.85rem";
ta.style.lineHeight = "1.4";
ta.style.resize = "vertical";
}
const countBadge = E("span", {
style: "font-weight:600;font-size:0.85rem;margin-left:auto;color:var(--text-color-medium,#666);",
});
function updateCount() {
const text = ta ? ta.value : "";
const validLines = text.split("\n").filter(l => l.trim() && !l.trim().startsWith("#"));
countBadge.textContent = _("Records: %d").format(validLines.length);
}
if (ta) {
ta.addEventListener("input", updateCount);
setTimeout(updateCount, 50);
}
const sortBtn = E("button", {
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: function () {
if (!ta) return;
const lines = ta.value.split("\n")
.map(l => l.trim())
.filter(Boolean);
const comments = lines.filter(l => l.startsWith("#"));
const records = lines.filter(l => !l.startsWith("#")).sort();
ta.value = [...comments, ...records].join("\n");
updateCount();
},
}, _("Sort Alphabetically"));
const cleanBtn = E("button", {
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: function () {
if (!ta) return;
const lines = ta.value.split("\n")
.map(l => l.trim())
.filter(l => l && !l.startsWith("#"));
ta.value = lines.join("\n");
updateCount();
},
}, _("Clean Comments"));
const importHostsBtn = E("button", {
class: "cbi-button cbi-button-action",
type: "button",
style: "margin-left:6px;",
click: function () {
showImportHostsModal(section_id, o);
},
}, _("Import Hosts File"));
return E("div", {}, [
node,
E("div", { style: "display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;" }, [
sortBtn,
cleanBtn,
importHostsBtn,
countBadge,
]),
]);
};
o = section.taboption("settings", form.DynamicList, "hosts_list_urls", _("Remote Hosts Lists"),
_("URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>."));
o.depends("action", "hosts");
o.placeholder = "https://raw.githubusercontent.com/.../hosts";
o.modalonly = true;
o.renderWidget = function (section_id, _option_index, _cfgvalue) {
const pkg = UCI_PACKAGE;
function getUrls() {
let v = uci.get(pkg, section_id, "hosts_list_urls") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function getDisabled() {
let v = uci.get(pkg, section_id, "hosts_list_disabled") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function setUrls(urls) {
uci.set(pkg, section_id, "hosts_list_urls", urls.length ? urls : null);
}
function setDisabled(disabled) {
uci.set(pkg, section_id, "hosts_list_disabled", disabled.length ? disabled : null);
}
const container = E("div", { class: "cbi-section-node" });
function render() {
container.innerHTML = "";
const urls = getUrls();
const disabled = getDisabled();
const table = E("table", { class: "cbi-section-table", style: "width:100%;" });
const tbody = E("tbody");
urls.forEach(function (url, idx) {
const isOff = disabled.includes(url);
const tr = E("tr", { class: "cbi-section-table-row" });
const tdCheck = E("td", { class: "cbi-section-table-cell", style: "width:30px;text-align:center;" });
const cb = E("input", {
type: "checkbox",
class: "cbi-input-checkbox",
checked: !isOff,
change: function () {
const nowEnabled = this.checked;
let d = getDisabled();
if (nowEnabled) {
d = d.filter(function (x) { return x !== url; });
} else {
if (!d.includes(url)) d.push(url);
}
setDisabled(d);
uci.save();
render();
},
});
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const tdUrl = E("td", { class: "cbi-section-table-cell" });
const urlSpan = E("span", {
style: isOff ? "text-decoration:line-through;opacity:0.6;" : "",
}, url);
tdUrl.appendChild(urlSpan);
tr.appendChild(tdUrl);
const tdStatus = E("td", { class: "cbi-section-table-cell", style: "width:60px;text-align:center;white-space:nowrap;" });
tdStatus.appendChild(E("span", {
style: isOff ? "color:#e53935;" : "color:#2e7d32;",
}, isOff ? "OFF" : "ON"));
tr.appendChild(tdStatus);
const tdDel = E("td", { class: "cbi-section-table-cell", style: "width:40px;text-align:center;" });
const delBtn = E("button", {
class: "btn cbi-button cbi-button-remove",
type: "button",
title: _("Remove"),
click: function () {
let u = getUrls();
let d = getDisabled();
u.splice(idx, 1);
d = d.filter(function (x) { return x !== url; });
setUrls(u);
setDisabled(d);
uci.save();
render();
},
}, "\u2715");
tdDel.appendChild(delBtn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
const addRow = E("div", { class: "cbi-section-create", style: "margin-top:8px;display:flex;gap:4px;align-items:center;" });
const input = E("input", {
type: "text",
class: "cbi-input-text",
placeholder: "https://raw.githubusercontent.com/.../hosts",
style: "flex:1;",
});
const addBtn = E("button", {
class: "btn cbi-button cbi-button-action",
type: "button",
click: function () {
const val = (input.value || "").trim();
if (!val) return;
let u = getUrls();
if (u.includes(val)) { input.value = ""; return; }
u.push(val);
setUrls(u);
uci.save();
input.value = "";
render();
},
}, "+");
input.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") addBtn.click();
});
addRow.appendChild(input);
addRow.appendChild(addBtn);
container.appendChild(addRow);
}
render();
return container;
};
o = section.taboption("settings", form.Flag, "hosts_list_auto_update", _("Auto-update Hosts Lists"),
_("Automatically update remote hosts lists on schedule"));
o.depends("action", "hosts");
o.default = "0";
o.rmempty = false;
o.modalonly = true;
o = section.taboption("settings", form.Value, "hosts_list_update_interval", _("Hosts Lists Update Interval"),
_("Use sing-box duration format like 1d, 12h or 6h"));
o.depends("action", "hosts");
o.depends({ action: "hosts", hosts_list_auto_update: "1" });
o.placeholder = "24h";
o.default = "24h";
o.rmempty = false;
o.modalonly = true;
o.validate = function (_section_id, value) {
if (!value) return true;
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value)
? true
: _("Use sing-box duration format like 1d, 12h or 6h");
};
o = section.taboption(
"settings",
form.Button,
@@ -8678,7 +9212,7 @@ function createSectionContent(section) {
"sudoku", "masque", "openvpn", "zapret", "zapret2", "byedpi"
].forEach((act) => o.depends("action", act));
addTextConditionField(section, {
const domainConditionOption = addTextConditionField(section, {
key: "domain_suffix",
optionName: "domain",
legacyTextOptionName: "domain_suffix_text",
@@ -8706,8 +9240,10 @@ function createSectionContent(section) {
});
},
});
dependsOnRoutingAction(domainConditionOption);
domainConditionOption.depends("action", "dns");
addTextConditionField(section, {
const userDomainsConditionOption = addTextConditionField(section, {
key: "user_domains",
optionName: "user_domains",
legacyTextOptionName: "user_domains_text",
@@ -8717,6 +9253,8 @@ function createSectionContent(section) {
),
textAnalyze: analyzeDomainSuffixText,
});
dependsOnRoutingAction(userDomainsConditionOption);
userDomainsConditionOption.depends("action", "dns");
const ipConditionOption = addTextConditionField(section, {
key: "ip_cidr",
@@ -8737,6 +9275,8 @@ function createSectionContent(section) {
);
builtInRulesetOption.modalonly = true;
builtInRulesetOption.placeholder = _("Service list");
dependsOnRoutingAction(builtInRulesetOption);
builtInRulesetOption.depends("action", "dns");
builtInRulesetOption.load = function (section_id) {
loadRulesetValues(this);
return getBuiltInRulesetReferences(section_id);
@@ -499,446 +499,6 @@ function createSmartDetectSectionsWidget(section_id) {
return wrapper;
}
function parseHostsFile(text, options) {
options = options || {};
const skipLoopback = options.skipLoopback !== false;
const lines = (text || "").split(/\r?\n/);
const entries = [];
let skippedComments = 0;
let skippedLoopback = 0;
let skippedInvalid = 0;
const loopbackIPs = new Set([
"127.0.0.1",
"0.0.0.0",
"::1",
"0:0:0:0:0:0:0:1",
"0000:0000:0000:0000:0000:0000:0000:0001",
]);
const loopbackHosts = new Set([
"localhost",
"localhost.localdomain",
"local",
"broadcasthost",
]);
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
const hashIdx = line.indexOf("#");
if (hashIdx !== -1) {
if (hashIdx === 0) {
skippedComments++;
continue;
}
line = line.substring(0, hashIdx).trim();
if (!line) {
skippedComments++;
continue;
}
}
const parts = line.split(/\s+/);
if (parts.length < 2) {
skippedInvalid++;
continue;
}
const ip = parts[0].trim();
const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
const isIPv6 = ip.indexOf(":") !== -1 && /^[0-9a-fA-F:]+$/.test(ip);
if (!isIPv4 && !isIPv6) {
skippedInvalid++;
continue;
}
const isIpLoopback = loopbackIPs.has(ip);
for (let j = 1; j < parts.length; j++) {
const domain = parts[j].toLowerCase().trim();
if (!domain) continue;
const isHostLoopback = loopbackHosts.has(domain);
if (skipLoopback && (isIpLoopback || isHostLoopback)) {
skippedLoopback++;
continue;
}
if (domain.length > 253 || /^[^a-z0-9_.-]/i.test(domain)) {
skippedInvalid++;
continue;
}
entries.push({ domain: domain, ip: ip });
}
}
return {
entries: entries,
skippedComments: skippedComments,
skippedLoopback: skippedLoopback,
skippedInvalid: skippedInvalid,
totalLines: lines.length,
};
}
function showImportHostsModal(section_id, optionRef) {
const fileInput = E("input", {
type: "file",
accept: ".txt,.hosts,hosts,*",
style: "display:none",
});
const fileSelectBtn = E(
"button",
{
class: "cbi-button cbi-button-neutral",
type: "button",
click: function () {
fileInput.click();
},
},
_("Choose Hosts File..."),
);
const loadCurrentBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
click: function () {
let existingEntries = [];
const uiEl = optionRef ? optionRef.getUIElement(section_id) : null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (Array.isArray(v)) existingEntries = v;
else if (typeof v === "string" && v)
existingEntries = v.split("\n").map(l => l.trim()).filter(l => l);
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
textArea.value = existingEntries.join("\n");
fileStatus.textContent = _("Loaded %d current record(s)").format(existingEntries.length);
updatePreview();
},
},
_("Load Current Records"),
);
const fileStatus = E(
"span",
{
style:
"margin-left:10px;font-size:0.85rem;color:var(--text-color-medium,#888);",
},
_("No file selected"),
);
const textArea = E("textarea", {
class: "cbi-input-textarea",
rows: 8,
style:
"width:100%;font-family:monospace;font-size:0.85rem;margin-top:8px;resize:vertical;",
placeholder:
"127.0.0.1 localhost\n192.168.1.100 server.local\n10.0.0.5 example.com alias.com",
});
const skipLoopbackCb = E("input", {
type: "checkbox",
id: "hosts-skip-loopback",
checked: true,
style: "margin-right:6px;vertical-align:middle;",
});
const overwriteCb = E("input", {
type: "checkbox",
id: "hosts-overwrite",
checked: false,
style: "margin-right:6px;vertical-align:middle;",
});
const previewBox = E(
"div",
{
style:
"margin-top:12px;padding:10px 14px;background:var(--background-color-secondary,#f8f9fa);border:1px solid var(--border-color-medium,#e0e0e0);border-radius:4px;font-size:0.85rem;line-height:1.5;",
},
[
E(
"em",
{},
_("Paste content or upload a file to preview imported DNS records."),
),
],
);
const importBtn = E(
"button",
{
class: "cbi-button cbi-button-action",
type: "button",
disabled: true,
},
_("Import"),
);
let currentParsedResult = null;
function updatePreview() {
const text = textArea.value;
const skipLoopback = skipLoopbackCb.checked;
const overwrite = overwriteCb.checked;
if (!text.trim()) {
previewBox.innerHTML = "";
previewBox.appendChild(
E(
"em",
{},
_("Paste content or upload a file to preview imported DNS records."),
),
);
importBtn.disabled = true;
importBtn.textContent = _("Import");
currentParsedResult = null;
return;
}
const result = parseHostsFile(text, { skipLoopback: skipLoopback });
currentParsedResult = result;
let existingEntries = [];
const uiEl = optionRef ? optionRef.getUIElement(section_id) : null;
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (Array.isArray(v)) existingEntries = v;
else if (typeof v === "string" && v)
existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2) {
domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
}
let newCount = 0;
let updatedCount = 0;
for (const item of result.entries) {
if (domainMap.has(item.domain.toLowerCase())) {
updatedCount++;
} else {
newCount++;
}
domainMap.set(item.domain.toLowerCase(), item.ip);
}
previewBox.innerHTML = "";
previewBox.appendChild(
E(
"div",
{
style:
"display:grid;grid-template-columns:1fr 1fr;gap:6px;row-gap:4px;",
},
[
E("div", {}, [
E("strong", {}, _("Total lines: ")),
`${result.totalLines}`,
]),
E("div", {}, [
E("strong", {}, _("Parsed records: ")),
`${result.entries.length}`,
]),
E("div", {}, [
E("strong", {}, _("Skipped loopback: ")),
`${result.skippedLoopback}`,
]),
E("div", {}, [
E("strong", {}, _("Skipped comments/invalid: ")),
`${result.skippedComments + result.skippedInvalid}`,
]),
E("div", {}, [
E("strong", {}, _("New domains: ")),
`${newCount}`,
]),
E("div", {}, [
E("strong", {}, _("Updated/duplicates: ")),
`${updatedCount}`,
]),
],
),
);
if (result.entries.length > 0) {
importBtn.disabled = false;
importBtn.textContent = _("Import (%d records)").format(
result.entries.length,
);
} else {
importBtn.disabled = true;
importBtn.textContent = _("Import");
}
}
fileInput.addEventListener("change", function (ev) {
const file = ev.target.files[0];
if (!file) return;
fileStatus.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
const reader = new FileReader();
reader.onload = function (e) {
textArea.value = e.target.result || "";
updatePreview();
};
reader.readAsText(file);
});
textArea.addEventListener("input", updatePreview);
skipLoopbackCb.addEventListener("change", updatePreview);
overwriteCb.addEventListener("change", updatePreview);
importBtn.addEventListener("click", function () {
if (!currentParsedResult || !currentParsedResult.entries.length) return;
const overwrite = overwriteCb.checked;
const uiEl = optionRef ? optionRef.getUIElement(section_id) : null;
let existingEntries = [];
if (uiEl && typeof uiEl.getValue === "function") {
const v = uiEl.getValue();
if (Array.isArray(v)) existingEntries = v;
else if (typeof v === "string" && v)
existingEntries = v.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
}
if (!existingEntries.length) {
const uciVal = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
existingEntries = Array.isArray(uciVal)
? uciVal
: uciVal
? [uciVal]
: [];
}
const domainMap = new Map();
if (!overwrite) {
for (const entry of existingEntries) {
const parts = `${entry}`.trim().split(/\s+/);
if (parts.length >= 2) {
domainMap.set(parts[0].toLowerCase(), parts[1]);
}
}
}
for (const item of currentParsedResult.entries) {
domainMap.set(item.domain.toLowerCase(), item.ip);
}
const finalEntries = [];
for (const [domain, ip] of domainMap.entries()) {
finalEntries.push(`${domain} ${ip}`);
}
uci.set(UCI_PACKAGE, section_id, "dns_hosts", finalEntries.join("\n"));
if (uiEl && typeof uiEl.setValue === "function") {
uiEl.setValue(finalEntries.join("\n"));
}
ui.hideModal();
ui.addNotification(
null,
_("Successfully imported %d DNS record(s)").format(finalEntries.length),
"info",
);
});
const modalBody = E("div", { class: "cbi-map" }, [
E("div", { style: "margin-bottom:12px;display:flex;align-items:center;flex-wrap:wrap;gap:8px;" }, [
fileInput,
fileSelectBtn,
loadCurrentBtn,
fileStatus,
]),
E(
"label",
{ style: "display:block;margin-bottom:4px;font-weight:bold;" },
_("Or paste hosts file content:"),
),
textArea,
E("div", { style: "margin-top:10px;" }, [
E(
"label",
{
style:
"display:flex;align-items:center;cursor:pointer;margin-bottom:6px;",
},
[
skipLoopbackCb,
E(
"span",
{},
_(
"Skip loopback & local entries (127.0.0.1, ::1, 0.0.0.0, localhost)",
),
),
],
),
E(
"label",
{ style: "display:flex;align-items:center;cursor:pointer;" },
[
overwriteCb,
E(
"span",
{},
_(
"Overwrite existing records (if unchecked, merge with existing records)",
),
),
],
),
]),
previewBox,
]);
ui.showModal(
_("Import Hosts File"),
[
modalBody,
E("div", { class: "button-row", style: "margin-top:16px;" }, [
importBtn,
E(
"button",
{
class: "btn cbi-button cbi-button-neutral",
type: "button",
click: () => ui.hideModal(),
},
_("Close"),
),
]),
],
"cbi-modal",
);
}
function createSettingsContent(section, capabilities) {
settingsDnsDynamicState.dnsType = uci.get(UCI_PACKAGE, "settings", "dns_type") || "udp";
@@ -1106,292 +666,6 @@ function createSettingsContent(section, capabilities) {
o.default = "0";
o.rmempty = false;
const dnsHostsOpt = section.option(
form.TextValue,
"dns_hosts",
_("Custom DNS Records (Hosts)"),
_("One record per line: <code>example.com 192.168.1.100</code> or <code>192.168.1.100 example.com</code>. Lines starting with <code>#</code> are ignored."),
);
dnsHostsOpt.rows = 12;
dnsHostsOpt.cfgvalue = function(section_id) {
const val = uci.get(UCI_PACKAGE, section_id, "dns_hosts");
if (!val) return "";
if (Array.isArray(val)) return val.join("\n");
return String(val || "");
};
dnsHostsOpt.write = function(section_id, formvalue) {
const lines = String(formvalue || "").split("\n")
.map(l => l.trim())
.filter(l => l && !l.startsWith("#"));
if (lines.length === 0) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
} else {
uci.set(UCI_PACKAGE, section_id, "dns_hosts", lines.join("\n"));
}
};
dnsHostsOpt.remove = function(section_id) {
uci.unset(UCI_PACKAGE, section_id, "dns_hosts");
};
dnsHostsOpt.validate = function(section_id, value) {
if (!value) return true;
const lines = value.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const parts = trimmed.split(/\s+/);
if (parts.length < 2) {
return _('Invalid line: "%s". Use: domain ip or ip domain').format(
trimmed.length > 40 ? trimmed.substring(0, 40) + "…" : trimmed,
);
}
}
return true;
};
const _origDnsHostsRenderWidget = dnsHostsOpt.renderWidget;
dnsHostsOpt.renderWidget = function(section_id, option_index, cfgvalue) {
const node = _origDnsHostsRenderWidget.call(this, section_id, option_index, cfgvalue);
const ta = node.tagName === "TEXTAREA" ? node : node.querySelector("textarea");
if (ta) {
ta.style.fontFamily = "monospace";
ta.style.fontSize = "0.85rem";
ta.style.lineHeight = "1.4";
ta.style.resize = "vertical";
}
const countBadge = E("span", {
style: "font-weight:600;font-size:0.85rem;margin-left:auto;color:var(--text-color-medium,#666);",
});
function updateCount() {
const text = ta ? ta.value : "";
const validLines = text.split("\n").filter(l => l.trim() && !l.trim().startsWith("#"));
countBadge.textContent = _("Records: %d").format(validLines.length);
}
if (ta) {
ta.addEventListener("input", updateCount);
setTimeout(updateCount, 50);
}
const importBtn = E("button", {
class: "cbi-button cbi-button-action",
type: "button",
click: ui.createHandlerFn(this, function() {
showImportHostsModal(section_id, dnsHostsOpt);
}),
}, _("Import Hosts File"));
const sortBtn = E("button", {
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: ui.createHandlerFn(this, function() {
if (!ta) return;
const lines = ta.value.split("\n")
.map(l => l.trim())
.filter(Boolean);
const comments = lines.filter(l => l.startsWith("#"));
const records = lines.filter(l => !l.startsWith("#")).sort();
ta.value = [...comments, ...records].join("\n");
updateCount();
}),
}, _("Sort Alphabetically"));
const cleanBtn = E("button", {
class: "cbi-button cbi-button-neutral",
type: "button",
style: "margin-left:6px;",
click: ui.createHandlerFn(this, function() {
if (!ta) return;
const lines = ta.value.split("\n")
.map(l => l.trim())
.filter(l => l && !l.startsWith("#"));
ta.value = lines.join("\n");
updateCount();
}),
}, _("Clean Comments"));
return E("div", {}, [
node,
E("div", { style: "display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-top:8px;" }, [
importBtn,
sortBtn,
cleanBtn,
countBadge,
]),
]);
};
// ─── Remote Hosts Lists ──────────────────────────────────────────────────
const hostsListUrlsOpt = section.option(
form.DynamicList,
"hosts_list_urls",
_("Remote Hosts Lists"),
_("URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>. Entries are resolved via both dnsmasq and sing-box DNS."),
);
hostsListUrlsOpt.placeholder = "https://raw.githubusercontent.com/.../hosts";
hostsListUrlsOpt.renderWidget = function (section_id, _option_index, _cfgvalue) {
const pkg = UCI_PACKAGE;
function getUrls() {
let v = uci.get(pkg, "settings", "hosts_list_urls") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function getDisabled() {
let v = uci.get(pkg, "settings", "hosts_list_disabled") || [];
if (!Array.isArray(v)) v = v ? [v] : [];
return v;
}
function setUrls(urls) {
uci.set(pkg, "settings", "hosts_list_urls", urls.length ? urls : null);
}
function setDisabled(disabled) {
uci.set(pkg, "settings", "hosts_list_disabled", disabled.length ? disabled : null);
}
const container = E("div", { class: "cbi-section-node" });
function render() {
container.innerHTML = "";
const urls = getUrls();
const disabled = getDisabled();
const table = E("table", { class: "cbi-section-table", style: "width:100%;" });
const tbody = E("tbody");
urls.forEach(function (url, idx) {
const isOff = disabled.includes(url);
const tr = E("tr", { class: "cbi-section-table-row" });
const tdCheck = E("td", { class: "cbi-section-table-cell", style: "width:30px;text-align:center;" });
const cb = E("input", {
type: "checkbox",
class: "cbi-input-checkbox",
checked: !isOff,
change: function () {
const nowEnabled = this.checked;
let d = getDisabled();
if (nowEnabled) {
d = d.filter(function (x) { return x !== url; });
} else {
if (!d.includes(url)) d.push(url);
}
setDisabled(d);
uci.save();
render();
},
});
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
const tdUrl = E("td", { class: "cbi-section-table-cell" });
const urlSpan = E("span", {
style: isOff ? "text-decoration:line-through;opacity:0.6;" : "",
}, url);
tdUrl.appendChild(urlSpan);
tr.appendChild(tdUrl);
const tdStatus = E("td", { class: "cbi-section-table-cell", style: "width:60px;text-align:center;white-space:nowrap;" });
tdStatus.appendChild(E("span", {
style: isOff ? "color:#e53935;" : "color:#2e7d32;",
}, isOff ? "OFF" : "ON"));
tr.appendChild(tdStatus);
const tdDel = E("td", { class: "cbi-section-table-cell", style: "width:40px;text-align:center;" });
const delBtn = E("button", {
class: "btn cbi-button cbi-button-remove",
type: "button",
title: _("Remove"),
click: function () {
let u = getUrls();
let d = getDisabled();
u.splice(idx, 1);
d = d.filter(function (x) { return x !== url; });
setUrls(u);
setDisabled(d);
uci.save();
render();
},
}, "\u2715");
tdDel.appendChild(delBtn);
tr.appendChild(tdDel);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
const addRow = E("div", { class: "cbi-section-create", style: "margin-top:8px;display:flex;gap:4px;align-items:center;" });
const input = E("input", {
type: "text",
class: "cbi-input-text",
placeholder: "https://raw.githubusercontent.com/.../hosts",
style: "flex:1;",
});
const addBtn = E("button", {
class: "btn cbi-button cbi-button-action",
type: "button",
click: function () {
const val = (input.value || "").trim();
if (!val) return;
let u = getUrls();
if (u.includes(val)) { input.value = ""; return; }
u.push(val);
setUrls(u);
uci.save();
input.value = "";
render();
},
}, "+");
input.addEventListener("keydown", function (ev) {
if (ev.key === "Enter") addBtn.click();
});
addRow.appendChild(input);
addRow.appendChild(addBtn);
container.appendChild(addRow);
}
render();
return container;
};
o = section.option(
form.Flag,
"hosts_list_auto_update",
_("Auto-update Hosts Lists"),
_("Automatically update remote hosts lists on schedule"),
);
o.default = "0";
o.rmempty = false;
o = section.option(
form.Value,
"hosts_list_update_interval",
_("Hosts Lists Update Interval"),
_("Use sing-box duration format like 1d, 12h or 6h"),
);
o.depends("hosts_list_auto_update", "1");
o.placeholder = "24h";
o.default = "24h";
o.rmempty = false;
o.validate = function (_section_id, value) {
if (!value) return true;
return /^(?=.*[1-9])([0-9]+(?:\.[0-9]+)?(?:ns|us|ms|s|m|h|d))+$/.test(value)
? true
: _("Use sing-box duration format like 1d, 12h or 6h");
};
// ─── DNS Strategy ────────────────────────────────────────────────────────
o = section.option(form.ListValue, "dns_strategy", _("DNS Strategy"));
+9 -6
View File
@@ -1217,6 +1217,9 @@ msgstr "Проверка верхних DNS"
msgid "Host"
msgstr "Хост"
msgid "Hosts"
msgstr "Хосты"
msgid "Hosts Lists Update Interval"
msgstr "Интервал обновления списков хостов"
@@ -1797,7 +1800,7 @@ msgid "Network interface settings"
msgstr "Настройки сетевого интерфейса"
msgid "New domains:"
msgstr ""
msgstr "Новых доменов:"
msgid "New version %s is available for %s"
msgstr "Доступна новая версия %s для %s"
@@ -2019,7 +2022,7 @@ msgid "Padding"
msgstr "Padding"
msgid "Parsed records:"
msgstr ""
msgstr "Распознано записей:"
msgid "Password"
msgstr "Пароль"
@@ -2598,10 +2601,10 @@ msgid "Skip TLS certificate verification (not recommended)"
msgstr ""
msgid "Skipped comments/invalid:"
msgstr ""
msgstr "Пропущено комментариев/невалидных:"
msgid "Skipped loopback:"
msgstr ""
msgstr "Пропущено loopback:"
msgid "Smart Detect"
msgstr "Smart Detect"
@@ -2931,7 +2934,7 @@ msgid "Top level has the highest priority; lower levels are used as fallback"
msgstr "Верхний уровень имеет наивысший приоритет; нижние уровни используются как резерв"
msgid "Total lines:"
msgstr ""
msgstr "Всего строк:"
msgid "Trace"
msgstr "Проверить"
@@ -3096,7 +3099,7 @@ msgid "Update this subscription automatically"
msgstr "Автоматически обновлять эту подписку"
msgid "Updated/duplicates:"
msgstr ""
msgstr "Обновлено/дубликатов:"
msgid "Uplink"
msgstr "Исходящий"
Binary file not shown.
@@ -1755,6 +1755,9 @@ msgstr ""
msgid "Host"
msgstr ""
msgid "Hosts"
msgstr ""
#: ../luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/settings.js:1247
msgid "Hosts Lists Update Interval"
msgstr ""
+1 -5
View File
@@ -4,6 +4,7 @@ config settings 'settings'
list applied_migrations 'enable_component_checks'
list applied_migrations 'http_connection_urls'
list applied_migrations 'dns_hosts_to_option'
list applied_migrations 'global_hosts_to_section'
option dns_type 'udp'
list dns_server '77.88.8.8'
list bootstrap_dns_server '77.88.8.8'
@@ -67,11 +68,6 @@ config settings 'settings'
option ai_anomaly_detection_enabled '1'
option ai_anomaly_reconnect_threshold '10'
# ─── Hosts Lists ────────────────────────────────────────────────────────
# list hosts_list_urls 'https://raw.githubusercontent.com/.../hosts'
option hosts_list_auto_update '0'
option hosts_list_update_interval '24h'
#config section 'subscription_example'
# option label 'Subscription'
+60 -32
View File
@@ -40,6 +40,30 @@ function remove_file(path) {
run("rm -f " + shell_quote(path));
}
function opt(section, key, default_value) {
let v = section[key];
if (v == null) return default_value;
return v;
}
function bool_opt(section, key, default_value) {
let v = section[key];
if (v == null) return default_value;
return v == "1" || v == "true";
}
function enabled_hosts_sections() {
let result = [];
let cursor = uci.cursor();
cursor.foreach(CONFIG_NAME, "section", function(section) {
if (opt(section, ".type", "") != "section") return;
if (opt(section, "action", "") != "hosts") return;
if (!bool_opt(section, "enabled", true)) return;
push(result, section);
});
return result;
}
function http_get_to_file(url, output_path) {
run("mkdir -p " + shell_quote(HOSTS_TMP_DIR));
let cmd = "wget -q -O " + shell_quote(output_path) + " --timeout=" + CONNECT_TIMEOUT + " " + shell_quote(url) + " 2>&1";
@@ -141,19 +165,27 @@ function write_hosts_cache(entries, source_urls) {
function get_hosts_urls() {
let urls = [];
let uci_urls = uci.get(CONFIG_NAME + ".settings.hosts_list_urls");
if (uci_urls == "")
return urls;
let disabled = [];
let uci_disabled = uci.get(CONFIG_NAME + ".settings.hosts_list_disabled");
if (type(uci_disabled) == "array") {
for (let d in uci_disabled) {
let s = trim(d);
if (s != "") push(disabled, s);
for (let section in enabled_hosts_sections()) {
let section_urls = opt(section, "hosts_list_urls", "");
if (type(section_urls) == "array") {
for (let url in section_urls) {
let s = trim(url);
if (s != "") push(urls, s);
}
} else if (section_urls != null && section_urls != "") {
push(urls, trim(section_urls));
}
let section_disabled = opt(section, "hosts_list_disabled", "");
if (type(section_disabled) == "array") {
for (let d in section_disabled) {
let s = trim(d);
if (s != "") push(disabled, s);
}
} else if (section_disabled != null && section_disabled != "") {
push(disabled, trim(section_disabled));
}
} else if (uci_disabled != null && uci_disabled != "") {
push(disabled, trim(uci_disabled));
}
let is_disabled = function(url) {
@@ -162,18 +194,12 @@ function get_hosts_urls() {
return false;
};
if (type(uci_urls) == "array") {
for (let url in uci_urls) {
let s = trim(url);
if (s != "" && !is_disabled(s))
push(urls, s);
}
} else {
let s = trim(uci_urls);
if (s != "" && !is_disabled(s))
push(urls, s);
let result = [];
for (let url in urls) {
if (!is_disabled(url))
push(result, url);
}
return urls;
return result;
}
function hosts_list_update(target_url) {
@@ -224,18 +250,20 @@ function hosts_list_update(target_url) {
}
function hosts_list_status() {
let urls = get_hosts_urls();
let active_urls = get_hosts_urls();
let all_urls = [];
let uci_urls = uci.get(CONFIG_NAME + ".settings.hosts_list_urls");
if (type(uci_urls) == "array") {
for (let url in uci_urls) {
let s = trim(url);
if (s != "") push(all_urls, s);
for (let section in enabled_hosts_sections()) {
let section_urls = opt(section, "hosts_list_urls", "");
if (type(section_urls) == "array") {
for (let url in section_urls) {
let s = trim(url);
if (s != "") push(all_urls, s);
}
} else if (section_urls != null && section_urls != "") {
push(all_urls, trim(section_urls));
}
} else if (uci_urls != null && uci_urls != "") {
push(all_urls, trim(uci_urls));
}
let disabled_count = length(all_urls) - length(urls);
let disabled_count = length(all_urls) - length(active_urls);
let st = fs.stat(HOSTS_CACHE_FILE);
let cache_exists = st != null;
let entry_count = 0;
@@ -244,7 +272,7 @@ function hosts_list_status() {
entry_count = int(st.size);
}
print('{"urls":' + length(urls) + ',"total":' + length(all_urls) + ',"disabled":' + disabled_count + ',"cache_exists":' + (cache_exists ? 'true' : 'false') + ',"cache_size":' + entry_count + '}');
print('{"urls":' + length(active_urls) + ',"total":' + length(all_urls) + ',"disabled":' + disabled_count + ',"cache_exists":' + (cache_exists ? 'true' : 'false') + ',"cache_size":' + entry_count + '}');
}
let command = ARGV[0] || "";
+5 -3
View File
@@ -590,6 +590,8 @@ function rule_has_list_update_source(section) {
return false;
if (option(section, "action", "") == "dns")
return list_has_remote_references(option(section, "domain_ip_lists", ""));
if (option(section, "action", "") == "hosts")
return false;
return (
rule_config.has_community_subnet_list(connections.community_lists_value(section)) ||
@@ -2088,7 +2090,7 @@ function import_domain_ip_list_file_into_rulesets(filepath, section) {
let ruleset_filepath = domain_ip_list_ruleset_path(section);
let ok = nft_module_success([ "split-domain-subnet-file", filepath, domains_tmpfile, subnets_tmpfile ]);
let domains_only = option(section, "action", "") == "dns";
let domains_only = option(section, "action", "") == "dns" || option(section, "action", "") == "hosts";
if (ok)
ok = ruleset_module_success([ "import-plain-list", domains_tmpfile, ruleset_filepath, "domain_suffix", "domains", "5000" ]);
if (ok && !domains_only)
@@ -2251,7 +2253,7 @@ function import_all_preset_lists(settings) {
function import_builtin_subnets_from_rule(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
if (option(section, "action", "") == "dns")
if (option(section, "action", "") == "dns" || option(section, "action", "") == "hosts")
return true;
let ok = true;
@@ -2391,7 +2393,7 @@ function import_custom_ruleset_subnets_from_remote(url, format, section, label,
function import_rule_sets_with_subnets_from_rule(section, settings) {
if (!bool_option(section, "enabled", true))
return true;
if (option(section, "action", "") == "dns")
if (option(section, "action", "") == "dns" || option(section, "action", "") == "hosts")
return true;
let references = connections.rule_sets_with_subnets(section);
+63 -1
View File
@@ -1308,11 +1308,73 @@ function migrate_dns_hosts_to_option(ctx) {
set_option(ctx, ctx.model.settings, "dns_hosts", join("\n", entries));
}
function migrate_global_hosts_to_section(ctx) {
let settings = object_or_empty(ctx.model.settings);
let has_urls = option_exists(settings, "hosts_list_urls");
let has_disabled = option_exists(settings, "hosts_list_disabled");
let has_auto_update = option_exists(settings, "hosts_list_auto_update");
let has_update_interval = option_exists(settings, "hosts_list_update_interval");
let has_dns_hosts = option_exists(settings, "dns_hosts");
if (!has_urls && !has_disabled && !has_auto_update && !has_update_interval && !has_dns_hosts)
return;
let already_migrated = false;
for (let section in ctx.model.sections) {
if (option(section, "action", "") == "hosts") {
already_migrated = true;
break;
}
}
if (already_migrated) {
delete_option(ctx, ctx.model.settings, "hosts_list_urls");
delete_option(ctx, ctx.model.settings, "hosts_list_disabled");
delete_option(ctx, ctx.model.settings, "hosts_list_auto_update");
delete_option(ctx, ctx.model.settings, "hosts_list_update_interval");
return;
}
let hosts_section_name = "hosts";
let hosts_section = { ".name": hosts_section_name, ".type": "section" };
record_operation(ctx, { op: "create", section: hosts_section_name, type: "section", anonymous: false });
set_option(ctx, hosts_section, "label", "Hosts");
set_option(ctx, hosts_section, "enabled", "1");
set_option(ctx, hosts_section, "action", "hosts");
if (has_urls) {
let urls = list_option(settings, "hosts_list_urls");
if (length(urls) > 0)
set_list_option(ctx, hosts_section, "hosts_list_urls", urls);
delete_option(ctx, ctx.model.settings, "hosts_list_urls");
}
if (has_disabled) {
let disabled = list_option(settings, "hosts_list_disabled");
if (length(disabled) > 0)
set_list_option(ctx, hosts_section, "hosts_list_disabled", disabled);
delete_option(ctx, ctx.model.settings, "hosts_list_disabled");
}
if (has_auto_update) {
set_option(ctx, hosts_section, "hosts_list_auto_update", option(settings, "hosts_list_auto_update", "0"));
delete_option(ctx, ctx.model.settings, "hosts_list_auto_update");
}
if (has_update_interval) {
set_option(ctx, hosts_section, "hosts_list_update_interval", option(settings, "hosts_list_update_interval", "24h"));
delete_option(ctx, ctx.model.settings, "hosts_list_update_interval");
}
if (has_dns_hosts) {
set_option(ctx, hosts_section, "dns_hosts", option(settings, "dns_hosts", ""));
delete_option(ctx, ctx.model.settings, "dns_hosts");
}
}
const MIGRATIONS = [
{ id: "interface_sections", run: migrate_interface_sections },
{ id: "enable_component_checks", run: migrate_enable_component_checks },
{ id: "http_connection_urls", run: migrate_http_connection_urls },
{ id: "dns_hosts_to_option", run: migrate_dns_hosts_to_option }
{ id: "dns_hosts_to_option", run: migrate_dns_hosts_to_option },
{ id: "global_hosts_to_section", run: migrate_global_hosts_to_section }
];
function apply_migrations(ctx) {
+1 -1
View File
@@ -811,7 +811,7 @@ function rule_action(section) {
function rule_action_supported(action) {
return contains([ "connection", "proxy", "outbound", "vpn", "awg", "warp",
"anytls", "snell", "mieru", "sudoku", "masque", "openvpn",
"bypass", "block", "dns", "zapret", "zapret2", "byedpi" ], as_string(action));
"bypass", "block", "dns", "zapret", "zapret2", "byedpi", "hosts" ], as_string(action));
}
function server_routing_section_action_supported(action) {
+2 -2
View File
@@ -1598,7 +1598,7 @@ function nft_rule_signature_body(body, section) {
let action = option(section, "action", "");
body = signature_add_value(body, "rule." + section_name + ".action", action);
if (action == "dns")
if (action == "dns" || action == "hosts")
return body;
body = signature_add_value(body, "rule." + section_name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + section_name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
@@ -1747,7 +1747,7 @@ function nft_add_section_source_matchers(section, table, chunk_size_text) {
function nft_populate_runtime_set_for_section(section, deferred_sections, table, common_set, port_set, ip_port_set, interface_set, localv4_set, mark, mangle_chain_context, inserted_fully_routed_ips, common6_set, ip_port6_set, localv6_set) {
if (!bool_option(section, "enabled", true))
return true;
if (section_action(section) == "dns")
if (section_action(section) == "dns" || section_action(section) == "hosts")
return true;
let ports = section_rule_ports_csv(section);
@@ -103,6 +103,7 @@ const PRIORITY_UC = LIB_DIR + "/singbox/priority.uc";
const DNS_FAILOVER_UC = LIB_DIR + "/singbox/dns_failover.uc";
const SUBSCRIPTION_CACHE_UC = LIB_DIR + "/subscription/cache.uc";
const UPDATES_UC = LIB_DIR + "/components/updates.uc";
const HOSTS_UC = LIB_DIR + "/components/hosts.uc";
const STATE_UC = LIB_DIR + "/service/state.uc";
const RELOAD_UC = LIB_DIR + "/service/reload.uc";
const UI_UC = LIB_DIR + "/service/ui.uc";
@@ -747,6 +748,8 @@ function start_main() {
if (status != 0)
return status;
module_background(HOSTS_UC, [ "list-update" ]);
status = module_status(SINGBOX_UC, [ "configure-service" ]);
if (status != 0)
return status;
@@ -1048,6 +1051,7 @@ function parse_reload_plan(output) {
changed_byedpi_runtime: 0,
changed_cron: 0,
changed_list: 0,
changed_hosts_list: 0,
needs_sing_box_reload: 0,
needs_nft_rebuild: 0,
needs_zapret_restart: 0,
@@ -1057,6 +1061,7 @@ function parse_reload_plan(output) {
needs_dnsmasq_restore: 0,
needs_cron_refresh: 0,
needs_list_update: 0,
needs_hosts_update: 0,
has_work: 0
};
@@ -1091,6 +1096,7 @@ function reload_actions_summary(plan) {
actions = append_reload_action(actions, plan.needs_dnsmasq_configure || plan.needs_dnsmasq_restore, "dnsmasq");
actions = append_reload_action(actions, plan.needs_cron_refresh, "scheduled jobs");
actions = append_reload_action(actions, plan.needs_list_update, "remote lists");
actions = append_reload_action(actions, plan.needs_hosts_update, "hosts lists");
return actions;
}
@@ -1270,6 +1276,9 @@ function reload(reason) {
if (plan.needs_byedpi_restart == 1)
module_success(BYEDPI_UC, [ "stop-runtime" ]);
if (plan.needs_hosts_update == 1)
module_success(HOSTS_UC, [ "list-update" ]);
if (plan.needs_nft_rebuild == 1) {
log_message("Rebuilding nftables rules", "info");
status = nft_rebuild_runtime();
+12 -2
View File
@@ -65,6 +65,7 @@ function emit_reload_plan(previous, current, context) {
zapret2_runtime: current.zapret2_runtime != previous.zapret2_runtime,
byedpi_runtime: current.byedpi_runtime != previous.byedpi_runtime,
list: current.list != previous.list,
hosts_list: current.hosts_list != previous.hosts_list,
cron: current.cron != previous.cron
};
@@ -77,7 +78,8 @@ function emit_reload_plan(previous, current, context) {
dnsmasq_configure: false,
dnsmasq_restore: false,
cron_refresh: false,
list_update: false
list_update: false,
hosts_update: false
};
if (changed.sing_box)
@@ -120,6 +122,11 @@ function emit_reload_plan(previous, current, context) {
if (changed.list && context.has_list_update_sources)
needs.list_update = true;
if (changed.hosts_list) {
needs.hosts_update = true;
needs.sing_box_reload = true;
}
if (needs.nft_rebuild && context.has_nft_list_update_sources)
needs.list_update = true;
@@ -147,7 +154,8 @@ function emit_reload_plan(previous, current, context) {
needs.dnsmasq_configure ||
needs.dnsmasq_restore ||
needs.cron_refresh ||
needs.list_update;
needs.list_update ||
needs.hosts_update;
emit("urltest_new_enabled_sections", new_urltest_enabled_sections(
previous.urltest_sections,
@@ -166,6 +174,7 @@ function emit_reload_plan(previous, current, context) {
emit_bool("changed_byedpi_runtime", changed.byedpi_runtime);
emit_bool("changed_cron", changed.cron);
emit_bool("changed_list", changed.list);
emit_bool("changed_hosts_list", changed.hosts_list);
emit_bool("needs_sing_box_reload", needs.sing_box_reload);
emit_bool("needs_nft_rebuild", needs.nft_rebuild);
@@ -176,6 +185,7 @@ function emit_reload_plan(previous, current, context) {
emit_bool("needs_dnsmasq_restore", needs.dnsmasq_restore);
emit_bool("needs_cron_refresh", needs.cron_refresh);
emit_bool("needs_list_update", needs.list_update);
emit_bool("needs_hosts_update", needs.hosts_update);
emit_bool("has_work", has_work);
}
+54 -2
View File
@@ -167,6 +167,7 @@ const RELOAD_STATE_FIELDS = [
"zapret2_runtime_signature",
"byedpi_runtime_signature",
"list_signature",
"hosts_list_signature",
"cron_signature",
"urltest_enabled_sections",
"dont_touch_dhcp"
@@ -715,6 +716,8 @@ function rule_has_list_update_source(enabled, action, community_lists, remote_do
return false;
if (as_string(action) == "dns")
return list_has_remote_references(domain_ip_lists);
if (as_string(action) == "hosts")
return false;
return (
has_community_subnet_list(community_lists) ||
@@ -726,7 +729,7 @@ function rule_has_list_update_source(enabled, action, community_lists, remote_do
}
function rule_has_nft_list_update_source(enabled, action, community_lists, remote_subnet_lists, rule_set_with_subnets, domain_ip_lists) {
if (!arg_bool(enabled) || as_string(action) == "dns")
if (!arg_bool(enabled) || as_string(action) == "dns" || as_string(action) == "hosts")
return false;
return (
@@ -917,7 +920,7 @@ function nft_runtime_signature_body(settings, sections) {
let action = option(section, "action", "");
body = signature_add_value(body, "rule." + name + ".action", action);
if (action == "dns")
if (action == "dns" || action == "hosts")
continue;
body = signature_add_value(body, "rule." + name + ".ip_cidr", section_rule_condition_csv(section, "ip_cidr", "subnets"));
body = signature_add_value(body, "rule." + name + ".source_ip_cidr", section_rule_condition_csv(section, "source_ip_cidr", "subnets"));
@@ -1130,6 +1133,14 @@ function append_list_update_signature_body(body, section) {
body = signature_add_value(body, "lists." + name + ".domain_ip_lists", option(section, "domain_ip_lists", ""));
return body;
}
if (action == "hosts") {
body = signature_add_value(body, "lists." + name + ".hosts_list_urls", option(section, "hosts_list_urls", ""));
body = signature_add_value(body, "lists." + name + ".hosts_list_disabled", option(section, "hosts_list_disabled", ""));
body = signature_add_value(body, "lists." + name + ".dns_hosts", option(section, "dns_hosts", ""));
body = signature_add_value(body, "lists." + name + ".hosts_list_auto_update", option(section, "hosts_list_auto_update", ""));
body = signature_add_value(body, "lists." + name + ".hosts_list_update_interval", option(section, "hosts_list_update_interval", ""));
return body;
}
body = signature_add_value(body, "lists." + name + ".ports", section_rule_ports_csv(section));
body = signature_add_value(body, "lists." + name + ".community_subnet_lists", rule_config.filter_community_subnet_lists_value(connections.community_lists_value(section)));
@@ -1150,6 +1161,24 @@ function list_update_signature_body(sections) {
return body;
}
function hosts_list_signature_body(sections) {
let body = "";
for (let section in sections) {
section = object_or_empty(section);
if (option(section, "action", "") != "hosts")
continue;
if (!bool_option(section, "enabled", true))
continue;
let name = section_name(section);
body = signature_add_value(body, "hosts." + name + ".hosts_list_urls", option(section, "hosts_list_urls", ""));
body = signature_add_value(body, "hosts." + name + ".hosts_list_disabled", option(section, "hosts_list_disabled", ""));
body = signature_add_value(body, "hosts." + name + ".dns_hosts", option(section, "dns_hosts", ""));
body = signature_add_value(body, "hosts." + name + ".hosts_list_auto_update", option(section, "hosts_list_auto_update", "0"));
body = signature_add_value(body, "hosts." + name + ".hosts_list_update_interval", option(section, "hosts_list_update_interval", ""));
}
return body;
}
function cron_signature_body(settings, sections) {
let body = signature_add_value("", "settings.update_interval", settings_update_interval(settings));
body = signature_add_value(body, "settings.component_update_check_interval", settings_component_update_check_interval(settings));
@@ -1167,6 +1196,17 @@ function cron_signature_body(settings, sections) {
body = signature_add_value(body, "subscription." + name + ".subscription_update_interval", section_subscription_update_interval(section));
}
for (let section in sections) {
section = object_or_empty(section);
if (option(section, "action", "") != "hosts")
continue;
if (!bool_option(section, "enabled", true))
continue;
let name = section_name(section);
body = signature_add_value(body, "hosts." + name + ".hosts_list_auto_update", option(section, "hosts_list_auto_update", "0"));
body = signature_add_value(body, "hosts." + name + ".hosts_list_update_interval", option(section, "hosts_list_update_interval", ""));
}
return body;
}
@@ -1305,6 +1345,11 @@ function append_sing_box_rule_signature_body(body, section, sections) {
if (bool_option(section, "dns_detour_enabled", false))
body = signature_add_value(body, prefix + ".dns_detour_section", option(section, "dns_detour_section", ""));
}
else if (action == "hosts") {
body = signature_add_value(body, prefix + ".hosts_list_urls", option(section, "hosts_list_urls", ""));
body = signature_add_value(body, prefix + ".hosts_list_disabled", option(section, "hosts_list_disabled", ""));
body = signature_add_value(body, prefix + ".dns_hosts", option(section, "dns_hosts", ""));
}
body = signature_add_value(body, prefix + ".domain", section_rule_condition_csv(section, "domain", "domains"));
body = signature_add_value(body, prefix + ".domain_suffix", section_rule_condition_csv(section, "domain_suffix", "domains"));
@@ -1599,6 +1644,7 @@ function reload_state_values_from_sources(format, settings, sections, servers, d
zapret2_runtime_signature: signature_hash(zapret2_runtime_signature_body(sections)),
byedpi_runtime_signature: signature_hash(byedpi_runtime_signature_body(sections)),
list_signature: signature_hash(list_update_signature_body(sections)),
hosts_list_signature: signature_hash(hosts_list_signature_body(sections)),
cron_signature: signature_hash(cron_signature_body(settings, sections)),
urltest_enabled_sections: urltest_enabled_sections_value(sections),
dont_touch_dhcp: dont_touch_dhcp_value(settings)
@@ -2018,6 +2064,12 @@ else if (mode == "list-update-signature-fixture") {
let data = fixture_data(ARGV[1]);
exit(print_signature_hash(list_update_signature_body(fixture_section_list(data))) ? 0 : 1);
}
else if (mode == "hosts-list-signature")
exit(print_signature_hash(hosts_list_signature_body(uci_sections("section"))) ? 0 : 1);
else if (mode == "hosts-list-signature-fixture") {
let data = fixture_data(ARGV[1]);
exit(print_signature_hash(hosts_list_signature_body(fixture_section_list(data))) ? 0 : 1);
}
else if (mode == "cron-signature")
exit(print_signature_hash(cron_signature_body(uci_settings(), uci_sections("section"))) ? 0 : 1);
else if (mode == "cron-signature-fixture") {
@@ -1388,6 +1388,9 @@ function add_outbound_for_section(config, section, taken, sections) {
else if (action == "dns") {
add_dns_server_for_section(config, section);
}
else if (action == "hosts") {
/* hosts-only action — no outbound or route needed */
}
else {
ctx.runtime_generate_unsupported("unsupported action " + action);
}
@@ -1413,8 +1416,12 @@ function reserve_section_outbound_tags(sections, taken) {
}
function add_route_for_section(config, section) {
if (option(section, "action", "") == "dns")
let action = option(section, "action", "");
if (action == "dns")
add_dns_action_rules_for_section(config, section);
else if (action == "hosts") {
/* hosts-only action — entries are consumed globally via combined cache */
}
else
add_combined_route_for_section(config, section);
}
+2
View File
@@ -59,6 +59,8 @@ function target(section, outbound_tag_name) {
return { action: "route", outbound: runtime_constants.BYPASS_OUTBOUND_TAG };
if (action == "block")
return { action: "reject" };
if (action == "hosts" || action == "dns")
return { action: "route", outbound: runtime_constants.DIRECT_OUTBOUND_TAG };
return { unsupported: "unsupported action " + action };
}
+5 -5
View File
@@ -175,7 +175,7 @@ assert(JSON.stringify(config.settings.dns_server) === JSON.stringify(['9.9.9.9']
assert(JSON.stringify(config.settings.bootstrap_dns_server) === JSON.stringify(['1.1.1.1']), 'legacy Bootstrap DNS scalar migrated to ordered list');
assert(config.settings.config_version === '1.0.5', 'legacy config should be marked at the current schema version');
assert(config.settings.component_update_check_enabled === '1', 'component update checks should be enabled during migration');
assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option']), 'legacy config should record named migrations');
assert(JSON.stringify(config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'legacy config should record named migrations');
function assert(condition, message) {
if (!condition) {
@@ -394,7 +394,7 @@ function assert(condition, message) {
assert(out.changed === true, '1.0.1 config should require migration');
assert(out.config.settings.config_version === '1.0.5', 'config schema version should advance to 1.0.5');
assert(out.config.settings.component_update_check_enabled === '1', 'updates from 1.0.1 and below should enable component update checks');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option']), 'named migrations should be recorded');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'named migrations should be recorded');
assert(!Object.prototype.hasOwnProperty.call(section, 'interfaces'), 'parent interface list should be removed');
assert(JSON.stringify(interfaces.map(item => item.name)) === JSON.stringify(['awg0', 'tun0']), 'interfaces should keep their order');
for (const item of interfaces) {
@@ -432,7 +432,7 @@ function assert(condition, message) {
assert(out.config.settings.component_update_check_enabled === '0', '1.0.2 config must preserve an explicitly disabled component check');
assert(out.config.settings.config_version === '1.0.5', '1.0.2 config should advance through the HTTP URL migration schema');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option']), 'newer configs should mark skipped migrations');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'newer configs should mark skipped migrations');
NODE
cat >"$WORK_DIR/forkop-1.0.4-http.json" <<'JSON'
@@ -498,7 +498,7 @@ assert(JSON.stringify(jsonOutbounds[1]) === JSON.stringify({
server_port: 8080,
}), 'unnamed HTTP URL should receive a unique http tag');
assert(jsonOutbounds[2].type === 'direct' && jsonOutbounds[2].tag === 'http', 'existing JSON outbounds should remain unchanged');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option']), 'HTTP URL migration should be recorded');
assert(JSON.stringify(out.config.settings.applied_migrations) === JSON.stringify(['interface_sections', 'enable_component_checks', 'http_connection_urls', 'dns_hosts_to_option', 'global_hosts_to_section']), 'HTTP URL migration should be recorded');
NODE
cat >"$WORK_DIR/forkop-1.0.5-http.json" <<'JSON'
@@ -613,7 +613,7 @@ grep -Fxq 'tachyon.settings.config_version=1.0.5' "$WORK_DIR/runtime-version.sta
fail "runtime version migration must advance config_version"
grep -Fxq 'tachyon.settings.component_update_check_enabled=1' "$WORK_DIR/runtime-version.state" ||
fail "runtime version migration must enable component update checks"
grep -Fq 'tachyon.settings.applied_migrations=interface_sections enable_component_checks http_connection_urls dns_hosts_to_option' "$WORK_DIR/runtime-version.state" ||
grep -Fq 'tachyon.settings.applied_migrations=interface_sections enable_component_checks http_connection_urls dns_hosts_to_option global_hosts_to_section' "$WORK_DIR/runtime-version.state" ||
fail "runtime migration must record stable migration names"
grep -Eq '^tachyon\.main\.outbound_jsons=\{ "type": "http", "tag": "http", "server": "proxy\.example", "server_port": 8080 \}$' "$WORK_DIR/runtime-version.state" ||
fail "runtime migration must convert native HTTP proxy links to JSON outbounds"
+2
View File
@@ -74,6 +74,8 @@ grep -Fq "list applied_migrations 'http_connection_urls'" "$TACHYON_CONFIG" ||
fail "new installations must mark the HTTP connection URL migration as applied"
grep -Fq "list applied_migrations 'dns_hosts_to_option'" "$TACHYON_CONFIG" ||
fail "new installations must mark the dns_hosts to option migration as applied"
grep -Fq "list applied_migrations 'global_hosts_to_section'" "$TACHYON_CONFIG" ||
fail "new installations must mark the global hosts to section migration as applied"
grep -Fq '/usr/lib/tachyon/config/migration.uc migrate' "$TACHYON_MAKEFILE" ||
fail "OpenWrt package postinst must run configuration migrations"
[ "$(grep -Fc '/usr/lib/tachyon/config/migration.uc migrate' "$BUILD_SCRIPT")" -ge 3 ] ||