Compare commits

..

2 Commits

6 changed files with 212 additions and 17 deletions

View File

@ -2,11 +2,7 @@ package ru.kirillius.pf.sdn;
import lombok.Getter; import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import ru.kirillius.pf.sdn.External.API.Components.DNSMASQ; import ru.kirillius.pf.sdn.External.API.Components.*;
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.GitSubscription;
import ru.kirillius.pf.sdn.External.API.HEInfoProvider; import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription; import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription;
@ -161,7 +157,7 @@ public class App implements Context, Closeable {
.configFile(new File(getArgument("c", args))) .configFile(new File(getArgument("c", args)))
.appLibrary(new File(getArgument("l", args))) .appLibrary(new File(getArgument("l", args)))
.repository(getArgument("r", args)) .repository(getArgument("r", args))
.availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.class, DNSMASQ.class)).build())) { .availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.class, DNSMASQ.class, SRM.class)).build())) {
Wait.when(app.running::get); Wait.when(app.running::get);
if (app.shouldRestart.get()) { if (app.shouldRestart.get()) {
System.exit(42); System.exit(42);

View File

@ -0,0 +1,102 @@
package ru.kirillius.pf.sdn.External.API.Components;
import jakarta.servlet.http.HttpServletResponse;
import org.json.JSONObject;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.json.JSONUtility;
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.pf.sdn.web.WebService;
import ru.kirillius.pf.sdn.web.WebhookServlet;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
/**
* Component integrating with OpenVPN to expose management RPC and synchronize route exports.
*/
public final class SRM extends AbstractComponent<JSONObject> {
private final static String CTX = SRM.class.getSimpleName();
private final static String HOOK_NAME = "SRM";
private final EventListener<NetworkResourceBundle> updateEvent;
private final List<IPv4Subnet> lastSubnets = new ArrayList<>();
private final AtomicLong lastUpdate = new AtomicLong(0);
private EventListener<WebhookServlet.RequestContext> hookListener;
public SRM(Context context) {
super(context);
var eventsHandler = context.getEventsHandler();
updateEvent = eventsHandler.getNetworkManagerUpdateEvent().add(bundle -> {
var subnets = new HashSet<>(bundle.getSubnets());
synchronized (lastSubnets) {
if (lastSubnets.size() != subnets.size() || !subnets.containsAll(lastSubnets)) {
lastSubnets.clear();
lastSubnets.addAll(subnets);
lastUpdate.set(System.currentTimeMillis());
}
}
});
var webService = context.getServiceManager().getService(WebService.class);
var webhookServlet = webService.getWebhookServlet();
hookListener = webhookServlet.registerHook(HOOK_NAME, httpContext -> {
var resp = httpContext.response();
if (!httpContext.request().getMethod().equals("GET")) {
resp.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
var path = httpContext.request().getRequestURI();
resp.setHeader("Cache-Control", "no-cache");
if (path.endsWith("/update")) {
resp.setHeader("Content-Type", "text/plain");
var writer = resp.getWriter();
writer.print(lastUpdate.get());
writer.close();
return;
}
if (path.endsWith("/subnets")) {
resp.setHeader("Content-Type", "application/json");
var subnets = new ArrayList<IPv4Subnet>();
synchronized (lastSubnets) {
//noinspection CollectionAddAllCanBeReplacedWithConstructor
subnets.addAll(lastSubnets);
subnets.sort(Comparator.comparingLong(IPv4Subnet::getLongAddress));
}
var json = JSONUtility.serializeCollection(subnets, IPv4Subnet.class, null);
var writer = resp.getWriter();
writer.print(json.toString());
writer.close();
return;
}
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
});
}
@Override
public void close() {
var eventsHandler = context.getEventsHandler();
if (updateEvent != null) {
eventsHandler.getNetworkManagerUpdateEvent().remove(updateEvent);
}
if (hookListener != null) {
var webService = context.getServiceManager().getService(WebService.class);
var webhookServlet = webService.getWebhookServlet();
webhookServlet.unregisterHook(HOOK_NAME, hookListener);
hookListener = null;
}
}
}

View File

@ -1,6 +1,5 @@
package ru.kirillius.pf.sdn.web; package ru.kirillius.pf.sdn.web;
import lombok.Getter; import lombok.Getter;
import org.eclipse.jetty.ee10.servlet.DefaultServlet; import org.eclipse.jetty.ee10.servlet.DefaultServlet;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
@ -10,10 +9,7 @@ import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.core.AppService; import ru.kirillius.pf.sdn.core.AppService;
import ru.kirillius.pf.sdn.core.Auth.AuthManager; import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Context; import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.RPC.Auth; import ru.kirillius.pf.sdn.web.RPC.*;
import ru.kirillius.pf.sdn.web.RPC.NetworkManager;
import ru.kirillius.pf.sdn.web.RPC.RPC;
import ru.kirillius.pf.sdn.web.RPC.SubscriptionManager;
import ru.kirillius.pf.sdn.web.RPC.System; import ru.kirillius.pf.sdn.web.RPC.System;
import ru.kirillius.utils.logging.SystemLogger; import ru.kirillius.utils.logging.SystemLogger;
@ -28,7 +24,6 @@ import java.util.Set;
*/ */
public class WebService extends AppService { public class WebService extends AppService {
/** /**
* Stops the embedded HTTP server. * Stops the embedded HTTP server.
*/ */
@ -73,7 +68,7 @@ public class WebService extends AppService {
this.addConnector(connector); this.addConnector(connector);
var servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS); var servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS);
servletContext.addServlet(webhookServlet, WebhookServlet.CONTEXT_PATH);
servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH); servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH);
var holder = servletContext.addServlet(DefaultServlet.class, "/"); var holder = servletContext.addServlet(DefaultServlet.class, "/");
try { try {
@ -81,6 +76,7 @@ public class WebService extends AppService {
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
this.setHandler(servletContext); this.setHandler(servletContext);
start(); start();
@ -98,21 +94,18 @@ public class WebService extends AppService {
} }
} }
var isProtectedAccess = call.getMethod().getAnnotation(ProtectedMethod.class); var isProtectedAccess = call.getMethod().getAnnotation(ProtectedMethod.class);
if (isProtectedAccess != null) { if (isProtectedAccess != null) {
if (!authorized) throw new SecurityException("Forbidden"); if (!authorized) throw new SecurityException("Forbidden");
} }
}); });
for (var handlerClass : RPCHandlerTypes) { for (var handlerClass : RPCHandlerTypes) {
var instance = RPC.instantiate(handlerClass, context); var instance = RPC.instantiate(handlerClass, context);
//noinspection unchecked //noinspection unchecked
JSONRPC.addTargetInstance((Class<? super RPC>) handlerClass, instance); JSONRPC.addTargetInstance((Class<? super RPC>) handlerClass, instance);
} }
JSONRPC.getErrorHandler().add(throwable -> { JSONRPC.getErrorHandler().add(throwable -> {
SystemLogger.error("JRPC Request " + SystemLogger.error("JRPC Request " +
(throwable.getRequestData() == null ? "" : throwable.getRequestData().toString()) + (throwable.getRequestData() == null ? "" : throwable.getRequestData().toString()) +
@ -122,12 +115,13 @@ public class WebService extends AppService {
} }
} }
private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(Auth.class, NetworkManager.class, SubscriptionManager.class, System.class); private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(Auth.class, NetworkManager.class, SubscriptionManager.class, System.class);
@Getter @Getter
private final JSONRPCServlet JSONRPC = new JSONRPCServlet(); private final JSONRPCServlet JSONRPC = new JSONRPCServlet();
private final HTTPServer httpServer; private final HTTPServer httpServer;
@Getter
private final WebhookServlet webhookServlet = new WebhookServlet();
/** /**
* Starts the web service and publishes the JSON-RPC servlet. * Starts the web service and publishes the JSON-RPC servlet.

View File

@ -0,0 +1,55 @@
package ru.kirillius.pf.sdn.web;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import ru.kirillius.java.utils.events.ConcurrentEventHandler;
import ru.kirillius.java.utils.events.EventHandler;
import ru.kirillius.java.utils.events.EventListener;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
public class WebhookServlet extends HttpServlet {
public final static String PATH = "/webhook/";
public final static String CONTEXT_PATH = PATH + "*";
public record RequestContext(HttpServletRequest request, HttpServletResponse response) {
}
private final Map<String, EventHandler<RequestContext>> handlers = new ConcurrentHashMap<>();
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
var path = req.getRequestURI();
if (path.startsWith(PATH)) {
var name = path.substring(PATH.length()).split(Pattern.quote("/"))[0];
if (handlers.containsKey(name)) {
try {
handlers.get(name).invoke(new RequestContext(req, resp));
return;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
super.service(req, resp);
}
public EventListener<RequestContext> registerHook(String name, EventListener<RequestContext> listener) {
if (!handlers.containsKey(name)) {
handlers.put(name, new ConcurrentEventHandler<>());
}
return handlers.get(name).add(listener);
}
public void unregisterHook(String name, EventListener<RequestContext> listener) {
if (!handlers.containsKey(name)) {
return;
}
handlers.get(name).remove(listener);
}
}

View File

@ -11,6 +11,7 @@ import { TDNSConfig } from '../pages/TDNS.js';
import { FRRConfig } from '../pages/FRR.js'; import { FRRConfig } from '../pages/FRR.js';
import { ROSConfig } from '../pages/ROS.js'; import { ROSConfig } from '../pages/ROS.js';
import { DNSMASQConfig } from '../pages/DNSMASQ.js'; import { DNSMASQConfig } from '../pages/DNSMASQ.js';
import { SRMPage } from '../pages/SRM.js';
import { SettingsPage } from '../pages/Settings.js'; import { SettingsPage } from '../pages/Settings.js';
import { LogsPage } from '../pages/Logs.js'; import { LogsPage } from '../pages/Logs.js';
import { NetworkResourcesPage } from '../pages/NetworkResources.js'; import { NetworkResourcesPage } from '../pages/NetworkResources.js';
@ -34,6 +35,7 @@ const allMenuItems = [
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' }, { 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: 'Настройка 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: 'Настройка DNSMASQ', path: 'dnsmasq', component: 'ru.kirillius.pf.sdn.External.API.Components.DNSMASQ' },
{ label: 'SRM', path: 'srm', component: 'ru.kirillius.pf.sdn.External.API.Components.SRM' },
{ label: 'Журнал', path: 'logs', component: null }, { label: 'Журнал', path: 'logs', component: null },
{ label: 'Сетевые ресурсы', path: 'network-resources', component: null }, { label: 'Сетевые ресурсы', path: 'network-resources', component: null },
{ label: 'Разблокировка сайта', path: 'unlock-site', component: null }, { label: 'Разблокировка сайта', path: 'unlock-site', component: null },
@ -92,6 +94,11 @@ const routes = {
mount: DNSMASQConfig.mount, mount: DNSMASQConfig.mount,
unmount: DNSMASQConfig.unmount unmount: DNSMASQConfig.unmount
}, },
'#srm': {
render: SRMPage.render,
mount: SRMPage.mount,
unmount: SRMPage.unmount
},
'#logs': { '#logs': {
render: LogsPage.render, render: LogsPage.render,
mount: LogsPage.mount, mount: LogsPage.mount,

41
webui/src/pages/SRM.js Normal file
View File

@ -0,0 +1,41 @@
import $ from 'jquery';
function getBaseUrl() {
return window.location.origin;
}
export const SRMPage = {
render: () => {
const baseUrl = getBaseUrl();
const updateUrl = `${baseUrl}/webhook/SRM/update`;
const subnetsUrl = `${baseUrl}/webhook/SRM/subnets`;
return `
<h1 class="page-title">SRM Webhooks</h1>
<div class="component-config-form">
<p class="hint-text" style="margin-bottom: 20px;">
SRM предоставляет HTTP webhook'ы для получения информации о текущем состоянии подсетей.
Настройка не требуется компонент отслеживает изменения автоматически.
</p>
<div class="form-group">
<label for="srm-update-url">Последнее обновление</label>
<textarea id="srm-update-url" class="form-control" readonly rows="1"
style="font-family: monospace; resize: vertical; cursor: text;"
onclick="this.select();">${updateUrl}</textarea>
<small class="hint-text">Возвращает временную метку (unix timestamp) последнего обновления списка подсетей.</small>
</div>
<div class="form-group" style="margin-top: 20px;">
<label for="srm-subnets-url">Список подсетей</label>
<textarea id="srm-subnets-url" class="form-control" readonly rows="1"
style="font-family: monospace; resize: vertical; cursor: text;"
onclick="this.select();">${subnetsUrl}</textarea>
<small class="hint-text">Возвращает текущий список подсетей в формате JSON array.</small>
</div>
</div>
`;
},
mount: async () => {},
unmount: () => {}
};