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", "name": "fe-app-tachyon",
"version": "1.2.47", "version": "1.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.2.47", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@babel/parser": "7.28.4", "@babel/parser": "7.28.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fe-app-tachyon", "name": "fe-app-tachyon",
"version": "1.2.77", "version": "1.3.6",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
+29 -3
View File
@@ -1,12 +1,19 @@
// language=CSS // language=CSS
export const styles = ` export const styles = `
.tachyon-partial-modal__body {} .tachyon-partial-modal__body {
width: 100%;
box-sizing: border-box;
}
.tachyon-partial-modal__content { .tachyon-partial-modal__content {
max-height: 70vh; max-height: 75vh;
overflow: scroll; overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
width: 100%;
} }
.tachyon-partial-modal__footer { .tachyon-partial-modal__footer {
@@ -33,4 +40,23 @@ export const styles = `
.tachyon-partial-modal__checkbox-text { .tachyon-partial-modal__checkbox-text {
line-height: 1.2; 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; opacity: 1;
transform: translateY(0); 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) => { getFuzzerHistory: async (limit?: number) => {
const response = await executeShellCommand({ const response = await executeShellCommand({
command: '/usr/bin/tachyon', command: '/usr/bin/tachyon',
args: [ args: [Tachyon.AvailableMethods.FUZZER_HISTORY, String(limit || 20)],
Tachyon.AvailableMethods.FUZZER_HISTORY,
String(limit || 20),
],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS, timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS,
}); });
const parsed = parseJsonObjectOutput<Tachyon.FuzzerHistoryResult>( const parsed = parseJsonObjectOutput<Tachyon.FuzzerHistoryResult>(
@@ -743,7 +743,9 @@ describe('TachyonShellMethods.componentAction', () => {
running: !jobFinished, running: !jobFinished,
component: 'tachyon', component: 'tachyon',
action: 'install', 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', current_version: '1.3.4',
latest_version: '1.3.4', latest_version: '1.3.4',
job_id: 'job-1', job_id: 'job-1',
@@ -759,7 +761,9 @@ describe('TachyonShellMethods.componentAction', () => {
running: !jobFinished, running: !jobFinished,
component: 'tachyon', component: 'tachyon',
action: 'install', 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', current_version: '1.3.4',
latest_version: '1.3.4', latest_version: '1.3.4',
job_id: 'job-1', job_id: 'job-1',
@@ -220,7 +220,9 @@ describe('TachyonShellMethods Fuzzer Actions', () => {
zapret: { splits: ['1'] }, zapret: { splits: ['1'] },
byedpi: { splits: ['1'] }, byedpi: { splits: ['1'] },
custom_strategies: [], custom_strategies: [],
} as unknown as Parameters<typeof TachyonShellMethods.saveFuzzerPatterns>[0]; } as unknown as Parameters<
typeof TachyonShellMethods.saveFuzzerPatterns
>[0];
const res = await TachyonShellMethods.saveFuzzerPatterns(patternsPayload); const res = await TachyonShellMethods.saveFuzzerPatterns(patternsPayload);
expect(res.success).toBe(true); expect(res.success).toBe(true);
@@ -124,8 +124,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
activeTab === 'patterns' ? 'flex' : 'none'; activeTab === 'patterns' ? 'flex' : 'none';
tabContentCustom.style.display = activeTab === 'custom' ? 'flex' : 'none'; tabContentCustom.style.display = activeTab === 'custom' ? 'flex' : 'none';
tabContentAi.style.display = activeTab === 'ai' ? 'flex' : 'none'; tabContentAi.style.display = activeTab === 'ai' ? 'flex' : 'none';
tabContentHistory.style.display = tabContentHistory.style.display = activeTab === 'history' ? 'flex' : 'none';
activeTab === 'history' ? 'flex' : 'none';
if (activeTab === 'patterns') renderPatternsTab(); if (activeTab === 'patterns') renderPatternsTab();
if (activeTab === 'custom') renderCustomTab(); if (activeTab === 'custom') renderCustomTab();
@@ -316,7 +315,8 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
E( E(
'div', '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'))], [autoApplyCheckbox, E('span', {}, _('Auto-apply best strategy'))],
), ),
@@ -459,7 +459,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
// Results Table // Results Table
const resultsContainer = E('div', { const resultsContainer = E('div', {
style: 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( const tableEl = E(
@@ -467,7 +467,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
{ {
class: 'table', class: 'table',
style: 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( E(
@@ -826,11 +826,14 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
type: 'text', type: 'text',
class: 'cbi-input-text', class: 'cbi-input-text',
placeholder: _('Strategy Name'), placeholder: _('Strategy Name'),
style: 'flex: 1;', style: 'flex: 1 1 180px; min-width: 140px;',
}); });
const engineSel = E( const engineSel = E(
'select', '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: 'zapret2' }, 'Zapret v2'),
E('option', { value: 'zapret' }, 'Zapret v1'), E('option', { value: 'zapret' }, 'Zapret v1'),
@@ -841,7 +844,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
type: 'text', type: 'text',
class: 'cbi-input-text', class: 'cbi-input-text',
placeholder: _('Command-line arguments string...'), 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({ const addBtn = renderButton({
@@ -1015,7 +1018,7 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
placeholder: _( placeholder: _(
'Optional context (e.g. "Rostelecom, YouTube 4K buffering is slow")...', '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({ const aiSynthesizeBtn = renderButton({
@@ -1483,7 +1486,10 @@ export function renderStrategyFuzzerModal(ruleNames: string[] = []) {
return; 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: '🔴' }, rst: { bg: 'rgba(220, 53, 69, 0.12)', border: '#dc3545', icon: '🔴' },
throttle: { throttle: {
bg: 'rgba(255, 193, 7, 0.12)', bg: 'rgba(255, 193, 7, 0.12)',
@@ -39,6 +39,12 @@ export const styles = `
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.tachyon_updates-page__component {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
} }
.tachyon_updates-page__component { .tachyon_updates-page__component {
@@ -48,7 +54,8 @@ export const styles = `
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-width: max-content; min-width: 0;
box-sizing: border-box;
} }
.tachyon_updates-page__component__header { .tachyon_updates-page__component__header {
@@ -4221,10 +4221,7 @@ var TachyonShellMethods = {
getFuzzerHistory: async (limit) => { getFuzzerHistory: async (limit) => {
const response = await executeShellCommand({ const response = await executeShellCommand({
command: "/usr/bin/tachyon", command: "/usr/bin/tachyon",
args: [ args: [Tachyon.AvailableMethods.FUZZER_HISTORY, String(limit || 20)],
Tachyon.AvailableMethods.FUZZER_HISTORY,
String(limit || 20)
],
timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS timeout: COMPONENT_ACTION_RPC_TIMEOUT_MS
}); });
const parsed = parseJsonObjectOutput( const parsed = parseJsonObjectOutput(
@@ -10291,12 +10288,19 @@ var styles2 = `
// src/partials/modal/styles.ts // src/partials/modal/styles.ts
var styles3 = ` var styles3 = `
.tachyon-partial-modal__body {} .tachyon-partial-modal__body {
width: 100%;
box-sizing: border-box;
}
.tachyon-partial-modal__content { .tachyon-partial-modal__content {
max-height: 70vh; max-height: 75vh;
overflow: scroll; overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
width: 100%;
} }
.tachyon-partial-modal__footer { .tachyon-partial-modal__footer {
@@ -10323,6 +10327,25 @@ var styles3 = `
.tachyon-partial-modal__checkbox-text { .tachyon-partial-modal__checkbox-text {
line-height: 1.2; 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 // src/partials/button/renderButton.ts
@@ -12297,13 +12320,13 @@ function renderStrategyFuzzerModal(ruleNames = []) {
] ]
); );
const resultsContainer = E("div", { 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( const tableEl = E(
"table", "table",
{ {
class: "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( E(
@@ -12621,11 +12644,14 @@ function renderStrategyFuzzerModal(ruleNames = []) {
type: "text", type: "text",
class: "cbi-input-text", class: "cbi-input-text",
placeholder: _("Strategy Name"), placeholder: _("Strategy Name"),
style: "flex: 1;" style: "flex: 1 1 180px; min-width: 140px;"
}); });
const engineSel = E( const engineSel = E(
"select", "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: "zapret2" }, "Zapret v2"),
E("option", { value: "zapret" }, "Zapret v1"), E("option", { value: "zapret" }, "Zapret v1"),
@@ -12636,7 +12662,7 @@ function renderStrategyFuzzerModal(ruleNames = []) {
type: "text", type: "text",
class: "cbi-input-text", class: "cbi-input-text",
placeholder: _("Command-line arguments string..."), 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({ const addBtn = renderButton({
text: _("+ Add Strategy"), text: _("+ Add Strategy"),
@@ -12789,7 +12815,7 @@ function renderStrategyFuzzerModal(ruleNames = []) {
placeholder: _( placeholder: _(
'Optional context (e.g. "Rostelecom, YouTube 4K buffering is slow")...' '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({ const aiSynthesizeBtn = renderButton({
text: _("\u{1F9E0} Synthesize with AI"), text: _("\u{1F9E0} Synthesize with AI"),
@@ -19833,6 +19859,12 @@ var styles6 = `
width: 100%; width: 100%;
min-width: 0; min-width: 0;
} }
.tachyon_updates-page__component {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
} }
.tachyon_updates-page__component { .tachyon_updates-page__component {
@@ -19842,7 +19874,8 @@ var styles6 = `
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-width: max-content; min-width: 0;
box-sizing: border-box;
} }
.tachyon_updates-page__component__header { .tachyon_updates-page__component__header {
@@ -20268,6 +20301,48 @@ ${PartialStyles}
opacity: 1; opacity: 1;
transform: translateY(0); 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 // src/helpers/injectGlobalStyles.ts
@@ -8,6 +8,18 @@
const UCI_PACKAGE = "tachyon"; const UCI_PACKAGE = "tachyon";
const AVATAR_PRESETS = {
"👶": _("Toddler (👶)"),
"🧒": _("Child (🧒)"),
"🧑": _("Teen (🧑)"),
"🎮": _("Gamer (🎮)"),
"📱": _("Phone (📱)"),
"💻": _("Laptop / PC (💻)"),
"🏠": _("Family (🏠)"),
"🔒": _("Strict (🔒)"),
"🐾": _("Pets / IoT (🐾)"),
};
const DAY_LABELS = { const DAY_LABELS = {
monday: _("Monday"), monday: _("Monday"),
tuesday: _("Tuesday"), 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"); 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 // Enabled switch
let o = section.option(form.Flag, "enabled", _("Enable")); let o = section.option(form.Flag, "enabled", _("Enable"));
o.default = "1"; o.default = "1";
@@ -190,15 +204,43 @@ function createParentalContent(section) {
o.editable = true; o.editable = true;
o.width = "5rem"; o.width = "5rem";
// Device column in table // Profile display (Avatar + Name)
o = section.option(form.DummyValue, "_device_display", _("Device / IP / MAC")); 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.rawhtml = true;
o.modalonly = false; o.modalonly = false;
o.cfgvalue = function (sectionId) { o.cfgvalue = function (sectionId) {
const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip"); const rawIp = uci.get(UCI_PACKAGE, sectionId, "device_ip");
const devs = normalizeListValues(rawIp); const devs = normalizeListValues(rawIp);
if (devs.length === 0) { 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) => const isMacAddr = (dev) =>
/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/.test(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] const badges = [...macs, ...ips]
.map((dev) => { .map((dev) => {
const isMac = isMacAddr(dev); const isMac = isMacAddr(dev);
const bg = isMac ? "#2d3748" : "#2a3441";
const color = isMac ? "#b794f4" : "#63b3ed";
const label = isMac ? "MAC" : "IP"; const label = isMac ? "MAC" : "IP";
return ( 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>` + `<span style="opacity:0.75;font-weight:700;margin-right:3px;">${label}:</span>` +
dev + dev +
"</span>" "</span>"
); );
}) })
.join(""); .join("");
return "<div>" + badges + "</div>"; return '<div style="display:flex;flex-wrap:wrap;gap:2px;">' + badges + "</div>";
}; };
o.textvalue = function (sectionId) { o.textvalue = function (sectionId) {
const devs = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "device_ip")); 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 // Target column in table
@@ -232,7 +605,7 @@ function createParentalContent(section) {
o.cfgvalue = function (sectionId) { o.cfgvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all"; const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
if (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 rawSecs = uci.get(UCI_PACKAGE, sectionId, "sections");
const secNames = normalizeListValues(rawSecs); const secNames = normalizeListValues(rawSecs);
@@ -247,13 +620,13 @@ function createParentalContent(section) {
const sec = uci.get(UCI_PACKAGE, name); const sec = uci.get(UCI_PACKAGE, name);
const label = (sec && (sec.label || sec[".name"])) || name; const label = (sec && (sec.label || sec[".name"])) || name;
return ( 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 + label +
"</span>" "</span>"
); );
}) })
.join(""); .join("");
return "<div>" + badges + "</div>"; return '<div style="display:flex;flex-wrap:wrap;gap:2px;">' + badges + "</div>";
}; };
o.textvalue = function (sectionId) { o.textvalue = function (sectionId) {
const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all"; const target = uci.get(UCI_PACKAGE, sectionId, "target") || "all";
@@ -269,9 +642,9 @@ function createParentalContent(section) {
o.cfgvalue = function (sectionId) { o.cfgvalue = function (sectionId) {
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (action === "allow") { 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) { o.textvalue = function (sectionId) {
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; 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 e = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59";
const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days")); const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days"));
return ( 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 + s +
" — " + " — " +
e + e +
'</strong><br><small style="opacity:0.7;">' + '</strong><br><small style="opacity:0.75;">' +
days + days +
"</small></div>" "</small></div>"
); );
@@ -313,7 +686,7 @@ function createParentalContent(section) {
return '<span style="opacity:0.5;">' + _("None") + "</span>"; return '<span style="opacity:0.5;">' + _("None") + "</span>";
} }
return ( return (
'<span style="color:#63b3ed;font-weight:500;">⏳ ' + '<span style="color:var(--primary-color-medium, #3182ce);font-weight:500;">⏳ ' +
raw + raw +
" " + " " +
_("min/day") + _("min/day") +
@@ -337,17 +710,17 @@ function createParentalContent(section) {
} }
const modeBadge = const modeBadge =
mode === "allow" mode === "allow"
? '<span style="color:#68d391;font-weight:600;">🟢 ' + _("Allow only") + "</span>" ? '<span style="color:var(--success-color-medium, #38a169);font-weight:600;">🟢 ' + _("Allow only") + "</span>"
: '<span style="color:#fc8181;font-weight:600;">🚫 ' + _("Block") + "</span>"; : '<span style="color:var(--error-color-medium, #e53e3e);font-weight:600;">🚫 ' + _("Block") + "</span>";
const badges = domains const badges = domains
.map( .map(
(d) => (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 + d +
"</span>", "</span>",
) )
.join(""); .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) { o.textvalue = function (sectionId) {
const domains = normalizeListValues(uci.get(UCI_PACKAGE, sectionId, "blocked_domains")); 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"; const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0";
if (!enabled) { if (!enabled) {
return ( return (
'<span style="display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-size:11px;background:#4a5568;color:#e2e8f0;">' + '<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:#a0aec0;margin-right:5px;display:inline-block;"></span>' + '<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;display:inline-block;opacity:0.7;"></span>' +
_("Disabled") + _("Disabled") +
"</span>" "</span>"
); );
@@ -373,20 +746,20 @@ function createParentalContent(section) {
const active = isScheduleCurrentlyActive(sectionId); const active = isScheduleCurrentlyActive(sectionId);
const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block";
if (active) { if (active) {
const color = action === "allow" ? "#48bb78" : "#e53e3e"; const color = action === "allow" ? "var(--success-color-medium, #38a169)" : "var(--error-color-medium, #e53e3e)";
const bg = action === "allow" ? "rgba(72,187,120,0.2)" : "rgba(229,62,62,0.2)"; 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.4)" : "rgba(229,62,62,0.4)"; const border = action === "allow" ? "rgba(72,187,120,0.3)" : "rgba(229,62,62,0.3)";
const text = action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)"); const text = action === "allow" ? _("Active (Allowed)") : _("Active (Blocking)");
return ( 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="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 + text +
"</span>" "</span>"
); );
} }
return ( 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="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:#718096;margin-right:5px;display:inline-block;"></span>' + '<span style="width:6px;height:6px;border-radius:50%;background:currentColor;margin-right:5px;display:inline-block;opacity:0.6;"></span>' +
_("Pending") + _("Pending") +
"</span>" "</span>"
); );
@@ -408,15 +781,37 @@ function createParentalContent(section) {
o.rmempty = true; o.rmempty = true;
o.placeholder = _("e.g. Kids Phone (Night YouTube)"); 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 // Device selector with LAN hostnames integration
o = section.option( o = section.option(
form.DynamicList, form.DynamicList,
"device_ip", "device_ip",
_("Target Devices (IP / MAC)"), _("Additional Individual 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."), _("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.modalonly = true;
o.rmempty = false; o.rmempty = true;
o.placeholder = "192.168.1.150 or AA:BB:CC:DD:EE:FF"; o.placeholder = "192.168.1.150 or AA:BB:CC:DD:EE:FF";
o.validate = validateDevice; o.validate = validateDevice;
o.renderWidget = function (sectionId, optionIndex, cfgvalue) { o.renderWidget = function (sectionId, optionIndex, cfgvalue) {
@@ -581,7 +976,7 @@ function createParentalContent(section) {
uci.unset(UCI_PACKAGE, sectionId, "blocked_domains"); uci.unset(UCI_PACKAGE, sectionId, "blocked_domains");
}; };
o.renderWidget = function (sectionId, optionIndex, cfgvalue) { 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 // Preset buttons row
const presetRow = E("div", { style: "margin-bottom:6px;display:flex;flex-wrap:wrap;gap:4px;" }); 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", { const textarea = E("textarea", {
id: `cbid.tachyon.${sectionId}.blocked_domains`, id: `cbid.tachyon.${sectionId}.blocked_domains`,
class: "cbi-input-textarea", 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", placeholder: "youtube.com\ngooglevideo.com",
}); });
if (cfgvalue) textarea.value = cfgvalue; if (cfgvalue) textarea.value = cfgvalue;
@@ -657,6 +1052,7 @@ function createParentalContent(section) {
} }
const EntryPoint = { const EntryPoint = {
createProfileContent,
createParentalContent, createParentalContent,
}; };
@@ -460,11 +460,25 @@ const EntryPoint = {
}); });
server.createServerContent(serverSection, uiCapabilities); 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( const parentalSection = tachyonMap.section(
form.GridSection, form.GridSection,
"schedule", "schedule",
_("Parental Control"), _("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( configureGridSection(
parentalSection, 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. # language: auto-detected from LuCI if not set.
# Values: ru (default), en # 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."); 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 target = option(schedule, "target", "all");
let sec_names = list_option(schedule, "sections"); let sec_names = list_option(schedule, "sections");
if (length(sec_names) == 0) { 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."); 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) { function validate_runtime_config(context) {
let settings = settings_section(); let settings = settings_section();
let sections = sections_by_type("section"); let sections = sections_by_type("section");
let schedules = sections_by_type("schedule"); let schedules = sections_by_type("schedule");
let profiles = sections_by_type("profile");
validate_runtime_mark_ranges_context(context); validate_runtime_mark_ranges_context(context);
@@ -1830,8 +1897,11 @@ function validate_runtime_config(context) {
for (let section in sections) for (let section in sections)
validate_rule(section, sections, context); validate_rule(section, sections, context);
for (let profile in profiles)
validate_profile(profile, profiles);
for (let schedule in schedules) for (let schedule in schedules)
validate_schedule(schedule, sections); validate_schedule(schedule, sections, profiles);
} }
function context_from_runtime() { 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. // 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. // Falls back to 4534 when the config is missing or unparseable.
function get_mixed_port() { function get_mixed_port() {
let data = fs.readfile("/etc/sing-box/config.json"); let data = fs.readfile("/etc/sing-box/config.json");
@@ -487,6 +489,25 @@ function get_mixed_port() {
let parsed; let parsed;
try { parsed = json(data); } catch (e) { return 4534; } try { parsed = json(data); } catch (e) { return 4534; }
if (parsed == null || parsed.inbounds == null) 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) { for (let inbound in parsed.inbounds) {
if ((inbound.type == "mixed" || inbound.type == "http") && inbound.listen_port != null) { if ((inbound.type == "mixed" || inbound.type == "http") && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10); 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 ] ]; 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) { for (let schedule in schedules) {
schedule = object_or_empty(schedule); schedule = object_or_empty(schedule);
if (!bool_option(schedule, "enabled", true)) if (!bool_option(schedule, "enabled", true))
continue; continue;
let raw_ips = list_option(schedule, "device_ip"); let raw_ips = resolve_schedule_devices(schedule, profiles);
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
if (length(raw_ips) == 0) if (length(raw_ips) == 0)
continue; continue;
@@ -987,7 +1065,7 @@ function nft_add_schedule_rules_from_schedules(schedules, sections, table) {
} }
function nft_add_schedule_rules_from_uci(table, sections) { 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 ─────────────────────────────────────────── // ─── 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 // dns-in inbound as usual. MAC-address devices match via ether saddr, which
// also covers devices whose IP is DHCP-assigned. // 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; let added = false;
for (let schedule in schedules) { for (let schedule in schedules) {
schedule = object_or_empty(schedule); schedule = object_or_empty(schedule);
@@ -1009,11 +1087,7 @@ function nft_add_dns_block_rules_from_schedules(schedules, table) {
if (single_domain == "") continue; if (single_domain == "") continue;
} }
let raw_ips = list_option(schedule, "device_ip"); let raw_ips = resolve_schedule_devices(schedule, profiles);
if (length(raw_ips) == 0) {
let single_ip = option(schedule, "device_ip", "");
if (single_ip != "") raw_ips = [ single_ip ];
}
if (length(raw_ips) == 0) if (length(raw_ips) == 0)
continue; 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; return true;
} }
function nft_add_dns_block_rules_from_uci(table) { 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) { 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"]); 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 + ".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 + ".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 + ".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 + ".sections", join(",", list_option(schedule, "sections")));
body = signature_add_value(body, "schedule." + name + ".action", option(schedule, "action", "block")); 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; 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 = ""; let body = "";
body = signature_add_value(body, "settings.source_network_interfaces", option(settings, "source_network_interfaces", "br-lan")); 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) for (let section in sections)
body = nft_rule_signature_body(body, object_or_empty(section)); 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) for (let schedule in schedules)
body = nft_schedule_signature_body(body, object_or_empty(schedule)); body = nft_schedule_signature_body(body, object_or_empty(schedule));
return signature_hash(body); return signature_hash(body);
} }
function print_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); let hash = nft_runtime_signature_from_settings_and_sections(settings, sections, schedules, profiles);
if (hash == "") if (hash == "")
return false; 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_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_schedule_rules_from_uci(table, uci_sections("section")) &&
nft_add_dns_block_rules_from_uci(table) && 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, "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_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); 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( return print_nft_runtime_signature_from_settings_and_sections(
uci_settings(), uci_settings(),
uci_sections("section"), 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( return print_nft_runtime_signature_from_settings_and_sections(
fixture_settings(data), fixture_settings(data),
fixture_section_list(data, "section"), 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") 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); 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") 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") 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") 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); 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") else if (mode == "nft-rebuild-runtime-from-uci")
@@ -74,6 +74,31 @@ function list_option(section, key) {
function quota_schedules() { function quota_schedules() {
let result = []; 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")) { for (let section in uci_core.section_objects(CONFIG_NAME, "schedule")) {
if (!bool_option(section, "enabled", true)) if (!bool_option(section, "enabled", true))
continue; continue;
@@ -81,17 +106,36 @@ function quota_schedules() {
if (minutes <= 0) if (minutes <= 0)
continue; continue;
let devices = []; let devices = [];
let idents_seen = {};
for (let dev in list_option(section, "device_ip")) { for (let dev in list_option(section, "device_ip")) {
let is_mac = match(dev, MAC_RE) != null; let is_mac = match(dev, MAC_RE) != null;
// Skip IPv6 entries: enforcement sets cover MACs and IPv4 only.
if (!is_mac && match(dev, /:/) != null) if (!is_mac && match(dev, /:/) != null)
continue; 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) if (length(devices) == 0)
continue; continue;
push(result, { push(result, {
label: as_string(object_or_empty(section)[".name"]), label: as_string(object_or_empty(section).label || object_or_empty(section)[".name"]),
minutes, minutes,
devices devices
}); });
@@ -277,7 +321,7 @@ function tick() {
devices[ident] = { minutes, blocked: minutes >= schedule.minutes }; devices[ident] = { minutes, blocked: minutes >= schedule.minutes };
if (minutes >= schedule.minutes) { if (minutes >= schedule.minutes) {
log_message("device " + ident + " hit daily quota (" + as_string(schedule.minutes) + " min); blocking until midnight", "info"); 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 { } else {
devices[ident] = { minutes, blocked: was_blocked }; devices[ident] = { minutes, blocked: was_blocked };
+16
View File
@@ -116,6 +116,22 @@ function get_mixed_port() {
let parsed; let parsed;
try { parsed = json(data); } catch (e) { return 4534; } try { parsed = json(data); } catch (e) { return 4534; }
if (parsed == null || parsed.inbounds == null) 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) { for (let inbound in parsed.inbounds) {
if (inbound.type == "mixed" && inbound.listen_port != null) { if (inbound.type == "mixed" && inbound.listen_port != null) {
let port = int(inbound.listen_port, 10); 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"); 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 // 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. // window on urltest.switched, so a burst of log lines still costs one poll.
function notify_urltest_switch(ev) { function notify_urltest_switch(ev) {
let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram")); let tcfg = common.object_or_empty(uci_core.get_all(CONFIG_NAME, "telegram"));
if (tcfg.enabled != "1" || tcfg.notify_crash == "0") return; if (tcfg.enabled != "1" || tcfg.notify_crash == "0") return;
let ports = mixed_ports_from_config(); let p_res = command_capture(command_from_args(["curl", "-s", get_clash_url("proxies")]));
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"]));
if (p_res && p_res.status == 0 && p_res.output) { if (p_res && p_res.status == 0 && p_res.output) {
try { try {
let p_data = json(p_res.output); 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", "")) != ""; 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) { function schedule_source_ip_cidrs(schedule) {
let result = []; let result = [];
for (let raw in schedule_list_value(schedule, "device_ip")) { 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; let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null;
if (is_mac) if (is_mac)
continue; continue;
if (core_ip.valid_ip(device)) if (core_ip.valid_ip(device)) {
push(result, core_ip.valid_ipv6(device) ? device + "/128" : device + "/32"); let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32";
else if (core_ip.valid_ip_cidr(device)) if (index(result, cidr) < 0) push(result, cidr);
push(result, device); } 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; return result;
} }
@@ -780,6 +810,73 @@ function enabled_content_block_schedules() {
return result; 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) { function add_content_block_dns_inbound(config) {
push(config.inbounds, { push(config.inbounds, {
type: "direct", type: "direct",
@@ -841,12 +938,18 @@ function add_content_block_route_rules(config, schedules) {
function add_content_blocking(config) { function add_content_blocking(config) {
let schedules = enabled_content_block_schedules(); 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; return;
add_content_block_dns_inbound(config); add_content_block_dns_inbound(config);
add_content_block_dns_rules(config, schedules); 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, schedules);
add_content_block_route_rules(config, profiles);
} }
function generate_config(output_path, service_address, mwan3_active, supports_xhttp) { 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" [ "$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" fail "Validator must reject non-existent target section"
fi 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'