release: 1.3.6 - Family Profiles, Mobile Responsiveness, Mixed Proxy fix & AI Doctor Fuzzer improvements
This commit is contained in:
+1582
-1039
File diff suppressed because it is too large
Load Diff
+1360
-1116
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "fe-app-tachyon",
|
||||
"version": "1.2.77",
|
||||
"version": "1.3.6",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+310
-149
File diff suppressed because it is too large
Load Diff
+1360
-1116
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
for (let schedule in schedules) {
|
||||
schedule = object_or_empty(schedule);
|
||||
if (!bool_option(schedule, "enabled", true))
|
||||
continue;
|
||||
|
||||
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 = 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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user