feat: add custom dns resolver
CI / Windows x64 (push) Successful in 9m31s

This commit is contained in:
2026-08-09 15:47:38 +07:00
parent ccaf521da4
commit 0223dd817b
12 changed files with 2780 additions and 10 deletions
+130 -4
View File
@@ -17,7 +17,12 @@ async function api(path, body) {
/* ── composables ───────────────────────────────────────────── */
function useRules() {
const rules = ref({ mode: 'blacklist', blocked: [], allowed: [], enforce: true });
// форма объекта должна совпадать с серверной с самого начала: `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);
@@ -54,6 +59,19 @@ function useLog() {
return { lines, load };
}
function useSites() {
const dns = ref({
listening: false, upstream: '', queries: 0, blocked: 0,
cache_hits: 0, timeouts: 0, error: null, attempts: [],
});
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'); };
@@ -152,6 +170,95 @@ const RulesPanel = {
</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 RandomKill = {
emits: ['schedule'],
setup(_, { emit }) {
@@ -247,15 +354,16 @@ const EventLog = {
/* ── корневой компонент ────────────────────────────────────── */
const App = {
components: { UpdateBar, RulesPanel, RandomKill, ProcessTable, EventLog },
components: { UpdateBar, RulesPanel, SitesPanel, 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(); }, 3000);
usePolling(() => { loadProcesses(); loadLog(); loadDns(); }, 3000);
// версия меняется раз в сутки в лучшем случае — частить незачем
usePolling(loadUpdate, 30000);
@@ -276,7 +384,18 @@ const App = {
const whitelist = computed(() => rules.value.mode === 'whitelist');
return { rules, groups, lines, update, whitelist, save, remove, toggle, kill, scheduleRandom, checkUpdate };
// правка списка сайтов меняет и правила, и показания резолвера
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">
@@ -285,6 +404,13 @@ const App = {
</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 })" />
<random-kill @schedule="scheduleRandom" />
<process-table :groups="groups" :whitelist="whitelist" @toggle="toggle" @kill="kill" />
<event-log :lines="lines" />`,
+5 -1
View File
@@ -39,11 +39,15 @@
.warn { color:#e0b872; }
pre { margin:0; font:12px/1.6 ui-monospace, Consolas, monospace; color:#9aa4b2;
white-space:pre-wrap; max-height:200px; overflow:auto; }
code { font:12px/1.5 ui-monospace, Consolas, monospace; color:#c8d0da;
background:#232833; border-radius:4px; padding:1px 4px; }
</style>
</head>
<body>
<div id="app" v-cloak></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- Vue отдаёт сама служба: панель не должна зависеть от сети, тем более от
разрешения имён, которым служба и управляет. -->
<script src="/vue.js"></script>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long