476 lines
19 KiB
JavaScript
476 lines
19 KiB
JavaScript
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
|
||
|
||
async function api(path, body) {
|
||
const init = body
|
||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||
: {};
|
||
const res = await fetch(path, init);
|
||
// сессия истекла — иначе открытая вкладка молча перестала бы обновляться
|
||
if (res.status === 401) {
|
||
location.href = '/login';
|
||
throw new Error('нужен вход');
|
||
}
|
||
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
/* ── composables ───────────────────────────────────────────── */
|
||
|
||
function useRules() {
|
||
// форма объекта должна совпадать с серверной с самого начала: `save` шлёт
|
||
// правила целиком, и отсутствующее здесь поле сервер прочитал бы как пустое
|
||
const rules = ref({
|
||
mode: 'blacklist', blocked: [], allowed: [], enforce: true,
|
||
blocked_sites: [], sites_enforce: false, dns_lockdown: false,
|
||
});
|
||
const load = async () => { rules.value = await api('/rules'); };
|
||
const block = (name) => api('/rules/block', { name }).then(load);
|
||
const allow = (name) => api('/rules/allow', { name }).then(load);
|
||
const remove = (name) => api('/rules/remove', { name }).then(load);
|
||
// patch мержим в текущее состояние: сервер принимает объект правил целиком
|
||
const save = (patch) => api('/rules', { ...rules.value, ...patch }).then(load);
|
||
return { rules, load, block, allow, remove, save };
|
||
}
|
||
|
||
function useProcesses() {
|
||
const raw = ref([]);
|
||
const load = async () => { raw.value = await api('/processes'); };
|
||
const kill = (name) => api('/kill', { name }).then(load);
|
||
|
||
// одноимённые процессы схлопываем — иначе двадцать строк chrome.exe
|
||
const groups = computed(() => {
|
||
const map = new Map();
|
||
for (const p of raw.value) {
|
||
const key = p.name.toLowerCase();
|
||
if (!map.has(key)) map.set(key, { key, name: p.name, count: 0, mem: 0, blocked: p.blocked });
|
||
const g = map.get(key);
|
||
g.count += 1;
|
||
g.mem += p.memory_mb;
|
||
}
|
||
return [...map.values()].sort((a, b) => b.mem - a.mem);
|
||
});
|
||
|
||
return { groups, load, kill };
|
||
}
|
||
|
||
function useLog() {
|
||
const lines = ref([]);
|
||
const load = async () => { lines.value = await api('/log'); };
|
||
return { lines, load };
|
||
}
|
||
|
||
function useSites() {
|
||
const dns = ref({
|
||
listening: false, upstream: '', queries: 0, blocked: 0,
|
||
cache_hits: 0, timeouts: 0, error: null, attempts: [], recent: [],
|
||
});
|
||
const load = async () => { dns.value = await api('/dns'); };
|
||
// сам список закрытых сайтов живёт в правилах, здесь — только состояние
|
||
// резолвера и попытки; поэтому после правки перечитываем и то, и другое
|
||
const block = (name) => api('/sites/block', { name });
|
||
const remove = (name) => api('/sites/remove', { name });
|
||
return { dns, load, block, remove };
|
||
}
|
||
|
||
function useUpdate() {
|
||
const info = ref({ current: '', latest: null, available: false, phase: 'idle', checked_at: null, error: null });
|
||
const load = async () => { info.value = await api('/update'); };
|
||
// после ручной проверки состояние меняется не мгновенно — перечитываем чуть позже
|
||
const check = () => api('/update/check', {}).then(() => setTimeout(load, 1500));
|
||
return { info, load, check };
|
||
}
|
||
|
||
function usePolling(fn, ms) {
|
||
let id = null;
|
||
onMounted(() => { fn(); id = setInterval(fn, ms); });
|
||
onUnmounted(() => clearInterval(id));
|
||
}
|
||
|
||
/* ── компоненты ────────────────────────────────────────────── */
|
||
|
||
const NameChips = {
|
||
props: {
|
||
title: { type: String, required: true },
|
||
names: { type: Array, default: () => [] },
|
||
},
|
||
emits: ['remove'],
|
||
setup(props) {
|
||
return { sorted: computed(() => [...props.names].sort()) };
|
||
},
|
||
template: `
|
||
<div class="chips">
|
||
<div class="muted">{{ title }}</div>
|
||
<span v-if="!sorted.length" class="muted">пусто</span>
|
||
<span v-for="name in sorted" :key="name" class="chip">
|
||
{{ name }}
|
||
<button title="убрать из списка" @click="$emit('remove', name)">×</button>
|
||
</span>
|
||
</div>`,
|
||
};
|
||
|
||
const UpdateBar = {
|
||
props: { info: { type: Object, required: true } },
|
||
emits: ['check'],
|
||
setup(props) {
|
||
const status = computed(() => {
|
||
const i = props.info;
|
||
if (i.phase === 'checking') return 'проверка…';
|
||
if (i.phase === 'installing') return `устанавливается ${i.latest}, панель перезапустится`;
|
||
if (i.error) return i.error;
|
||
if (i.available) return `доступна версия ${i.latest}`;
|
||
return 'установлена последняя версия';
|
||
});
|
||
return {
|
||
status,
|
||
checking: computed(() => props.info.phase === 'checking'),
|
||
bad: computed(() => Boolean(props.info.error)),
|
||
};
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<div class="row" style="justify-content:space-between">
|
||
<div>
|
||
<h2 style="margin:0">Версия {{ info.current || '—' }}</h2>
|
||
<span :class="bad ? 'warn' : 'muted'">{{ status }}</span>
|
||
</div>
|
||
<button :disabled="checking" @click="$emit('check')">Проверить обновления</button>
|
||
</div>
|
||
<div v-if="info.installer" class="muted" style="margin-top:8px">
|
||
Установщик, {{ info.installer.time }}:
|
||
<span :class="{ warn: !info.installer.ok }">{{ info.installer.message }}</span>
|
||
</div>
|
||
</section>`,
|
||
};
|
||
|
||
const RulesPanel = {
|
||
components: { NameChips },
|
||
props: { rules: { type: Object, required: true } },
|
||
emits: ['update', 'remove'],
|
||
template: `
|
||
<section class="card">
|
||
<h2>Правила</h2>
|
||
<div class="row">
|
||
<label>Режим:
|
||
<select :value="rules.mode" @change="$emit('update', { mode: $event.target.value })">
|
||
<option value="blacklist">чёрный список</option>
|
||
<option value="whitelist">белый список</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<input type="checkbox" :checked="rules.enforce"
|
||
@change="$emit('update', { enforce: $event.target.checked })">
|
||
применять правила
|
||
</label>
|
||
</div>
|
||
<p v-if="rules.mode === 'whitelist' && !rules.allowed.length" class="warn">
|
||
Белый список пуст — сервер намеренно ничего не завершает, иначе снесло бы систему.
|
||
</p>
|
||
<name-chips title="Заблокированы" :names="rules.blocked" @remove="$emit('remove', $event)" />
|
||
<name-chips title="Разрешены" :names="rules.allowed" @remove="$emit('remove', $event)" />
|
||
</section>`,
|
||
};
|
||
|
||
const SitesPanel = {
|
||
components: { NameChips },
|
||
props: {
|
||
sites: { type: Array, default: () => [] },
|
||
dns: { type: Object, required: true },
|
||
// enabled — сама галочка, active — работает ли блокировка на деле:
|
||
// общий выключатель «применять правила» гасит и её
|
||
enabled: { type: Boolean, default: false },
|
||
active: { type: Boolean, default: false },
|
||
lockdown: { type: Boolean, default: false },
|
||
},
|
||
emits: ['block', 'remove', 'enable', 'lockdown'],
|
||
setup(props, { emit }) {
|
||
const input = ref('');
|
||
const submit = () => {
|
||
const value = input.value.trim();
|
||
if (!value) return;
|
||
emit('block', value);
|
||
input.value = '';
|
||
};
|
||
|
||
const status = computed(() => {
|
||
const d = props.dns;
|
||
if (props.enabled && !props.active) return 'выключено общей галочкой «применять правила»';
|
||
if (!props.active) return 'выключено — служба имена не разрешает';
|
||
if (d.error) return d.error;
|
||
if (!d.listening) return 'резолвер запускается…';
|
||
return `спрашиваю ${d.upstream} · запросов ${d.queries}, ` +
|
||
`отклонено ${d.blocked}, из кэша ${d.cache_hits}` +
|
||
(d.timeouts ? `, без ответа ${d.timeouts}` : '');
|
||
});
|
||
|
||
const ago = (s) => (s < 60 ? `${s} с назад` : `${Math.floor(s / 60)} мин назад`);
|
||
|
||
return {
|
||
input, submit, status, ago,
|
||
// тревожно только то, что должно работать, но не работает
|
||
bad: computed(() => props.active && (props.dns.error || !props.dns.listening)),
|
||
idle: computed(() => !props.active && props.sites.length > 0),
|
||
};
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<div class="row" style="justify-content:space-between">
|
||
<h2 style="margin:0">Сайты</h2>
|
||
<label class="row">
|
||
<input type="checkbox" :checked="enabled"
|
||
@change="$emit('enable', $event.target.checked)">
|
||
блокировать сайты
|
||
</label>
|
||
</div>
|
||
<div :class="bad ? 'warn' : 'muted'" style="margin:8px 0 10px">{{ status }}</div>
|
||
<p v-if="idle" class="warn" style="margin:0 0 10px">
|
||
Список составлен, но блокировка выключена — сайты открываются.
|
||
</p>
|
||
<div class="row">
|
||
<input v-model="input" placeholder="discord.com" style="flex:1;min-width:140px"
|
||
@keyup.enter="submit">
|
||
<button @click="submit">Закрыть</button>
|
||
</div>
|
||
<p class="muted" style="margin:8px 0 0">
|
||
Запись закрывает и поддомены: <code>discord.com</code> закроет и
|
||
<code>gateway.discord.com</code>.
|
||
</p>
|
||
<name-chips title="Закрытые сайты" :names="sites" @remove="$emit('remove', $event)" />
|
||
<label class="row" style="margin-top:12px">
|
||
<input type="checkbox" :checked="lockdown" :disabled="!active"
|
||
@change="$emit('lockdown', $event.target.checked)">
|
||
<span :class="{ muted: !active }">запретить обход через чужие серверы имён</span>
|
||
</label>
|
||
<p v-if="lockdown && active" class="warn" style="margin:6px 0 0">
|
||
Правило брандмауэра закрывает 53-й порт всем, кроме службы. Если интернет
|
||
пропадёт — снимите галочку.
|
||
</p>
|
||
<div v-if="dns.attempts.length" class="scroll" style="margin-top:12px;max-height:180px">
|
||
<table>
|
||
<thead><tr><th>Куда ломились</th><th>Раз</th><th>Когда</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="a in dns.attempts" :key="a.name">
|
||
<td>{{ a.name }}</td>
|
||
<td>{{ a.count }}</td>
|
||
<td class="muted">{{ ago(a.ago_secs) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>`,
|
||
};
|
||
|
||
const QueryLog = {
|
||
props: {
|
||
entries: { type: Array, default: () => [] },
|
||
active: { type: Boolean, default: false },
|
||
},
|
||
setup(props) {
|
||
const filter = ref('');
|
||
const visible = computed(() => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
return q ? props.entries.filter((e) => e.name.includes(q)) : props.entries;
|
||
});
|
||
|
||
const verdicts = { blocked: 'закрыт', cached: 'из кэша', upstream: 'наверх' };
|
||
const ago = (s) => (s < 60 ? `${s} с` : `${Math.floor(s / 60)} мин`);
|
||
|
||
return { filter, visible, verdicts, ago };
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<div class="row" style="justify-content:space-between">
|
||
<h2 style="margin:0">Последние запросы</h2>
|
||
<span class="muted">{{ visible.length }} из {{ entries.length }}</span>
|
||
</div>
|
||
<p v-if="!active" class="muted" style="margin:8px 0 0">
|
||
Блокировка выключена — резолвер запросов не видит.
|
||
</p>
|
||
<template v-else>
|
||
<div class="row" style="margin:10px 0">
|
||
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
|
||
</div>
|
||
<p class="muted" style="margin:0 0 8px">
|
||
Хранится в памяти, на диск не пишется и пропадает вместе с выключением.
|
||
</p>
|
||
<div class="scroll" style="max-height:260px">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Когда</th><th>Имя</th><th>Тип</th><th>Что было</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(e, i) in visible" :key="i"
|
||
:class="{ blocked: e.verdict === 'blocked' }">
|
||
<td class="muted">{{ ago(e.ago_secs) }}</td>
|
||
<td>{{ e.name }}</td>
|
||
<td class="muted">{{ e.kind }}</td>
|
||
<td>{{ verdicts[e.verdict] || e.verdict }}</td>
|
||
</tr>
|
||
<tr v-if="!visible.length">
|
||
<td colspan="4" class="muted">пусто</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</template>
|
||
</section>`,
|
||
};
|
||
|
||
const RandomKill = {
|
||
emits: ['schedule'],
|
||
setup(_, { emit }) {
|
||
const name = ref('');
|
||
const min = ref(5);
|
||
const max = ref(30);
|
||
|
||
const submit = () => {
|
||
const value = name.value.trim();
|
||
if (!value) return;
|
||
emit('schedule', {
|
||
name: value,
|
||
min_secs: Math.max(0, min.value) * 60,
|
||
max_secs: Math.max(1, max.value) * 60,
|
||
});
|
||
name.value = '';
|
||
};
|
||
|
||
return { name, min, max, submit };
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<h2>Случайное завершение</h2>
|
||
<div class="row">
|
||
<input v-model="name" placeholder="game.exe" style="flex:1;min-width:140px"
|
||
@keyup.enter="submit">
|
||
<input v-model.number="min" type="number" min="0" style="width:70px"> —
|
||
<input v-model.number="max" type="number" min="1" style="width:70px">
|
||
<span class="muted">мин</span>
|
||
<button @click="submit">Запланировать</button>
|
||
</div>
|
||
</section>`,
|
||
};
|
||
|
||
const ProcessTable = {
|
||
props: {
|
||
groups: { type: Array, default: () => [] },
|
||
whitelist: { type: Boolean, default: false },
|
||
},
|
||
emits: ['toggle', 'kill'],
|
||
setup(props) {
|
||
const filter = ref('');
|
||
const visible = computed(() => {
|
||
const q = filter.value.trim().toLowerCase();
|
||
return q ? props.groups.filter((g) => g.key.includes(q)) : props.groups;
|
||
});
|
||
return { filter, visible };
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<h2>Процессы</h2>
|
||
<div class="row" style="margin-bottom:10px">
|
||
<input v-model="filter" placeholder="фильтр по имени" style="flex:1">
|
||
<span class="muted">{{ visible.length }} из {{ groups.length }}</span>
|
||
</div>
|
||
<div class="scroll">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Имя</th><th>Экз.</th><th>Память</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="g in visible" :key="g.key" :class="{ blocked: g.blocked }">
|
||
<td>{{ g.name }}</td>
|
||
<td>{{ g.count }}</td>
|
||
<td>{{ g.mem }} МБ</td>
|
||
<td class="right">
|
||
<button @click="$emit('toggle', g)">
|
||
{{ g.blocked ? (whitelist ? 'Внести в белый список' : 'Разрешить') : 'Блокировать' }}
|
||
</button>
|
||
<button class="danger" style="margin-left:6px" @click="$emit('kill', g.name)">
|
||
Убить
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>`,
|
||
};
|
||
|
||
const EventLog = {
|
||
props: { lines: { type: Array, default: () => [] } },
|
||
setup(props) {
|
||
return { recent: computed(() => props.lines.slice(-40).reverse().join('\n')) };
|
||
},
|
||
template: `
|
||
<section class="card">
|
||
<h2>Журнал</h2>
|
||
<pre>{{ recent || 'пусто' }}</pre>
|
||
</section>`,
|
||
};
|
||
|
||
/* ── корневой компонент ────────────────────────────────────── */
|
||
|
||
const App = {
|
||
components: { UpdateBar, RulesPanel, SitesPanel, QueryLog, RandomKill, ProcessTable, EventLog },
|
||
setup() {
|
||
const { rules, load: loadRules, block, allow, remove, save } = useRules();
|
||
const { groups, load: loadProcesses, kill } = useProcesses();
|
||
const { lines, load: loadLog } = useLog();
|
||
const { info: update, load: loadUpdate, check: checkUpdate } = useUpdate();
|
||
const { dns, load: loadDns, block: blockSite, remove: removeSite } = useSites();
|
||
|
||
onMounted(loadRules);
|
||
usePolling(() => { loadProcesses(); loadLog(); loadDns(); }, 3000);
|
||
// версия меняется раз в сутки в лучшем случае — частить незачем
|
||
usePolling(loadUpdate, 30000);
|
||
|
||
// в whitelist «заблокирован» означает отсутствие в allowed — действия зеркальные
|
||
const toggle = async (g) => {
|
||
if (rules.value.mode === 'whitelist') {
|
||
await (g.blocked ? allow(g.name) : remove(g.name));
|
||
} else {
|
||
await (g.blocked ? remove(g.name) : block(g.name));
|
||
}
|
||
loadProcesses();
|
||
};
|
||
|
||
const scheduleRandom = async (payload) => {
|
||
await api('/kill/random', payload);
|
||
loadLog();
|
||
};
|
||
|
||
const whitelist = computed(() => rules.value.mode === 'whitelist');
|
||
|
||
// правка списка сайтов меняет и правила, и показания резолвера
|
||
const onSite = async (action, name) => {
|
||
await action(name);
|
||
await Promise.all([loadRules(), loadDns()]);
|
||
};
|
||
|
||
return {
|
||
rules, groups, lines, update, dns, whitelist,
|
||
save, remove, toggle, kill, scheduleRandom, checkUpdate,
|
||
blockSite: (name) => onSite(blockSite, name),
|
||
removeSite: (name) => onSite(removeSite, name),
|
||
};
|
||
},
|
||
template: `
|
||
<div class="row" style="justify-content:space-between">
|
||
<h1>Родительский контроль</h1>
|
||
<form method="post" action="/logout"><button>Выйти</button></form>
|
||
</div>
|
||
<update-bar :info="update" @check="checkUpdate" />
|
||
<rules-panel :rules="rules" @update="save" @remove="remove" />
|
||
<sites-panel :sites="rules.blocked_sites" :dns="dns"
|
||
:enabled="rules.sites_enforce"
|
||
:active="rules.enforce && rules.sites_enforce"
|
||
:lockdown="rules.dns_lockdown"
|
||
@block="blockSite" @remove="removeSite"
|
||
@enable="save({ sites_enforce: $event })"
|
||
@lockdown="save({ dns_lockdown: $event })" />
|
||
<query-log :entries="dns.recent" :active="rules.enforce && rules.sites_enforce" />
|
||
<random-kill @schedule="scheduleRandom" />
|
||
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
|
||
<event-log :lines="lines" />`,
|
||
};
|
||
|
||
createApp(App).mount('#app'); |