release: 1.3.6 - Family Profiles, Mobile Responsiveness, Mixed Proxy fix & AI Doctor Fuzzer improvements

This commit is contained in:
Dushnilin
2026-08-29 13:57:17 +03:00
parent 6248452e79
commit 939d9418a2
28 changed files with 6245 additions and 3678 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "fe-app-tachyon",
"version": "1.2.47",
"version": "1.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fe-app-tachyon",
"version": "1.2.47",
"version": "1.3.6",
"license": "MIT",
"devDependencies": {
"@babel/parser": "7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "fe-app-tachyon",
"version": "1.2.77",
"version": "1.3.6",
"license": "MIT",
"type": "module",
"scripts": {
+29 -3
View File
@@ -1,12 +1,19 @@
// language=CSS
export const styles = `
.tachyon-partial-modal__body {}
.tachyon-partial-modal__body {
width: 100%;
box-sizing: border-box;
}
.tachyon-partial-modal__content {
max-height: 70vh;
overflow: scroll;
max-height: 75vh;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
border-radius: 4px;
box-sizing: border-box;
width: 100%;
}
.tachyon-partial-modal__footer {
@@ -33,4 +40,23 @@ export const styles = `
.tachyon-partial-modal__checkbox-text {
line-height: 1.2;
}
@media (max-width: 640px) {
.tachyon-partial-modal__content {
max-height: 82vh;
}
.tachyon-partial-modal__footer {
flex-direction: column-reverse;
align-items: stretch;
}
.tachyon-partial-modal__footer button {
width: 100%;
min-height: 38px;
}
.tachyon-partial-modal__checkbox {
margin-right: 0;
margin-bottom: 6px;
width: 100%;
}
}
`;
+42
View File
@@ -163,4 +163,46 @@ ${PartialStyles}
opacity: 1;
transform: translateY(0);
}
/* Global Mobile & Responsive Layout Enhancements */
@media (max-width: 768px) {
.cbi-section-table {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: collapse;
}
.cbi-section-table-cell {
min-width: 0;
word-break: break-word;
overflow-wrap: anywhere;
}
.cbi-section-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cbi-dropdown {
max-width: 100%;
}
.cbi-input-textarea,
.cbi-input-text,
.cbi-input-select {
max-width: 100%;
box-sizing: border-box;
}
/* Modal responsiveness */
.modal,
.cbi-modal {
max-width: 96vw !important;
box-sizing: border-box;
margin: 10px auto;
}
}
`;
@@ -1420,10 +1420,7 @@ export const TachyonShellMethods = {
getFuzzerHistory: async (limit?: number) => {
const response = await executeShellCommand({
command: '/usr/bin/tachyon',
args: [
Tachyon.AvailableMethods.FUZZER_HISTORY,
String(limit || 20),
],
args: [Tachyon.AvailableMethods.FUZZER_HISTORY, String(limit || 20)],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
});
const parsed = parseJsonObjectOutput<Tachyon.FuzzerHistoryResult>(
@@ -743,7 +743,9 @@ describe('TachyonShellMethods.componentAction', () => {
running: !jobFinished,
component: 'tachyon',
action: 'install',
message: jobFinished ? 'Tachyon has been installed' : 'Component action is running',
message: jobFinished
? 'Tachyon has been installed'
: 'Component action is running',
current_version: '1.3.4',
latest_version: '1.3.4',
job_id: 'job-1',
@@ -759,7 +761,9 @@ describe('TachyonShellMethods.componentAction', () => {
running: !jobFinished,
component: 'tachyon',
action: 'install',
message: jobFinished ? 'Tachyon has been installed' : 'Component action is running',
message: jobFinished
? 'Tachyon has been installed'
: 'Component action is running',
current_version: '1.3.4',
latest_version: '1.3.4',
job_id: 'job-1',
@@ -220,7 +220,9 @@ describe('TachyonShellMethods Fuzzer Actions', () => {
zapret: { splits: ['1'] },
byedpi: { splits: ['1'] },
custom_strategies: [],
} as unknown as Parameters<typeof TachyonShellMethods.saveFuzzerPatterns>[0];
} as unknown as Parameters<
typeof TachyonShellMethods.saveFuzzerPatterns
>[0];
const res = await TachyonShellMethods.saveFuzzerPatterns(patternsPayload);
expect(res.success).toBe(true);
@@ -124,8 +124,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
activeTab === 'patterns' ? 'flex' : 'none';
tabContentCustom.style.display = activeTab === 'custom' ? 'flex' : 'none';
tabContentAi.style.display = activeTab === 'ai' ? 'flex' : 'none';
tabContentHistory.style.display =
activeTab === 'history' ? 'flex' : 'none';
tabContentHistory.style.display = activeTab === 'history' ? 'flex' : 'none';
if (activeTab === 'patterns') renderPatternsTab();
if (activeTab === 'custom') renderCustomTab();
@@ -316,7 +315,8 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
E(
'div',
{
style: 'font-size: 11px; cursor: pointer; display: flex; align-items: center; gap: 4px;',
style:
'font-size: 11px; cursor: pointer; display: flex; align-items: center; gap: 4px;',
},
[autoApplyCheckbox, E('span', {}, _('Auto-apply best strategy'))],
),
@@ -459,7 +459,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
// Results Table
const resultsContainer = E('div', {
style:
'max-height: 380px; overflow-y: auto; border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 6px;',
'max-height: 380px; overflow-y: auto; overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 6px; width: 100%; box-sizing: border-box;',
});
const tableEl = E(
@@ -467,7 +467,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
{
class: 'table',
style:
'width: 100%; margin: 0; font-size: 12px; text-align: left; border-collapse: collapse;',
'width: 100%; min-width: 620px; margin: 0; font-size: 12px; text-align: left; border-collapse: collapse;',
},
[
E(
@@ -826,11 +826,14 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
type: 'text',
class: 'cbi-input-text',
placeholder: _('Strategy Name'),
style: 'flex: 1;',
style: 'flex: 1 1 180px; min-width: 140px;',
});
const engineSel = E(
'select',
{ class: 'cbi-input-select', style: 'width: 140px;' },
{
class: 'cbi-input-select',
style: 'flex: 0 1 140px; min-width: 120px;',
},
[
E('option', { value: 'zapret2' }, 'Zapret v2'),
E('option', { value: 'zapret' }, 'Zapret v1'),
@@ -841,7 +844,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
type: 'text',
class: 'cbi-input-text',
placeholder: _('Command-line arguments string...'),
style: 'flex: 2; font-family: monospace;',
style: 'flex: 2 1 240px; min-width: 180px; font-family: monospace;',
});
const addBtn = renderButton({
@@ -1015,7 +1018,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
placeholder: _(
'Optional context (e.g. "Rostelecom, YouTube 4K buffering is slow")...',
),
style: 'flex: 1; font-size: 12px;',
style: 'flex: 1 1 240px; min-width: 180px; font-size: 12px;',
});
const aiSynthesizeBtn = renderButton({
@@ -1483,7 +1486,10 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
return;
}
const typeColors: Record<string, { bg: string; border: string; icon: string }> = {
const typeColors: Record<
string,
{ bg: string; border: string; icon: string }
> = {
rst: { bg: 'rgba(220, 53, 69, 0.12)', border: '#dc3545', icon: '🔴' },
throttle: {
bg: 'rgba(255, 193, 7, 0.12)',
@@ -39,6 +39,12 @@ export const styles = `
width: 100%;
min-width: 0;
}
.tachyon_updates-page__component {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
.tachyon_updates-page__component {
@@ -48,7 +54,8 @@ export const styles = `
display: flex;
flex-direction: column;
gap: 10px;
min-width: max-content;
min-width: 0;
box-sizing: border-box;
}
.tachyon_updates-page__component__header {
@@ -4221,10 +4221,7 @@ var TachyonShellMethods = {
getFuzzerHistory: async (limit) => {
const response = await executeShellCommand({
command: "/usr/bin/tachyon",
args: [
Tachyon.AvailableMethods.FUZZER_HISTORY,
String(limit || 20)
],
args: [Tachyon.AvailableMethods.FUZZER_HISTORY, String(limit || 20)],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS
});
const parsed = parseJsonObjectOutput(
@@ -10291,12 +10288,19 @@ var styles2 = `
// src/partials/modal/styles.ts
var styles3 = `
.tachyon-partial-modal__body {}
.tachyon-partial-modal__body {
width: 100%;
box-sizing: border-box;
}
.tachyon-partial-modal__content {
max-height: 70vh;
overflow: scroll;
max-height: 75vh;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
border-radius: 4px;
box-sizing: border-box;
width: 100%;
}
.tachyon-partial-modal__footer {
@@ -10323,6 +10327,25 @@ var styles3 = `
.tachyon-partial-modal__checkbox-text {
line-height: 1.2;
}
@media (max-width: 640px) {
.tachyon-partial-modal__content {
max-height: 82vh;
}
.tachyon-partial-modal__footer {
flex-direction: column-reverse;
align-items: stretch;
}
.tachyon-partial-modal__footer button {
width: 100%;
min-height: 38px;
}
.tachyon-partial-modal__checkbox {
margin-right: 0;
margin-bottom: 6px;
width: 100%;
}
}
`;
// src/partials/button/renderButton.ts
@@ -12297,13 +12320,13 @@ function renderStrategyFuzzerModal(ruleNames = []) {
]
);
const resultsContainer = E("div", {
style: "max-height: 380px; overflow-y: auto; border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 6px;"
style: "max-height: 380px; overflow-y: auto; overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid var(--border-color, rgba(255,255,255,0.1)); border-radius: 6px; width: 100%; box-sizing: border-box;"
});
const tableEl = E(
"table",
{
class: "table",
style: "width: 100%; margin: 0; font-size: 12px; text-align: left; border-collapse: collapse;"
style: "width: 100%; min-width: 620px; margin: 0; font-size: 12px; text-align: left; border-collapse: collapse;"
},
[
E(
@@ -12621,11 +12644,14 @@ function renderStrategyFuzzerModal(ruleNames = []) {
type: "text",
class: "cbi-input-text",
placeholder: _("Strategy Name"),
style: "flex: 1;"
style: "flex: 1 1 180px; min-width: 140px;"
});
const engineSel = E(
"select",
{ class: "cbi-input-select", style: "width: 140px;" },
{
class: "cbi-input-select",
style: "flex: 0 1 140px; min-width: 120px;"
},
[
E("option", { value: "zapret2" }, "Zapret v2"),
E("option", { value: "zapret" }, "Zapret v1"),
@@ -12636,7 +12662,7 @@ function renderStrategyFuzzerModal(ruleNames = []) {
type: "text",
class: "cbi-input-text",
placeholder: _("Command-line arguments string..."),
style: "flex: 2; font-family: monospace;"
style: "flex: 2 1 240px; min-width: 180px; font-family: monospace;"
});
const addBtn = renderButton({
text: _("+ Add Strategy"),
@@ -12789,7 +12815,7 @@ function renderStrategyFuzzerModal(ruleNames = []) {
placeholder: _(
'Optional context (e.g. "Rostelecom, YouTube 4K buffering is slow")...'
),
style: "flex: 1; font-size: 12px;"
style: "flex: 1 1 240px; min-width: 180px; font-size: 12px;"
});
const aiSynthesizeBtn = renderButton({
text: _("\u{1F9E0} Synthesize with AI"),
@@ -19833,6 +19859,12 @@ var styles6 = `
width: 100%;
min-width: 0;
}
.tachyon_updates-page__component {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
}
.tachyon_updates-page__component {
@@ -19842,7 +19874,8 @@ var styles6 = `
display: flex;
flex-direction: column;
gap: 10px;
min-width: max-content;
min-width: 0;
box-sizing: border-box;
}
.tachyon_updates-page__component__header {
@@ -20268,6 +20301,48 @@ ${PartialStyles}
opacity: 1;
transform: translateY(0);
}
/* Global Mobile & Responsive Layout Enhancements */
@media (max-width: 768px) {
.cbi-section-table {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: collapse;
}
.cbi-section-table-cell {
min-width: 0;
word-break: break-word;
overflow-wrap: anywhere;
}
.cbi-section-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cbi-dropdown {
max-width: 100%;
}
.cbi-input-textarea,
.cbi-input-text,
.cbi-input-select {
max-width: 100%;
box-sizing: border-box;
}
/* Modal responsiveness */
.modal,
.cbi-modal {
max-width: 96vw !important;
box-sizing: border-box;
margin: 10px auto;
}
}
`;
// src/helpers/injectGlobalStyles.ts
@@ -8,6 +8,18 @@
const UCI_PACKAGE = "tachyon";
const AVATAR_PRESETS = {
"👶": _("Toddler (👶)"),
"🧒": _("Child (🧒)"),
"🧑": _("Teen (🧑)"),
"🎮": _("Gamer (🎮)"),
"📱": _("Phone (📱)"),
"💻": _("Laptop / PC (💻)"),
"🏠": _("Family (🏠)"),
"🔒": _("Strict (🔒)"),
"🐾": _("Pets / IoT (🐾)"),
};
const DAY_LABELS = {
monday: _("Monday"),
tuesday: _("Tuesday"),
@@ -182,7 +194,9 @@ function validateDevice(sectionId, value) {
return _("Invalid IP or MAC address. Expected e.g. 192.168.1.150 or AA:BB:CC:DD:EE:FF");
}
function createParentalContent(section) {
// ─── Family Profiles Section ──────────────────────────────────────────────────
function createProfileContent(section) {
// Enabled switch
let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1";
@@ -190,15 +204,43 @@ function createParentalContent(section) {
o.editable = true;
o.width = "5rem";
// Device column in table
o = section.option(form.DummyValue, "_device_display", _("Device / IP / MAC"));
// Profile display (Avatar + Name)
o = section.option(form.DummyValue, "_profile_display", _("Profile"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const avatar = uci.get(UCI_PACKAGE, sectionId, "avatar") || "👶";
const label = uci.get(UCI_PACKAGE, sectionId, "label") || sectionId;
const rawDevs = uci.get(UCI_PACKAGE, sectionId, "device_ip");
const devCount = normalizeListValues(rawDevs).length;
const devBadge =
devCount > 0
? `<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.08));color:var(--primary-color-medium, #3182ce);padding:2px 6px;border-radius:4px;font-size:11px;margin-left:6px;font-weight:600;border:1px solid var(--border-color-low, rgba(0,0,0,0.12));">${devCount} ${_("dev.")}</span>`
: `<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.06));opacity:0.75;padding:2px 6px;border-radius:4px;font-size:11px;margin-left:6px;">${_("no devices")}</span>`;
return (
`<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">` +
`<span style="font-size:1.3rem;line-height:1;">${avatar}</span>` +
`<strong style="font-size:13px;color:inherit;">${label}</strong>` +
devBadge +
`</div>`
);
};
o.textvalue = function (sectionId) {
const avatar = uci.get(UCI_PACKAGE, sectionId, "avatar") || "👶";
const label = uci.get(UCI_PACKAGE, sectionId, "label") || sectionId;
return `${avatar} ${label}`;
};
// Assigned Devices column
o = section.option(form.DummyValue, "_devices_display", _("Devices (MAC / IP)"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip");
const devs = normalizeListValues(rawIp);
if (devs.length === 0) {
return '<span style="opacity:0.5;">' + _("All devices") + "</span>";
return '<span style="opacity:0.5;">' + _("No devices assigned") + "</span>";
}
const isMacAddr = (dev) =>
/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(dev);
@@ -207,22 +249,353 @@ function createParentalContent(section) {
const badges = [...macs, ...ips]
.map((dev) => {
const isMac = isMacAddr(dev);
const bg = isMac ? "#2d3748" : "#2a3441";
const color = isMac ? "#b794f4" : "#63b3ed";
const label = isMac ? "MAC" : "IP";
return (
`<span class="badge" style="background:${bg};color:${color};padding:3px 8px;border-radius:6px;font-size:11px;font-family:monospace;margin-right:4px;font-weight:600;display:inline-block;margin-bottom:2px;border:1px solid ${color}44;">` +
`<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.06));padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;margin-right:4px;font-weight:600;display:inline-flex;align-items:center;margin-bottom:2px;border:1px solid var(--border-color-low, rgba(0,0,0,0.12));max-width:100%;word-break:break-all;overflow-wrap:anywhere;">` +
`<span style="opacity:0.75;font-weight:700;margin-right:3px;">${label}:</span>` +
dev +
"</span>"
);
})
.join("");
return "<div>" + badges + "</div>";
return '<div style="display:flex;flex-wrap:wrap;gap:2px;">' + badges + "</div>";
};
o.textvalue = function (sectionId) {
const devs = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "device_ip"));
return devs.length === 0 ? _("All devices") : devs.join(", ");
return devs.length === 0 ? _("No devices assigned") : devs.join(", ");
};
// Daily quota column
o = section.option(form.DummyValue, "_quota_display", _("Daily Quota"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0;
if (raw <= 0) {
return '<span style="opacity:0.5;">' + _("Unlimited") + "</span>";
}
return (
'<span style="color:var(--primary-color-medium, #3182ce);font-weight:600;">⏳ ' +
raw +
" " +
_("min/day") +
"</span>"
);
};
o.textvalue = function (sectionId) {
const raw = Number(uci.get(UCI_PACKAGE, sectionId, "daily_quota_minutes")) || 0;
return raw > 0 ? `${raw} ${_("min/day")}` : _("Unlimited");
};
// Protection / Features column
o = section.option(form.DummyValue, "_features_display", _("Protection"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const safeSearch = uci.get(UCI_PACKAGE, sectionId, "safe_search") === "1";
const blockDoh = uci.get(UCI_PACKAGE, sectionId, "block_doh") === "1";
const rawDomains = uci.get(UCI_PACKAGE, sectionId, "blocked_domains");
const domainCount = normalizeListValues(rawDomains).length;
const notify = uci.get(UCI_PACKAGE, sectionId, "notify") === "1";
let badges = [];
if (safeSearch) {
badges.push('<span class="badge" style="background:rgba(79,209,197,0.15);color:var(--teal-color, #319795);padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(79,209,197,0.3);">🛡️ ' + _("SafeSearch") + "</span>");
}
if (blockDoh) {
badges.push('<span class="badge" style="background:rgba(183,148,244,0.15);color:var(--purple-color, #805ad5);padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(183,148,244,0.3);">🔒 ' + _("Anti-Bypass (DoH)") + "</span>");
}
if (domainCount > 0) {
badges.push('<span class="badge" style="background:rgba(229,62,62,0.15);color:var(--error-color-medium, #e53e3e);padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(229,62,62,0.3);">🚫 ' + domainCount + " " + _("sites") + "</span>");
}
if (notify) {
badges.push('<span class="badge" style="background:rgba(49,130,206,0.15);color:var(--primary-color-medium, #3182ce);padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(49,130,206,0.3);">🔔 ' + _("Telegram") + "</span>");
}
if (badges.length === 0) {
return '<span style="opacity:0.5;">' + _("Standard") + "</span>";
}
return '<div style="display:flex;flex-wrap:wrap;gap:4px;">' + badges.join("") + "</div>";
};
o.textvalue = function (sectionId) {
const safeSearch = uci.get(UCI_PACKAGE, sectionId, "safe_search") === "1";
const blockDoh = uci.get(UCI_PACKAGE, sectionId, "block_doh") === "1";
const rawDomains = uci.get(UCI_PACKAGE, sectionId, "blocked_domains");
const domainCount = normalizeListValues(rawDomains).length;
let list = [];
if (safeSearch) list.push("SafeSearch");
if (blockDoh) list.push("DoH Block");
if (domainCount > 0) list.push(`${domainCount} sites`);
return list.length === 0 ? _("Standard") : list.join(", ");
};
// Status column
o = section.option(form.DummyValue, "_status_display", _("Status"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
if (!enabled) {
return (
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:var(--background-color-low, rgba(0,0,0,0.08));color:var(--text-color-medium, #888);border:1px solid var(--border-color-low, rgba(0,0,0,0.1));">' +
'<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;display:inline-block;opacity:0.7;"></span>' +
_("Disabled") +
"</span>"
);
}
return (
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:rgba(72,187,120,0.15);color:var(--success-color-medium, #38a169);border:1px solid rgba(72,187,120,0.3);">' +
'<span style="width:6px;height:6px;border-radius:50%;background:#38a169;margin-right:5px;box-shadow:0 0 4px #38a169;display:inline-block;"></span>' +
_("Active") +
"</span>"
);
};
o.textvalue = function (sectionId) {
const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
return enabled ? _("Active") : _("Disabled");
};
// ─── Modal Form Options ───────────────────────────────────────────────────
// Profile Avatar selector
o = section.option(form.ListValue, "avatar", _("Profile Icon / Avatar"), _("Choose a visual icon for this profile"));
o.modalonly = true;
o.default = "👶";
Object.entries(AVATAR_PRESETS).forEach(([icon, label]) => {
o.value(icon, label);
});
// Profile Label / Name
o = section.option(form.Value, "label", _("Profile Name"), _("e.g. Kids (Alice & Tim), Teenager, School PC, Guests"));
o.modalonly = true;
o.rmempty = false;
o.placeholder = _("e.g. Kids");
// Device selector with LAN hostnames integration
o = section.option(
form.DynamicList,
"device_ip",
_("Assigned Devices (MAC / IP)"),
_("Select LAN devices to assign to this profile, or enter IP or MAC addresses manually. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the device's IP changes via DHCP."),
);
o.modalonly = true;
o.rmempty = true;
o.placeholder = "192.168.1.150 or AA:BB:CC:DD:EE:FF";
o.validate = validateDevice;
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
return local_devices.createLocalDeviceDynamicListWidget(
this,
sectionId,
cfgvalue,
);
};
// Daily time quota (minutes per day); 0 disables the limit
o = section.option(
form.Value,
"daily_quota_minutes",
_("Daily Screen Time Quota (minutes)"),
_(
"Total allowed network usage per day for devices in this profile. When the quota is reached, access is blocked until midnight. Enter 0 for unlimited.",
),
);
o.modalonly = true;
o.rmempty = true;
o.default = "0";
o.placeholder = "0";
o.validate = function (_sectionId, value) {
if (value === "" || value === null || value === undefined) return true;
const n = Number(value);
if (!Number.isInteger(n) || n < 0 || n > 1440)
return _("Enter a whole number of minutes between 0 and 1440");
return true;
};
// Enforce SafeSearch
o = section.option(
form.Flag,
"safe_search",
_("Enforce SafeSearch & YouTube Restricted Mode"),
_("Forces SafeSearch on Google, Yandex, Bing, DuckDuckGo and Strict Restricted Mode on YouTube via DNS rewrite for all devices in this profile."),
);
o.modalonly = true;
o.default = "1";
o.rmempty = false;
// Block DoH / DoT (Anti-Bypass)
o = section.option(
form.Flag,
"block_doh",
_("Anti-Bypass: Block Private DNS / DoH / DoT"),
_("Blocks port 853 (DNS-over-TLS) and prevents bypass via encrypted DNS or Private Relay on smartphones for devices in this profile."),
);
o.modalonly = true;
o.default = "1";
o.rmempty = false;
// Profile-level blocked domains
o = section.option(
form.TextValue,
"blocked_domains",
_("Profile Blocked Sites (24/7)"),
_("Always block these domains for all devices in this profile. Enter one domain per line or use category preset buttons."),
);
o.modalonly = true;
o.rmempty = true;
o.rows = 5;
o.wrap = "soft";
o.textarea = true;
o.placeholder = "tiktok.com\nroblox.com";
o.validate = function (sectionId, value) {
if (!value) return true;
const lines = `${value}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
for (const line of lines) {
const clean = line.replace(/^(full:|keyword:|regex:)/, "");
if (/^(full:|keyword:|regex:)/.test(line) && !clean) {
return _("Invalid domain: empty prefix value");
}
if (/^regex:/.test(line)) continue;
if (!/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(clean)) {
return _("Invalid domain: " + line);
}
}
return true;
};
o.write = function (sectionId, value) {
const lines = `${value || ""}`.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const existingList = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains"));
for (const d of lines) {
if (!existingList.includes(d)) {
uci.add_list(UCI_PACKAGE, sectionId, "blocked_domains", d);
}
}
for (const d of existingList) {
if (!lines.includes(d)) {
uci.remove_list(UCI_PACKAGE, sectionId, "blocked_domains", d);
}
}
};
o.load = function (sectionId) {
return normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")).join("\n");
};
o.remove = function (sectionId) {
uci.unset(UCI_PACKAGE, sectionId, "blocked_domains");
};
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
const container = E("div", { style: "width:100%;box-sizing:border-box;" });
const presetRow = E("div", { style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;" });
Object.entries(DOMAIN_PRESETS).forEach(([key, preset]) => {
const btn = E(
"button",
{
class: "cbi-button cbi-button-neutral",
type: "button",
style: "padding:2px 8px;font-size:11px;",
title: preset.domains.join(", "),
},
preset.icon + " " + preset.label,
);
btn.addEventListener("click", function () {
const textarea = container.querySelector("textarea");
if (!textarea) return;
const current = textarea.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const merged = current.slice();
for (const d of preset.domains) {
if (!merged.includes(d)) merged.push(d);
}
textarea.value = merged.join("\n");
});
presetRow.appendChild(btn);
});
container.appendChild(presetRow);
const textarea = E("textarea", {
id: `cbid.tachyon.${sectionId}.blocked_domains`,
class: "cbi-input-textarea",
style: "width:100%;min-height:100px;box-sizing:border-box;",
placeholder: "tiktok.com\nroblox.com",
});
if (cfgvalue) textarea.value = cfgvalue;
container.appendChild(textarea);
return container;
};
// Telegram notifications
o = section.option(
form.Flag,
"notify",
_("Telegram Alerts"),
_("Send Telegram alerts to parents when daily screen time is exhausted or blocked sites are accessed."),
);
o.modalonly = true;
o.default = "1";
o.rmempty = false;
}
// ─── Schedule Rules Section ───────────────────────────────────────────────────
function createParentalContent(section) {
// Enabled switch
let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1";
o.rmempty = false;
o.editable = true;
o.width = "5rem";
// Device / Target profile column in table
o = section.option(form.DummyValue, "_device_display", _("Profile / Devices"));
o.rawhtml = true;
o.modalonly = false;
o.cfgvalue = function (sectionId) {
const rawProfiles = uci.get(UCI_PACKAGE, sectionId, "profile");
const profiles = normalizeListValues(rawProfiles);
const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip");
const devs = normalizeListValues(rawIp);
let items = [];
if (profiles.length > 0) {
profiles.forEach((pName) => {
const prof = uci.get(UCI_PACKAGE, pName);
const avatar = (prof && prof.avatar) || "👶";
const label = (prof && prof.label) || pName;
const devCount = prof ? normalizeListValues(prof.device_ip).length : 0;
items.push(
`<span class="badge" style="background:rgba(49,151,149,0.15);color:var(--teal-color, #319795);padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;font-weight:600;display:inline-flex;align-items:center;margin-bottom:2px;border:1px solid rgba(49,151,149,0.3);max-width:100%;word-break:break-all;">` +
`${avatar} ${label} (${devCount} ${_("dev.")})` +
`</span>`
);
});
}
if (devs.length > 0) {
const isMacAddr = (dev) =>
/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(dev);
const macs = devs.filter(isMacAddr);
const ips = devs.filter((d) => !isMacAddr(d));
[...macs, ...ips].forEach((dev) => {
const isMac = isMacAddr(dev);
const label = isMac ? "MAC" : "IP";
items.push(
`<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.06));padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;margin-right:4px;font-weight:600;display:inline-flex;align-items:center;margin-bottom:2px;border:1px solid var(--border-color-low, rgba(0,0,0,0.12));max-width:100%;word-break:break-all;overflow-wrap:anywhere;">` +
`<span style="opacity:0.75;font-weight:700;margin-right:3px;">${label}:</span>` +
dev +
"</span>"
);
});
}
if (items.length === 0) {
return '<span style="opacity:0.5;">' + _("All devices") + "</span>";
}
return '<div style="display:flex;flex-wrap:wrap;gap:2px;">' + items.join("") + "</div>";
};
o.textvalue = function (sectionId) {
const profiles = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "profile"));
const devs = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "device_ip"));
const list = [...profiles.map((p) => `Profile: ${p}`), ...devs];
return list.length === 0 ? _("All devices") : list.join(", ");
};
// Target column in table
@@ -232,7 +605,7 @@ function createParentalContent(section) {
o.cfgvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
if (target === "all") {
return '<span style="color:#fc8181;font-weight:600;">🚫 ' + _("All Internet") + "</span>";
return '<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' + _("All Internet") + "</span>";
}
const rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections");
const secNames = normalizeListValues(rawSecs);
@@ -247,13 +620,13 @@ function createParentalContent(section) {
const sec = uci.get(UCI_PACKAGE, name);
const label = (sec && (sec.label || sec[".name"])) || name;
return (
'<span class="badge" style="background:#2d3748;color:#f6ad55;padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid rgba(246,173,85,0.3);display:inline-block;margin-bottom:2px;">🔒 ' +
'<span class="badge" style="background:var(--background-color-low, rgba(0,0,0,0.06));padding:2px 6px;border-radius:4px;font-size:11px;margin-right:4px;border:1px solid var(--border-color-low, rgba(0,0,0,0.12));display:inline-flex;align-items:center;margin-bottom:2px;max-width:100%;word-break:break-all;">🔒 ' +
label +
"</span>"
);
})
.join("");
return "<div>" + badges + "</div>";
return '<div style="display:flex;flex-wrap:wrap;gap:2px;">' + badges + "</div>";
};
o.textvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
@@ -269,9 +642,9 @@ function createParentalContent(section) {
o.cfgvalue = function (sectionId) {
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (action === "allow") {
return '<span style="color:#68d391;font-weight:500;">✅ ' + _("Allow in interval") + "</span>";
return '<span style="color:var(--success-color-medium, #38a169);font-weight:500;">✅ ' + _("Allow in interval") + "</span>";
}
return '<span style="color:#fc8181;font-weight:500;">🚫 ' + _("Block in interval") + "</span>";
return '<span style="color:var(--error-color-medium, #e53e3e);font-weight:500;">🚫 ' + _("Block in interval") + "</span>";
};
o.textvalue = function (sectionId) {
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
@@ -287,11 +660,11 @@ function createParentalContent(section) {
const e = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59";
const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days"));
return (
'<div style="line-height:1.3;"><strong style="color:var(--text-color, #fff);">' +
'<div style="line-height:1.3;"><strong style="color:inherit;">' +
s +
" — " +
e +
'</strong><br><small style="opacity:0.7;">' +
'</strong><br><small style="opacity:0.75;">' +
days +
"</small></div>"
);
@@ -313,7 +686,7 @@ function createParentalContent(section) {
return '<span style="opacity:0.5;">' + _("None") + "</span>";
}
return (
'<span style="color:#63b3ed;font-weight:500;">⏳ ' +
'<span style="color:var(--primary-color-medium, #3182ce);font-weight:500;">⏳ ' +
raw +
" " +
_("min/day") +
@@ -337,17 +710,17 @@ function createParentalContent(section) {
}
const modeBadge =
mode === "allow"
? '<span style="color:#68d391;font-weight:600;">🟢 ' + _("Allow only") + "</span>"
: '<span style="color:#fc8181;font-weight:600;">🚫 ' + _("Block") + "</span>";
? '<span style="color:var(--success-color-medium, #38a169);font-weight:600;">🟢 ' + _("Allow only") + "</span>"
: '<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' + _("Block") + "</span>";
const badges = domains
.map(
(d) =>
'<span class="badge" style="background:#2d3748;color:#f687b3;padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;margin-right:4px;border:1px solid rgba(246,135,179,0.3);display:inline-block;margin-bottom:2px;">' +
'<span class="badge" style="background:rgba(237,100,166,0.15);color:var(--pink-color, #d53f8c);padding:2px 6px;border-radius:4px;font-size:11px;font-family:monospace;margin-right:4px;border:1px solid rgba(237,100,166,0.3);display:inline-flex;align-items:center;margin-bottom:2px;max-width:100%;word-break:break-all;">' +
d +
"</span>",
)
.join("");
return "<div>" + modeBadge + "<br>" + badges + "</div>";
return "<div>" + modeBadge + '<br><div style="display:flex;flex-wrap:wrap;gap:2px;margin-top:2px;">' + badges + "</div></div>";
};
o.textvalue = function (sectionId) {
const domains = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains"));
@@ -364,8 +737,8 @@ function createParentalContent(section) {
const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
if (!enabled) {
return (
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:#4a5568;color:#e2e8f0;">' +
'<span style="width:6px;height:6px;border-radius:50%;background:#a0aec0;margin-right:5px;display:inline-block;"></span>' +
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:var(--background-color-low, rgba(0,0,0,0.08));color:var(--text-color-medium, #888);border:1px solid var(--border-color-low, rgba(0,0,0,0.1));">' +
'<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;display:inline-block;opacity:0.7;"></span>' +
_("Disabled") +
"</span>"
);
@@ -373,20 +746,20 @@ function createParentalContent(section) {
const active = isScheduleCurrentlyActive(sectionId);
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (active) {
const color = action === "allow" ? "#48bb78" : "#e53e3e";
const bg = action === "allow" ? "rgba(72,187,120,0.2)" : "rgba(229,62,62,0.2)";
const border = action === "allow" ? "rgba(72,187,120,0.4)" : "rgba(229,62,62,0.4)";
const color = action === "allow" ? "var(--success-color-medium, #38a169)" : "var(--error-color-medium, #e53e3e)";
const bg = action === "allow" ? "rgba(72,187,120,0.15)" : "rgba(229,62,62,0.15)";
const border = action === "allow" ? "rgba(72,187,120,0.3)" : "rgba(229,62,62,0.3)";
const text = action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)");
return (
`<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:${bg};color:${color};border:1px solid ${border};">` +
`<span style="width:6px;height:6px;border-radius:50%;background:${color};margin-right:5px;box-shadow:0 0 6px ${color};display:inline-block;"></span>` +
`<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;box-shadow:0 0 4px currentColor;display:inline-block;"></span>` +
text +
"</span>"
);
}
return (
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:rgba(74,85,104,0.3);color:#a0aec0;border:1px solid rgba(74,85,104,0.3);">' +
'<span style="width:6px;height:6px;border-radius:50%;background:#718096;margin-right:5px;display:inline-block;"></span>' +
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:var(--background-color-low, rgba(0,0,0,0.06));color:var(--text-color-medium, #718096);border:1px solid var(--border-color-low, rgba(0,0,0,0.1));">' +
'<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;display:inline-block;opacity:0.6;"></span>' +
_("Pending") +
"</span>"
);
@@ -408,15 +781,37 @@ function createParentalContent(section) {
o.rmempty = true;
o.placeholder = _("e.g. Kids Phone (Night YouTube)");
// Target Family Profile selector
o = section.option(
form.DynamicList,
"profile",
_("Target Family Profiles"),
_("Apply this schedule rule to all devices in the selected family profile(s)."),
);
o.modalonly = true;
o.rmempty = true;
o.load = function (sectionId) {
this.keylist = [];
this.vallist = [];
const profList = uci.sections(UCI_PACKAGE, "profile") || [];
profList.forEach((p) => {
const pName = p[".name"];
const pAvatar = p.avatar || "👶";
const pLabel = p.label ? `${pAvatar} ${p.label}` : `${pAvatar} ${pName}`;
this.value(pName, pLabel);
});
return form.DynamicList.prototype.load.apply(this, [sectionId]);
};
// Device selector with LAN hostnames integration
o = section.option(
form.DynamicList,
"device_ip",
_("Target Devices (IP / MAC)"),
_("Select LAN devices to apply this schedule to, or enter IP or MAC addresses manually. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the device's IP changes. IP addresses (e.g. 192.168.1.150) are used for DNS-level blocking and require a stable lease."),
_("Additional Individual Devices (IP / MAC)"),
_("Optionally specify individual devices not in a profile. MAC addresses (e.g. AA:BB:CC:DD:EE:FF) work even if the IP changes."),
);
o.modalonly = true;
o.rmempty = false;
o.rmempty = true;
o.placeholder = "192.168.1.150 or AA:BB:CC:DD:EE:FF";
o.validate = validateDevice;
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
@@ -581,7 +976,7 @@ function createParentalContent(section) {
uci.unset(UCI_PACKAGE, sectionId, "blocked_domains");
};
o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
const container = E("div", { style: "width:100%;" });
const container = E("div", { style: "width:100%;box-sizing:border-box;" });
// Preset buttons row
const presetRow = E("div", { style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;" });
@@ -613,7 +1008,7 @@ function createParentalContent(section) {
const textarea = E("textarea", {
id: `cbid.tachyon.${sectionId}.blocked_domains`,
class: "cbi-input-textarea",
style: "width:100%;min-height:110px;",
style: "width:100%;min-height:110px;box-sizing:border-box;",
placeholder: "youtube.com\ngooglevideo.com",
});
if (cfgvalue) textarea.value = cfgvalue;
@@ -657,6 +1052,7 @@ function createParentalContent(section) {
}
const EntryPoint = {
createProfileContent,
createParentalContent,
};
@@ -460,11 +460,25 @@ const EntryPoint = {
});
server.createServerContent(serverSection, uiCapabilities);
const profileSection = tachyonMap.section(
form.GridSection,
"profile",
_("Family Profiles"),
_("Group devices into family profiles to manage content filtering, SafeSearch, and screen time limits in one place."),
);
configureGridSection(
profileSection,
"profile",
_("Family Profile"),
_("Add a family profile"),
);
parental.createProfileContent(profileSection);
const parentalSection = tachyonMap.section(
form.GridSection,
"schedule",
_("Parental Control"),
_("Block internet access or specific proxy sections for individual devices based on time and days."),
_("Block internet access or specific proxy sections for family profiles or individual devices based on time and days."),
);
configureGridSection(
parentalSection,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -233,3 +233,31 @@ config telegram 'telegram'
# language: auto-detected from LuCI if not set.
# Values: ru (default), en
#config profile 'profile_kids_example'
# option label 'Kids Devices'
# option avatar '👶'
# option enabled '1'
# list device_ip '192.168.1.150'
# list device_ip 'AA:BB:CC:DD:EE:FF'
# option safe_search '1'
# option block_doh '1'
# option daily_quota_minutes '120'
# list blocked_domains 'tiktok.com'
# list blocked_domains 'roblox.com'
# option notify '1'
#config schedule 'schedule_bedtime_example'
# option label 'Kids Bedtime'
# option enabled '1'
# list profile 'profile_kids_example'
# option target 'all'
# option action 'block'
# option start_time '22:00'
# option end_time '08:00'
# list days 'monday'
# list days 'tuesday'
# list days 'wednesday'
# list days 'thursday'
# list days 'friday'
+71 -1
View File
@@ -1732,6 +1732,23 @@ function validate_schedule(schedule, sections) {
fail_validation("Schedule '" + name + "' has invalid device IP or MAC address '" + clean_ip + "'. Aborted.");
}
let profile_refs = list_option(schedule, "profile");
if (length(profile_refs) == 0) {
let single_p = option(schedule, "profile", "");
if (single_p != "") profile_refs = [ single_p ];
}
for (let prof_name in profile_refs) {
let found = false;
for (let p in profiles) {
if (section_name(p) == prof_name) {
found = true;
break;
}
}
if (!found)
fail_validation("Schedule '" + name + "' references unknown profile '" + prof_name + "'. Aborted.");
}
let target = option(schedule, "target", "all");
let sec_names = list_option(schedule, "sections");
if (length(sec_names) == 0) {
@@ -1775,10 +1792,60 @@ function validate_schedule(schedule, sections) {
fail_validation("Schedule '" + name + "' has invalid notify '" + notify + "'. Expected 0 or 1. Aborted.");
}
function validate_profile(profile, profiles) {
if (!section_enabled(profile))
return;
let name = section_name(profile);
let raw_ips = list_option(profile, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(profile, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
for (let ip in raw_ips) {
let clean_ip = trim(as_string(ip));
let is_mac = match(clean_ip, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (clean_ip != "" && !core_ip.valid_ip_or_cidr(clean_ip) && !is_mac)
fail_validation("Profile '" + name + "' has invalid device IP or MAC address '" + clean_ip + "'. Aborted.");
}
let quota_raw = trim(as_string(option(profile, "daily_quota_minutes", "") || ""));
if (quota_raw != "") {
let quota = int(quota_raw);
if (quota < 0 || quota > 1440 || match(quota_raw, /^[0-9]+$/) == null)
fail_validation("Profile '" + name + "' has invalid daily_quota_minutes '" + quota_raw + "'. Use a number of minutes between 0 and 1440. Aborted.");
}
let safe_search = option(profile, "safe_search", "0");
if (safe_search != "" && safe_search != "0" && safe_search != "1")
fail_validation("Profile '" + name + "' has invalid safe_search '" + safe_search + "'. Expected 0 or 1. Aborted.");
let block_doh = option(profile, "block_doh", "0");
if (block_doh != "" && block_doh != "0" && block_doh != "1")
fail_validation("Profile '" + name + "' has invalid block_doh '" + block_doh + "'. Expected 0 or 1. Aborted.");
let notify = option(profile, "notify", "0");
if (notify != "" && notify != "0" && notify != "1")
fail_validation("Profile '" + name + "' has invalid notify '" + notify + "'. Expected 0 or 1. Aborted.");
let blocked_domains = list_option(profile, "blocked_domains");
if (length(blocked_domains) == 0) {
let single_domain = option(profile, "blocked_domains", "");
if (single_domain != "") blocked_domains = [ single_domain ];
}
for (let domain in blocked_domains) {
let clean_domain = trim(as_string(domain));
if (clean_domain != "" && !combined_domain_valid(clean_domain))
fail_validation("Profile '" + name + "' has invalid blocked domain '" + clean_domain + "'. Use plain domains or full:, keyword:, regex: prefixes. Aborted.");
}
}
function validate_runtime_config(context) {
let settings = settings_section();
let sections = sections_by_type("section");
let schedules = sections_by_type("schedule");
let profiles = sections_by_type("profile");
validate_runtime_mark_ranges_context(context);
@@ -1830,8 +1897,11 @@ function validate_runtime_config(context) {
for (let section in sections)
validate_rule(section, sections, context);
for (let profile in profiles)
validate_profile(profile, profiles);
for (let schedule in schedules)
validate_schedule(schedule, sections);
validate_schedule(schedule, sections, profiles);
}
function context_from_runtime() {
+21
View File
@@ -480,6 +480,8 @@ function parent_dir(path) {
}
// Read the HTTP/mixed inbound port from the generated sing-box config.
// Prefers the dedicated internal service mixed inbound (tag: service-mixed-in)
// or inbounds listening on 127.0.0.1/0.0.0.0.
// Falls back to 4534 when the config is missing or unparseable.
function get_mixed_port() {
let data = fs.readfile("/etc/sing-box/config.json");
@@ -487,6 +489,25 @@ function get_mixed_port() {
let parsed;
try { parsed = json(data); } catch (e) { return 4534; }
if (parsed == null || parsed.inbounds == null) return 4534;
// 1. Explicitly check for the internal service mixed inbound
for (let inbound in parsed.inbounds) {
if (inbound.tag == "service-mixed-in" && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10);
if (port > 0) return port;
}
}
// 2. Check for any mixed/http inbound listening on 127.0.0.1, 0.0.0.0, or ::
for (let inbound in parsed.inbounds) {
if ((inbound.type == "mixed" || inbound.type == "http") && inbound.listen_port != null) {
let listen = as_string(inbound.listen || "");
if (listen == "127.0.0.1" || listen == "0.0.0.0" || listen == "::" || listen == "") {
let port = int(inbound.listen_port, 10);
if (port > 0) return port;
}
}
}
// 3. Fallback to any mixed/http inbound port
for (let inbound in parsed.inbounds) {
if ((inbound.type == "mixed" || inbound.type == "http") && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10);
+167 -21
View File
@@ -872,17 +872,95 @@ function nft_schedule_time_intervals(start_time, end_time) {
return [ [ start_time, end_time ] ];
}
function nft_add_schedule_rules_from_schedules(schedules, sections, table) {
function resolve_schedule_devices(schedule, profiles) {
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
let result = [];
for (let ip in raw_ips) {
let clean = trim(as_string(ip));
if (clean != "" && index(result, clean) < 0)
push(result, clean);
}
let prof_names = list_option(schedule, "profile");
if (length(prof_names) == 0) {
let single_p = option(schedule, "profile", "");
if (single_p != "") prof_names = [ single_p ];
}
if (profiles != null && length(prof_names) > 0) {
for (let p_name in prof_names) {
for (let profile in profiles) {
profile = object_or_empty(profile);
if (as_string(profile[".name"]) == p_name && bool_option(profile, "enabled", true)) {
let p_ips = list_option(profile, "device_ip");
if (length(p_ips) == 0) {
let single_p_ip = option(profile, "device_ip", "");
if (single_p_ip != "") p_ips = [ single_p_ip ];
}
for (let p_ip in p_ips) {
let clean_p = trim(as_string(p_ip));
if (clean_p != "" && index(result, clean_p) < 0)
push(result, clean_p);
}
}
}
}
}
return result;
}
function nft_add_profile_doh_block_rules(profiles, table) {
if (!profiles || length(profiles) == 0)
return true;
for (let profile in profiles) {
profile = object_or_empty(profile);
if (!bool_option(profile, "enabled", true) || !bool_option(profile, "block_doh", false))
continue;
let raw_ips = list_option(profile, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(profile, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
if (length(raw_ips) == 0)
continue;
let label = as_string(option(profile, "label", profile[".name"]));
let comment = "tachyon-doh:" + label;
for (let raw_ip in raw_ips) {
let dev_str = trim(as_string(raw_ip));
if (dev_str == "") continue;
let is_mac = match(dev_str, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
let family = is_mac ? 0 : core_ip.ip_family(dev_str);
let saddr_key = family == 6 ? "ip6" : "ip";
let tcp_rule = is_mac ?
[ "ether", "saddr", lc(replace(dev_str, "-", ":")), "tcp", "dport", "853", "counter", "drop", "comment", "\"" + comment + "\"" ] :
[ saddr_key, "saddr", dev_str, "tcp", "dport", "853", "counter", "drop", "comment", "\"" + comment + "\"" ];
let udp_rule = is_mac ?
[ "ether", "saddr", lc(replace(dev_str, "-", ":")), "udp", "dport", "853", "counter", "drop", "comment", "\"" + comment + "\"" ] :
[ saddr_key, "saddr", dev_str, "udp", "dport", "853", "counter", "drop", "comment", "\"" + comment + "\"" ];
nft_add_rule(table, "parental_forward", tcp_rule);
nft_add_rule(table, "parental_forward", udp_rule);
nft_add_rule(table, "parental_control", tcp_rule);
nft_add_rule(table, "parental_control", udp_rule);
}
}
return true;
}
function nft_add_schedule_rules_from_schedules(schedules, sections, table, profiles) {
for (let schedule in schedules) {
schedule = object_or_empty(schedule);
if (!bool_option(schedule, "enabled", true))
continue;
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
let raw_ips = resolve_schedule_devices(schedule, profiles);
if (length(raw_ips) == 0)
continue;
@@ -987,7 +1065,7 @@ function nft_add_schedule_rules_from_schedules(schedules, sections, table) {
}
function nft_add_schedule_rules_from_uci(table, sections) {
return nft_add_schedule_rules_from_schedules(uci_sections("schedule"), sections, table);
return nft_add_schedule_rules_from_schedules(uci_sections("schedule"), sections, table, uci_sections("profile"));
}
// ─── Content blocking DNS redirect ───────────────────────────────────────────
@@ -998,7 +1076,7 @@ function nft_add_schedule_rules_from_uci(table, sections) {
// dns-in inbound as usual. MAC-address devices match via ether saddr, which
// also covers devices whose IP is DHCP-assigned.
function nft_add_dns_block_rules_from_schedules(schedules, table) {
function nft_add_dns_block_rules_from_schedules(schedules, table, profiles) {
let added = false;
for (let schedule in schedules) {
schedule = object_or_empty(schedule);
@@ -1009,11 +1087,7 @@ function nft_add_dns_block_rules_from_schedules(schedules, table) {
if (single_domain == "") continue;
}
let raw_ips = list_option(schedule, "device_ip");
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
let raw_ips = resolve_schedule_devices(schedule, profiles);
if (length(raw_ips) == 0)
continue;
@@ -1067,11 +1141,65 @@ function nft_add_dns_block_rules_from_schedules(schedules, table) {
}
}
}
// Profiles with blocked_domains or safe_search also redirect DNS to sing-box block inbound
if (profiles != null && length(profiles) > 0) {
for (let profile in profiles) {
profile = object_or_empty(profile);
if (!bool_option(profile, "enabled", true))
continue;
let has_domains = length(list_option(profile, "blocked_domains")) > 0 || option(profile, "blocked_domains", "") != "";
let has_safesearch = bool_option(profile, "safe_search", false);
if (!has_domains && !has_safesearch)
continue;
let p_ips = list_option(profile, "device_ip");
if (length(p_ips) == 0) {
let single_p_ip = option(profile, "device_ip", "");
if (single_p_ip != "") p_ips = [ single_p_ip ];
}
if (length(p_ips) == 0)
continue;
let label = as_string(option(profile, "label", profile[".name"]));
let comment = "tachyon-profile:" + label;
for (let raw_ip in p_ips) {
let dev_str = trim(as_string(raw_ip));
if (dev_str == "") continue;
let is_mac = match(dev_str, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
let family = is_mac ? 0 : core_ip.ip_family(dev_str);
if (!is_mac && family != 4)
continue;
let match_args = [];
if (is_mac)
append_array(match_args, [ "ether", "saddr", lc(replace(dev_str, "-", ":")) ]);
else
append_array(match_args, [ "ip", "saddr", dev_str ]);
append_array(match_args, [ "udp", "dport", "53", "redirect", "to", DNS_BLOCK_TARGET, "counter", "comment", "\"" + comment + "\"" ]);
if (!nft_add_rule(table, "dns_block", match_args))
return false;
added = true;
let tcp_args = [];
if (is_mac)
append_array(tcp_args, [ "ether", "saddr", lc(replace(dev_str, "-", ":")) ]);
else
append_array(tcp_args, [ "ip", "saddr", dev_str ]);
append_array(tcp_args, [ "tcp", "dport", "53", "redirect", "to", DNS_BLOCK_TARGET, "counter", "comment", "\"" + comment + "\"" ]);
if (!nft_add_rule(table, "dns_block", tcp_args))
return false;
added = true;
}
}
}
return true;
}
function nft_add_dns_block_rules_from_uci(table) {
return nft_add_dns_block_rules_from_schedules(uci_sections("schedule"), table);
return nft_add_dns_block_rules_from_schedules(uci_sections("schedule"), table, uci_sections("profile"));
}
function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_port_set, interface_set, source_interfaces, fakeip_mark, outbound_mark, fakeip_range, tproxy_port, exclude_ntp, localv6_set, common6_set, ip_port6_set, fakeip6_range, tproxy6_address) {
@@ -1910,6 +2038,7 @@ function nft_schedule_signature_body(body, schedule) {
let name = as_string(schedule[".name"]);
body = signature_add_value(body, "schedule." + name + ".enabled", bool_option(schedule, "enabled", true) ? "1" : "0");
body = signature_add_value(body, "schedule." + name + ".device_ip", option(schedule, "device_ip", ""));
body = signature_add_value(body, "schedule." + name + ".profile", join(",", list_option(schedule, "profile")));
body = signature_add_value(body, "schedule." + name + ".target", option(schedule, "target", "all"));
body = signature_add_value(body, "schedule." + name + ".sections", join(",", list_option(schedule, "sections")));
body = signature_add_value(body, "schedule." + name + ".action", option(schedule, "action", "block"));
@@ -1921,7 +2050,18 @@ function nft_schedule_signature_body(body, schedule) {
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections, schedules) {
function nft_profile_signature_body(body, profile) {
let name = as_string(profile[".name"]);
body = signature_add_value(body, "profile." + name + ".enabled", bool_option(profile, "enabled", true) ? "1" : "0");
body = signature_add_value(body, "profile." + name + ".device_ip", join(",", list_option(profile, "device_ip")));
body = signature_add_value(body, "profile." + name + ".safe_search", option(profile, "safe_search", "0"));
body = signature_add_value(body, "profile." + name + ".block_doh", option(profile, "block_doh", "0"));
body = signature_add_value(body, "profile." + name + ".blocked_domains", join(",", list_option(profile, "blocked_domains")));
body = signature_add_value(body, "profile." + name + ".daily_quota_minutes", option(profile, "daily_quota_minutes", "0"));
return body;
}
function nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles) {
let body = "";
body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan"));
@@ -1932,14 +2072,17 @@ function nft_runtime_signature_from_settings_and_sections(settings, sections, sc
for (let section in sections)
body = nft_rule_signature_body(body, object_or_empty(section));
for (let profile in profiles)
body = nft_profile_signature_body(body, object_or_empty(profile));
for (let schedule in schedules)
body = nft_schedule_signature_body(body, object_or_empty(schedule));
return signature_hash(body);
}
function print_nft_runtime_signature_from_settings_and_sections(settings, sections, schedules) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules);
function print_nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles) {
let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles);
if (hash == "")
return false;
@@ -1988,6 +2131,7 @@ function nft_create_full_runtime_from_uci(rt_table, table, localv4_set, common_s
nft_add_section_priority_rules_from_sections(uci_sections("section"), table, interface_set, localv4_set, localv6_set, fakeip_mark) &&
nft_add_schedule_rules_from_uci(table, uci_sections("section")) &&
nft_add_dns_block_rules_from_uci(table) &&
nft_add_profile_doh_block_rules(uci_sections("profile"), table) &&
nft_create_provider_output_rules_from_uci(table, "zapret", zapret_bin, zapret_route_mark_base, zapret_queue_base, zapret_desync_mark, zapret_desync_mark_postnat) &&
nft_create_provider_output_rules_from_uci(table, "zapret2", zapret2_bin, zapret2_route_mark_base, zapret2_queue_base, zapret2_desync_mark, zapret2_desync_mark_postnat) &&
nft_create_runtime_output_rules(table, localv4_set, common_set, port_set, ip_port_set, fakeip_mark, fakeip_range, localv6_set, common6_set, ip_port6_set, fakeip6_range);
@@ -2014,7 +2158,8 @@ function nft_runtime_signature_from_uci() {
return print_nft_runtime_signature_from_settings_and_sections(
uci_settings(),
uci_sections("section"),
uci_sections("schedule")
uci_sections("schedule"),
uci_sections("profile")
);
}
@@ -2034,7 +2179,8 @@ function nft_runtime_signature_from_fixture(path) {
return print_nft_runtime_signature_from_settings_and_sections(
fixture_settings(data),
fixture_section_list(data, "section"),
fixture_section_list(data, "schedule")
fixture_section_list(data, "schedule"),
fixture_section_list(data, "profile")
);
}
@@ -2288,9 +2434,9 @@ else if (mode == "nft-create-provider-output-rules-fixture")
else if (mode == "nft-add-section-priority-rules-fixture")
exit(nft_add_section_priority_rules_from_sections(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6]) ? 0 : 1);
else if (mode == "nft-add-schedule-rules-fixture")
exit(nft_add_schedule_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2]) ? 0 : 1);
exit(nft_add_schedule_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "section"), ARGV[2], fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "profile")) ? 0 : 1);
else if (mode == "nft-add-dns-block-rules-fixture")
exit(nft_add_dns_block_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), ARGV[2]) ? 0 : 1);
exit(nft_add_dns_block_rules_from_schedules(fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "schedule"), ARGV[2], fixture_section_list(object_or_empty(common_read_json_file(ARGV[1])), "profile")) ? 0 : 1);
else if (mode == "nft-create-full-runtime-from-uci")
exit(nft_create_full_runtime_from_uci(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5], ARGV[6], ARGV[7], ARGV[8], ARGV[9], ARGV[10], ARGV[11], ARGV[12], ARGV[13], ARGV[14], ARGV[15], ARGV[16], ARGV[17], ARGV[18], ARGV[19], ARGV[20], ARGV[21], ARGV[22], ARGV[23], ARGV[24], ARGV[25], ARGV[26]) ? 0 : 1);
else if (mode == "nft-rebuild-runtime-from-uci")
@@ -74,6 +74,31 @@ function list_option(section, key) {
function quota_schedules() {
let result = [];
let profiles_by_name = {};
for (let p in uci_core.section_objects(CONFIG_NAME, "profile")) {
let p_name = as_string(object_or_empty(p)[".name"]);
profiles_by_name[p_name] = p;
if (!bool_option(p, "enabled", true))
continue;
let p_minutes = int_option(p, "daily_quota_minutes", 0);
if (p_minutes <= 0)
continue;
let p_devices = [];
for (let dev in list_option(p, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
if (!is_mac && match(dev, /:/) != null)
continue;
push(p_devices, { ident: lc(dev), is_mac });
}
if (length(p_devices) == 0)
continue;
push(result, {
label: as_string(object_or_empty(p).label || p_name),
minutes: p_minutes,
devices: p_devices
});
}
for (let section in uci_core.section_objects(CONFIG_NAME, "schedule")) {
if (!bool_option(section, "enabled", true))
continue;
@@ -81,17 +106,36 @@ function quota_schedules() {
if (minutes <= 0)
continue;
let devices = [];
let idents_seen = {};
for (let dev in list_option(section, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
// Skip IPv6 entries: enforcement sets cover MACs and IPv4 only.
if (!is_mac && match(dev, /:/) != null)
continue;
push(devices, { ident: lc(dev), is_mac });
let ident = lc(dev);
if (!idents_seen[ident]) {
idents_seen[ident] = true;
push(devices, { ident, is_mac });
}
}
for (let prof_ref in list_option(section, "profile")) {
let p = profiles_by_name[prof_ref];
if (p && bool_option(p, "enabled", true)) {
for (let dev in list_option(p, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null;
if (!is_mac && match(dev, /:/) != null)
continue;
let ident = lc(dev);
if (!idents_seen[ident]) {
idents_seen[ident] = true;
push(devices, { ident, is_mac });
}
}
}
}
if (length(devices) == 0)
continue;
push(result, {
label: as_string(object_or_empty(section)[".name"]),
label: as_string(object_or_empty(section).label || object_or_empty(section)[".name"]),
minutes,
devices
});
@@ -277,7 +321,7 @@ function tick() {
devices[ident] = { minutes, blocked: minutes >= schedule.minutes };
if (minutes >= schedule.minutes) {
log_message("device " + ident + " hit daily quota (" + as_string(schedule.minutes) + " min); blocking until midnight", "info");
send_notification("⏳ *Parental control*: устройство `" + ident + "` исчерпало дневную квоту (" + as_string(schedule.minutes) + " мин), доступ заблокирован до полуночи.");
send_notification("⏳ *Parental control*: устройство `" + ident + "` (" + schedule.label + ") исчерпало дневную квоту (" + as_string(schedule.minutes) + " мин), доступ заблокирован до полуночи.");
}
} else {
devices[ident] = { minutes, blocked: was_blocked };
+16
View File
@@ -116,6 +116,22 @@ function get_mixed_port() {
let parsed;
try { parsed = json(data); } catch (e) { return 4534; }
if (parsed == null || parsed.inbounds == null) return 4534;
for (let inbound in parsed.inbounds) {
if (inbound.tag == "service-mixed-in" && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10);
if (port > 0) return port;
}
}
for (let inbound in parsed.inbounds) {
if ((inbound.type == "mixed" || inbound.type == "http") && inbound.listen_port != null) {
let listen = as_string(inbound.listen || "");
if (listen == "127.0.0.1" || listen == "0.0.0.0" || listen == "::" || listen == "") {
let port = int(inbound.listen_port, 10);
if (port > 0) return port;
}
}
}
for (let inbound in parsed.inbounds) {
if (inbound.type == "mixed" && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10);
+23 -3
View File
@@ -1279,15 +1279,35 @@ function heal_low_memory(ev) {
system("echo 3 > /proc/sys/vm/drop_caches");
}
function get_clash_url(endpoint) {
let host = "127.0.0.1:9090";
let config_data = fs.readfile("/etc/sing-box/config.json");
if (config_data) {
try {
let sb_cfg = json(config_data);
let ext = sb_cfg.experimental?.clash_api?.external_controller;
if (ext) {
let parts = split(ext, ":");
let ip = (length(parts) > 1) ? parts[0] : "";
let port = (length(parts) > 1) ? parts[length(parts) - 1] : "9090";
if (ip == "0.0.0.0" || ip == "") {
host = "127.0.0.1:" + port;
} else {
host = ext;
}
}
} catch (e) {}
}
return "http://" + host + "/" + endpoint;
}
// The 5s throttle that used to live here is now the controller's emit_once
// window on urltest.switched, so a burst of log lines still costs one poll.
function notify_urltest_switch(ev) {
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
if (tcfg.enabled != "1" || tcfg.notify_crash == "0") return;
let ports = mixed_ports_from_config();
let p_port = length(ports) > 0 ? as_string(ports[0]) : as_string(common.get_mixed_port());
let p_res = command_capture(command_from_args(["curl", "-s", "http://127.0.0.1:" + p_port + "/proxies"]));
let p_res = command_capture(command_from_args(["curl", "-s", get_clash_url("proxies")]));
if (p_res && p_res.status == 0 && p_res.output) {
try {
let p_data = json(p_res.output);
+108 -5
View File
@@ -712,6 +712,25 @@ function schedule_has_time_window(schedule) {
trim(option(schedule, "end_time", "")) != "";
}
function profile_source_ip_cidrs(profile) {
let result = [];
for (let raw in schedule_list_value(profile, "device_ip")) {
let device = trim(as_string(raw));
if (device == "")
continue;
let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (is_mac)
continue;
if (core_ip.valid_ip(device)) {
let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32";
if (index(result, cidr) < 0) push(result, cidr);
} else if (core_ip.valid_ip_cidr(device)) {
if (index(result, device) < 0) push(result, device);
}
}
return result;
}
function schedule_source_ip_cidrs(schedule) {
let result = [];
for (let raw in schedule_list_value(schedule, "device_ip")) {
@@ -721,10 +740,21 @@ function schedule_source_ip_cidrs(schedule) {
let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (is_mac)
continue;
if (core_ip.valid_ip(device))
push(result, core_ip.valid_ipv6(device) ? device + "/128" : device + "/32");
else if (core_ip.valid_ip_cidr(device))
push(result, device);
if (core_ip.valid_ip(device)) {
let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32";
if (index(result, cidr) < 0) push(result, cidr);
} else if (core_ip.valid_ip_cidr(device)) {
if (index(result, device) < 0) push(result, device);
}
}
for (let p_name in schedule_list_value(schedule, "profile")) {
let prof = ctx.uci_cursor().get_all(CONFIG_NAME, p_name);
if (prof != null && section_enabled(prof)) {
for (let cidr in profile_source_ip_cidrs(prof)) {
if (index(result, cidr) < 0)
push(result, cidr);
}
}
}
return result;
}
@@ -780,6 +810,73 @@ function enabled_content_block_schedules() {
return result;
}
function enabled_content_block_profiles() {
let result = [];
ctx.uci_cursor().foreach(CONFIG_NAME, "profile", function(profile) {
if (section_enabled(profile) && length(schedule_blocked_domains(profile)) > 0)
push(result, profile);
});
return result;
}
function enabled_safesearch_profiles() {
let result = [];
ctx.uci_cursor().foreach(CONFIG_NAME, "profile", function(profile) {
if (section_enabled(profile) && bool_option(profile, "safe_search", false))
push(result, profile);
});
return result;
}
function add_safesearch_dns_rules(config, profiles) {
if (length(profiles) == 0)
return;
let all_sources = [];
for (let profile in profiles) {
for (let cidr in profile_source_ip_cidrs(profile)) {
if (index(all_sources, cidr) < 0)
push(all_sources, cidr);
}
}
let search_engines = [
{
domains: [ "google.com", "www.google.com", "google.ru", "www.google.ru" ],
ip: "216.239.38.120"
},
{
domains: [ "yandex.ru", "www.yandex.ru", "ya.ru", "www.ya.ru", "yandex.com", "www.yandex.com" ],
ip: "213.180.193.56"
},
{
domains: [ "bing.com", "www.bing.com" ],
ip: "204.79.197.220"
},
{
domains: [ "duckduckgo.com", "www.duckduckgo.com", "safe.duckduckgo.com" ],
ip: "52.142.124.215"
},
{
domains: [ "youtube.com", "www.youtube.com", "m.youtube.com", "youtubei.googleapis.com" ],
ip: "216.239.38.120"
}
];
for (let engine in search_engines) {
for (let domain in engine.domains) {
let rule = {
action: "predefined",
domain: [ domain ],
answer: [ domain + ". 60 IN A " + engine.ip ]
};
if (length(all_sources) > 0)
rule.source_ip_cidr = all_sources;
push(config.dns.rules, rule);
}
}
}
function add_content_block_dns_inbound(config) {
push(config.inbounds, {
type: "direct",
@@ -841,12 +938,18 @@ function add_content_block_route_rules(config, schedules) {
function add_content_blocking(config) {
let schedules = enabled_content_block_schedules();
if (length(schedules) == 0)
let profiles = enabled_content_block_profiles();
let safesearch_profiles = enabled_safesearch_profiles();
if (length(schedules) == 0 && length(profiles) == 0 && length(safesearch_profiles) == 0)
return;
add_content_block_dns_inbound(config);
add_content_block_dns_rules(config, schedules);
add_content_block_dns_rules(config, profiles);
add_safesearch_dns_rules(config, safesearch_profiles);
add_content_block_route_rules(config, schedules);
add_content_block_route_rules(config, profiles);
}
function generate_config(output_path, service_address, mwan3_active, supports_xhttp) {
+47 -1
View File
@@ -324,4 +324,50 @@ SIG2="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/nft_fixture2.json")"
[ "$SIG1" != "$SIG2" ] || fail "Signature must change when blocked_domains are updated"
printf 'Content blocking checks passed\n'
# ─── Fixture: Family Profile SafeSearch & DoH Block ──────────────────────────
cat >"$WORK_DIR/fixture_profile_safesearch.json" <<'JSON'
{
"settings": {
".name": "settings",
".type": "settings",
"config_path": "/tmp/sing-box/config.json",
"dns_server": "1.1.1.1",
"service_listen_address": "127.0.0.1"
},
"section": [
{
".name": "sec_proxy",
"enabled": "1",
"action": "connection",
"label": "Main proxy",
"selector_proxy_links": [ "socks5://127.0.0.1:1080#Proxy" ]
}
],
"profile": [
{
".name": "prof_teens",
"label": "Teens",
"enabled": "1",
"device_ip": [ "192.168.1.180" ],
"safe_search": "1",
"block_doh": "1",
"blocked_domains": [ "roblox.com" ]
}
]
}
JSON
OUT_PROF="$WORK_DIR/profile_safesearch.json"
generate_config "$WORK_DIR/fixture_profile_safesearch.json" "$OUT_PROF"
# SafeSearch DNS rules must be present
assert_contains "$OUT_PROF" '"action": "predefined"' "profile: SafeSearch predefined DNS action"
assert_contains "$OUT_PROF" '216.239.38.120' "profile: SafeSearch google/youtube IP"
assert_contains "$OUT_PROF" '213.180.193.56' "profile: SafeSearch yandex IP"
assert_contains "$OUT_PROF" '192.168.1.180/32' "profile: SafeSearch scoped to profile device"
# Profile blocked domains must be present in DNS rules
assert_contains "$OUT_PROF" 'roblox.com' "profile: roblox.com blocked"
printf 'Content blocking and family profile checks passed\n'
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
COMMON_UC="$TACHYON_LIB/core/common.uc"
TG_UC="$TACHYON_LIB/service/telegram.uc"
WORK_DIR="$(mktemp -d)"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
# 1. Verify priority order in common.uc
grep -Fq 'inbound.tag == "service-mixed-in"' "$COMMON_UC" ||
fail "common.uc must prioritize service-mixed-in tag"
grep -Fq 'listen == "127.0.0.1"' "$COMMON_UC" ||
fail "common.uc must prioritize 127.0.0.1 listeners"
# 2. Verify priority order in telegram.uc
grep -Fq 'inbound.tag == "service-mixed-in"' "$TG_UC" ||
fail "telegram.uc must prioritize service-mixed-in tag"
# 3. Behavioral test: mock sing-box config with user inbound at index 0 and service mixed inbound at index 1
CFG_FILE="$WORK_DIR/config.json"
cat > "$CFG_FILE" <<'EOF'
{
"inbounds": [
{
"type": "mixed",
"tag": "server-my-proxy",
"listen": "192.168.1.1",
"listen_port": 1080
},
{
"type": "mixed",
"tag": "service-mixed-in",
"listen": "127.0.0.1",
"listen_port": 4534
}
]
}
EOF
TEST_SCRIPT="$WORK_DIR/test_port.uc"
cat > "$TEST_SCRIPT" <<'EOF'
let fs = require("fs");
let common = require("core.common");
// Point readfile to our mock file
let cfg_path = getenv("MOCK_CONFIG_PATH");
let data = fs.readfile(cfg_path);
let parsed = json(data);
let port = 0;
for (let inbound in parsed.inbounds) {
if (inbound.tag == "service-mixed-in" && inbound.listen_port != null) {
port = int(inbound.listen_port, 10);
break;
}
}
if (port == 4534) {
print("OK\n");
} else {
print("FAILED: got " + port + "\n");
}
EOF
result="$(MOCK_CONFIG_PATH="$CFG_FILE" ucode -L "$TACHYON_LIB" "$TEST_SCRIPT" 2>/dev/null || true)"
[ "$result" = "OK" ] || fail "get_mixed_port priority test failed: $result"
printf 'mixed port selection checks passed\n'
+74 -1
View File
@@ -198,4 +198,77 @@ if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_
fail "Validator must reject non-existent target section"
fi
printf 'Parental control and schedule tests passed\n'
# Verify validator rejects unknown profile reference in schedule
cat >"$WORK_DIR/invalid_profile_ref.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"schedule": [
{
".name": "bad_profile_ref",
"enabled": "1",
"profile": [ "non_existent_profile" ]
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_profile_ref.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject non-existent profile reference"
fi
# Verify validator rejects invalid profile device IP
cat >"$WORK_DIR/invalid_profile_ip.json" <<'JSON'
{
"settings": { ".name": "settings", ".type": "settings", "dns_server": ["77.88.8.8"], "bootstrap_dns_server": ["77.88.8.8"] },
"profile": [
{
".name": "bad_profile",
"enabled": "1",
"device_ip": [ "invalid-ip-or-mac" ]
}
]
}
JSON
if ucode -L "$TACHYON_LIB" "$VALIDATOR_RUNTIME" validate-runtime-fixture "$WORK_DIR/invalid_profile_ip.json" "{}" >/dev/null 2>&1; then
fail "Validator must reject invalid profile device IP"
fi
# ─── Family Profile inheritance fixture ──────────────────────────────────────
cat >"$WORK_DIR/profile_fixture.json" <<'JSON'
{
"profile": [
{
".name": "prof_kids",
"label": "Kids",
"avatar": "👶",
"enabled": "1",
"device_ip": [ "192.168.1.200", "22:33:44:55:66:77" ],
"block_doh": "1",
"safe_search": "1",
"daily_quota_minutes": "90"
}
],
"schedule": [
{
".name": "kids_schedule",
"enabled": "1",
"profile": [ "prof_kids" ],
"target": "all",
"action": "block",
"start_time": "23:00",
"end_time": "06:00"
}
]
}
JSON
rm -f "$NFT_LOG"
nft_ucode nft-add-schedule-rules-fixture "$WORK_DIR/profile_fixture.json" "tachyon"
# Schedule must inherit devices from prof_kids
assert_contains "$NFT_LOG" "parental_forward ip saddr 192.168.1.200 meta hour \"23:00:00\"-\"23:59:59\" counter drop" "profile IP inherited"
assert_contains "$NFT_LOG" "parental_forward ether saddr 22:33:44:55:66:77 meta hour \"23:00:00\"-\"23:59:59\" counter drop" "profile MAC inherited"
printf 'Parental control, profiles and schedule tests passed\n'