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:
@@ -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[];
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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] || "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ] ||
|
||||
|
||||
Reference in New Issue
Block a user