From 991007a39b08fff1d24da70b4b073e4fe8968388 Mon Sep 17 00:00:00 2001 From: kirillius Date: Wed, 1 Jul 2026 02:43:47 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=20=D0=B4=D0=BB=D1=8F=20Rou?= =?UTF-8?q?terOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../main/java/ru/kirillius/pf/sdn/App.java | 3 +- .../pf/sdn/External/API/Components/ROS.java | 144 +++++++++ pom.xml | 6 +- webui/src/modules/router.js | 7 + webui/src/pages/ROS.js | 293 ++++++++++++++++++ 6 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java create mode 100644 webui/src/pages/ROS.js diff --git a/.gitignore b/.gitignore index 6ef8757..4afe540 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ ovpn-connector.json app/src/main/resources/htdocs/ *.pfapp cache/ +test/ 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 0b81991..ed1e93e 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/App.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/App.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.SneakyThrows; 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; import ru.kirillius.pf.sdn.External.API.Components.TDNS; import ru.kirillius.pf.sdn.External.API.GitSubscription; import ru.kirillius.pf.sdn.External.API.HEInfoProvider; @@ -159,7 +160,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)).build())) { + .availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.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/ROS.java b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java new file mode 100644 index 0000000..93b70b8 --- /dev/null +++ b/app/src/main/java/ru/kirillius/pf/sdn/External/API/Components/ROS.java @@ -0,0 +1,144 @@ +package ru.kirillius.pf.sdn.External.API.Components; + +import lombok.*; +import me.legrange.mikrotik.ApiConnection; +import me.legrange.mikrotik.MikrotikApiException; +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.IPv4Subnet; +import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle; +import ru.kirillius.utils.logging.SystemLogger; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Component that synchronises FRR routing instances with the aggregated subnet list. + */ +public final class ROS extends AbstractComponent { + + private final static String CTX = ROS.class.getSimpleName(); + private final EventListener subscription; + + /** + * Binds the component to the application context and subscribes to network updates. + */ + public ROS(Context context) { + super(context); + subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateSubnets(bundle.getSubnets())); + } + + /** + * Synchronises FRR instances with the provided subnet list. + */ + private void updateSubnets(List subnets) { + for (var entry : config.instances) { + SystemLogger.message("Updating subnets in RouterOS " + entry.host, CTX); + + try (var connection = ApiConnection.connect(entry.host)) { + connection.login(entry.username, entry.password); + SystemLogger.message("Fetching existing subnets...", CTX); + var existingSubnets = new HashMap(); + var result = connection.execute("/ip/route/print where routing-table=" + entry.VRF); + result.forEach(row -> { + if (row.containsKey("static") && row.get("static").equals("true")) { + existingSubnets.put(row.get(".id"), new IPv4Subnet(row.get("dst-address"))); + } + }); + + //удаляем лишние подсети + + var subnetsToRemove = existingSubnets.keySet().stream().filter(id -> !subnets.contains(existingSubnets.get(id))).toList(); + if (!subnetsToRemove.isEmpty()) { + SystemLogger.message(subnetsToRemove.size() + " subnets should be removed", CTX); + subnetsToRemove.forEach(id -> { + try { + connection.execute("/ip/route/remove .id=" + id); + } catch (MikrotikApiException e) { + throw new RuntimeException("Failed to remove subnet " + id, e); + } + }); + } + + //добавляем новые подсети + var subnetsToAdd = subnets.stream().filter(subnet -> !existingSubnets.containsValue(subnet)).toList(); + if (!subnetsToAdd.isEmpty()) { + SystemLogger.message(subnetsToAdd.size() + " subnets should be added", CTX); + subnetsToAdd.forEach(subnet -> { + try { + connection.execute("/ip/route/add dst-address=" + subnet + " gateway=" + entry.gateway + " routing-table=" + entry.VRF); + } catch (MikrotikApiException e) { + throw new RuntimeException("Failed to remove subnet " + subnet, e); + } + }); + } + + SystemLogger.message("ROS update is complete", CTX); + + } catch (Exception e) { + SystemLogger.error("Failed to execute api command because of error", CTX, e); + } + + } + } + + /** + * Removes the subscription from the context event handler. + */ + @Override + public void close() throws IOException { + context.getEventsHandler().getNetworkManagerUpdateEvent().remove(subscription); + } + + /** + * Configuration describing FRR instances and how subnets should be rendered for them. + */ + @JSONSerializable + public static class ROSConfig { + + @Getter + @Setter + @JSONArrayProperty(type = Entry.class) + private List instances = new ArrayList<>(); + + /** + * Declarative description of a single FRR instance managed by the component. + */ + @Builder + @AllArgsConstructor + @NoArgsConstructor + @JSONSerializable + public static class Entry { + @Getter + @Setter + @JSONProperty + private String host = "127.0.0.1"; + + @Getter + @Setter + @JSONProperty + private String username = "admin"; + + @Getter + @Setter + @JSONProperty + private String password = "passwd"; + + @Getter + @Setter + @JSONProperty + private String VRF = "main"; + + @Getter + @Setter + @JSONProperty + private String gateway = "127.0.0.1"; + } + } +} diff --git a/pom.xml b/pom.xml index 83c0236..8a24fda 100644 --- a/pom.xml +++ b/pom.xml @@ -79,7 +79,11 @@ test - + + me.legrange + mikrotik + 3.0.8 + org.projectlombok diff --git a/webui/src/modules/router.js b/webui/src/modules/router.js index eff839e..ae6d832 100644 --- a/webui/src/modules/router.js +++ b/webui/src/modules/router.js @@ -9,6 +9,7 @@ import { getEnabledComponents } from './app.js'; 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 { SettingsPage } from '../pages/Settings.js'; import { LogsPage } from '../pages/Logs.js'; import { NetworkResourcesPage } from '../pages/NetworkResources.js'; @@ -30,6 +31,7 @@ const allMenuItems = [ { label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' }, { 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: 'Журнал', path: 'logs', component: null }, { label: 'Сетевые ресурсы', path: 'network-resources', component: null }, { label: 'Разблокировка сайта', path: 'unlock-site', component: null }, @@ -78,6 +80,11 @@ const routes = { mount: FRRConfig.mount, unmount: FRRConfig.unmount }, + '#ros': { + render: ROSConfig.render, + mount: ROSConfig.mount, + unmount: ROSConfig.unmount + }, '#logs': { render: LogsPage.render, mount: LogsPage.mount, diff --git a/webui/src/pages/ROS.js b/webui/src/pages/ROS.js new file mode 100644 index 0000000..7f0fcd6 --- /dev/null +++ b/webui/src/pages/ROS.js @@ -0,0 +1,293 @@ +import $ from 'jquery'; +import { JSONRPC } from '@/json-rpc.js'; + +const ROS_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.ROS'; + +const FIELD_IDS = { + container: 'ros-config-container', + instancesList: 'ros-instances-list', + addInstanceButton: 'ros-add-instance-btn', + saveButton: 'save-ros-btn', + status: 'ros-status-message' +}; + +const CLASS_NAMES = { + instance: 'ros-instance-entry', + removeInstanceButton: 'ros-remove-instance-btn', + host: 'ros-host', + username: 'ros-username', + password: 'ros-password', + vrf: 'ros-vrf', + gateway: 'ros-gateway' +}; + +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 || '', + username: instance?.username || '', + password: instance?.password || '', + VRF: instance?.VRF || 'main', + gateway: instance?.gateway || '' + })) + }; +} + +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 || 'Ошибка выполнения действия ROS:', 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 renderROSForm() { + const $container = $(SELECTORS.container); + $container.html(` +
+

Инстансы RouterOS

+

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

+
+
+ +
+
+ +
+ +
+ `); + + 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 username = $instance.find(`.${CLASS_NAMES.username}`).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 VRF = $instance.find(`.${CLASS_NAMES.vrf}`).val().trim(); + const gateway = $instance.find(`.${CLASS_NAMES.gateway}`).val().trim(); + + instances.push({ + host, + username, + password, + VRF, + gateway + }); + }); + + return { instances }; +} + +async function loadConfig() { + try { + const fullConfig = await JSONRPC.System.getComponentConfig(ROS_COMPONENT_NAME); + currentConfig = normalizeConfig(fullConfig); + return true; + } catch (error) { + console.error('Ошибка при загрузке конфига ROS:', error); + currentConfig = { instances: [] }; + return false; + } +} + +function handleAddInstance() { + const $list = $(SELECTORS.instancesList); + $list.append(createInstanceRow({ + host: '', + username: '', + password: '', + VRF: 'main', + gateway: '' + })); +} + +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(ROS_COMPONENT_NAME, newConfig); + currentConfig = normalizeConfig(newConfig); + populateInstances(); + }, { + success: 'Конфигурация ROS успешно сохранена.', + error: 'Ошибка при сохранении конфигурации ROS.', + log: 'Ошибка сохранения конфига ROS' + }); +} + +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 ROSConfig = { + render: () => ` +

Настройка ROS

+
+

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

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

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

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