Compare commits

..

2 Commits

31 changed files with 79 additions and 1426 deletions

1
.gitignore vendored
View File

@ -46,4 +46,3 @@ ovpn-connector.json
app/src/main/resources/htdocs/
*.pfapp
cache/
test/

View File

@ -1,16 +0,0 @@
FROM alpine:latest
ARG VERSION
RUN apk add --no-cache openjdk25
RUN mkdir -p /data /data/local
COPY docker/config.json /data/config.json
COPY docker/entrypoint.sh /entrypoint.sh
COPY launcher/target/${VERSION}.pfapp /default.pfapp
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

123
Jenkinsfile vendored
View File

@ -1,123 +0,0 @@
@groovy.transform.Field String VERSION
@NonCPS
String extractBaseVersion(String pom) {
def matcher = pom =~ /<artifactId>pf-sdn<\/artifactId>\s*<version>([0-9.]+)<\/version>/
return matcher.find() ? matcher.group(1) : null
}
pipeline {
agent any
environment {
NEXUS_URL = 'http://172.16.0.59:8001'
NEXUS_CREDENTIALS_ID = 'nexus_cred'
}
stages {
stage('Set build number into version') {
steps {
script {
def pom = readFile('pom.xml')
def baseVersion = extractBaseVersion(pom)
if (!baseVersion) {
error "Project version not found in pom.xml"
}
echo "Base version from pom: ${baseVersion}"
def parts = baseVersion.tokenize('.')
parts[parts.size() - 1] = env.BUILD_NUMBER
VERSION = parts.join('.')
echo "New project version: ${VERSION}"
sh "mvn -B versions:set -DnewVersion=${VERSION} -DprocessAllModule"
sh 'mvn -B versions:commit'
}
}
}
stage('Maven package') {
steps {
sh 'mvn -B clean package verify'
}
}
stage('Build docker image') {
steps {
sh "docker build -f Dockerfile --build-arg VERSION=${VERSION} -t pfsdn:${VERSION} ."
echo "Saving docker image pfsdn:${VERSION}"
sh "docker save pfsdn:${VERSION} | gzip > pfsdn-${VERSION}.tar.gz"
}
}
stage('Push docker image to Nexus') {
steps {
script {
def nexusHost = NEXUS_URL
.replaceAll(/^https?:\/\//, '')
.replaceAll(/\/+$/, '')
def nexusImage = "${nexusHost}/pfsdn:${VERSION}"
withCredentials([usernamePassword(
credentialsId: NEXUS_CREDENTIALS_ID,
usernameVariable: 'NEXUS_USER',
passwordVariable: 'NEXUS_PASS'
)]) {
sh """
echo "\${NEXUS_PASS}" | docker login ${nexusHost} -u "\${NEXUS_USER}" --password-stdin
docker tag pfsdn:${VERSION} ${nexusImage}
docker push ${nexusImage}
docker rmi ${nexusImage} || true
docker rmi pfsdn:${VERSION} || true
"""
}
}
}
}
stage('Upload app to Nexus') {
steps {
script {
withCredentials([usernamePassword(credentialsId: NEXUS_CREDENTIALS_ID,
usernameVariable: 'NEXUS_USER',
passwordVariable: 'NEXUS_PASSWORD')]) {
sh """
curl -f -v -u ${NEXUS_USER}:${NEXUS_PASSWORD} \
--upload-file launcher/target/${VERSION}.pfapp \
http://172.16.0.59:8081/repository/object-storage/apps/${VERSION}.pfapp
"""
}
}
}
}
stage('Deploy') {
steps {
script {
archiveArtifacts artifacts: "pfsdn-${VERSION}.tar.gz", fingerprint: true
echo "Saving pfapp artifact ${VERSION}.pfapp"
archiveArtifacts artifacts: "launcher/target/${VERSION}.pfapp", fingerprint: true
}
}
}
}
post {
always {
sh "docker rmi pfsdn:${VERSION} || true"
}
success {
cleanWs()
}
}
}

View File

@ -6,7 +6,7 @@
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>1.1.0.0</version>
<version>1.0.1.5</version>
</parent>
<artifactId>pf-sdn.app</artifactId>

View File

@ -2,7 +2,9 @@ package ru.kirillius.pf.sdn;
import lombok.Getter;
import lombok.SneakyThrows;
import ru.kirillius.pf.sdn.External.API.Components.*;
import ru.kirillius.pf.sdn.External.API.Components.FRR;
import ru.kirillius.pf.sdn.External.API.Components.OVPN;
import ru.kirillius.pf.sdn.External.API.Components.TDNS;
import ru.kirillius.pf.sdn.External.API.GitSubscription;
import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription;
@ -24,7 +26,6 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.regex.Pattern;
@ -115,10 +116,8 @@ public class App implements Context, Closeable {
serviceManager.getService(ComponentHandlerService.class).syncComponentsWithConfig();
serviceManager.getService(ResourceUpdateService.class).start();
if(!isExternalManagement()) {
removeObsoleteVersions();
}
}
private void removeObsoleteVersions() {
var appVersion = serviceManager.getService(AppUpdateService.class).getAppVersion();
@ -160,7 +159,7 @@ public class App implements Context, Closeable {
.configFile(new File(getArgument("c", args)))
.appLibrary(new File(getArgument("l", args)))
.repository(getArgument("r", args))
.availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class, ROS.class, DNSMASQ.class, SRM.class)).build())) {
.availableComponentClasses(List.of(FRR.class, OVPN.class, TDNS.class)).build())) {
Wait.when(app.running::get);
if (app.shouldRestart.get()) {
System.exit(42);
@ -178,15 +177,8 @@ public class App implements Context, Closeable {
*/
public void requestExit(boolean restart) {
running.set(false);
if(!isExternalManagement()) {
shouldRestart.set(restart);
}
}
@Override
public boolean isExternalManagement() {
return Objects.equals(System.getProperty("app.external.management", Boolean.FALSE.toString()), Boolean.TRUE.toString());
}
/**
* Closes all managed services.

View File

@ -1,196 +0,0 @@
package ru.kirillius.pf.sdn.External.API.Components;
import lombok.*;
import org.json.JSONObject;
import org.json.JSONTokener;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.json.JSONArrayProperty;
import ru.kirillius.json.JSONProperty;
import ru.kirillius.json.JSONSerializable;
import ru.kirillius.pf.sdn.core.AbstractComponent;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
* Component that synchronises FRR routing instances with the aggregated subnet list.
*/
public final class DNSMASQ extends AbstractComponent<DNSMASQ.DNSMASQConfig> {
private final static String CTX = DNSMASQ.class.getSimpleName();
private final EventListener<NetworkResourceBundle> subscription;
private final HttpClient client;
public DNSMASQ(Context context) {
super(context);
subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateDomains(bundle.getDomains()));
client = HttpClient.newHttpClient();
}
private final static String FILENAME = "/etc/dnsmasq.conf";
private String parseResponseLine(String line) {
if (!line.startsWith("data: ")) {
return null;
}
var json = new JSONObject(new JSONTokener(line.substring(6)));
if (json.has("body")) {
return json.getJSONObject("body").getJSONObject("Files").getString(FILENAME);
}
return null;
}
private HttpRequest.Builder createRequest(DNSMASQConfig.Entry entry, String endpoint) {
var auth = entry.user + ":" + entry.password;
var encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes());
var url = entry.host + endpoint;
return HttpRequest.newBuilder().uri(URI.create(url)).header("Authorization", "Basic " + encodedAuth);
}
private void saveConfig(DNSMASQConfig.Entry entry, String config) {
var json = new JSONObject();
json.put(FILENAME, config);
var request = createRequest(entry, "/save")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.toString()))
.build();
try {
var response = client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() >= 400) {
throw new IOException("Bad response code " + response.statusCode());
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException("Failed to save config to DNSMASQ " + entry.host, e);
}
}
private String fetchConfig(DNSMASQConfig.Entry entry) {
var request = createRequest(entry, "/sync")
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache")
.GET()
.build();
try {
return client.sendAsync(request, HttpResponse.BodyHandlers.ofLines())
.thenApply(response -> {
var result = new AtomicReference<String>(null);
response.body()
.takeWhile(s -> result.get() == null)
.forEach(s -> result.set(parseResponseLine(s)));
return result.get();
}).get(30, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
throw new RuntimeException("Unable to fetch config from DNSMASQ " + entry.host, e);
}
}
private final static String SERVER_PREFIX = "server=";
private void updateDomains(List<String> domains) {
for (DNSMASQConfig.Entry entry : config.instances) {
try {
final var config = new ArrayList<>(
Arrays.stream(fetchConfig(entry)
.split(Pattern.quote("\n")))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList()
);
var existing = new ArrayList<String>();
var changed = new AtomicBoolean(false);
for (var line : config) {
//#server=/repo.kirillius.ru/172.16.0.10
if (line.startsWith(SERVER_PREFIX) && line.endsWith(entry.forwarder)) {
var split = line.split(Pattern.quote("/"));
if (split.length == 3) {
existing.add(split[1]);
}
}
}
//remove
existing.stream()
.filter(d -> !domains.contains(d))
.forEach(domain -> {
config.remove(SERVER_PREFIX + "/" + domain + "/" + entry.forwarder);
changed.set(true);
});
//add missing
domains.stream()
.filter(o -> !existing.contains(o))
.forEach(domain -> {
config.add(SERVER_PREFIX + "/" + domain + "/" + entry.forwarder);
changed.set(true);
});
if (changed.get()) {
saveConfig(entry, String.join("\n", config));
}
} catch (Exception e) {
SystemLogger.error("DNS update failed", CTX, e);
}
}
}
/**
* Removes the subscription from the context event handler.
*/
@Override
public void close() throws IOException {
context.getEventsHandler().getNetworkManagerUpdateEvent().remove(subscription);
client.close();
}
@JSONSerializable
public static class DNSMASQConfig {
@Getter
@Setter
@JSONArrayProperty(type = Entry.class)
private List<Entry> instances = new ArrayList<>();
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JSONSerializable
public static class Entry {
@Getter
@Setter
@JSONProperty
private String host = "http://127.0.0.1:5380";
@Getter
@Setter
@JSONProperty
private String user = "foo";
@Getter
@Setter
@JSONProperty
private String password = "bar";
@Getter
@Setter
@JSONProperty
private String forwarder = "127.0.0.1";
}
}
}

View File

@ -1,162 +0,0 @@
package ru.kirillius.pf.sdn.External.API.Components;
import lombok.*;
import me.legrange.mikrotik.ApiConnection;
import me.legrange.mikrotik.MikrotikApiException;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.json.JSONArrayProperty;
import ru.kirillius.json.JSONProperty;
import ru.kirillius.json.JSONSerializable;
import ru.kirillius.pf.sdn.core.AbstractComponent;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Networking.IPv4Subnet;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Component that synchronises FRR routing instances with the aggregated subnet list.
*/
public final class ROS extends AbstractComponent<ROS.ROSConfig> {
private final static String CTX = ROS.class.getSimpleName();
private final EventListener<NetworkResourceBundle> subscription;
/**
* Binds the component to the application context and subscribes to network updates.
*/
public ROS(Context context) {
super(context);
subscription = context.getEventsHandler().getNetworkManagerUpdateEvent().add(bundle -> updateSubnets(bundle.getSubnets()));
}
/**
* Synchronises FRR instances with the provided subnet list.
*/
private void updateSubnets(List<IPv4Subnet> subnets) {
for (var entry : config.instances) {
SystemLogger.message("Updating subnets in RouterOS " + entry.host, CTX);
try (var connection = ApiConnection.connect(entry.host)) {
connection.login(entry.username, entry.password);
SystemLogger.message("Fetching existing subnets...", CTX);
var existingSubnets = new HashMap<String, IPv4Subnet>();
var result = connection.execute("/ip/route/print .proplist=.id,dst-address,static,gateway where static=true and routing-table=" + entry.VRF);
result.forEach(row -> {
if (row.containsKey("static") && row.get("gateway").contains(entry.gateway)) {
existingSubnets.put(row.get(".id"), new IPv4Subnet(row.get("dst-address")));
}
});
//удаляем лишние подсети
var subnetsToRemove = existingSubnets.keySet().stream().filter(id -> !subnets.contains(existingSubnets.get(id))).toList();
if (!subnetsToRemove.isEmpty()) {
SystemLogger.message(subnetsToRemove.size() + " subnets should be removed", CTX);
var counter = new AtomicInteger(0);
subnetsToRemove.forEach(id -> {
try {
connection.execute("/ip/route/remove .id=" + id);
counter.incrementAndGet();
if (counter.get() % 100 == 0) {
SystemLogger.message(counter.get() + " subnets has been removed", CTX);
}
} catch (MikrotikApiException e) {
throw new RuntimeException("Failed to remove subnet " + id, e);
}
});
SystemLogger.message("All subnets has been removed", CTX);
}
//добавляем новые подсети
var subnetsToAdd = subnets.stream().filter(subnet -> !existingSubnets.containsValue(subnet)).toList();
if (!subnetsToAdd.isEmpty()) {
SystemLogger.message(subnetsToAdd.size() + " subnets should be added", CTX);
var counter = new AtomicInteger(0);
subnetsToAdd.forEach(subnet -> {
try {
connection.execute("/ip/route/add dst-address=" + subnet + " gateway=" + entry.gateway + " routing-table=" + entry.VRF);
counter.incrementAndGet();
if (counter.get() % 100 == 0) {
SystemLogger.message(counter.get() + " subnets has been added", CTX);
}
} catch (MikrotikApiException e) {
throw new RuntimeException("Failed to remove subnet " + subnet, e);
}
});
SystemLogger.message("All subnets has been added", CTX);
}
SystemLogger.message("ROS update is complete", CTX);
} catch (Exception e) {
SystemLogger.error("Failed to execute api command because of error", CTX, e);
}
}
}
/**
* Removes the subscription from the context event handler.
*/
@Override
public void close() throws IOException {
context.getEventsHandler().getNetworkManagerUpdateEvent().remove(subscription);
}
/**
* Configuration describing FRR instances and how subnets should be rendered for them.
*/
@JSONSerializable
public static class ROSConfig {
@Getter
@Setter
@JSONArrayProperty(type = Entry.class)
private List<Entry> instances = new ArrayList<>();
/**
* Declarative description of a single FRR instance managed by the component.
*/
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JSONSerializable
public static class Entry {
@Getter
@Setter
@JSONProperty
private String host = "127.0.0.1";
@Getter
@Setter
@JSONProperty
private String username = "admin";
@Getter
@Setter
@JSONProperty
private String password = "passwd";
@Getter
@Setter
@JSONProperty
private String VRF = "main";
@Getter
@Setter
@JSONProperty
private String gateway = "127.0.0.1";
}
}
}

View File

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

View File

@ -43,7 +43,8 @@ public class HEInfoProvider implements ASInfoProvider {
return getIPv4Subnets(inputStream);
}
} else {
throw new RuntimeException("Unable to get info about AS" + as);
SystemLogger.error("Unable to get info about AS" + as, CTX);
return Collections.emptyList();
}
}
}

View File

@ -39,7 +39,8 @@ public class RIPEInfoProvider implements ASInfoProvider {
return getIPv4Subnets(inputStream);
}
} else {
throw new RuntimeException("Unable to get info about AS" + as);
SystemLogger.error("Unable to get info about AS" + as, CTX);
return Collections.emptyList();
}
}
}

View File

@ -15,7 +15,6 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
/**
@ -94,7 +93,7 @@ public class TDNSAPI implements Closeable {
private long soaSerial;
@JSONProperty(required = false)
@Getter
private Instant expiry;
private Date expiry;
@JSONProperty(required = false)
@Getter
private boolean isExpired;
@ -106,7 +105,7 @@ public class TDNSAPI implements Closeable {
private boolean disabled;
@JSONProperty
@Getter
private Instant lastModified;
private Date lastModified;
}
/**

View File

@ -2,6 +2,7 @@ package ru.kirillius.pf.sdn.web.RPC;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;
import ru.kirillius.json.DefaultPropertySerializer;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
@ -88,7 +89,8 @@ public class SubscriptionManager implements RPC {
@JRPCArgument(name = "subnets") JSONArray subnets,
@JRPCArgument(name = "addresses") JSONArray addresses,
@JRPCArgument(name = "storage") String storage,
@JRPCArgument(name = "subscribe") boolean subscribe) throws IOException {
@JRPCArgument(name = "subscribe") boolean subscribe,
@JRPCArgument(name = "append") boolean append) throws IOException {
var repositoryConfig = findLocalRepo(storage);
var directory = new File(repositoryConfig.getSource());
if (!directory.exists()) {
@ -96,25 +98,43 @@ public class SubscriptionManager implements RPC {
}
var domainList = new ArrayList<String>();
var targetFile = new File(directory, name + ".json");
var bundle = new NetworkResourceBundle();
if (append) {
if (targetFile.exists()) {
try (var stream = new FileInputStream(targetFile)) {
var json = new JSONObject(new JSONTokener(stream));
bundle.add(
JSONUtility.deserializeStructure(json, NetworkResourceBundle.class)
);
}
}
}
domains.forEach(d -> domainList.add(d.toString()));
try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(directory, name + ".json"))))) {
try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(targetFile)))) {
var merged = JSONUtility.deserializeCollection(subnets, IPv4Subnet.class, null).collect(Collectors.toList());
addresses.forEach(address -> {
merged.add(new IPv4Subnet(address.toString(), 32));
});
//noinspection rawtypes
@SuppressWarnings("unchecked")
var asnList = (List<Integer>) JSONUtility.deserializeCollection(ASN, Integer.class, (Class) DefaultPropertySerializer.class).toList();
writer.write(JSONUtility.serializeStructure(
NetworkResourceBundle.builder()
bundle.add(NetworkResourceBundle.builder()
.ASN(asnList)
.subnets(merged)
.domains(domainList)
.description(description)
.build()
).toString(2));
.build());
writer.write(JSONUtility.serializeStructure(bundle).toString(2));
}
if (subscribe) {
var subscribedResources = context.getConfig().getSubscribedResources();

View File

@ -122,19 +122,10 @@ public class System implements RPC {
@ProtectedMethod
@JRPCMethod
public void doUpdate() {
if(context.isExternalManagement()){
return;
}
var updateService = context.getServiceManager().getService(AppUpdateService.class);
updateService.updateApp();
}
@ProtectedMethod
@JRPCMethod
public boolean isExternalManaged() {
return context.isExternalManagement();
}
@ProtectedMethod
@JRPCMethod
public JSONArray getRepositoryTypes() {

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>1.1.0.0</version>
<version>1.0.1.5</version>
</parent>

View File

@ -27,10 +27,8 @@ import java.util.regex.Pattern;
public class AppUpdateService extends AppService {
public final static String EXTENSION = ".pfapp";
private static final String CTX = AppUpdateService.class.getSimpleName();
private static final Pattern VERSION_LINK_PATTERN = Pattern.compile(
"<a\\s+[^>]*href=\"[^\"]*?([0-9]+(?:\\.[0-9]+)+\\.pfapp)\"",
Pattern.CASE_INSENSITIVE
);
private static final Pattern VERSION_LINK_PATTERN = Pattern.compile("<a\\s+[^>]*href=\"([0-9]+(?:\\.[0-9]+)*\\.pfapp)\"", Pattern.CASE_INSENSITIVE);
private final String repository;
private final Path appLibraryPath;
private final Class<?> anchorClass;

View File

@ -26,7 +26,4 @@ public interface Context {
* @param shouldRestart {@code true} to exit with restart intent, {@code false} to shut down.
*/
void requestExit(boolean shouldRestart);
boolean isExternalManagement();
}

View File

@ -137,7 +137,7 @@ public class IPv4Util {
var overlapped = new ArrayList<IPv4Subnet>();
var orderedByPrefix = result.stream().sorted(Comparator.comparing(IPv4Subnet::getPrefixLength)).toList();
orderedByPrefix.stream()
.filter(subnet -> subnet.getPrefixLength() < 32)
.filter(subnet -> subnet.getPrefixLength() > 32)
.forEach(parent -> orderedByPrefix.forEach(subnet -> {
if (subnet.equals(parent)) {
return;

View File

@ -1,42 +0,0 @@
{
"subscribedResources": [],
"subscriptions": [
{
"name": "updates",
"source": "https://git.kirillius.ru/kirillius/protected-resources-list.git",
"type": "ru.kirillius.pf.sdn.External.API.GitSubscription",
"script": ""
},
{
"name": "local",
"source": "/data/local",
"type": "ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription",
"script": ""
}
],
"enabledComponents": [],
"httpPort": 8080,
"filteredResources": {
"description": "",
"domains": [],
"subnets": [],
"ASN": []
},
"mergeSubnets": true,
"updateSubscriptionsInterval": 6,
"mergeSubnetsWithUsage": 51,
"componentsConfig": {},
"customResources": {
"description": "",
"domains": [],
"subnets": [],
"ASN": []
},
"passwordHash": "",
"displayDebuggingInfo": true,
"cachingAS": true,
"host": "0.0.0.0",
"updateASInterval": 12,
"cacheDirectory": "/var/cache/pf-sdn",
"passwordSalt": "bf932418-10db-4e03-b5cd-23bc1bfb86e4"
}

View File

@ -1,2 +0,0 @@
#!/bin/sh
exec java -Xmx256m -Dapp.external.management=true -jar default.pfapp --c=/data/config.json -l=. -r="https://objects.repo.kirillius.ru/service/rest/repository/browse/object-storage/apps/"

View File

@ -6,23 +6,13 @@
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>1.1.0.0</version>
<version>1.0.1.5</version>
</parent>
<artifactId>launcher</artifactId>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>default-jar</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>

View File

@ -6,7 +6,7 @@
<parent>
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>1.1.0.0</version>
<version>1.0.1.5</version>
</parent>
<artifactId>pf-sdn.ovpn-connector</artifactId>

10
pom.xml
View File

@ -6,7 +6,7 @@
<groupId>ru.kirillius</groupId>
<artifactId>pf-sdn</artifactId>
<version>1.1.0.0</version>
<version>1.0.1.5</version>
<packaging>pom</packaging>
@ -79,11 +79,7 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>me.legrange</groupId>
<artifactId>mikrotik</artifactId>
<version>3.0.8.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
@ -96,7 +92,7 @@
<dependency>
<groupId>ru.kirillius</groupId>
<artifactId>json-rpc-servlet</artifactId>
<version>2.1.5.0</version>
<version>2.1.4.0</version>
</dependency>
</dependencies>

View File

@ -9,9 +9,6 @@ import { getEnabledComponents } from './app.js';
import { OVPNConfig } from '../pages/OVPN.js';
import { TDNSConfig } from '../pages/TDNS.js';
import { FRRConfig } from '../pages/FRR.js';
import { ROSConfig } from '../pages/ROS.js';
import { DNSMASQConfig } from '../pages/DNSMASQ.js';
import { SRMPage } from '../pages/SRM.js';
import { SettingsPage } from '../pages/Settings.js';
import { LogsPage } from '../pages/Logs.js';
import { NetworkResourcesPage } from '../pages/NetworkResources.js';
@ -33,9 +30,6 @@ const allMenuItems = [
{ label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' },
{ label: 'Настройка TDNS', path: 'tdns', component: 'ru.kirillius.pf.sdn.External.API.Components.TDNS' },
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' },
{ label: 'Настройка ROS', path: 'ros', component: 'ru.kirillius.pf.sdn.External.API.Components.ROS' },
{ label: 'Настройка DNSMASQ', path: 'dnsmasq', component: 'ru.kirillius.pf.sdn.External.API.Components.DNSMASQ' },
{ label: 'SRM', path: 'srm', component: 'ru.kirillius.pf.sdn.External.API.Components.SRM' },
{ label: 'Журнал', path: 'logs', component: null },
{ label: 'Сетевые ресурсы', path: 'network-resources', component: null },
{ label: 'Разблокировка сайта', path: 'unlock-site', component: null },
@ -84,21 +78,6 @@ const routes = {
mount: FRRConfig.mount,
unmount: FRRConfig.unmount
},
'#ros': {
render: ROSConfig.render,
mount: ROSConfig.mount,
unmount: ROSConfig.unmount
},
'#dnsmasq': {
render: DNSMASQConfig.render,
mount: DNSMASQConfig.mount,
unmount: DNSMASQConfig.unmount
},
'#srm': {
render: SRMPage.render,
mount: SRMPage.mount,
unmount: SRMPage.unmount
},
'#logs': {
render: LogsPage.render,
mount: LogsPage.mount,

View File

@ -1,284 +0,0 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
const DNSMASQ_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.DNSMASQ';
const FIELD_IDS = {
container: 'dnsmasq-config-container',
instancesList: 'dnsmasq-instances-list',
addInstanceButton: 'dnsmasq-add-instance-btn',
saveButton: 'save-dnsmasq-btn',
status: 'dnsmasq-status-message'
};
const CLASS_NAMES = {
instance: 'dnsmasq-instance-entry',
removeInstanceButton: 'dnsmasq-remove-instance-btn',
host: 'dnsmasq-host',
user: 'dnsmasq-user',
password: 'dnsmasq-password',
forwarder: 'dnsmasq-forwarder'
};
const SELECTORS = {
container: `#${FIELD_IDS.container}`,
instancesList: `#${FIELD_IDS.instancesList}`,
addInstanceButton: `#${FIELD_IDS.addInstanceButton}`,
saveButton: `#${FIELD_IDS.saveButton}`,
status: `#${FIELD_IDS.status}`
};
let currentConfig = { instances: [] };
let statusTimeoutId = null;
let instanceCounter = 0;
const getStatusElement = () => $(SELECTORS.status);
function normalizeConfig(config) {
if (!config || !Array.isArray(config.instances)) {
return { instances: [] };
}
return {
instances: config.instances.map(instance => ({
host: instance?.host || '127.0.0.1',
user: instance?.user || '',
password: instance?.password || '',
forwarder: instance?.forwarder || '127.0.0.1'
}))
};
}
function clearStatus() {
const $status = getStatusElement();
if (!$status.length) {
return;
}
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
$status.stop(true, true).hide().text('').removeClass('success-message error-message');
}
function updateStatus(message, type) {
const $status = getStatusElement();
if (!$status.length) {
return;
}
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
}
$status
.removeClass('success-message error-message')
.addClass(type === 'success' ? 'success-message' : 'error-message')
.text(message)
.show();
statusTimeoutId = window.setTimeout(() => {
$status.fadeOut();
}, 5000);
}
async function runAction($button, pendingText, action, messages) {
if (!$button.length) {
return;
}
const originalText = $button.text();
$button.prop('disabled', true).text(pendingText);
clearStatus();
try {
const result = await action();
if (messages?.success) {
const successMessage = typeof messages.success === 'function'
? messages.success(result)
: messages.success;
if (successMessage) {
updateStatus(successMessage, 'success');
}
}
} catch (error) {
console.error(messages?.log || 'Ошибка выполнения действия DNSMASQ:', error);
if (messages?.error) {
updateStatus(messages.error, 'error');
}
} finally {
$button.prop('disabled', false).text(originalText);
}
}
function resetCounters() {
instanceCounter = 0;
}
function getNextInstanceId() {
instanceCounter += 1;
return instanceCounter;
}
function createInstanceRow(instance = {}) {
const instanceId = getNextInstanceId();
return `
<div class="${CLASS_NAMES.instance}" data-instance-id="${instanceId}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0;">Инстанс</h4>
<button type="button" class="btn-link ${CLASS_NAMES.removeInstanceButton}">Удалить</button>
</div>
<div class="form-group">
<label for="dnsmasq-host-${instanceId}">Хост</label>
<input type="text" id="dnsmasq-host-${instanceId}" class="form-control ${CLASS_NAMES.host}" placeholder="http://127.0.0.1:9090" value="${instance.host || '127.0.0.1'}">
</div>
<div class="form-group">
<label for="dnsmasq-user-${instanceId}">Имя пользователя</label>
<input type="text" id="dnsmasq-user-${instanceId}" class="form-control ${CLASS_NAMES.user}" value="${instance.user || ''}">
</div>
<div class="form-group">
<label for="dnsmasq-password-${instanceId}">Пароль</label>
<input type="password" id="dnsmasq-password-${instanceId}" class="form-control ${CLASS_NAMES.password}" placeholder="Оставьте пустым для сохранения текущего пароля" data-original-password="${instance.password || ''}">
</div>
<div class="form-group">
<label for="dnsmasq-forwarder-${instanceId}">Forwarder</label>
<input type="text" id="dnsmasq-forwarder-${instanceId}" class="form-control ${CLASS_NAMES.forwarder}" placeholder="127.0.0.1" value="${instance.forwarder || '127.0.0.1'}">
</div>
</div>
`;
}
function populateInstances() {
const instances = currentConfig.instances.length ? currentConfig.instances : [];
const $list = $(SELECTORS.instancesList);
resetCounters();
$list.empty();
instances.forEach(instance => {
$list.append(createInstanceRow(instance));
});
}
function renderDNSMASQForm() {
const $container = $(SELECTORS.container);
$container.html(`
<div class="component-config-form">
<h3 class="config-section-title">Инстансы DNSMASQ</h3>
<p class="hint-text" style="margin-bottom: 20px;">Настройте параметры подключения к DNSMASQ для синхронизации списков серверов. Можно добавить несколько инстансов.</p>
<div id="${FIELD_IDS.instancesList}"></div>
<div class="form-group" style="margin-top: 10px;">
<button type="button" id="${FIELD_IDS.addInstanceButton}" class="btn-secondary" style="width: 220px;">Добавить инстанс</button>
</div>
<div class="action-buttons" style="margin-top: 40px;">
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 220px;">Применить Конфигурацию</button>
</div>
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
</div>
`);
populateInstances();
attachEventHandlers();
}
function collectConfigFromForm() {
const instances = [];
$(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => {
const $instance = $(element);
const host = $instance.find(`.${CLASS_NAMES.host}`).val().trim();
const user = $instance.find(`.${CLASS_NAMES.user}`).val().trim();
const $passwordField = $instance.find(`.${CLASS_NAMES.password}`);
const newPassword = $passwordField.val();
const originalPassword = $passwordField.data('original-password') || '';
const password = newPassword ? newPassword : originalPassword;
const forwarder = $instance.find(`.${CLASS_NAMES.forwarder}`).val().trim();
instances.push({
host,
user,
password,
forwarder
});
});
return { instances };
}
async function loadConfig() {
try {
const fullConfig = await JSONRPC.System.getComponentConfig(DNSMASQ_COMPONENT_NAME);
currentConfig = normalizeConfig(fullConfig);
return true;
} catch (error) {
console.error('Ошибка при загрузке конфига DNSMASQ:', error);
currentConfig = { instances: [] };
return false;
}
}
function handleAddInstance() {
const $list = $(SELECTORS.instancesList);
$list.append(createInstanceRow({
host: '127.0.0.1',
user: '',
password: '',
forwarder: '127.0.0.1'
}));
}
function handleRemoveInstance(event) {
event.preventDefault();
$(event.currentTarget).closest(`.${CLASS_NAMES.instance}`).remove();
}
async function handleSave() {
const $button = $(SELECTORS.saveButton);
clearStatus();
const newConfig = collectConfigFromForm();
await runAction($button, 'Применение...', async () => {
await JSONRPC.System.setComponentConfig(DNSMASQ_COMPONENT_NAME, newConfig);
currentConfig = normalizeConfig(newConfig);
populateInstances();
}, {
success: 'Конфигурация DNSMASQ успешно сохранена.',
error: 'Ошибка при сохранении конфигурации DNSMASQ.',
log: 'Ошибка сохранения конфига DNSMASQ'
});
}
function attachEventHandlers() {
$(SELECTORS.saveButton).off('click').on('click', handleSave);
$(SELECTORS.addInstanceButton).off('click').on('click', handleAddInstance);
$(SELECTORS.instancesList)
.off('click', `.${CLASS_NAMES.removeInstanceButton}`).on('click', `.${CLASS_NAMES.removeInstanceButton}`, handleRemoveInstance);
}
function detachEventHandlers() {
$(SELECTORS.saveButton).off('click');
$(SELECTORS.addInstanceButton).off('click');
$(SELECTORS.instancesList)
.off('click', `.${CLASS_NAMES.removeInstanceButton}`);
}
export const DNSMASQConfig = {
render: () => `
<h1 class="page-title">Настройка DNSMASQ</h1>
<div id="${FIELD_IDS.container}">
<p>Загрузка конфигурации...</p>
</div>
`,
mount: async () => {
const success = await loadConfig();
if (success) {
renderDNSMASQForm();
} else {
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию DNSMASQ.</p>');
}
},
unmount: () => {
detachEventHandlers();
clearStatus();
currentConfig = { instances: [] };
}
};

View File

@ -1,293 +0,0 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
const ROS_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.ROS';
const FIELD_IDS = {
container: 'ros-config-container',
instancesList: 'ros-instances-list',
addInstanceButton: 'ros-add-instance-btn',
saveButton: 'save-ros-btn',
status: 'ros-status-message'
};
const CLASS_NAMES = {
instance: 'ros-instance-entry',
removeInstanceButton: 'ros-remove-instance-btn',
host: 'ros-host',
username: 'ros-username',
password: 'ros-password',
vrf: 'ros-vrf',
gateway: 'ros-gateway'
};
const SELECTORS = {
container: `#${FIELD_IDS.container}`,
instancesList: `#${FIELD_IDS.instancesList}`,
addInstanceButton: `#${FIELD_IDS.addInstanceButton}`,
saveButton: `#${FIELD_IDS.saveButton}`,
status: `#${FIELD_IDS.status}`
};
let currentConfig = { instances: [] };
let statusTimeoutId = null;
let instanceCounter = 0;
const getStatusElement = () => $(SELECTORS.status);
function normalizeConfig(config) {
if (!config || !Array.isArray(config.instances)) {
return { instances: [] };
}
return {
instances: config.instances.map(instance => ({
host: instance?.host || '',
username: instance?.username || '',
password: instance?.password || '',
VRF: instance?.VRF || 'main',
gateway: instance?.gateway || ''
}))
};
}
function clearStatus() {
const $status = getStatusElement();
if (!$status.length) {
return;
}
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
$status.stop(true, true).hide().text('').removeClass('success-message error-message');
}
function updateStatus(message, type) {
const $status = getStatusElement();
if (!$status.length) {
return;
}
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
}
$status
.removeClass('success-message error-message')
.addClass(type === 'success' ? 'success-message' : 'error-message')
.text(message)
.show();
statusTimeoutId = window.setTimeout(() => {
$status.fadeOut();
}, 5000);
}
async function runAction($button, pendingText, action, messages) {
if (!$button.length) {
return;
}
const originalText = $button.text();
$button.prop('disabled', true).text(pendingText);
clearStatus();
try {
const result = await action();
if (messages?.success) {
const successMessage = typeof messages.success === 'function'
? messages.success(result)
: messages.success;
if (successMessage) {
updateStatus(successMessage, 'success');
}
}
} catch (error) {
console.error(messages?.log || 'Ошибка выполнения действия ROS:', error);
if (messages?.error) {
updateStatus(messages.error, 'error');
}
} finally {
$button.prop('disabled', false).text(originalText);
}
}
function resetCounters() {
instanceCounter = 0;
}
function getNextInstanceId() {
instanceCounter += 1;
return instanceCounter;
}
function createInstanceRow(instance = {}) {
const instanceId = getNextInstanceId();
return `
<div class="${CLASS_NAMES.instance}" data-instance-id="${instanceId}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0;">Инстанс</h4>
<button type="button" class="btn-link ${CLASS_NAMES.removeInstanceButton}">Удалить</button>
</div>
<div class="form-group">
<label for="ros-host-${instanceId}">Хост</label>
<input type="text" id="ros-host-${instanceId}" class="form-control ${CLASS_NAMES.host}" value="${instance.host || ''}">
</div>
<div class="form-group">
<label for="ros-username-${instanceId}">Имя пользователя</label>
<input type="text" id="ros-username-${instanceId}" class="form-control ${CLASS_NAMES.username}" value="${instance.username || ''}">
</div>
<div class="form-group">
<label for="ros-password-${instanceId}">Пароль</label>
<input type="password" id="ros-password-${instanceId}" class="form-control ${CLASS_NAMES.password}" placeholder="Оставьте пустым для сохранения текущего пароля" data-original-password="${instance.password || ''}">
</div>
<div class="form-group">
<label for="ros-vrf-${instanceId}">VRF (роутинг-таблица)</label>
<input type="text" id="ros-vrf-${instanceId}" class="form-control ${CLASS_NAMES.vrf}" value="${instance.VRF || 'main'}">
</div>
<div class="form-group">
<label for="ros-gateway-${instanceId}">Шлюз</label>
<input type="text" id="ros-gateway-${instanceId}" class="form-control ${CLASS_NAMES.gateway}" placeholder="127.0.0.1" value="${instance.gateway || ''}">
</div>
</div>
`;
}
function populateInstances() {
const instances = currentConfig.instances.length ? currentConfig.instances : [];
const $list = $(SELECTORS.instancesList);
resetCounters();
$list.empty();
instances.forEach(instance => {
$list.append(createInstanceRow(instance));
});
}
function renderROSForm() {
const $container = $(SELECTORS.container);
$container.html(`
<div class="component-config-form">
<h3 class="config-section-title">Инстансы RouterOS</h3>
<p class="hint-text" style="margin-bottom: 20px;">Настройте параметры подключения к MikroTik RouterOS для синхронизации маршрутов. Можно добавить несколько инстансов.</p>
<div id="${FIELD_IDS.instancesList}"></div>
<div class="form-group" style="margin-top: 10px;">
<button type="button" id="${FIELD_IDS.addInstanceButton}" class="btn-secondary" style="width: 220px;">Добавить инстанс</button>
</div>
<div class="action-buttons" style="margin-top: 40px;">
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 220px;">Применить Конфигурацию</button>
</div>
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
</div>
`);
populateInstances();
attachEventHandlers();
}
function collectConfigFromForm() {
const instances = [];
$(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => {
const $instance = $(element);
const host = $instance.find(`.${CLASS_NAMES.host}`).val().trim();
const username = $instance.find(`.${CLASS_NAMES.username}`).val().trim();
const $passwordField = $instance.find(`.${CLASS_NAMES.password}`);
const newPassword = $passwordField.val();
const originalPassword = $passwordField.data('original-password') || '';
const password = newPassword ? newPassword : originalPassword;
const VRF = $instance.find(`.${CLASS_NAMES.vrf}`).val().trim();
const gateway = $instance.find(`.${CLASS_NAMES.gateway}`).val().trim();
instances.push({
host,
username,
password,
VRF,
gateway
});
});
return { instances };
}
async function loadConfig() {
try {
const fullConfig = await JSONRPC.System.getComponentConfig(ROS_COMPONENT_NAME);
currentConfig = normalizeConfig(fullConfig);
return true;
} catch (error) {
console.error('Ошибка при загрузке конфига ROS:', error);
currentConfig = { instances: [] };
return false;
}
}
function handleAddInstance() {
const $list = $(SELECTORS.instancesList);
$list.append(createInstanceRow({
host: '',
username: '',
password: '',
VRF: 'main',
gateway: ''
}));
}
function handleRemoveInstance(event) {
event.preventDefault();
$(event.currentTarget).closest(`.${CLASS_NAMES.instance}`).remove();
}
async function handleSave() {
const $button = $(SELECTORS.saveButton);
clearStatus();
const newConfig = collectConfigFromForm();
await runAction($button, 'Применение...', async () => {
await JSONRPC.System.setComponentConfig(ROS_COMPONENT_NAME, newConfig);
currentConfig = normalizeConfig(newConfig);
populateInstances();
}, {
success: 'Конфигурация ROS успешно сохранена.',
error: 'Ошибка при сохранении конфигурации ROS.',
log: 'Ошибка сохранения конфига ROS'
});
}
function attachEventHandlers() {
$(SELECTORS.saveButton).off('click').on('click', handleSave);
$(SELECTORS.addInstanceButton).off('click').on('click', handleAddInstance);
$(SELECTORS.instancesList)
.off('click', `.${CLASS_NAMES.removeInstanceButton}`).on('click', `.${CLASS_NAMES.removeInstanceButton}`, handleRemoveInstance);
}
function detachEventHandlers() {
$(SELECTORS.saveButton).off('click');
$(SELECTORS.addInstanceButton).off('click');
$(SELECTORS.instancesList)
.off('click', `.${CLASS_NAMES.removeInstanceButton}`);
}
export const ROSConfig = {
render: () => `
<h1 class="page-title">Настройка ROS</h1>
<div id="${FIELD_IDS.container}">
<p>Загрузка конфигурации...</p>
</div>
`,
mount: async () => {
const success = await loadConfig();
if (success) {
renderROSForm();
} else {
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию ROS.</p>');
}
},
unmount: () => {
detachEventHandlers();
clearStatus();
currentConfig = { instances: [] };
}
};

View File

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

View File

@ -87,14 +87,10 @@ async function renderSystemAndManagerStatus() {
const currentVersion = (versionInfo && versionInfo.current) || '';
const downloadedVersion = (versionInfo && versionInfo.downloaded) || '';
configChanged = await JSONRPC.System.isConfigChanged();
const isExternalManaged = await JSONRPC.System.isExternalManaged();
const hasAvailableUpdate = availableVersion && availableVersion !== currentVersion;
const isDownloaded = hasAvailableUpdate && downloadedVersion === availableVersion;
const updateButtonDisabled = !hasAvailableUpdate || isDownloaded || isExternalManaged;
const updateButtonDisabled = !hasAvailableUpdate || isDownloaded;
const systemStatus = (() => {
if (isExternalManaged) {
return 'Управляется извне';
}
if (!hasAvailableUpdate) {
return 'Нет обновлений';
}
@ -108,7 +104,6 @@ async function renderSystemAndManagerStatus() {
<span>Текущая версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${currentVersion || '-'}</span></span><br />
<span class="last-version">Последняя версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${availableVersion || '-'}</span></span>
</div>
${isExternalManaged ? '<p class="status-line text-red-500">Обновление ПО управляется внешней системой, установка из веб-интерфейса недоступна.</p>' : ''}
<p class="status-line ${configChanged ? 'text-yellow-500' : 'text-green-500'}">
Конфигурация: ${configChanged ? 'изменена' : 'актуальна'}
</p>
@ -230,15 +225,8 @@ function attachEventHandlers() {
$btn.prop('disabled', true).text('Обновление...');
try {
const updatePromise = JSONRPC.System.doUpdate();
await JSONRPC.System.doUpdate();
alert('Обновление ПО запущено!');
await updatePromise;
if (confirm('Обновление завершено. Перезагрузить?')) {
await JSONRPC.System.restart();
setTimeout(() => {
window.location.reload();
}, 10000);
}
await renderSystemAndManagerStatus();
} catch (e) {
alert('Ошибка при запуске обновления ПО!');

View File

@ -53,6 +53,12 @@ function renderSubscriptionList() {
}).join('');
const html = `
<div class="select-all-container" style="margin-bottom: 10px;">
<label>
<input type="checkbox" id="select-all-checkbox">
Выделить все
</label>
</div>
<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>
@ -63,6 +69,11 @@ function renderSubscriptionList() {
}
function attachEventHandlers() {
$('#select-all-checkbox').on('change', function() {
const isChecked = $(this).is(':checked');
$('.subscription-checkbox').prop('checked', isChecked);
});
$('#save-subscriptions-btn').on('click', async function () {
const $btn = $(this);
const $message = $('#subscription-status-message');

View File

@ -308,7 +308,8 @@ function handleAdd() {
selectedSubnets,
selectedAddresses,
storage,
true
true,
false
).then(() => {
setStatus('Ресурсы успешно добавлены.', 'success');
}).catch(error => {