добавил плагин для RouterOS
This commit is contained in:
parent
52f57ea7c3
commit
991007a39b
|
|
@ -46,3 +46,4 @@ ovpn-connector.json
|
|||
app/src/main/resources/htdocs/
|
||||
*.pfapp
|
||||
cache/
|
||||
test/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ROS.ROSConfig> {
|
||||
|
||||
private final static String CTX = ROS.class.getSimpleName();
|
||||
private final EventListener<NetworkResourceBundle> 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<IPv4Subnet> 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<String, IPv4Subnet>();
|
||||
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<Entry> 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
6
pom.xml
6
pom.xml
|
|
@ -79,7 +79,11 @@
|
|||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>me.legrange</groupId>
|
||||
<artifactId>mikrotik</artifactId>
|
||||
<version>3.0.8</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<div class="${CLASS_NAMES.instance}" data-instance-id="${instanceId}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4 style="margin: 0;">Инстанс</h4>
|
||||
<button type="button" class="btn-link ${CLASS_NAMES.removeInstanceButton}">Удалить</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ros-host-${instanceId}">Хост</label>
|
||||
<input type="text" id="ros-host-${instanceId}" class="form-control ${CLASS_NAMES.host}" value="${instance.host || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ros-username-${instanceId}">Имя пользователя</label>
|
||||
<input type="text" id="ros-username-${instanceId}" class="form-control ${CLASS_NAMES.username}" value="${instance.username || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ros-password-${instanceId}">Пароль</label>
|
||||
<input type="password" id="ros-password-${instanceId}" class="form-control ${CLASS_NAMES.password}" placeholder="Оставьте пустым для сохранения текущего пароля" data-original-password="${instance.password || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ros-vrf-${instanceId}">VRF (роутинг-таблица)</label>
|
||||
<input type="text" id="ros-vrf-${instanceId}" class="form-control ${CLASS_NAMES.vrf}" value="${instance.VRF || 'main'}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ros-gateway-${instanceId}">Шлюз</label>
|
||||
<input type="text" id="ros-gateway-${instanceId}" class="form-control ${CLASS_NAMES.gateway}" placeholder="127.0.0.1" value="${instance.gateway || ''}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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(`
|
||||
<div class="component-config-form">
|
||||
<h3 class="config-section-title">Инстансы RouterOS</h3>
|
||||
<p class="hint-text" style="margin-bottom: 20px;">Настройте параметры подключения к MikroTik RouterOS для синхронизации маршрутов. Можно добавить несколько инстансов.</p>
|
||||
<div id="${FIELD_IDS.instancesList}"></div>
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<button type="button" id="${FIELD_IDS.addInstanceButton}" class="btn-secondary" style="width: 220px;">Добавить инстанс</button>
|
||||
</div>
|
||||
<div class="action-buttons" style="margin-top: 40px;">
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 220px;">Применить Конфигурацию</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
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: () => `
|
||||
<h1 class="page-title">Настройка ROS</h1>
|
||||
<div id="${FIELD_IDS.container}">
|
||||
<p>Загрузка конфигурации...</p>
|
||||
</div>
|
||||
`,
|
||||
mount: async () => {
|
||||
const success = await loadConfig();
|
||||
if (success) {
|
||||
renderROSForm();
|
||||
} else {
|
||||
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию ROS.</p>');
|
||||
}
|
||||
},
|
||||
unmount: () => {
|
||||
detachEventHandlers();
|
||||
clearStatus();
|
||||
currentConfig = { instances: [] };
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue