большой рефакторинг + вэбка

This commit is contained in:
kirillius 2025-09-30 19:33:09 +03:00
parent 32e65a7742
commit aa726d4653
63 changed files with 6384 additions and 349 deletions

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ build/
/.idea/
/.mvn/
/config.json
/web-server/src/main/webui/src/json-rpc.js
/.cache/
ovpn-connector.json

View File

@ -25,7 +25,6 @@
</dependency>
<!-- https://mvnrepository.com/artifact/org.eclipse.jgit/org.eclipse.jgit -->
<dependency>
<groupId>org.eclipse.jgit</groupId>
@ -40,6 +39,37 @@
<version>0.39.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-server -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>12.0.12</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>prepare-package</phase>
<goals>
<goal>java</goal>
</goals>
<configuration>
<mainClass>ru.kirillius.json.rpc.CodeGeneration.AutoGenerationUtility</mainClass>
<arguments>
<argument>--b=${basedir}</argument>
<argument>${project.basedir}/../webui/src/json-rpc.js</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,48 +1,182 @@
package ru.kirillius.pf.sdn;
import lombok.Getter;
import lombok.SneakyThrows;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
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.TDNS;
import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Auth.TokenStorage;
import ru.kirillius.pf.sdn.core.*;
import ru.kirillius.pf.sdn.core.Component;
import ru.kirillius.pf.sdn.core.Networking.ASInfoService;
import ru.kirillius.pf.sdn.core.Networking.NetworkManager;
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionManager;
import ru.kirillius.pf.sdn.core.Util.Wait;
import ru.kirillius.pf.sdn.web.HTTPServer;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
public class App extends AppContext {
public class App implements Context, Closeable {
private final static File configFile = new File("config.json");
protected final static String CTX = App.class.getSimpleName();
static {
SystemLogger.initializeLogging(Level.INFO, List.of(InMemoryLogHandler.class));
SystemLogger.setExceptionDumping(true);
}
private final AtomicBoolean shouldRestart = new AtomicBoolean(false);
private final AtomicBoolean running = new AtomicBoolean(true);
@Getter
private final NetworkManager networkManager;
@Getter
private Config config;
@Getter
private final AuthManager authManager;
@Getter
private final HTTPServer server;
@Getter
private final ASInfoService ASInfoService;
@Getter
private final SubscriptionManager subscriptionManager;
@Getter
private final UpdateManager updateManager;
@Getter
private final TokenStorage tokenStorage;
@Getter
private final ContextEventsHandler EventsHandler = new ContextEventsHandler();
private final List<Component<?>> loadedComponents = new ArrayList<>();
@SneakyThrows
public App(File configFile) {
super(configFile);
try {
config = Config.load(configFile);
} catch (IOException e) {
config = new Config();
try {
Config.store(config, configFile);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
authManager = new AuthManager(this);
ASInfoService = new ASInfoService();
ASInfoService.setProvider(new HEInfoProvider(this));
networkManager = new NetworkManager(this);
networkManager.getInputResources().add(config.getCustomResources());
subscriptionManager = new SubscriptionManager(this);
updateManager = new UpdateManager(this);
tokenStorage = new TokenStorage(this);
subscribe();
updateManager.start();
initComponents();
server = new HTTPServer(this);
if (config.getPasswordHash() == null || config.getPasswordHash().isEmpty()) {
SystemLogger.error("There is no password for admin. Setting default password: admin", CTX);
getAuthManager().updatePassword("admin");
}
getSubscriptionManager().triggerUpdate();
}
static {
SystemLogger.initializeLogging(Level.INFO, Collections.emptyList());
}
public static void main(String[] args) {
try (App app = new App(configFile)) {
app.getEventsHandler().getNetworkManagerUpdateEvent().add(new EventListener<NetworkResourceBundle>() {
@Override
public void invoke(NetworkResourceBundle bundle) throws Exception {
SystemLogger.message("Network resource bundle updated.", CTX);
}
});
while (true) {
Thread.yield();
var restart = false;
do {
try (var app = new App(configFile)) {
Wait.when(app.running::get);
restart = app.shouldRestart.get();
} catch (Exception e) {
SystemLogger.error("Unhandled error", CTX, e);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
} while (restart);
}
public void triggerRestart() {
SystemLogger.message("Restarting app", CTX);
running.set(false);
shouldRestart.set(true);
}
}
public void triggerShutdown() {
SystemLogger.message("Shutting down app", CTX);
running.set(false);
}
public Collection<Class<? extends Component<?>>> getComponentClasses() {
return List.of(FRR.class, OVPN.class, TDNS.class);
}
public void initComponents() {
var enabledPlugins = config.getEnabledComponents();
(List.copyOf(loadedComponents)).forEach(plugin -> {
if (!enabledPlugins.contains(plugin.getClass())) {
SystemLogger.message("Unloading plugin: " + plugin.getClass().getSimpleName(), CTX);
try {
plugin.close();
} catch (IOException e) {
SystemLogger.error("Error on plugin unload", CTX, e);
} finally {
loadedComponents.remove(plugin);
}
}
});
var loadedClasses = loadedComponents.stream().map(plugin -> plugin.getClass()).toList();
enabledPlugins.forEach(pluginClass -> {
if (loadedClasses.contains(pluginClass)) {
return;
}
SystemLogger.message("Loading plugin: " + pluginClass.getSimpleName(), CTX);
var plugin = Component.loadPlugin(pluginClass, this);
loadedComponents.add(plugin);
});
}
private void subscribe() {
var eventsHandler = getEventsHandler();
eventsHandler.getSubscriptionsUpdateEvent().add(bundle -> {
var manager = getNetworkManager();
var inputResources = getNetworkManager().getInputResources();
inputResources.clear();
inputResources.add(config.getCustomResources());
inputResources.add(bundle);
manager.triggerUpdate(false);
});
}
@Override
public Component<?> getPluginInstance(Class<? extends Component<?>> pluginClass) {
return loadedComponents.stream().filter(plugin -> plugin.getClass().equals(pluginClass)).findFirst().orElse(null);
}
@Override
public void close() throws IOException {
loadedComponents.forEach(plugin -> {
try {
plugin.close();
} catch (IOException e) {
SystemLogger.error("Error closing plugin", CTX, e);
}
});
ASInfoService.close();
networkManager.close();
try {
server.stop();
} catch (Exception e) {
SystemLogger.error("Error stopping server", CTX, e);
}
}
}

View File

@ -1,106 +0,0 @@
package ru.kirillius.pf.sdn;
import lombok.Getter;
import org.eclipse.jetty.server.Server;
import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Config;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.ContextEventsHandler;
import ru.kirillius.pf.sdn.core.Networking.ASInfoService;
import ru.kirillius.pf.sdn.core.Networking.NetworkManager;
import ru.kirillius.pf.sdn.core.Plugin;
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionManager;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class AppContext implements Context, Closeable {
protected final static String CTX = AppContext.class.getSimpleName();
public AppContext(File configFile) {
try {
config = Config.load(configFile);
} catch (IOException e) {
config = new Config();
try {
Config.store(config, configFile);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
authManager = new AuthManager(this);
server = new Server();
ASInfoService = new ASInfoService();
ASInfoService.setProvider(new HEInfoProvider(this));
networkManager = new NetworkManager(this);
networkManager.getInputResources().add(config.getCustomResources());
subscriptionManager = new SubscriptionManager(this);
subscribe();
initPlugins();
}
private void initPlugins() {
config.getEnabledPlugins().forEach(pluginClass -> {
var plugin = Plugin.loadPlugin(pluginClass, this);
loadedPlugins.add(plugin);
});
}
private final List<Plugin<?>> loadedPlugins = new ArrayList<>();
private void subscribe() {
var eventsHandler = getEventsHandler();
eventsHandler.getSubscriptionsUpdateEvent().add(bundle -> {
var manager = getNetworkManager();
var inputResources = getNetworkManager().getInputResources();
inputResources.clear();
inputResources.add(config.getCustomResources());
inputResources.add(bundle);
manager.triggerUpdate();
});
}
@Getter
private final NetworkManager networkManager;
@Getter
private Config config;
@Getter
private final AuthManager authManager;
@Getter
private final Server server;
@Getter
private final ASInfoService ASInfoService;
@Getter
private final SubscriptionManager subscriptionManager;
@Override
public void close() throws IOException {
loadedPlugins.forEach(plugin -> {
try {
plugin.close();
} catch (IOException e) {
SystemLogger.error("Error closing plugin", CTX, e);
}
});
ASInfoService.close();
networkManager.close();
try {
server.stop();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Getter
private final ContextEventsHandler EventsHandler = new ContextEventsHandler();
}

View File

@ -1,11 +1,12 @@
package ru.kirillius.pf.sdn.External.API;
package ru.kirillius.pf.sdn.External.API.Components;
import lombok.*;
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.AbstractPlugin;
import ru.kirillius.pf.sdn.External.API.ShellExecutor;
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;
@ -17,13 +18,13 @@ import java.util.List;
import java.util.function.Consumer;
import java.util.regex.Pattern;
public final class FRRPlugin extends AbstractPlugin<FRRPlugin.FRRConfig> {
public final class FRR extends AbstractComponent<FRR.FRRConfig> {
private final static String SUBNET_PATTERN = "{%subnet}";
private final static String GW_PATTERN = "{%gateway}";
private final static String CTX = FRRPlugin.class.getSimpleName();
private final static String CTX = FRR.class.getSimpleName();
private final EventListener<NetworkResourceBundle> subscription;
public FRRPlugin(Context context) {
public FRR(Context context) {
super(context);
subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateSubnets(bundle.getSubnets()));
}

View File

@ -0,0 +1,72 @@
package ru.kirillius.pf.sdn.External.API.Components;
import lombok.Getter;
import lombok.Setter;
import org.json.JSONArray;
import org.json.JSONObject;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.json.JSONProperty;
import ru.kirillius.json.JSONSerializable;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.External.API.ShellExecutor;
import ru.kirillius.pf.sdn.core.AbstractComponent;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Util.IPv4Util;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.IOException;
public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
private final static String CTX = OVPN.class.getSimpleName();
private final EventListener<JSONRPCServlet> subscription;
public OVPN(Context context) {
super(context);
subscription = context.getEventsHandler().getRPCInitEvent()
.add(servlet -> servlet.addTargetInstance(OVPN.class, OVPN.this));
}
@JRPCMethod
@ProtectedMethod
public void restartSystemService() {
try (var shell = new ShellExecutor(config.shellConfig)) {
shell.executeCommand(new String[]{config.restartCommand});
} catch (IOException e) {
SystemLogger.error("Error when trying to restart OVPN", CTX, e);
}
}
@JRPCMethod
@ProtectedMethod
public JSONArray getManagedRoutes() {
var array = new JSONArray();
context.getNetworkManager().getOutputResources().getSubnets().stream().map(subnet -> {
var json = new JSONObject();
json.put("address", subnet.getAddress());
json.put("mask", IPv4Util.maskToString(IPv4Util.calculateMask(subnet.getPrefixLength())));
return json;
}).forEach(array::put);
return array;
}
@Override
public void close() throws IOException {
context.getEventsHandler().getRPCInitEvent().remove(subscription);
}
@JSONSerializable
public static class OVPNConfig {
@Getter
@Setter
@JSONProperty
private ShellExecutor.Config shellConfig = new ShellExecutor.Config();
@Getter
@Setter
@JSONProperty
private String restartCommand = "rc-service openvpn restart";
}
}

View File

@ -1,11 +1,12 @@
package ru.kirillius.pf.sdn.External.API;
package ru.kirillius.pf.sdn.External.API.Components;
import lombok.*;
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.AbstractPlugin;
import ru.kirillius.pf.sdn.External.API.TDNSAPI;
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;
@ -15,12 +16,12 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public final class TechnitiumPlugin extends AbstractPlugin<TechnitiumPlugin.TechnitiumConfig> {
public final class TDNS extends AbstractComponent<TDNS.TechnitiumConfig> {
private final static String CTX = TechnitiumPlugin.class.getSimpleName();
private final static String CTX = TDNS.class.getSimpleName();
private final EventListener<NetworkResourceBundle> subscription;
public TechnitiumPlugin(Context context) {
public TDNS(Context context) {
super(context);
subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateSubnets(bundle.getDomains()));
}
@ -28,10 +29,10 @@ public final class TechnitiumPlugin extends AbstractPlugin<TechnitiumPlugin.Tech
private void updateSubnets(List<String> domains) {
for (var instance : config.instances) {
SystemLogger.message("Updating zones on DNS server " + instance.server, CTX);
try (var api = new TechnitiumDNSAPI(instance.server, instance.token)) {
try (var api = new TDNSAPI(instance.server, instance.token)) {
var existingForwardZones = api.getZones().stream()
.filter(zoneResponse -> zoneResponse.getType() == TechnitiumDNSAPI.ZoneType.Forwarder)
.map(TechnitiumDNSAPI.ZoneResponse::getName)
.filter(zoneResponse -> zoneResponse.getType() == TDNSAPI.ZoneType.Forwarder)
.map(TDNSAPI.ZoneResponse::getName)
.collect(Collectors.toCollection(ArrayList::new));
existingForwardZones.forEach(zoneName -> {
if (!domains.contains(zoneName)) {

View File

@ -16,12 +16,12 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class TechnitiumDNSAPI implements Closeable {
public class TDNSAPI implements Closeable {
private final String server;
private final String authToken;
private final HttpClient httpClient;
public TechnitiumDNSAPI(String server, String authToken) {
public TDNSAPI(String server, String authToken) {
this.server = server;
httpClient = HttpClient.newBuilder().build();
this.authToken = authToken;

View File

@ -0,0 +1,40 @@
package ru.kirillius.pf.sdn;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.logging.Handler;
import java.util.logging.LogRecord;
public class InMemoryLogHandler extends Handler {
private final static Queue<String> queue = new ConcurrentLinkedQueue<>();
private final static DateFormat dateFormat = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss", Locale.US);
private String format(LogRecord logRecord) {
return "[" + dateFormat.format(new Date(logRecord.getMillis())) + "][" + logRecord.getLevel().getName() + "] " + logRecord.getMessage().trim();
}
public static Collection<String> getMessages() {
return Collections.unmodifiableCollection(queue);
}
@Override
public void publish(LogRecord logRecord) {
if (queue.size() >= 1000) {
queue.poll();
}
queue.add(format(logRecord));
}
@Override
public void flush() {
}
@Override
public void close() {
}
}

View File

@ -5,12 +5,14 @@ import org.eclipse.jetty.ee10.servlet.DefaultServlet;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
import org.jetbrains.annotations.Nullable;
import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.utils.logging.SystemLogger;
import ru.kirillius.pf.sdn.web.RPC.AuthRPC;
import ru.kirillius.pf.sdn.web.RPC.Auth;
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.utils.logging.SystemLogger;
import java.net.MalformedURLException;
import java.net.URL;
@ -23,8 +25,6 @@ public class HTTPServer extends Server {
private final static String LOG_CONTEXT = HTTPServer.class.getSimpleName();
private final static String TOKEN_HEADER = "X-Auth-token";
private final JSONRPCServlet JSONRPC = new JSONRPCServlet();
private String getResourceBase() throws MalformedURLException {
var resourceFile = getClass().getClassLoader().getResource("htdocs/index.html");
return new URL(Objects.requireNonNull(resourceFile).getProtocol(), resourceFile.getHost(), resourceFile.getPath()
@ -32,26 +32,35 @@ public class HTTPServer extends Server {
.toString();
}
private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(AuthRPC.class);
private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(Auth.class, NetworkManager.class, SubscriptionManager.class, System.class);
public HTTPServer(Context appContext, int port, @Nullable String host) throws Exception {
public HTTPServer(Context appContext) {
var config = appContext.getConfig();
var connector = new ServerConnector(this);
connector.setPort(port);
connector.setPort(config.getHttpPort());
var host = config.getHost();
if (host != null && !host.equals(ANY_HOST)) {
connector.setHost(host);
}
this.addConnector(connector);
ServletContextHandler servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS);
var servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS);
var JSONRPC = new JSONRPCServlet();
servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH);
var holder = servletContext.addServlet(DefaultServlet.class, "/");
holder.setInitParameter("resourceBase", getResourceBase());
try {
holder.setInitParameter("resourceBase", getResourceBase());
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
this.setHandler(servletContext);
start();
try {
start();
} catch (Exception e) {
throw new RuntimeException("Error starting HTTPServer", e);
}
JSONRPC.addRequestHandler((request, response, call) -> {
var authManager = appContext.getAuthManager();
@ -87,5 +96,13 @@ public class HTTPServer extends Server {
(throwable.getRequestData() == null ? "" : throwable.getRequestData().toString()) +
" has failed with error", LOG_CONTEXT, throwable.getError());
});
try {
appContext.getEventsHandler().getRPCInitEvent().invoke(JSONRPC);
} catch (Exception e) {
SystemLogger.error("Error on RPC init event", CTX, e);
}
}
private final static String CTX = HTTPServer.class.getSimpleName();
}

View File

@ -0,0 +1,93 @@
package ru.kirillius.pf.sdn.web.RPC;
import org.json.JSONArray;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCContext;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.json.rpc.Servlet.CallContext;
import ru.kirillius.pf.sdn.core.Auth.AuthToken;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
import java.util.Date;
public class Auth implements RPC {
private final Context context;
public Auth(Context context) {
this.context = context;
}
@ProtectedMethod
@JRPCMethod
public JSONArray listTokens() {
return JSONUtility.serializeCollection(context.getTokenStorage().getTokens(), AuthToken.class, null);
}
@ProtectedMethod
@JRPCMethod
public void removeToken(@JRPCArgument(name = "token") String token) {
context.getTokenStorage().remove(new AuthToken(token));
}
@ProtectedMethod
@JRPCMethod
public String createAPIToken(@JRPCArgument(name = "description") String description) {
var token = new AuthToken();
token.setDescription(description);
context.getTokenStorage().add(token);
return token.getToken();
}
@ProtectedMethod
@JRPCMethod
public String createToken(@JRPCContext CallContext call) {
var UA = call.getRequest().getHeader("User-Agent");
if (UA == null) {
UA = "Unknown user agent";
}
var authManager = context.getAuthManager();
var token = authManager.createToken(UA +" "+ new Date());
authManager.setSessionToken(call.getSession(), token);
return token.getToken();
}
@JRPCMethod
public boolean startSessionByPassword(@JRPCArgument(name = "password") String password, @JRPCContext CallContext call) {
var authManager = context.getAuthManager();
if (authManager.validatePassword(password)) {
authManager.setSessionAuthState(call.getSession(), true);
return true;
}
return false;
}
@JRPCMethod
public boolean startSessionByToken(@JRPCArgument(name = "token") String token, @JRPCContext CallContext call) {
var authManager = context.getAuthManager();
if (authManager.validateToken(token)) {
authManager.setSessionAuthState(call.getSession(), true);
return true;
}
return false;
}
@JRPCMethod
public boolean isAuthenticated(@JRPCContext CallContext call) {
var authManager = context.getAuthManager();
return authManager.getSessionAuthState(call.getSession());
}
@JRPCMethod
public void logout(@JRPCContext CallContext call) {
var authManager = context.getAuthManager();
authManager.setSessionAuthState(call.getSession(), false);
var token = authManager.getSessionToken(call.getSession());
if (token != null) {
authManager.invalidateToken(token);
authManager.setSessionToken(call.getSession(), null);
}
}
}

View File

@ -0,0 +1,34 @@
package ru.kirillius.pf.sdn.web.RPC;
import org.json.JSONObject;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
public class NetworkManager implements RPC {
private final Context context;
public NetworkManager(Context context) {
this.context = context;
}
@JRPCMethod
@ProtectedMethod
public boolean isUpdating() {
return context.getNetworkManager().isUpdatingNow();
}
@JRPCMethod
@ProtectedMethod
public void triggerUpdate(@JRPCArgument(name = "ignoreCache") boolean ignoreCache) {
context.getNetworkManager().triggerUpdate(ignoreCache);
}
@JRPCMethod
@ProtectedMethod
public JSONObject getOutputResources() {
return JSONUtility.serializeStructure(context.getNetworkManager().getOutputResources());
}
}

View File

@ -0,0 +1,51 @@
package ru.kirillius.pf.sdn.web.RPC;
import org.json.JSONArray;
import org.json.JSONObject;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
import java.util.stream.Collectors;
public class SubscriptionManager implements RPC {
private final Context context;
public SubscriptionManager(Context context) {
this.context = context;
}
@JRPCMethod
@ProtectedMethod
public boolean isUpdating() {
return context.getSubscriptionManager().isUpdatingNow();
}
@JRPCMethod
@ProtectedMethod
public void triggerUpdate() {
context.getSubscriptionManager().triggerUpdate();
}
@JRPCMethod
@ProtectedMethod
public JSONArray getSubscribedResources() {
return JSONUtility.serializeCollection(context.getConfig().getSubscribedResources(), String.class, null);
}
@JRPCMethod
@ProtectedMethod
public JSONObject getAvailableResources() {
return JSONUtility.serializeMap(context.getSubscriptionManager().getAvailableResources(), String.class, NetworkResourceBundle.class, null, null);
}
@JRPCMethod
@ProtectedMethod
public void setSubscribedResources(@JRPCArgument(name = "resources") JSONArray subscribedResources) {
context.getConfig().setSubscribedResources(JSONUtility.deserializeCollection(subscribedResources, String.class, null).collect(Collectors.toList()));
triggerUpdate();
}
}

View File

@ -0,0 +1,80 @@
package ru.kirillius.pf.sdn.web.RPC;
import org.json.JSONArray;
import org.json.JSONObject;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.pf.sdn.core.Component;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
import java.util.stream.Collectors;
public class System implements RPC {
private final Context context;
public System(Context context) {
this.context = context;
}
@ProtectedMethod
@JRPCMethod
public void restart() {
context.triggerRestart();
}
@ProtectedMethod
@JRPCMethod
public void shutdown() {
context.triggerShutdown();
}
@ProtectedMethod
@JRPCMethod
public JSONObject getConfig() {
return JSONUtility.serializeStructure(context.getConfig());
}
@ProtectedMethod
@JRPCMethod
public boolean isConfigChanged() {
return context.getConfig().isModified();
}
@ProtectedMethod
@JRPCMethod
public boolean hasUpdates() {
return true;
}
@ProtectedMethod
@JRPCMethod
public JSONArray getEnabledComponents() {
return JSONUtility.serializeCollection(context.getConfig().getEnabledComponents().stream().map(Class::getName).toList(), String.class, null);
}
@SuppressWarnings("unchecked")
@ProtectedMethod
@JRPCMethod
public void setEnabledComponents(@JRPCArgument(name = "components") JSONArray components) {
context.getConfig().setEnabledComponents(JSONUtility.deserializeCollection(components, String.class, null).map(s -> {
try {
return (Class<? extends Component<?>>) Class.forName(s);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Unable to load Component class " + s, e);
}
}).collect(Collectors.toList()));
context.initComponents();
}
@ProtectedMethod
@JRPCMethod
public JSONArray getAvailableComponents() {
return JSONUtility.serializeCollection(context.getComponentClasses().stream().map(Class::getName).toList(), String.class, null);
}
}

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
test ok
</body>
</html>

View File

@ -11,5 +11,24 @@
<artifactId>core</artifactId>
<dependencies>
<dependency>
<groupId>ru.kirillius.utils</groupId>
<artifactId>common-logging</artifactId>
<version>1.3.0.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
</project>

View File

@ -1,11 +1,11 @@
package ru.kirillius.pf.sdn.core;
public abstract class AbstractPlugin<CT> implements Plugin<CT> {
public abstract class AbstractComponent<CT> implements Component<CT> {
protected final CT config;
protected final Context context;
@SuppressWarnings({"unchecked", "rawtypes"})
public AbstractPlugin(Context context) {
public AbstractComponent(Context context) {
config = (CT) context.getConfig().getPluginsConfig().getConfig((Class) getClass());
this.context = context;
}

View File

@ -3,13 +3,17 @@ package ru.kirillius.pf.sdn.core.Auth;
import jakarta.servlet.http.HttpSession;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Util.HashUtil;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;
public class AuthManager {
private final Context context;
public final static String SESSION_AUTH_KEY = "auth";
public final static String SESSION_TOKEN = "token";
private final static String CTX = AuthManager.class.getSimpleName();
private final Context context;
public AuthManager(Context context) {
this.context = context;
@ -22,22 +26,27 @@ public class AuthManager {
public void updatePassword(String pass) {
var config = context.getConfig();
config.setPasswordSalt(UUID.randomUUID().toString());
config.setPasswordHash(
HashUtil.hash(pass, config.getPasswordSalt())
);
}
public AuthToken createToken(String description) {
var config = context.getConfig();
var token = new AuthToken();
token.setDescripton(description);
config.getTokens().add(token);
config.update();
token.setDescription(description);
var tokenStorage = context.getTokenStorage();
tokenStorage.add(token);
try {
tokenStorage.store();
} catch (IOException e) {
SystemLogger.error("Failed to save token storage", CTX, e);
}
return token;
}
public boolean validateToken(AuthToken token) {
return context.getConfig().getTokens().contains(token);
return context.getTokenStorage().contains(token);
}
public void setSessionAuthState(HttpSession session, boolean state) {
@ -57,9 +66,16 @@ public class AuthManager {
}
public void invalidateToken(AuthToken token) {
var config = context.getConfig();
config.getTokens().remove(token);
config.update();
var tokenStorage = context.getTokenStorage();
if (tokenStorage.contains(token)) {
tokenStorage.remove(token);
try {
tokenStorage.store();
} catch (IOException e) {
SystemLogger.error("Failed to save token storage", CTX, e);
}
}
}
public boolean validateToken(String token) {

View File

@ -19,7 +19,7 @@ public class AuthToken {
@Setter
@Getter
@JSONProperty
private String descripton = "untitled";
private String description = "untitled";
@Override
public boolean equals(Object o) {

View File

@ -0,0 +1,64 @@
package ru.kirillius.pf.sdn.core.Auth;
import org.json.JSONArray;
import org.json.JSONTokener;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.pf.sdn.core.Context;
import java.io.*;
import java.util.*;
import java.util.stream.Collectors;
public class TokenStorage {
private final File file;
public TokenStorage(Context context) {
file = new File(context.getConfig().getCacheDirectory(), "tokens.json");
if (file.exists()) {
try (var stream = new FileInputStream(file)) {
var json = new JSONArray(new JSONTokener(stream));
tokens = JSONUtility.deserializeCollection(json, AuthToken.class, null).collect(Collectors.toList());
} catch (IOException e) {
throw new RuntimeException("Failed to read token storage", e);
}
} else {
tokens = new ArrayList<>();
}
}
private final List<AuthToken> tokens;
public Collection<AuthToken> getTokens() {
synchronized (tokens) {
return Collections.unmodifiableList(tokens);
}
}
public void add(AuthToken... what) {
synchronized (tokens) {
Collections.addAll(tokens, what);
}
}
public boolean contains(AuthToken what) {
synchronized (tokens) {
return tokens.contains(what);
}
}
public void remove(AuthToken... what) {
synchronized (tokens) {
Arrays.stream(what).forEach(tokens::remove);
}
}
public synchronized void store() throws IOException {
try (var fileInputStream = new FileOutputStream(file)) {
try (var writer = new BufferedWriter(new OutputStreamWriter(fileInputStream))) {
writer.write(JSONUtility.serializeCollection(tokens, AuthToken.class, null).toString());
writer.flush();
}
}
}
}

View File

@ -5,16 +5,16 @@ import lombok.SneakyThrows;
import java.io.Closeable;
import java.lang.reflect.ParameterizedType;
public interface Plugin<CT> extends Closeable {
public interface Component<CT> extends Closeable {
@SuppressWarnings("unchecked")
static <T> Class<T> getConfigClass(Class<? extends Plugin<T>> pluginClass) {
static <T> Class<T> getConfigClass(Class<? extends Component<T>> pluginClass) {
var genericSuperclass = (ParameterizedType) pluginClass.getGenericSuperclass();
var typeArguments = genericSuperclass.getActualTypeArguments();
return (Class<T>) typeArguments[0];
}
@SneakyThrows
static <T extends Plugin<?>> T loadPlugin(Class<T> pluginClass, Context context) {
static <T extends Component<?>> T loadPlugin(Class<T> pluginClass, Context context) {
return pluginClass.getConstructor(Context.class).newInstance(context);
}
}

View File

@ -5,8 +5,10 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import org.json.JSONObject;
import org.json.JSONTokener;
import ru.kirillius.json.*;
import ru.kirillius.pf.sdn.core.Auth.AuthToken;
import ru.kirillius.json.JSONArrayProperty;
import ru.kirillius.json.JSONProperty;
import ru.kirillius.json.JSONSerializable;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.pf.sdn.core.Subscription.RepositoryConfig;
@ -19,6 +21,21 @@ import java.util.UUID;
@NoArgsConstructor
@JSONSerializable
public class Config {
@Getter
@Setter
@JSONProperty
private int updateSubscriptionsInterval = 6;
@Getter
@Setter
@JSONProperty
private boolean cachingAS = true;
@Getter
@Setter
@JSONProperty
private int updateASInterval = 12;
@Getter
private File loadedConfigFile = null;
@Setter
@ -31,11 +48,6 @@ public class Config {
@JSONProperty
private File cacheDirectory = new File("./.cache");
@Setter
@Getter
@JSONArrayProperty(type = AuthToken.class)
private List<AuthToken> tokens = Collections.emptyList();
@Setter
@Getter
@JSONArrayProperty(type = RepositoryConfig.class)
@ -55,7 +67,7 @@ public class Config {
@Setter
@Getter
@JSONArrayProperty(type = Class.class, required = false)
private List<Class<? extends Plugin<?>>> enabledPlugins = new ArrayList<>();
private List<Class<? extends Component<?>>> enabledComponents = new ArrayList<>();
@Setter
@Getter
@ -92,14 +104,6 @@ public class Config {
@JSONProperty
private NetworkResourceBundle filteredResources = new NetworkResourceBundle();
public void update() {
try {
store(this, loadedConfigFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void store(Config config, File file) throws IOException {
try (var fileInputStream = new FileOutputStream(file)) {
try (var writer = new BufferedWriter(new OutputStreamWriter(fileInputStream))) {
@ -122,9 +126,14 @@ public class Config {
var json = new JSONObject(new JSONTokener(stream));
var config = deserialize(json);
config.loadedConfigFile = file;
config.initialJSON = json;
return config;
}
}
private JSONObject initialJSON = new JSONObject();
public boolean isModified() {
return !initialJSON.equals(serialize(this));
}
}

View File

@ -1,21 +1,37 @@
package ru.kirillius.pf.sdn.core;
import org.eclipse.jetty.server.Server;
import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Auth.TokenStorage;
import ru.kirillius.pf.sdn.core.Networking.ASInfoService;
import ru.kirillius.pf.sdn.core.Networking.NetworkManager;
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionManager;
import java.util.Collection;
public interface Context {
Config getConfig();
AuthManager getAuthManager();
Server getServer();
ASInfoService getASInfoService();
NetworkManager getNetworkManager();
ContextEventsHandler getEventsHandler();
SubscriptionManager getSubscriptionManager();
UpdateManager getUpdateManager();
Component<?> getPluginInstance(Class<? extends Component<?>> pluginClass);
void triggerRestart();
void triggerShutdown();
TokenStorage getTokenStorage();
void initComponents();
Collection<Class<? extends Component<?>>> getComponentClasses();
}

View File

@ -3,6 +3,7 @@ package ru.kirillius.pf.sdn.core;
import lombok.Getter;
import ru.kirillius.java.utils.events.ConcurrentEventHandler;
import ru.kirillius.java.utils.events.EventHandler;
import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
public final class ContextEventsHandler {
@ -10,4 +11,6 @@ public final class ContextEventsHandler {
private final EventHandler<NetworkResourceBundle> networkManagerUpdateEvent = new ConcurrentEventHandler<>();
@Getter
private final EventHandler<NetworkResourceBundle> subscriptionsUpdateEvent = new ConcurrentEventHandler<>();
@Getter
private final EventHandler<JSONRPCServlet> RPCInitEvent = new ConcurrentEventHandler<>();
}

View File

@ -1,28 +1,42 @@
package ru.kirillius.pf.sdn.core.Networking;
import lombok.Getter;
import org.json.JSONObject;
import org.json.JSONTokener;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Util.IPv4Util;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.Closeable;
import java.io.IOException;
import java.io.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public class NetworkManager implements Closeable {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final static String CTX = NetworkManager.class.getSimpleName();
private final Context context;
private final File cacheFile;
public NetworkManager(Context context) {
this.context = context;
cacheFile = new File(context.getConfig().getCacheDirectory(), "as-cache.json");
if (cacheFile.exists() && context.getConfig().isCachingAS()) {
SystemLogger.message("Loading as cache file", CTX);
try (var is = new FileInputStream(cacheFile)) {
var json = new JSONObject(new JSONTokener(is));
json.keySet().forEach(key -> {
var as = Integer.parseInt(key);
prefixCache.put(as, JSONUtility.deserializeCollection(json.getJSONArray(key), IPv4Subnet.class, null).toList());
});
} catch (Exception e) {
SystemLogger.error("Failed to load as cache file " + cacheFile.getPath(), CTX, e);
}
}
}
private final AtomicReference<Future<?>> updateProcess = new AtomicReference<>();
private final AtomicBoolean cacheInvalid = new AtomicBoolean(true);
@Getter
private final NetworkResourceBundle inputResources = new NetworkResourceBundle();
@Getter
@ -35,7 +49,7 @@ public class NetworkManager implements Closeable {
private final Map<Integer, List<IPv4Subnet>> prefixCache = new ConcurrentHashMap<>();
public void triggerUpdate() {
public void triggerUpdate(boolean ignoreCache) {
if (isUpdatingNow()) {
return;
}
@ -48,8 +62,23 @@ public class NetworkManager implements Closeable {
var asn = new ArrayList<>(inputResources.getASN());
asn.removeAll(filteredResources.getASN());
if (cacheInvalid.get()) {
fetchPrefixes(asn);
var asnToFetch = new ArrayList<>(asn);
if (!ignoreCache) {
asnToFetch.removeAll(prefixCache.keySet());
}
fetchPrefixes(asnToFetch);
if (config.isCachingAS()) {
try (var os = new FileOutputStream(cacheFile)) {
var json = new JSONObject();
prefixCache.forEach((key, asnList) -> {
json.put(String.valueOf(key), JSONUtility.serializeCollection(asnList, IPv4Subnet.class, null));
});
os.write(json.toString().getBytes());
} catch (IOException e) {
SystemLogger.error("Unable to write file " + cacheFile.getPath(), CTX, e);
}
}
var subnets = new HashSet<>(inputResources.getSubnets());
@ -100,14 +129,10 @@ public class NetworkManager implements Closeable {
}
public void invalidateCache() {
cacheInvalid.set(true);
}
private void fetchPrefixes(List<Integer> systems) {
var service = context.getASInfoService();
systems.forEach(as -> {
SystemLogger.message("Fetching AS" + as + " prefixes...", CTX);
var service = context.getASInfoService();
var future = service.getPrefixes(as);
while (!future.isDone() && !future.isCancelled()) {

View File

@ -36,7 +36,7 @@ public class PluginConfigStorage {
try {
var pluginClass = loader.loadClass(key);
var value = json.getJSONObject(key);
var configClass = Plugin.getConfigClass((Class) pluginClass);
var configClass = Component.getConfigClass((Class) pluginClass);
storage.configs.put((Class)pluginClass, JSONUtility.deserializeStructure(value, configClass));
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
@ -46,13 +46,13 @@ public class PluginConfigStorage {
}
}
private final Map<Class<? extends Plugin<?>>, Object> configs = new ConcurrentHashMap<>();
private final Map<Class<? extends Component<?>>, Object> configs = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
@SneakyThrows
public <CT> CT getConfig(Class<? extends Plugin<CT>> pluginClass) {
public <CT> CT getConfig(Class<? extends Component<CT>> pluginClass) {
if (!configs.containsKey(pluginClass)) {
var configClass = Plugin.getConfigClass(pluginClass);
var configClass = Component.getConfigClass(pluginClass);
var instance = configClass.getConstructor().newInstance();
configs.put(pluginClass, instance);
}

View File

@ -3,9 +3,11 @@ package ru.kirillius.pf.sdn.core.Subscription;
import lombok.Getter;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.Closeable;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@ -34,12 +36,17 @@ public class SubscriptionManager implements Closeable {
return future != null && !future.isDone() && !future.isCancelled();
}
@Getter
private final Map<String, NetworkResourceBundle> availableResources = new ConcurrentHashMap<>();
public synchronized void triggerUpdate() {
if (isUpdatingNow()) {
return;
}
updateProcess.set(executor.submit(() -> {
var available = new HashMap<String, NetworkResourceBundle>();
var bundle = new NetworkResourceBundle();
var config = context.getConfig();
@ -61,20 +68,24 @@ public class SubscriptionManager implements Closeable {
if (subscribedResources.contains(resourceName)) {
bundle.add(resources.get(key));
}
available.put(resourceName, resources.get(key));
});
}
availableResources.clear();
availableResources.putAll(available);
outputResources.clear();
outputResources.add(bundle);
try {
context.getEventsHandler().getSubscriptionsUpdateEvent().invoke(outputResources);
} catch (Exception e) {
throw new RuntimeException(e); //FIXME to LOG
SystemLogger.error("Error on update event", CTX, e);
}
}));
}
private final static String CTX = SubscriptionManager.class.getSimpleName();
@Override
public void close() throws IOException {

View File

@ -0,0 +1,60 @@
package ru.kirillius.pf.sdn.core;
import lombok.SneakyThrows;
import ru.kirillius.pf.sdn.core.Util.Wait;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.Closeable;
import java.io.IOException;
import java.time.Duration;
public class UpdateManager implements Closeable {
private final Thread updateThread;
public UpdateManager(Context context) {
this.context = context;
updateThread = new Thread(new ThreadWorker());
}
public void start() {
updateThread.start();
}
private final Context context;
@Override
public void close() throws IOException {
updateThread.interrupt();
}
private final static String CTX = UpdateManager.class.getSimpleName();
private class ThreadWorker implements Runnable {
@SneakyThrows
@Override
public void run() {
var uptime = 0L;
var config = context.getConfig();
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofMinutes(1));
uptime++;
if (config.getUpdateSubscriptionsInterval() > 0 && uptime % (config.getUpdateSubscriptionsInterval()*60L) == 0) {
SystemLogger.message("Updating subscriptions", CTX);
var subscriptionManager = context.getSubscriptionManager();
subscriptionManager.triggerUpdate();
Wait.until(subscriptionManager::isUpdatingNow);
Wait.when(subscriptionManager::isUpdatingNow);
}
if (config.getUpdateASInterval() > 0 && uptime % (config.getUpdateASInterval() * 60L) == 0) {
SystemLogger.message("Updating cached AS", CTX);
var networkManager = context.getNetworkManager();
networkManager.triggerUpdate(true);
}
}
}
}
}

View File

@ -57,6 +57,14 @@ public class IPv4Util {
return ((ipLong >> 24) & 0xFF) + "." + ((ipLong >> 16) & 0xFF) + "." + ((ipLong >> 8) & 0xFF) + "." + (ipLong & 0xFF);
}
public static String maskToString(long maskLong) {
return String.format("%d.%d.%d.%d",
(maskLong >> 24) & 0xff,
(maskLong >> 16) & 0xff,
(maskLong >> 8) & 0xff,
maskLong & 0xff);
}
public interface SummarisationResult {
List<IPv4Subnet> getResult();
@ -97,7 +105,7 @@ public class IPv4Util {
}
if (parent.overlaps(subnet)) {
overlapped.add(subnet);
if(source.contains(subnet)) {
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
}
@ -135,10 +143,10 @@ public class IPv4Util {
result.remove(subnet);
result.remove(next);
result.add(largerSubnet);
if(source.contains(subnet)) {
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
if(source.contains(next)) {
if (source.contains(next)) {
mergedSubnets.add(next);
}
i++;
@ -215,7 +223,7 @@ public class IPv4Util {
if (100.0 * used.get() / candidate.count() >= usePercentage) {
//подсеть подходит под критерий
overlapped.forEach(subnet -> {
if(source.contains(subnet)) {
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
});

View File

@ -0,0 +1,32 @@
package ru.kirillius.pf.sdn.core.Util;
import java.time.Duration;
import java.util.function.Supplier;
public final class Wait {
private Wait() {
throw new AssertionError();
}
public static void until(Supplier<Boolean> condition) throws InterruptedException {
if (condition == null) {
return;
}
while (!condition.get() && !Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofSeconds(1));
}
}
public static void when(Supplier<Boolean> condition) throws InterruptedException {
if (condition == null) {
return;
}
while (condition.get() && !Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofSeconds(1));
}
}
}

64
ovpn-connector/pom.xml Normal file
View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>0.1.0.0</version>
</parent>
<artifactId>ovpn-connector</artifactId>
<properties>
<maven.compiler.source>24</maven.compiler.source>
<maven.compiler.target>24</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>module-info.class</exclude>
<exclude>JDOMAbout*class</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Main-Class>ru.kirillius.pf.sdn.External.API.ovpn.connector.App</Main-Class>
</manifestEntries>
</transformer>
</transformers>
<shadedArtifactAttached>true</shadedArtifactAttached> <!-- Make the shaded artifact not the main one -->
<shadedClassifierName>shaded</shadedClassifierName> <!-- set the suffix to the shaded jar -->
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,90 @@
package ru.kirillius.pf.sdn.External.API.ovpn.connector;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;
import ru.kirillius.json.JSONUtility;
import java.io.*;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class App {
private static Config loadConfig() {
var configFile = new File("ovpn-connector.json");
if (!configFile.exists()) {
throw new RuntimeException("Config file " + configFile.getAbsolutePath() + " does not exist");
}
try (var stream = new FileInputStream(configFile)) {
return JSONUtility.deserializeStructure(new JSONObject(new JSONTokener(stream)), Config.class);
} catch (IOException e) {
throw new RuntimeException("Failed to load config file", e);
}
}
private static void pushRoutes(JSONArray routes, File pushFile) {
try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(pushFile)))) {
for (var i = 0; i < routes.length(); i++) {
var entry = routes.getJSONObject(i);
var route = "push \"route " + entry.getString("address") + " " + entry.getString("mask") + "\"\n";
writer.write(route);
}
writer.flush();
} catch (IOException ex) {
throw new RuntimeException("Failed to write PUSH file", ex);
}
}
public static void main(String[] args) {
try {
var config = loadConfig();
if (args.length < 1) {
throw new RuntimeException("OVPN Push file is not specified in ARGS");
}
var pushFile = new File(args[0]);
var routes = sendRequest(config);
pushRoutes(routes, pushFile);
System.exit(0);
} catch (Exception e) {
e.printStackTrace(System.err);
System.exit(1);
}
}
private static JSONArray sendRequest(Config config) throws IOException, InterruptedException {
var json = new JSONObject();
json.put("jsonrpc", "2.0");
json.put("method", "ru.kirillius.pf.sdn.External.API.OVPNPlugin::getManagedRoutes");
json.put("params", new JSONObject());
json.put("id", 1);
try (var httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build()) {
// Создаем HTTP запрос
var request = HttpRequest.newBuilder()
.uri(URI.create(config.getHost() + "/jsonrpc/rpc"))
.header("Content-Type", "application/json")
.header("X-Auth-token", config.getToken())
.POST(HttpRequest.BodyPublishers.ofString(json.toString()))
.timeout(Duration.ofSeconds(30))
.build();
// Отправляем запрос и получаем ответ
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// Проверяем статус ответа
if (response.statusCode() != 200) {
throw new RuntimeException("HTTP error code: " + response.statusCode() + " body: " + response.body());
}
return new JSONArray(new JSONObject(response.body()).getJSONArray("result"));
} catch (ConnectException e) {
throw new RuntimeException("Unable to connect to remote server", e);
}
}
}

View File

@ -0,0 +1,16 @@
package ru.kirillius.pf.sdn.External.API.ovpn.connector;
import lombok.Getter;
import lombok.Setter;
import ru.kirillius.json.JSONProperty;
import ru.kirillius.json.JSONSerializable;
@JSONSerializable
@Getter
@Setter
public class Config {
@JSONProperty
private String token = "NO_TOKEN_IS_SET";
@JSONProperty
private String host = "http://127.0.0.1:8080";
}

76
pom.xml
View File

@ -9,10 +9,11 @@
<version>0.1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>web-client</module>
<module>core</module>
<module>web-server</module>
<module>app</module>
<module>ovpn-connector</module>
</modules>
<properties>
@ -21,6 +22,30 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.40</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
@ -53,46 +78,6 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.javatuples</groupId>
<artifactId>javatuples</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>9.2.0</version>
</dependency>
<dependency>
<groupId>ru.kirillius</groupId>
<artifactId>json-rpc-servlet</artifactId>
<version>2.1.4.0</version>
</dependency>
<dependency>
<groupId>ru.kirillius.utils</groupId>
<artifactId>common-logging</artifactId>
<version>1.3.0.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<version>2.0.9</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.eclipse.jetty/jetty-server -->
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
<version>12.0.12</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
@ -102,6 +87,13 @@
<scope>provided</scope>
</dependency>
<dependency>
<groupId>ru.kirillius</groupId>
<artifactId>json-rpc-servlet</artifactId>
<version>2.1.5.0</version>
</dependency>
</dependencies>
</project>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>0.1.0.0</version>
</parent>
<artifactId>web-client</artifactId>
</project>

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>0.1.0.0</version>
</parent>
<artifactId>web-server</artifactId>
<dependencies>
<dependency>
<groupId>ru.kirillius</groupId>
<artifactId>core</artifactId>
<version>0.1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -1,52 +0,0 @@
package ru.kirillius.pf.sdn.web.RPC;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCContext;
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.json.rpc.Servlet.CallContext;
import ru.kirillius.pf.sdn.core.Auth.AuthToken;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.ProtectedMethod;
public class AuthRPC implements RPC {
private final Context context;
public AuthRPC(Context context) {
this.context = context;
}
@ProtectedMethod
@JRPCMethod
public AuthToken getAuthToken(@JRPCContext CallContext call) {
return context.getAuthManager().getSessionToken(call.getSession());
}
@ProtectedMethod
@JRPCMethod
public AuthToken rememberCurrentUser(@JRPCContext CallContext call) {
var UA = call.getRequest().getHeader("User-Agent");
if (UA == null) {
UA = "Unknown user agent";
}
var authManager = context.getAuthManager();
var token = authManager.createToken(UA);
authManager.setSessionToken(call.getSession(), token);
return token;
}
@JRPCMethod
public boolean auth(@JRPCArgument(name = "password") String password, @JRPCContext CallContext call) {
var authManager = context.getAuthManager();
if (authManager.validatePassword(password)) {
authManager.setSessionAuthState(call.getSession(), true);
}
return false;
}
@JRPCMethod
public void logout(@JRPCContext CallContext call) {
var authManager = context.getAuthManager();
authManager.setSessionAuthState(call.getSession(), false);
}
}

30
webui/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

38
webui/README.md Normal file
View File

@ -0,0 +1,38 @@
# webui-vue
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

18
webui/index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="dark-theme">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDN Control</title>
</head>
<body>
<div id="app">
<div id="loader" style="height: 100vh; display: flex; justify-content: center; align-items: center; color: #fff;">
Загрузка...
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
webui/jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

3424
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
webui/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "webui",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"jquery": "^3.7.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass": "^1.93.2",
"vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2"
}
}

BIN
webui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

167
webui/src/json-rpc.js Normal file
View File

@ -0,0 +1,167 @@
export const JSONRPC = {
url: "/jsonrpc/rpc",
__id: 1,
/**
*
* @param method
* @param params
* @returns Object
* @private
*/
__invoke: async function (method, params) {
const request = await JSONRPC.__performRequest(method, params);
if (!request.success) {
console.error(request.result);
throw new Error("Failed to invoke method " + method + " with params " + JSON.stringify(params));
}
return request.result;
},
__performRequest: async function (method, params) {
const __this = this;
const resp = await fetch(
__this.url,
{
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
redirect: "follow",
referrerPolicy: "no-referrer",
body: JSON.stringify({
jsonrpc: '2.0',
method: method,
params: params,
id: __this.__id++
})
}
);
const success = resp.status === 200;
const result = (success ? (await resp.json()).result : {
"error": true,
"code": resp.status,
"status": resp.statusText,
"body": await resp.text()
});
return {
"result": result,
"success": success
};
}
};
export const TYPES = {};
JSONRPC.SubscriptionManager = {};
JSONRPC.SubscriptionManager.getSubscribedResources=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::getSubscribedResources", {});
};
JSONRPC.SubscriptionManager.getAvailableResources=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::getAvailableResources", {});
};
JSONRPC.SubscriptionManager.setSubscribedResources=async function(resources){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::setSubscribedResources", {"resources":resources});
};
JSONRPC.SubscriptionManager.isUpdating=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::isUpdating", {});
};
JSONRPC.SubscriptionManager.triggerUpdate=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::triggerUpdate", {});
};
JSONRPC.Auth = {};
JSONRPC.Auth.createToken=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::createToken", {});
};
JSONRPC.Auth.listTokens=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::listTokens", {});
};
JSONRPC.Auth.removeToken=async function(token){
return await JSONRPC.__invoke("web.RPC.Auth::removeToken", {"token":token});
};
JSONRPC.Auth.createAPIToken=async function(description){
return await JSONRPC.__invoke("web.RPC.Auth::createAPIToken", {"description":description});
};
JSONRPC.Auth.startSessionByPassword=async function(password){
return await JSONRPC.__invoke("web.RPC.Auth::startSessionByPassword", {"password":password});
};
JSONRPC.Auth.startSessionByToken=async function(token){
return await JSONRPC.__invoke("web.RPC.Auth::startSessionByToken", {"token":token});
};
JSONRPC.Auth.isAuthenticated=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::isAuthenticated", {});
};
JSONRPC.Auth.logout=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::logout", {});
};
JSONRPC.System = {};
JSONRPC.System.shutdown=async function(){
return await JSONRPC.__invoke("web.RPC.System::shutdown", {});
};
JSONRPC.System.getEnabledComponents=async function(){
return await JSONRPC.__invoke("web.RPC.System::getEnabledComponents", {});
};
JSONRPC.System.setEnabledComponents=async function(components){
return await JSONRPC.__invoke("web.RPC.System::setEnabledComponents", {"components":components});
};
JSONRPC.System.isConfigChanged=async function(){
return await JSONRPC.__invoke("web.RPC.System::isConfigChanged", {});
};
JSONRPC.System.hasUpdates=async function(){
return await JSONRPC.__invoke("web.RPC.System::hasUpdates", {});
};
JSONRPC.System.getAvailableComponents=async function(){
return await JSONRPC.__invoke("web.RPC.System::getAvailableComponents", {});
};
JSONRPC.System.getConfig=async function(){
return await JSONRPC.__invoke("web.RPC.System::getConfig", {});
};
JSONRPC.System.restart=async function(){
return await JSONRPC.__invoke("web.RPC.System::restart", {});
};
JSONRPC.NetworkManager = {};
JSONRPC.NetworkManager.getOutputResources=async function(){
return await JSONRPC.__invoke("web.RPC.NetworkManager::getOutputResources", {});
};
JSONRPC.NetworkManager.isUpdating=async function(){
return await JSONRPC.__invoke("web.RPC.NetworkManager::isUpdating", {});
};
JSONRPC.NetworkManager.triggerUpdate=async function(ignoreCache){
return await JSONRPC.__invoke("web.RPC.NetworkManager::triggerUpdate", {"ignoreCache":ignoreCache});
};
JSONRPC.OVPN = {};
JSONRPC.OVPN.restartSystemService=async function(){
return await JSONRPC.__invoke("ru.kirillius.pf.sdn.External.API.Components.OVPN::restartSystemService", {});
};
JSONRPC.OVPN.getManagedRoutes=async function(){
return await JSONRPC.__invoke("ru.kirillius.pf.sdn.External.API.Components.OVPN::getManagedRoutes", {});
};

20
webui/src/main.js Normal file
View File

@ -0,0 +1,20 @@
// src/main.js
import './styles/app.css';
import $ from 'jquery';
window.$ = window.jQuery = $;
// 1. Импорт JSONRPC по алиасу (предполагаем, что алиас настроен в vite.config.js)
import { JSONRPC } from '@/json-rpc.js';
// 🔥 Указываем URL сервера, как ты и сделал
JSONRPC.url = "http://localhost:8080" + JSONRPC.url;
// 2. Импорт главного модуля приложения
import { initApp } from './modules/app.js';
// Запуск приложения после загрузки DOM
$(document).ready(() => {
$('#loader').remove();
initApp();
});

23
webui/src/modules/app.js Normal file
View File

@ -0,0 +1,23 @@
import { renderLoginForm, renderDashboard } from './ui.js';
import { checkAuthOnStartup } from './auth.js'; // 🔥 Теперь импортируем здесь
// Глобальный статус приложения
export let isAuthenticated = false;
// Главная функция инициализации
export async function initApp() {
// 1. Проверяем авторизацию
const isInitialAuth = await checkAuthOnStartup();
setAuthenticated(isInitialAuth); // Устанавливаем состояние и рендерим
}
// Функция для смены состояния (например, после успешного логина/выхода)
export function setAuthenticated(status) {
isAuthenticated = status;
if (status) {
renderDashboard();
} else {
renderLoginForm();
}
}

95
webui/src/modules/auth.js Normal file
View File

@ -0,0 +1,95 @@
import { getAuthToken, setAuthToken, removeAuthToken } from '../utils/cookies.js';
// ⚠️ ОБНОВИ ПУТЬ! Этот импорт должен указывать на твой модуль для работы с RPC.
// Предполагается, что JSONRPC.Auth содержит методы:
// - isAuthenticated()
// - startSessionByToken(token)
// - startSessionByPassword(password)
// - createToken()
// - logout()
import { JSONRPC } from '@/json-rpc.js';
/**
* @private
* Проверяет, активна ли сессия (через API) или сохранен ли токен в cookies.
* @returns {Promise<boolean>}
*/
export async function checkAuthOnStartup() {
try {
// 1. Проверяем активную сессию (самый надежный способ)
const isAuthenticated = await JSONRPC.Auth.isAuthenticated();
if (isAuthenticated) {
return true;
}
// 2. Сессии нет, ищем токен в Cookie
const token = getAuthToken();
if (token) {
console.log("Токен найден, пытаемся стартовать сессию...");
// 3. Пытаемся стартовать сессию по токену
const success = await JSONRPC.Auth.startSessionByToken(token);
if (success) {
console.log("Сессия успешно восстановлена по токену.");
return true;
} else {
// Токен недействителен или просрочен
console.warn("Токен недействителен, удаляем его.");
removeAuthToken();
return false;
}
}
// 4. Нет ни сессии, ни токена
return false;
} catch (err) {
console.error('Ошибка при стартовой проверке аутентификации:', err);
// В случае ошибки сети/бэкенда, предполагаем, что не авторизованы
return false;
}
}
/**
* @public
* Обрабатывает вход по паролю.
* @param {string} password - Пароль пользователя.
* @param {boolean} rememberMe - Флаг, указывающий, нужно ли сохранить токен.
* @returns {Promise<boolean>} - true, если вход успешен.
*/
export async function handleLogin(password, rememberMe) {
// 1. Пытаемся стартовать сессию по паролю
const success = await JSONRPC.Auth.startSessionByPassword(password);
if (success) {
if (rememberMe) {
// 2. Успех: если "запомнить меня", создаем и сохраняем токен
const token = await JSONRPC.Auth.createToken();
setAuthToken(token);
console.log("Токен создан и сохранен.");
}
return true;
}
return false;
}
/**
* @public
* Обрабатывает выход из системы.
*/
export async function handleLogout() {
try {
// 1. Завершаем сессию на стороне сервера
await JSONRPC.Auth.logout();
} catch(e) {
console.warn("Ошибка при завершении сессии на сервере (возможно, уже была неактивна).", e);
}
// 2. Удаляем токен из Cookie
removeAuthToken();
console.log("Выход выполнен, токен удален.");
// Обработчик в ui.js сменит состояние приложения
}

104
webui/src/modules/router.js Normal file
View File

@ -0,0 +1,104 @@
// src/modules/router.js
import $ from 'jquery';
// 🔥 Импортируем только объект StatisticsPage
import { StatisticsPage } from '../pages/Statistics.js';
import { Subscriptions } from '../pages/Subscriptions.js';
import { Components } from '../pages/Components.js';
import { APITokens } from '../pages/APITokens.js';
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
let currentRouteHash = '';
// 1. Определение меню и путей
const menuItems = [
{ label: 'Статистика', path: 'stats' },
{ label: 'Подписки', path: 'subscriptions' },
{ label: 'Настройки', path: 'settings' },
{ label: 'Компоненты', path: 'components' },
{ label: 'API', path: 'api' },
];
// 2. Определение страниц
const routes = {
'#stats': {
render: StatisticsPage.render,
mount: StatisticsPage.mount,
// 🔥 Используем unmount из объекта страницы
unmount: StatisticsPage.unmount
},
'#subscriptions': {
render: Subscriptions.render,
mount: Subscriptions.mount,
unmount: Subscriptions.unmount
},
'#settings': {
render: () => '<h1 class="page-title">Системные Настройки</h1><p>Конфигурация сети, пользователя и безопасности.</p>',
mount: () => {},
unmount: () => {}
},
'#components': {
render: Components.render,
mount: Components.mount,
unmount: Components.unmount
},
'#api': {
render: APITokens.render,
mount: APITokens.mount,
unmount: APITokens.unmount
}
};
// 3. Функция рендеринга страницы
export function renderPage(hash) {
const $contentArea = $('#content-area');
const key = hash.startsWith('#') ? hash : '#' + hash;
// 🔥 НОВАЯ ПРОВЕРКА: Если страница уже открыта, просто обновляем меню и выходим
if (currentRouteHash === key) {
$('.menu-item').removeClass('active');
$(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active');
return;
}
// Определяем, какой хеш был активным до этого. Используем 'stats' как дефолт
const previousKey = currentRouteHash || '#stats';
// Шаг 1: Если мы меняем страницу, и предыдущая страница имеет unmount, вызываем его
if (previousKey !== key && routes[previousKey] && routes[previousKey].unmount) {
routes[previousKey].unmount();
}
if (routes[key]) {
// Рендерим HTML
$contentArea.html(routes[key].render());
// Вызываем функцию монтирования
routes[key].mount();
// Обновляем активный пункт меню
$('.menu-item').removeClass('active');
$(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active');
// Обновляем hash в адресной строке
if (window.location.hash !== key) {
history.pushState(null, null, key);
}
// Шаг 2: Успешно обновили страницу, сохраняем новый хеш
currentRouteHash = key;
} else {
// Если путь не найден, перенаправляем на "Статистику"
window.location.hash = 'stats';
}
}
// 4. Обработчик изменения хеша в браузере (для навигации по истории)
$(window).on('hashchange', function() {
renderPage(window.location.hash);
});
// Экспорт menuItems для построения сайдбара в ui.js
export { menuItems };

103
webui/src/modules/ui.js Normal file
View File

@ -0,0 +1,103 @@
import $ from 'jquery';
import { handleLogin, handleLogout } from './auth.js';
import { setAuthenticated } from './app.js';
import { renderPage, menuItems } from './router.js';
// Функция рендеринга формы авторизации
export function renderLoginForm() {
const html = `
<div id="login-container" class="full-screen flex-center">
<div class="card" style="width: 380px;">
<h2 style="margin-top: 0; color: var(--color-text);">Авторизация</h2>
<form id="login-form">
<div class="form-group">
<label for="password-input">Пароль</label>
<input type="password" id="password-input" class="form-control" placeholder="Введите пароль">
</div>
<div class="form-group" style="display: flex; align-items: center;">
<input type="checkbox" id="remember-me" style="margin-right: 10px;">
<label for="remember-me" style="margin-bottom: 0; font-weight: normal; cursor: pointer;">Запомнить меня</label>
</div>
<button type="submit" id="login-button" class="btn-primary">Войти</button>
<div id="login-error" class="error-message" style="display: none;"></div>
</form>
</div>
</div>
`;
$('#app').html(html);
// Прикрепление событий jQuery
$('#login-form').on('submit', async function(e) {
e.preventDefault();
const $button = $('#login-button');
const $error = $('#login-error');
const password = $('#password-input').val();
const rememberMe = $('#remember-me').prop('checked');
$error.hide().text('');
$button.prop('disabled', true).text('Вход...');
try {
const success = await handleLogin(password, rememberMe);
if (success) {
setAuthenticated(true);
} else {
$error.text('Ошибка входа: Неверный пароль.').show();
}
} catch (err) {
$error.text('Произошла ошибка при попытке входа.').show();
} finally {
$button.prop('disabled', false).text('Войти');
}
});
}
// Функция рендеринга рабочего стола (Dashboard)
export function renderDashboard() {
// Используем menuItems из router.js для построения сайдбара
const sidebarHtml = menuItems.map(item => {
return `<a href="#${item.path}" class="menu-item" data-path="${item.path}">${item.label}</a>`;
}).join('');
const html = `
<div id="dashboard-container" style="display: flex; min-height: 100vh;">
<div id="sidebar" class="sidebar">
<h2 class="sidebar-title">SDN Control</h2>
<nav id="main-nav">${sidebarHtml}</nav>
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
</div>
<main id="content-area" class="content-area">
</main>
</div>
`;
$('#app').html(html);
// Прикрепляем события для выхода
$('#logout-btn').on('click', async function(e) {
e.preventDefault();
await handleLogout();
setAuthenticated(false);
});
// Запускаем роутер с текущим хешем (или 'stats' по умолчанию)
renderPage(window.location.hash || '#stats');
// Прикрепляем события для меню (роутер)
$('#main-nav').on('click', '.menu-item', function(e) {
// Предотвращаем стандартный переход, чтобы обработать его через JS
e.preventDefault();
const path = $(this).data('path');
if (path) {
window.location.hash = path;
renderPage(path);
}
});
}

View File

@ -0,0 +1,5 @@
// src/pages/APITokens.js
import { APITokensPage } from '../ui/api_tokens.js';
export const APITokens = APITokensPage;

View File

@ -0,0 +1,5 @@
// src/pages/Components.js
import { ComponentsPage } from '../ui/components.js';
export const Components = ComponentsPage;

View File

@ -0,0 +1,22 @@
// src/pages/Statistics.js
import { renderStatisticsPage, stopPolling } from '../ui/statistics.js';
export const StatisticsPage = {
render: () => {
return `
<h1 class="page-title">Статистика</h1>
<div id="statistics-content">
Загрузка данных...
</div>
`;
},
mount: () => {
// Вызывает renderStatisticsPage, который запускает опрос
renderStatisticsPage();
},
// 🔥 ВСТРОЕННАЯ ФУНКЦИЯ UNMOUNT: Роутер использует StatisticsPage.unmount
unmount: stopPolling
};
// 🔥 УДАЛЕН ЭКСПОРТ { stopPolling }; из этого файла.

View File

@ -0,0 +1,6 @@
// src/pages/Subscriptions.js
import { SubscriptionsPage } from '../ui/subscriptions.js';
// Просто реэкспортируем объект для роутера
export const Subscriptions = SubscriptionsPage;

411
webui/src/styles/app.css Normal file
View File

@ -0,0 +1,411 @@
/* --- Сброс и Тёмные Переменные --- */
:root {
/* Основная цветовая палитра */
--color-primary: #3b82f6; /* Синий (Акцент) */
--color-primary-dark: #2563eb;
--color-text: #e5e7eb; /* Светлый текст */
--color-text-secondary: #9ca3af; /* Второстепенный текст */
--color-bg-main: #111827; /* Очень тёмный фон */
--color-bg-card: #1f2937; /* Фон карточек и сайдбара */
--color-border: #374151; /* Границы */
--color-error: #ef4444; /* Красный */
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--border-radius: 6px;
}
/* Применяем переменные к тёмной теме */
html.dark-theme, body {
background-color: var(--color-bg-main);
color: var(--color-text);
font-family: var(--font-family);
margin: 0;
padding: 0;
}
*, *::before, *::after {
box-sizing: border-box;
}
/* --- Общие Классы UI и Формы (для Авторизации) --- */
/* Контейнеры */
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.full-screen {
min-height: 100vh;
}
.card {
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
padding: 30px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* Форма и Поля */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
}
.form-control {
width: 100%;
padding: 12px 10px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-bg-main);
color: var(--color-text);
transition: border-color 0.2s, box-shadow 0.2s;
font-size: 16px;
}
.form-control:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
/* Кнопка */
.btn-primary {
display: block;
width: 100%;
padding: 12px;
background-color: var(--color-primary);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: background-color 0.2s;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: var(--color-error);
margin-top: 15px;
text-align: center;
}
.success-message {
color: #10b981; /* Зеленый */
margin-top: 15px;
text-align: center;
}
/* --- Стили Dashboard (Сайдбар и Общая Структура) --- */
.sidebar {
width: 250px;
background-color: var(--color-bg-card);
border-right: 1px solid var(--color-border);
flex-shrink: 0;
padding: 20px 0;
display: flex; /* Для прибивания кнопки выхода вниз */
flex-direction: column;
}
.sidebar-title {
color: var(--color-primary);
text-align: center;
margin-bottom: 30px;
font-size: 20px;
}
/* Стили меню */
.menu-item {
display: block;
padding: 12px 20px;
color: var(--color-text-secondary);
text-decoration: none;
transition: background-color 0.2s, color 0.2s;
font-size: 15px;
}
.menu-item:hover {
background-color: #2e3a4e; /* Чуть светлее, чем сайдбар */
color: var(--color-text);
}
.menu-item.active {
background-color: var(--color-primary);
color: white;
}
.logout-btn {
margin-top: auto; /* Прижимает к низу */
border-top: 1px solid var(--color-border);
}
.content-area {
flex-grow: 1;
padding: 30px;
overflow-y: auto;
}
.page-title {
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
padding-bottom: 15px;
margin-top: 0;
margin-bottom: 30px;
}
/* --- Стили для страницы Статистики --- */
.stats-grid {
display: grid;
gap: 20px;
margin-bottom: 30px;
}
/* Сетка для статусов (3 колонки) */
.status-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.stat-card {
background-color: var(--color-bg-card);
border-radius: var(--border-radius);
padding: 20px;
border: 1px solid var(--color-border);
transition: box-shadow 0.3s;
}
.stat-card:hover {
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.stat-card h3 {
margin-top: 0;
font-size: 18px;
color: var(--color-text);
border-bottom: 1px solid var(--color-border);
padding-bottom: 10px;
margin-bottom: 15px;
}
.status-line {
font-size: 14px;
margin-bottom: 15px;
}
.text-yellow-500 {
color: #f59e0b; /* Желтый для "обновляется" */
font-weight: 600;
}
.text-green-500 {
color: #10b981; /* Зеленый для "активен" */
font-weight: 600;
}
/* Кнопка вторичная (для обновления) */
.btn-secondary {
padding: 8px 15px;
background-color: transparent;
color: var(--color-primary);
border: 1px solid var(--color-primary);
border-radius: var(--border-radius);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background-color: rgba(59, 130, 246, 0.1);
}
.btn-secondary:disabled {
opacity: 0.7;
cursor: not-allowed;
color: var(--color-text-secondary);
border-color: var(--color-border);
}
/* --- Стили для страницы Статистики (Дополнение: Ресурсы) --- */
.resource-group {
border-top: 1px solid var(--color-border);
padding-top: 15px;
margin-top: 20px;
}
.resource-group h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: var(--color-text-secondary);
}
.resource-row {
display: flex;
gap: 15px;
justify-content: space-between;
}
.resource-badge {
text-align: center;
padding: 10px;
border-radius: var(--border-radius);
background-color: var(--color-bg-main); /* Более темный фон для выделения */
flex-grow: 1;
min-width: 0;
}
/* Класс для размера чисел в блоках (50% от исходного размера) */
.resource-count {
display: block;
font-weight: 700;
color: var(--color-primary);
margin-bottom: 3px;
font-size: 20px; /* Базовый размер для подписок */
}
/* Корректировка для уменьшения размера */
.resource-count.small-count {
font-size: 16px;
}
.resource-title {
display: block;
font-size: 12px;
color: var(--color-text-secondary);
}
/* --- Стили для страницы Подписок --- */
.subscription-list {
list-style: none;
padding: 0;
margin: 20px 0;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-bg-card);
}
.subscription-item {
padding: 15px;
border-bottom: 1px solid var(--color-border);
}
.subscription-item:last-child {
border-bottom: none;
}
.subscription-item label {
display: flex;
align-items: flex-start; /* Выравниваем элементы по верху */
cursor: pointer;
font-size: 16px;
}
.subscription-checkbox {
margin-right: 15px;
flex-shrink: 0;
width: 20px;
height: 20px;
margin-top: 2px;
}
.resource-info {
display: flex;
flex-direction: column; /* Обертка для двух строк */
flex-grow: 1;
}
.resource-main-line {
/* Первая строка: Описание (Ключ) */
display: flex;
margin-bottom: 3px;
}
.resource-description {
font-weight: 600;
color: var(--color-text);
margin-right: 8px;
}
/* 🔥 ОБНОВЛЕНО: Сделаем resource-key (используется для shortName) крупнее */
.resource-key {
font-weight: 600; /* Жирный шрифт */
color: var(--color-text); /* Основной цвет текста */
font-size: 16px; /* Крупнее, чтобы лучше читалось */
margin-right: 15px;
}
.resource-details {
/* Вторая строка: Детали / или в случае компонентов - полное имя */
font-size: 13px;
color: var(--color-text-secondary);
margin-left: 0;
}
/* --- Стили для страницы API Токенов --- */
.token-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-bg-card);
}
.token-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid var(--color-border);
}
.token-item:last-child {
border-bottom: none;
}
.token-description {
font-weight: 600;
color: var(--color-text);
flex-grow: 1;
}
.token-details {
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.token-value {
font-family: monospace;
font-size: 14px;
color: var(--color-text-secondary);
}
/* Кнопка-иконка для удаления */
.btn-icon {
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
padding: 5px;
border-radius: 4px;
transition: background-color 0.2s;
}
.btn-icon:hover {
background-color: rgba(239, 68, 68, 0.1); /* Легкий красный фон при наведении */
}
.delete-icon {
font-weight: 600;
font-size: 18px;
line-height: 1;
}

205
webui/src/ui/api_tokens.js Normal file
View File

@ -0,0 +1,205 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние ---
let tokens = [];
/**
* @private
* Загружает текущий список токенов.
* @returns {Promise<boolean>}
*/
async function loadTokens() {
try {
// Запрос списка токенов
tokens = await JSONRPC.Auth.listTokens();
tokens = tokens || [];
return true;
} catch (e) {
console.error("Ошибка при загрузке токенов:", e);
tokens = [];
return false;
}
}
/**
* @private
* Рендерит список токенов и кнопки управления.
*/
function renderTokenList() {
const $listContainer = $('#api-token-container');
// Кнопка "Создать токен"
const createButtonHtml = `
<button id="create-token-btn" class="btn-primary" style="width: 200px; margin-bottom: 30px;">
Создать токен API
</button>
<div id="token-status-message" class="success-message" style="display: none;"></div>
`;
if (tokens.length === 0) {
$listContainer.html(createButtonHtml + '<p>Активных API токенов не найдено.</p>');
attachEventHandlers();
return;
}
let listHtml = tokens.map(item => {
// Мы НЕ показываем полный токен, показываем только его начало/конец для идентификации
const displayToken = item.token ?
`${item.token.substring(0, 8)}...${item.token.substring(item.token.length - 4)}` :
'Неизвестный токен';
return `
<li class="token-item" data-full-token="${item.token}">
<div class="token-description">${item.description}</div>
<div class="token-details">
<span class="token-value">Ключ: ${displayToken}</span>
<button class="delete-token-btn btn-icon" data-token="${item.token}" title="Удалить токен">
<span class="delete-icon"></span>
</button>
</div>
</li>
`;
}).join('');
const html = `
${createButtonHtml}
<ul class="token-list">${listHtml}</ul>
`;
$listContainer.html(html);
attachEventHandlers();
}
/**
* @private
* Отображает модальное окно для создания токена.
*/
function showCreateTokenModal() {
// Используем простое модальное окно jQuery для минимализма
const description = prompt("Введите описание для нового API токена (например, 'Токен для Telegram бота'):");
if (description === null) {
return; // Пользователь нажал Отмена
}
if (description.trim() === "") {
alert("Описание токена не может быть пустым.");
return;
}
createToken(description.trim());
}
/**
* @private
* Логика создания токена и обновления UI.
* @param {string} description - Описание токена.
*/
async function createToken(description) {
const $btn = $('#create-token-btn');
const $message = $('#token-status-message');
$message.hide().removeClass('error-message').addClass('success-message');
$btn.prop('disabled', true).text('Создание...');
try {
const newTokenValue = await JSONRPC.Auth.createAPIToken(description);
if (!newTokenValue) {
throw new Error("Сервер не вернул токен.");
}
// Показываем токен пользователю ОДИН раз
prompt("ВАЖНО: Ваш новый токен API. Сохраните его, он больше не будет показан!", newTokenValue);
// Перезагружаем список
await loadTokens();
renderTokenList();
$message.text(`Токен "${description}" успешно создан.`).show();
} catch (e) {
console.error('Ошибка создания токена:', e);
$message.removeClass('success-message').addClass('error-message').text('Ошибка при создании токена.').show();
} finally {
$btn.prop('disabled', false).text('Создать токен API');
setTimeout(() => $message.fadeOut(), 8000);
}
}
/**
* @private
* Логика удаления токена.
* @param {string} token - Токен для удаления.
*/
async function deleteToken(token) {
if (!confirm("Вы уверены, что хотите удалить этот API токен?")) {
return;
}
const $item = $(`li.token-item[data-full-token="${token}"]`);
$item.addClass('deleting').css('opacity', 0.5); // Визуальная обратная связь
try {
await JSONRPC.Auth.removeToken(token);
// Удаляем из локального состояния
tokens = tokens.filter(item => item.token !== token);
// Перерисовываем список
renderTokenList();
const $message = $('#token-status-message');
$message.text('Токен успешно удален.').addClass('success-message').show();
} catch (e) {
console.error('Ошибка удаления токена:', e);
$item.removeClass('deleting').css('opacity', 1); // Восстанавливаем, если ошибка
const $message = $('#token-status-message');
$message.removeClass('success-message').addClass('error-message').text('Ошибка при удалении токена.').show();
}
}
/**
* @private
* Прикрепление обработчиков событий.
*/
function attachEventHandlers() {
$('#create-token-btn').off('click').on('click', showCreateTokenModal);
// Делегирование для кнопок удаления (они перерисовываются)
$('#api-token-container').off('click', '.delete-token-btn').on('click', '.delete-token-btn', function() {
const tokenToDelete = $(this).data('token');
if (tokenToDelete) {
deleteToken(tokenToDelete);
}
});
}
// --- Функции для роутера ---
export const APITokensPage = {
render: () => {
return `
<h1 class="page-title">API Токены</h1>
<div id="api-token-container">
<p>Загрузка токенов...</p>
</div>
`;
},
mount: async () => {
const success = await loadTokens();
if (success) {
renderTokenList();
} else {
$('#api-token-container').html('<p class="error-message">Не удалось загрузить токены. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
tokens = [];
$('#api-token-container').off(); // Удаляем все обработчики
}
};

134
webui/src/ui/components.js Normal file
View File

@ -0,0 +1,134 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние для страницы ---
let availableComponents = [];
let enabledComponents = [];
/**
* @private
* Извлекает короткое имя компонента, беря часть строки после последней точки.
* @param {string} fullName - Полное имя компонента (напр., ru.kirillius.pf.sdn.API).
* @returns {string} Короткое имя (напр., API).
*/
function getShortName(fullName) {
// Находим позицию последней точки
const lastDotIndex = fullName.lastIndexOf('.');
// Если точка найдена, возвращаем подстроку после неё.
// Если точки нет (или она в конце), возвращаем полное имя.
if (lastDotIndex > -1 && lastDotIndex < fullName.length - 1) {
return fullName.substring(lastDotIndex + 1);
}
return fullName;
}
/** Загружает все необходимые данные для страницы (без изменений) */
async function loadComponentData() {
try {
enabledComponents = await JSONRPC.System.getEnabledComponents();
availableComponents = await JSONRPC.System.getAvailableComponents();
availableComponents.sort();
} catch (e) {
console.error("Ошибка при загрузке данных компонентов:", e);
availableComponents = [];
enabledComponents = [];
return false;
}
return true;
}
/** Рендеринг HTML списка компонентов */
function renderComponentList() {
const $listContainer = $('#component-list-container');
if (availableComponents.length === 0) {
$listContainer.html('<p>Нет доступных системных компонентов.</p>');
return;
}
let listHtml = availableComponents.map(fullName => {
// 🔥 Применяем новую функцию для отображения
const shortName = getShortName(fullName);
// Проверяем, включен ли компонент
const isChecked = enabledComponents.includes(fullName); // Проверка по полному имени!
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="component-checkbox" value="${fullName}" ${isChecked ? 'checked' : ''}>
<span class="resource-key">${shortName}</span>
<span class="resource-details" style="margin-left: 10px;">(${fullName})</span>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-components-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="component-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
/** Обработчик кнопки "Сохранить" (без изменений, т.к. работаем с полными именами из value) */
function attachEventHandlers() {
$('#save-components-btn').on('click', async function() {
const $btn = $(this);
const $message = $('#component-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
// Собираем все отмеченные ключи компонентов (они содержат полные имена)
const newEnabledComponents = $('.component-checkbox:checked').map(function() {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
enabledComponents = newEnabledComponents;
$message.text('Настройки компонентов успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения компонентов:', e);
$message.text('Ошибка при сохранении компонентов.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
// --- Функции для роутера (без изменений) ---
export const ComponentsPage = {
render: () => {
return `
<h1 class="page-title">Управление Компонентами</h1>
<div id="component-list-container">
<p>Загрузка доступных компонентов...</p>
</div>
`;
},
mount: async () => {
const success = await loadComponentData();
if (success) {
renderComponentList();
} else {
$('#component-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableComponents = [];
enabledComponents = [];
}
};

198
webui/src/ui/statistics.js Normal file
View File

@ -0,0 +1,198 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
const $content = () => $('#statistics-content');
// 🔥 Переменная для хранения ID интервала опроса
let updateInterval = null;
const POLLING_INTERVAL = 5000; // 5 секунд
// --- Утилитарные функции для рендеринга UI-блоков ---
/** Создает HTML-карточку для отображения статуса и ресурсов */
const createStatusCard = (title, status, buttonId, buttonLabel, isUpdating, resourceStatsHtml = '') => {
const statusClass = status === 'Обновляется' || status === 'Обнаружено' ? 'text-yellow-500' : 'text-green-500';
return `
<div class="stat-card">
<h3>${title}</h3>
<p class="status-line">
Статус: <span class="${statusClass}">${status}</span>
</p>
<button id="${buttonId}" class="btn-secondary" ${isUpdating ? 'disabled' : ''}>
${isUpdating ? 'Обновление...' : buttonLabel}
</button>
${resourceStatsHtml}
</div>
`;
};
/** Создает HTML-блок для отображения ресурсов сети */
const createResourceStatsHtml = (resources) => {
return `
<div class="resource-group">
<h4>Ресурсы:</h4>
<div class="resource-row">
${createResourceBadge("Домены", resources.domains.length)}
${createResourceBadge("ASN", resources.ASN.length)}
${createResourceBadge("Подсети", resources.subnets.length)}
</div>
</div>
`;
};
/** Создает HTML-бэйдж для отдельного ресурса (с уменьшенным шрифтом) */
const createResourceBadge = (title, count) => {
return `
<div class="resource-badge">
<span class="resource-count small-count">${count}</span>
<span class="resource-title">${title}</span>
</div>
`;
};
// --- Основные функции API и DOM ---
/** Получение количества активных подписок. */
async function getSubscriptionCount() {
try {
// Используем обновленный метод getSubscribedResources
let subscribedResources = await JSONRPC.SubscriptionManager.getSubscribedResources();
return (subscribedResources && subscribedResources.length) || 0;
} catch(e) {
console.error("Ошибка при получении subscribedResources:", e);
return 0;
}
}
/** Запрос данных о ресурсах сети. */
async function getNetworkResources() {
try {
let res = await JSONRPC.NetworkManager.getOutputResources();
// Используем ключи, как они приходят с API: domains, ASN, subnets
return {
domains: res.domains || [],
ASN: res.ASN || [],
subnets: res.subnets || []
};
} catch(e) {
console.error("Ошибка при получении ресурсов:", e);
return { domains: [], ASN: [], subnets: [] };
}
}
/** 2. Запрос и рендеринг данных о ПО и Статусах */
async function renderSystemAndManagerStatus() {
const dataHtml = [];
// --- Получение данных ---
const hasUpdates = await JSONRPC.System.hasUpdates();
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
const isNetUpdating = await JSONRPC.NetworkManager.isUpdating();
const subCount = await getSubscriptionCount();
const networkResources = await getNetworkResources();
// 1. Информация об обновлении ПО
dataHtml.push(createStatusCard(
"Обновление ПО",
hasUpdates ? "Обнаружено" : "Нет обновлений",
"update-software-btn",
"Проверить",
false
));
// 2. Статус менеджера подписок (добавляем статистику)
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
<h4>Подписки:</h4>
<div class="resource-row">
${createResourceBadge("Активно", subCount)}
</div>
</div>`;
dataHtml.push(createStatusCard(
"Менеджер Подписок",
isSubUpdating ? "Обновляется" : "Активен",
"update-sub-btn",
"Обновить",
isSubUpdating,
subStatsHtml
));
// 3. Статус менеджера сетей (ВКЛЮЧАЕМ РЕСУРСЫ)
const netResourcesHtml = createResourceStatsHtml(networkResources);
dataHtml.push(createStatusCard(
"Менеджер Сетей",
isNetUpdating ? "Обновляется" : "Активен",
"update-net-btn",
"Обновить",
isNetUpdating,
netResourcesHtml
));
// Обновляем DOM один раз
$content().html(`<div class="stats-grid status-grid">${dataHtml.join('')}</div>`);
// Прикрепляем обработчики событий после рендеринга
attachEventHandlers();
}
// --- Главная функция монтирования ---
export async function renderStatisticsPage() {
// 1. Очищаем старый интервал (на случай, если он был)
stopPolling();
// 2. Первичный рендеринг
await renderSystemAndManagerStatus();
console.log("start interval")
// 3. Запускаем опрос каждые 5 секунд
updateInterval = setInterval(renderSystemAndManagerStatus, POLLING_INTERVAL);
}
/** 🔥 Остановка интервала опроса при уходе со страницы */
export function stopPolling() {
if (updateInterval) {
console.log("clear interval")
clearInterval(updateInterval);
updateInterval = null;
}
}
/** Прикрепление обработчиков к кнопкам */
function attachEventHandlers() {
// Обработчик для менеджера подписок
$('#update-sub-btn').off('click').on('click', async function() {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
await JSONRPC.SubscriptionManager.triggerUpdate();
alert("Обновление подписок запущено!");
renderSystemAndManagerStatus(); // Обновляем немедленно
} catch(e) {
alert("Ошибка при запуске обновления подписок!");
$btn.prop('disabled', false).text('Обновить');
}
});
// Обработчик для менеджера сетей
$('#update-net-btn').off('click').on('click', async function() {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
// Передаем аргумент true в triggerUpdate
await JSONRPC.NetworkManager.triggerUpdate(true);
alert("Обновление сетей запущено!");
renderSystemAndManagerStatus(); // Обновляем немедленно
} catch(e) {
alert("Ошибка при запуске обновления сетей!");
$btn.prop('disabled', false).text('Обновить');
}
});
// Кнопка проверки обновлений ПО
$('#update-software-btn').off('click').on('click', function() {
alert("Проверка обновлений ПО запущена...");
});
}

View File

@ -0,0 +1,126 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние для страницы ---
let availableResources = {};
let subscribedKeys = [];
/** Загружает все необходимые данные для страницы */
async function loadSubscriptionData() {
try {
subscribedKeys = await JSONRPC.SubscriptionManager.getSubscribedResources();
availableResources = await JSONRPC.SubscriptionManager.getAvailableResources();
} catch (e) {
console.error("Ошибка при загрузке данных подписок:", e);
availableResources = {};
subscribedKeys = [];
return false;
}
return true;
}
/** Рендеринг HTML списка */
function renderSubscriptionList() {
const $listContainer = $('#subscription-list-container');
const availableKeys = Object.keys(availableResources).sort();
if (availableKeys.length === 0) {
$listContainer.html('<p>Нет доступных ресурсов для подписки.</p>');
return;
}
let listHtml = availableKeys.map(key => {
const details = availableResources[key];
const isChecked = subscribedKeys.includes(key);
// 🔥 Обновлено: Используем description и меняем структуру
const description = details.description || 'Нет описания';
// Формируем строку с деталями (теперь на новой строке)
const detailText = `
Доменов: ${details.domains ? details.domains.length : 0},
ASN: ${details.ASN ? details.ASN.length : 0},
Подсетей: ${details.subnets ? details.subnets.length : 0}
`;
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="subscription-checkbox" value="${key}" ${isChecked ? 'checked' : ''}>
<div class="resource-info">
<div class="resource-main-line">
<span class="resource-description">${description}</span>
<span class="resource-key">(${key})</span>
</div>
<div class="resource-details">${detailText}</div>
</div>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-subscriptions-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="subscription-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
/** Обработчик кнопки "Сохранить" (остается без изменений) */
function attachEventHandlers() {
$('#save-subscriptions-btn').on('click', async function() {
const $btn = $(this);
const $message = $('#subscription-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
const newSubscribedKeys = $('.subscription-checkbox:checked').map(function() {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.SubscriptionManager.setSubscribedResources(newSubscribedKeys);
subscribedKeys = newSubscribedKeys;
$message.text('Настройки подписок успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения подписок:', e);
$message.text('Ошибка при сохранении подписок.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
// ... (SubscriptionsPage, loadSubscriptionData остаются без изменений) ...
export const SubscriptionsPage = {
render: () => {
return `
<h1 class="page-title">Управление Подписками</h1>
<div id="subscription-list-container">
<p>Загрузка доступных ресурсов...</p>
</div>
`;
},
mount: async () => {
const success = await loadSubscriptionData();
if (success) {
renderSubscriptionList();
} else {
$('#subscription-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableResources = {};
subscribedKeys = [];
}
};

View File

@ -0,0 +1,42 @@
// src/utils/cookies.js
const TOKEN_KEY = 'sdn_auth_token';
/**
* Получает токен авторизации из куки.
* @returns {string|null} Токен или null, если не найден.
*/
export function getAuthToken() {
const nameEQ = TOKEN_KEY + "=";
const ca = document.cookie.split(';');
for(let i=0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return null;
}
/**
* Устанавливает токен авторизации в куки на 1 год.
* @param {string} token - Токен для сохранения.
*/
export function setAuthToken(token) {
const expirationDate = new Date();
// Устанавливаем срок действия на 1 год
expirationDate.setTime(expirationDate.getTime() + (365 * 24 * 60 * 60 * 1000));
const expires = "expires=" + expirationDate.toUTCString();
// Используем secure и samesite=Lax (рекомендуется для современных браузеров)
// path=/ делает токен доступным для всего сайта
document.cookie = `${TOKEN_KEY}=${token};${expires};path=/;samesite=Lax`;
}
/**
* Удаляет токен авторизации.
*/
export function removeAuthToken() {
document.cookie = `${TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}

34
webui/vite.config.js Normal file
View File

@ -0,0 +1,34 @@
import { fileURLToPath, URL } from 'node:url'
// 🔥 ДОБАВЛЯЕМ ИМПОРТ node:path
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
// -----------------------------------------------------------------
// 🔥 ДОБАВЛЯЕМ ЭТОТ БЛОК! Явно разрешаем доступ к node_modules
server: {
fs: {
allow: [
// Позволяем доступ к корневой папке проекта (для src)
'.',
// Позволяем доступ к папке node_modules, чтобы Vite мог найти стили
path.resolve(__dirname, 'node_modules')
]
}
}
// -----------------------------------------------------------------
})