From c36d96677e4ee53f318b4634a1fab0f5078fc730 Mon Sep 17 00:00:00 2001 From: kirillius Date: Sat, 11 Jul 2026 22:58:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20DN?= =?UTF-8?q?SMASQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/ru/kirillius/pf/sdn/App.java | 3 +- .../sdn/External/API/Components/DNSMASQ.java | 196 ++++++++++++ .../pf/sdn/External/API/Components/ROS.java | 26 -- webui/src/modules/router.js | 7 + webui/src/pages/DNSMASQ.js | 284 ++++++++++++++++++ 5 files changed, 489 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/DNSMASQ.java create mode 100644 webui/src/pages/DNSMASQ.js diff --git a/app/src/main/java/ru/kirillius/pf/sdn/App.java b/app/src/main/java/ru/kirillius/pf/sdn/App.java index ed1e93e..eff90cf 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/App.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/App.java @@ -2,6 +2,7 @@ package ru.kirillius.pf.sdn; import lombok.Getter; import lombok.SneakyThrows; +import ru.kirillius.pf.sdn.External.API.Components.DNSMASQ; import ru.kirillius.pf.sdn.External.API.Components.FRR; import ru.kirillius.pf.sdn.External.API.Components.OVPN; import ru.kirillius.pf.sdn.External.API.Components.ROS; @@ -160,7 +161,7 @@ public class App implements Context, Closeable { .configFile(new File(getArgument("c", args))) .appLibrary(new File(getArgument("l", args))) .repository(getArgument("r", args)) - .availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.class)).build())) { + .availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.class, DNSMASQ.class)).build())) { Wait.when(app.running::get); if (app.shouldRestart.get()) { System.exit(42); diff --git a/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/DNSMASQ.java b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/DNSMASQ.java new file mode 100644 index 0000000..6d83975 --- /dev/null +++ b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/DNSMASQ.java @@ -0,0 +1,196 @@ +package ru.kirillius.pf.sdn.External.API.Components; + +import lombok.*; +import org.json.JSONObject; +import org.json.JSONTokener; +import ru.kirillius.java.utils.events.EventListener; +import ru.kirillius.json.JSONArrayProperty; +import ru.kirillius.json.JSONProperty; +import ru.kirillius.json.JSONSerializable; +import ru.kirillius.pf.sdn.core.AbstractComponent; +import ru.kirillius.pf.sdn.core.Context; +import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle; +import ru.kirillius.utils.logging.SystemLogger; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +/** + * Component that synchronises FRR routing instances with the aggregated subnet list. + */ +public final class DNSMASQ extends AbstractComponent { + + private final static String CTX = DNSMASQ.class.getSimpleName(); + private final EventListener subscription; + private final HttpClient client; + + public DNSMASQ(Context context) { + super(context); + subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateDomains(bundle.getDomains())); + client = HttpClient.newHttpClient(); + } + + private final static String FILENAME = "/etc/dnsmasq.conf"; + + private String parseResponseLine(String line) { + if (!line.startsWith("data: ")) { + return null; + } + var json = new JSONObject(new JSONTokener(line.substring(6))); + if (json.has("body")) { + return json.getJSONObject("body").getJSONObject("Files").getString(FILENAME); + } + return null; + } + + private HttpRequest.Builder createRequest(DNSMASQConfig.Entry entry, String endpoint) { + var auth = entry.user + ":" + entry.password; + var encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes()); + var url = entry.host + endpoint; + + return HttpRequest.newBuilder().uri(URI.create(url)).header("Authorization", "Basic " + encodedAuth); + } + + private void saveConfig(DNSMASQConfig.Entry entry, String config) { + var json = new JSONObject(); + json.put(FILENAME, config); + var request = createRequest(entry, "/save") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(json.toString())) + .build(); + try { + var response = client.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() >= 400) { + throw new IOException("Bad response code " + response.statusCode()); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Failed to save config to DNSMASQ " + entry.host, e); + } + } + + private String fetchConfig(DNSMASQConfig.Entry entry) { + var request = createRequest(entry, "/sync") + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .GET() + .build(); + try { + return client.sendAsync(request, HttpResponse.BodyHandlers.ofLines()) + .thenApply(response -> { + var result = new AtomicReference(null); + response.body() + .takeWhile(s -> result.get() == null) + .forEach(s -> result.set(parseResponseLine(s))); + return result.get(); + }).get(30, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException("Unable to fetch config from DNSMASQ " + entry.host, e); + } + } + + private final static String SERVER_PREFIX = "server="; + + private void updateDomains(List domains) { + for (DNSMASQConfig.Entry entry : config.instances) { + try { + final var config = new ArrayList<>( + Arrays.stream(fetchConfig(entry) + .split(Pattern.quote("\n"))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList() + ); + + var existing = new ArrayList(); + var changed = new AtomicBoolean(false); + + for (var line : config) { + //#server=/repo.kirillius.ru/172.16.0.10 + if (line.startsWith(SERVER_PREFIX) && line.endsWith(entry.forwarder)) { + var split = line.split(Pattern.quote("/")); + + if (split.length == 3) { + existing.add(split[1]); + } + } + } + //remove + existing.stream() + .filter(d -> !domains.contains(d)) + .forEach(domain -> { + config.remove(SERVER_PREFIX + "/" + domain + "/" + entry.forwarder); + changed.set(true); + }); + //add missing + domains.stream() + .filter(o -> !existing.contains(o)) + .forEach(domain -> { + config.add(SERVER_PREFIX + "/" + domain + "/" + entry.forwarder); + changed.set(true); + }); + if (changed.get()) { + saveConfig(entry, String.join("\n", config)); + } + } catch (Exception e) { + SystemLogger.error("DNS update failed", CTX, e); + } + } + + } + + /** + * Removes the subscription from the context event handler. + */ + @Override + public void close() throws IOException { + context.getEventsHandler().getNetworkManagerUpdateEvent().remove(subscription); + client.close(); + } + + @JSONSerializable + public static class DNSMASQConfig { + @Getter + @Setter + @JSONArrayProperty(type = Entry.class) + private List instances = new ArrayList<>(); + + @Builder + @AllArgsConstructor + @NoArgsConstructor + @JSONSerializable + public static class Entry { + @Getter + @Setter + @JSONProperty + private String host = "http://127.0.0.1:5380"; + + @Getter + @Setter + @JSONProperty + private String user = "foo"; + + @Getter + @Setter + @JSONProperty + private String password = "bar"; + + @Getter + @Setter + @JSONProperty + private String forwarder = "127.0.0.1"; + } + } +} diff --git a/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java index 5f27bfa..e0ea099 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; /** @@ -36,32 +35,7 @@ public final class ROS extends AbstractComponent { subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateSubnets(bundle.getSubnets())); } - public List> getRoutesByGatewayAndVRF(ApiConnection connection, String gateway, String vrf) - throws MikrotikApiException { - List> allRoutes = new ArrayList<>(); - int offset = 0; - final int LIMIT = 100; - -// while (true) { -// // Формируем запрос с пагинацией. Каждый параметр - отдельный аргумент. -// List> chunk = connection.execute( -// "/ip/route/print", -// "gateway=" + gateway, -// "routing-table=" + vrf, -// ".proplist=.id,dst-address,static", // Указываем нужные поля -// "limit=" + offset + "," + LIMIT -// ); -// -// if (chunk == null || chunk.isEmpty()) { -// break; // Выходим из цикла, если данных больше нет -// } -// -// allRoutes.addAll(chunk); -// offset += LIMIT; // Переходим к следующей порции -// } - return allRoutes; - } /** * Synchronises FRR instances with the provided subnet list. diff --git a/webui/src/modules/router.js b/webui/src/modules/router.js index ae6d832..1b4689d 100644 --- a/webui/src/modules/router.js +++ b/webui/src/modules/router.js @@ -10,6 +10,7 @@ import { OVPNConfig } from '../pages/OVPN.js'; import { TDNSConfig } from '../pages/TDNS.js'; import { FRRConfig } from '../pages/FRR.js'; import { ROSConfig } from '../pages/ROS.js'; +import { DNSMASQConfig } from '../pages/DNSMASQ.js'; import { SettingsPage } from '../pages/Settings.js'; import { LogsPage } from '../pages/Logs.js'; import { NetworkResourcesPage } from '../pages/NetworkResources.js'; @@ -32,6 +33,7 @@ const allMenuItems = [ { label: 'Настройка TDNS', path: 'tdns', component: 'ru.kirillius.pf.sdn.External.API.Components.TDNS' }, { label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' }, { label: 'Настройка ROS', path: 'ros', component: 'ru.kirillius.pf.sdn.External.API.Components.ROS' }, + { label: 'Настройка DNSMASQ', path: 'dnsmasq', component: 'ru.kirillius.pf.sdn.External.API.Components.DNSMASQ' }, { label: 'Журнал', path: 'logs', component: null }, { label: 'Сетевые ресурсы', path: 'network-resources', component: null }, { label: 'Разблокировка сайта', path: 'unlock-site', component: null }, @@ -85,6 +87,11 @@ const routes = { mount: ROSConfig.mount, unmount: ROSConfig.unmount }, + '#dnsmasq': { + render: DNSMASQConfig.render, + mount: DNSMASQConfig.mount, + unmount: DNSMASQConfig.unmount + }, '#logs': { render: LogsPage.render, mount: LogsPage.mount, diff --git a/webui/src/pages/DNSMASQ.js b/webui/src/pages/DNSMASQ.js new file mode 100644 index 0000000..a1510c8 --- /dev/null +++ b/webui/src/pages/DNSMASQ.js @@ -0,0 +1,284 @@ +import $ from 'jquery'; +import { JSONRPC } from '@/json-rpc.js'; + +const DNSMASQ_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.DNSMASQ'; + +const FIELD_IDS = { + container: 'dnsmasq-config-container', + instancesList: 'dnsmasq-instances-list', + addInstanceButton: 'dnsmasq-add-instance-btn', + saveButton: 'save-dnsmasq-btn', + status: 'dnsmasq-status-message' +}; + +const CLASS_NAMES = { + instance: 'dnsmasq-instance-entry', + removeInstanceButton: 'dnsmasq-remove-instance-btn', + host: 'dnsmasq-host', + user: 'dnsmasq-user', + password: 'dnsmasq-password', + forwarder: 'dnsmasq-forwarder' +}; + +const SELECTORS = { + container: `#${FIELD_IDS.container}`, + instancesList: `#${FIELD_IDS.instancesList}`, + addInstanceButton: `#${FIELD_IDS.addInstanceButton}`, + saveButton: `#${FIELD_IDS.saveButton}`, + status: `#${FIELD_IDS.status}` +}; + +let currentConfig = { instances: [] }; +let statusTimeoutId = null; +let instanceCounter = 0; + +const getStatusElement = () => $(SELECTORS.status); + +function normalizeConfig(config) { + if (!config || !Array.isArray(config.instances)) { + return { instances: [] }; + } + + return { + instances: config.instances.map(instance => ({ + host: instance?.host || '127.0.0.1', + user: instance?.user || '', + password: instance?.password || '', + forwarder: instance?.forwarder || '127.0.0.1' + })) + }; +} + +function clearStatus() { + const $status = getStatusElement(); + if (!$status.length) { + return; + } + if (statusTimeoutId) { + clearTimeout(statusTimeoutId); + statusTimeoutId = null; + } + $status.stop(true, true).hide().text('').removeClass('success-message error-message'); +} + +function updateStatus(message, type) { + const $status = getStatusElement(); + if (!$status.length) { + return; + } + if (statusTimeoutId) { + clearTimeout(statusTimeoutId); + } + $status + .removeClass('success-message error-message') + .addClass(type === 'success' ? 'success-message' : 'error-message') + .text(message) + .show(); + + statusTimeoutId = window.setTimeout(() => { + $status.fadeOut(); + }, 5000); +} + +async function runAction($button, pendingText, action, messages) { + if (!$button.length) { + return; + } + + const originalText = $button.text(); + $button.prop('disabled', true).text(pendingText); + clearStatus(); + + try { + const result = await action(); + if (messages?.success) { + const successMessage = typeof messages.success === 'function' + ? messages.success(result) + : messages.success; + if (successMessage) { + updateStatus(successMessage, 'success'); + } + } + } catch (error) { + console.error(messages?.log || 'Ошибка выполнения действия DNSMASQ:', error); + if (messages?.error) { + updateStatus(messages.error, 'error'); + } + } finally { + $button.prop('disabled', false).text(originalText); + } +} + +function resetCounters() { + instanceCounter = 0; +} + +function getNextInstanceId() { + instanceCounter += 1; + return instanceCounter; +} + +function createInstanceRow(instance = {}) { + const instanceId = getNextInstanceId(); + + return ` +
+
+

Инстанс

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ `; +} + +function populateInstances() { + const instances = currentConfig.instances.length ? currentConfig.instances : []; + const $list = $(SELECTORS.instancesList); + resetCounters(); + $list.empty(); + instances.forEach(instance => { + $list.append(createInstanceRow(instance)); + }); +} + +function renderDNSMASQForm() { + const $container = $(SELECTORS.container); + $container.html(` +
+

Инстансы DNSMASQ

+

Настройте параметры подключения к DNSMASQ для синхронизации списков серверов. Можно добавить несколько инстансов.

+
+
+ +
+
+ +
+ +
+ `); + + populateInstances(); + attachEventHandlers(); +} + +function collectConfigFromForm() { + const instances = []; + + $(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => { + const $instance = $(element); + + const host = $instance.find(`.${CLASS_NAMES.host}`).val().trim(); + const user = $instance.find(`.${CLASS_NAMES.user}`).val().trim(); + const $passwordField = $instance.find(`.${CLASS_NAMES.password}`); + const newPassword = $passwordField.val(); + const originalPassword = $passwordField.data('original-password') || ''; + const password = newPassword ? newPassword : originalPassword; + const forwarder = $instance.find(`.${CLASS_NAMES.forwarder}`).val().trim(); + + instances.push({ + host, + user, + password, + forwarder + }); + }); + + return { instances }; +} + +async function loadConfig() { + try { + const fullConfig = await JSONRPC.System.getComponentConfig(DNSMASQ_COMPONENT_NAME); + currentConfig = normalizeConfig(fullConfig); + return true; + } catch (error) { + console.error('Ошибка при загрузке конфига DNSMASQ:', error); + currentConfig = { instances: [] }; + return false; + } +} + +function handleAddInstance() { + const $list = $(SELECTORS.instancesList); + $list.append(createInstanceRow({ + host: '127.0.0.1', + user: '', + password: '', + forwarder: '127.0.0.1' + })); +} + +function handleRemoveInstance(event) { + event.preventDefault(); + $(event.currentTarget).closest(`.${CLASS_NAMES.instance}`).remove(); +} + +async function handleSave() { + const $button = $(SELECTORS.saveButton); + clearStatus(); + + const newConfig = collectConfigFromForm(); + + await runAction($button, 'Применение...', async () => { + await JSONRPC.System.setComponentConfig(DNSMASQ_COMPONENT_NAME, newConfig); + currentConfig = normalizeConfig(newConfig); + populateInstances(); + }, { + success: 'Конфигурация DNSMASQ успешно сохранена.', + error: 'Ошибка при сохранении конфигурации DNSMASQ.', + log: 'Ошибка сохранения конфига DNSMASQ' + }); +} + +function attachEventHandlers() { + $(SELECTORS.saveButton).off('click').on('click', handleSave); + $(SELECTORS.addInstanceButton).off('click').on('click', handleAddInstance); + $(SELECTORS.instancesList) + .off('click', `.${CLASS_NAMES.removeInstanceButton}`).on('click', `.${CLASS_NAMES.removeInstanceButton}`, handleRemoveInstance); +} + +function detachEventHandlers() { + $(SELECTORS.saveButton).off('click'); + $(SELECTORS.addInstanceButton).off('click'); + $(SELECTORS.instancesList) + .off('click', `.${CLASS_NAMES.removeInstanceButton}`); +} + +export const DNSMASQConfig = { + render: () => ` +

Настройка DNSMASQ

+
+

Загрузка конфигурации...

+
+ `, + mount: async () => { + const success = await loadConfig(); + if (success) { + renderDNSMASQForm(); + } else { + $(SELECTORS.container).html('

Не удалось загрузить конфигурацию DNSMASQ.

'); + } + }, + unmount: () => { + detachEventHandlers(); + clearStatus(); + currentConfig = { instances: [] }; + } +};