+ site unlocker
This commit is contained in:
parent
5e3666e899
commit
5bcc7d9549
|
|
@ -45,3 +45,4 @@ ovpn-connector.json
|
||||||
/webui/src/json-rpc.js
|
/webui/src/json-rpc.js
|
||||||
app/src/main/resources/htdocs/
|
app/src/main/resources/htdocs/
|
||||||
*.pfapp
|
*.pfapp
|
||||||
|
cache/
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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.Components.TDNS;
|
||||||
import ru.kirillius.pf.sdn.External.API.GitSubscription;
|
import ru.kirillius.pf.sdn.External.API.GitSubscription;
|
||||||
import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
|
import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
|
||||||
|
import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription;
|
||||||
import ru.kirillius.pf.sdn.core.*;
|
import ru.kirillius.pf.sdn.core.*;
|
||||||
import ru.kirillius.pf.sdn.core.Auth.AuthManager;
|
import ru.kirillius.pf.sdn.core.Auth.AuthManager;
|
||||||
import ru.kirillius.pf.sdn.core.Auth.TokenService;
|
import ru.kirillius.pf.sdn.core.Auth.TokenService;
|
||||||
|
|
@ -62,7 +63,8 @@ public class App implements Context, Closeable {
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
loadedConfig = new Config();
|
loadedConfig = new Config();
|
||||||
loadedConfig.setSubscriptions(new ArrayList<>(List.of(
|
loadedConfig.setSubscriptions(new ArrayList<>(List.of(
|
||||||
new RepositoryConfig("updates", GitSubscription.class, "https://git.kirillius.ru/kirillius/protected-resources-list.git", "")
|
new RepositoryConfig("updates", GitSubscription.class, "https://git.kirillius.ru/kirillius/protected-resources-list.git", ""),
|
||||||
|
new RepositoryConfig("local", LocalFilesystemSubscription.class, "/etc/pfsdn.res.d", "")
|
||||||
)));
|
)));
|
||||||
try {
|
try {
|
||||||
Config.store(loadedConfig, launcherConfig.getConfigFile());
|
Config.store(loadedConfig, launcherConfig.getConfigFile());
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves ASN prefix information from Hurricane Electric's public API.
|
* Retrieves ASN prefix information from Hurricane Electric's public API.
|
||||||
|
|
@ -44,6 +46,28 @@ public class HEInfoProvider implements ASInfoProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IPQueryInfo queryAddress(String address) {
|
||||||
|
var request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://bgp.he.net/ip/" + address))
|
||||||
|
.header("Accept", "text/html")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (var client = HttpClient.newHttpClient()) {
|
||||||
|
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
SystemLogger.error("Unable to get info about IP " + address + ", status " + response.statusCode(), CTX);
|
||||||
|
return emptyIPInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseIPInfo(response.body());
|
||||||
|
} catch (Exception e) {
|
||||||
|
SystemLogger.error("Failed to query info about IP " + address + ": " + e.getMessage(), CTX);
|
||||||
|
return emptyIPInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses IPv4 prefix entries from the Hurricane Electric API response.
|
* Parses IPv4 prefix entries from the Hurricane Electric API response.
|
||||||
*/
|
*/
|
||||||
|
|
@ -65,4 +89,50 @@ public class HEInfoProvider implements ASInfoProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final static String CTX = HEInfoProvider.class.getSimpleName();
|
private final static String CTX = HEInfoProvider.class.getSimpleName();
|
||||||
|
|
||||||
|
private static IPQueryInfo parseIPInfo(String html) {
|
||||||
|
var asnSet = new LinkedHashSet<Integer>();
|
||||||
|
var prefixes = new ArrayList<IPv4Subnet>();
|
||||||
|
|
||||||
|
var rowMatcher = ROW_PATTERN.matcher(html);
|
||||||
|
while (rowMatcher.find()) {
|
||||||
|
var row = rowMatcher.group(1);
|
||||||
|
|
||||||
|
var asMatcher = AS_PATTERN.matcher(row);
|
||||||
|
if (!asMatcher.find()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prefixMatcher = PREFIX_PATTERN.matcher(row);
|
||||||
|
if (!prefixMatcher.find()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var asn = Integer.parseInt(asMatcher.group(1));
|
||||||
|
asnSet.add(asn);
|
||||||
|
|
||||||
|
var prefix = prefixMatcher.group(1).replace("\u00A0", "").trim();
|
||||||
|
prefixes.add(new IPv4Subnet(prefix));
|
||||||
|
} catch (Exception e) {
|
||||||
|
SystemLogger.error("Unable to parse row: " + row, CTX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IPQueryInfo.builder()
|
||||||
|
.ASN(new ArrayList<>(asnSet))
|
||||||
|
.prefixes(prefixes)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPQueryInfo emptyIPInfo() {
|
||||||
|
return IPQueryInfo.builder()
|
||||||
|
.ASN(Collections.emptyList())
|
||||||
|
.prefixes(Collections.emptyList())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Pattern ROW_PATTERN = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL);
|
||||||
|
private static final Pattern AS_PATTERN = Pattern.compile("<a\\s+href=\"/AS(\\d+)\">\\s*AS\\d+\\s*</a>");
|
||||||
|
private static final Pattern PREFIX_PATTERN = Pattern.compile("<a\\s+href=\"/net/[^\"]+\">\\s*([0-9.]+/\\d+)\\s*</a>");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,33 @@
|
||||||
package ru.kirillius.pf.sdn.web.RPC;
|
package ru.kirillius.pf.sdn.web.RPC;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
import ru.kirillius.json.DefaultPropertySerializer;
|
||||||
|
import ru.kirillius.json.JSONArrayProperty;
|
||||||
|
import ru.kirillius.json.JSONSerializable;
|
||||||
import ru.kirillius.json.JSONUtility;
|
import ru.kirillius.json.JSONUtility;
|
||||||
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
|
import ru.kirillius.json.rpc.Annotations.JRPCArgument;
|
||||||
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
|
import ru.kirillius.json.rpc.Annotations.JRPCMethod;
|
||||||
|
import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription;
|
||||||
import ru.kirillius.pf.sdn.core.Context;
|
import ru.kirillius.pf.sdn.core.Context;
|
||||||
|
import ru.kirillius.pf.sdn.core.Networking.BGPInfoService;
|
||||||
|
import ru.kirillius.pf.sdn.core.Networking.IPv4Subnet;
|
||||||
|
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
|
||||||
import ru.kirillius.pf.sdn.core.Networking.NetworkingService;
|
import ru.kirillius.pf.sdn.core.Networking.NetworkingService;
|
||||||
|
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionService;
|
||||||
|
import ru.kirillius.pf.sdn.core.Util.DomainUtil;
|
||||||
|
import ru.kirillius.pf.sdn.core.Util.IPv4Util;
|
||||||
|
import ru.kirillius.pf.sdn.core.Util.Wait;
|
||||||
import ru.kirillius.pf.sdn.web.ProtectedMethod;
|
import ru.kirillius.pf.sdn.web.ProtectedMethod;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON-RPC handler exposing operations on the network aggregation service.
|
* JSON-RPC handler exposing operations on the network aggregation service.
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,4 +67,112 @@ public class NetworkManager implements RPC {
|
||||||
public JSONObject getOutputResources() {
|
public JSONObject getOutputResources() {
|
||||||
return JSONUtility.serializeStructure(context.getServiceManager().getService(NetworkingService.class).getOutputResources());
|
return JSONUtility.serializeStructure(context.getServiceManager().getService(NetworkingService.class).getOutputResources());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JRPCMethod
|
||||||
|
@ProtectedMethod
|
||||||
|
public void createLocalResourceFile(
|
||||||
|
@JRPCArgument(name = "name") String name,
|
||||||
|
@JRPCArgument(name = "domain") String domain,
|
||||||
|
@JRPCArgument(name = "ASN") JSONArray ASN,
|
||||||
|
@JRPCArgument(name = "subnets") JSONArray subnets,
|
||||||
|
@JRPCArgument(name = "addresses") JSONArray addresses) throws IOException {
|
||||||
|
|
||||||
|
|
||||||
|
var optional = context.getConfig().getSubscriptions().stream().filter(repositoryConfig -> repositoryConfig.getType().equals(LocalFilesystemSubscription.class)).findFirst();
|
||||||
|
if (optional.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Unable to find any local repository of subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
var repositoryConfig = optional.get();
|
||||||
|
|
||||||
|
var directory = new File(repositoryConfig.getSource());
|
||||||
|
if (!directory.exists()) {
|
||||||
|
throw new FileNotFoundException("Unable to find directory " + directory.getAbsolutePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(directory, name + ".json"))))) {
|
||||||
|
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()
|
||||||
|
.ASN(asnList)
|
||||||
|
.subnets(merged)
|
||||||
|
.domains(List.of(domain))
|
||||||
|
.description("Unlocked resource: " + name + " (" + domain + ")")
|
||||||
|
.build()
|
||||||
|
).toString(2));
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribedResources = context.getConfig().getSubscribedResources();
|
||||||
|
subscribedResources.add(repositoryConfig.getName() + ":" + name);
|
||||||
|
|
||||||
|
context.getServiceManager().getService(SubscriptionService.class).triggerUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@JRPCMethod
|
||||||
|
@ProtectedMethod
|
||||||
|
public JSONObject discoverBlockedDomainResources(@JRPCArgument(name = "domain") String domain, @JRPCArgument(name = "server") String nameserver) {
|
||||||
|
|
||||||
|
var addresses = DomainUtil.lookup(domain, nameserver);
|
||||||
|
|
||||||
|
var discoveredASN = new HashSet<Integer>();
|
||||||
|
var discoveredSubnets = new HashSet<IPv4Subnet>();
|
||||||
|
|
||||||
|
var infoService = context.getServiceManager().getService(BGPInfoService.class);
|
||||||
|
|
||||||
|
addresses.forEach(address -> {
|
||||||
|
if (discoveredSubnets.stream().anyMatch(subnet -> subnet.contains(address))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var future = infoService.getAddressInfo(address);
|
||||||
|
try {
|
||||||
|
Wait.until(() -> future.isDone() || future.isCancelled());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!future.isDone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var info = future.get();
|
||||||
|
discoveredASN.addAll(info.getASN());
|
||||||
|
discoveredSubnets.addAll(info.getPrefixes());
|
||||||
|
} catch (InterruptedException | ExecutionException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var merged = IPv4Util.summarySubnets(discoveredSubnets, 100);
|
||||||
|
|
||||||
|
return JSONUtility.serializeStructure(
|
||||||
|
DomainQueryInfo.builder()
|
||||||
|
.addresses(addresses)
|
||||||
|
.ASN(discoveredASN.stream().toList())
|
||||||
|
.subnets(merged.getResult())
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@JSONSerializable
|
||||||
|
public static class DomainQueryInfo {
|
||||||
|
@Getter
|
||||||
|
@JSONArrayProperty(type = String.class)
|
||||||
|
private List<String> addresses;
|
||||||
|
@JSONArrayProperty(type = IPv4Subnet.class, serializer = IPv4Subnet.Serializer.class)
|
||||||
|
@Getter
|
||||||
|
private List<IPv4Subnet> subnets;
|
||||||
|
@Getter
|
||||||
|
@JSONArrayProperty(type = Integer.class)
|
||||||
|
private List<Integer> ASN;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package ru.kirillius.pf.sdn.External.API;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class TDNS {
|
|
||||||
@Test
|
|
||||||
public void t() throws IOException {
|
|
||||||
try (TDNSAPI t = new TDNSAPI("http://8.8.8.8", "sdfdfdsf")) {
|
|
||||||
t.getZones();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package ru.kirillius.pf.sdn.core.Networking;
|
package ru.kirillius.pf.sdn.core.Networking;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
import ru.kirillius.pf.sdn.core.Context;
|
import ru.kirillius.pf.sdn.core.Context;
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
|
@ -14,6 +16,17 @@ public interface ASInfoProvider {
|
||||||
*/
|
*/
|
||||||
List<IPv4Subnet> getPrefixes(int as);
|
List<IPv4Subnet> getPrefixes(int as);
|
||||||
|
|
||||||
|
IPQueryInfo queryAddress(String address);
|
||||||
|
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
class IPQueryInfo {
|
||||||
|
@Getter
|
||||||
|
private List<Integer> ASN;
|
||||||
|
@Getter
|
||||||
|
private List<IPv4Subnet> prefixes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiates a provider class using the context-aware constructor.
|
* Instantiates a provider class using the context-aware constructor.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ public class BGPInfoService extends AppService {
|
||||||
return executor.submit(() -> provider.getPrefixes(as));
|
return executor.submit(() -> provider.getPrefixes(as));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Future<ASInfoProvider.IPQueryInfo> getAddressInfo(String address) {
|
||||||
|
return executor.submit(() -> provider.queryAddress(address));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuts down the background executor.
|
* Shuts down the background executor.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -130,4 +130,11 @@ public class IPv4Subnet {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public boolean contains(String address){
|
||||||
|
var longAddress = IPv4Util.ipAddressToLong(address);
|
||||||
|
var commonMask = IPv4Util.calculateMask(prefixLength);
|
||||||
|
return (this.longAddress & commonMask) == (longAddress & commonMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package ru.kirillius.pf.sdn.core.Util;
|
||||||
|
|
||||||
|
import ru.kirillius.utils.logging.SystemLogger;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves domain names to IPv4 addresses using the system {@code nslookup} command.
|
||||||
|
*/
|
||||||
|
public final class DomainUtil {
|
||||||
|
private static final String CTX = DomainUtil.class.getSimpleName();
|
||||||
|
|
||||||
|
private DomainUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of IPv4 addresses resolved for the provided domain.
|
||||||
|
*/
|
||||||
|
public static List<String> lookup(String domain, String server) {
|
||||||
|
if (domain == null || domain.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Domain must not be null or blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
var processBuilder = new ProcessBuilder("nslookup", domain.trim(), server.trim());
|
||||||
|
processBuilder.redirectErrorStream(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var process = processBuilder.start();
|
||||||
|
if (!process.waitFor(10, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
SystemLogger.error("nslookup timed out for domain " + domain, CTX);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
var output = readStream(process.getInputStream());
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
SystemLogger.error("nslookup failed for domain " + domain + ": " + output, CTX);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractIPv4(output);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
SystemLogger.error("nslookup was interrupted for domain " + domain + ": " + e.getMessage(), CTX);
|
||||||
|
} catch (IOException e) {
|
||||||
|
SystemLogger.error("Failed to execute nslookup for domain " + domain + ": " + e.getMessage(), CTX);
|
||||||
|
} catch (Exception e) {
|
||||||
|
SystemLogger.error("Unexpected error during nslookup for domain " + domain + ": " + e.getMessage(), CTX);
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> extractIPv4(String output) {
|
||||||
|
var addresses = new LinkedHashSet<String>();
|
||||||
|
var allowAddresses = false;
|
||||||
|
|
||||||
|
for (var rawLine : output.split("\\R")) {
|
||||||
|
var line = rawLine.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
allowAddresses = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("Name:")) {
|
||||||
|
allowAddresses = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAddresses) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("Address:") || line.startsWith("Addresses:")) {
|
||||||
|
var colonIndex = line.indexOf(':');
|
||||||
|
if (colonIndex == -1 || colonIndex == line.length() - 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokens = line.substring(colonIndex + 1).trim().split("\\s+");
|
||||||
|
for (var token : tokens) {
|
||||||
|
try {
|
||||||
|
IPv4Util.validateAddress(token);
|
||||||
|
addresses.add(token);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// skip invalid addresses such as IPv6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArrayList<>(addresses);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readStream(InputStream stream) throws IOException {
|
||||||
|
try (stream) {
|
||||||
|
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { FRRConfig } from '../pages/FRR.js';
|
||||||
import { SettingsPage } from '../pages/Settings.js';
|
import { SettingsPage } from '../pages/Settings.js';
|
||||||
import { LogsPage } from '../pages/Logs.js';
|
import { LogsPage } from '../pages/Logs.js';
|
||||||
import { NetworkResourcesPage } from '../pages/NetworkResources.js';
|
import { NetworkResourcesPage } from '../pages/NetworkResources.js';
|
||||||
|
import { UnlockSitePage } from '../pages/UnlockSite.js';
|
||||||
|
|
||||||
|
|
||||||
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
|
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
|
||||||
|
|
@ -30,6 +31,7 @@ const allMenuItems = [
|
||||||
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' },
|
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' },
|
||||||
{ label: 'Журнал', path: 'logs', component: null },
|
{ label: 'Журнал', path: 'logs', component: null },
|
||||||
{ label: 'Сетевые ресурсы', path: 'network-resources', component: null },
|
{ label: 'Сетевые ресурсы', path: 'network-resources', component: null },
|
||||||
|
{ label: 'Разблокировка сайта', path: 'unlock-site', component: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 2. Определение страниц
|
// 2. Определение страниц
|
||||||
|
|
@ -83,6 +85,11 @@ const routes = {
|
||||||
render: NetworkResourcesPage.render,
|
render: NetworkResourcesPage.render,
|
||||||
mount: NetworkResourcesPage.mount,
|
mount: NetworkResourcesPage.mount,
|
||||||
unmount: NetworkResourcesPage.unmount
|
unmount: NetworkResourcesPage.unmount
|
||||||
|
},
|
||||||
|
'#unlock-site': {
|
||||||
|
render: UnlockSitePage.render,
|
||||||
|
mount: UnlockSitePage.mount,
|
||||||
|
unmount: UnlockSitePage.unmount
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
import $ from 'jquery';
|
||||||
|
import { JSONRPC } from '@/json-rpc.js';
|
||||||
|
|
||||||
|
const FIELD_IDS = {
|
||||||
|
container: 'unlock-site-container',
|
||||||
|
form: 'unlock-site-form',
|
||||||
|
domainInput: 'unlock-site-domain',
|
||||||
|
serverInput: 'unlock-site-server',
|
||||||
|
searchButton: 'unlock-site-search',
|
||||||
|
results: 'unlock-site-results',
|
||||||
|
status: 'unlock-site-status',
|
||||||
|
addressesCheckbox: 'unlock-site-addresses-checkbox',
|
||||||
|
subnetsCheckbox: 'unlock-site-subnets-checkbox',
|
||||||
|
asnCheckbox: 'unlock-site-asn-checkbox',
|
||||||
|
nameInput: 'unlock-site-name',
|
||||||
|
addButton: 'unlock-site-add-button',
|
||||||
|
nameError: 'unlock-site-name-error',
|
||||||
|
domainError: 'unlock-site-domain-error',
|
||||||
|
serverError: 'unlock-site-server-error'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECTORS = Object.entries(FIELD_IDS).reduce((acc, [key, value]) => {
|
||||||
|
acc[key] = `#${value}`;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const DEFAULT_DOMAIN = 'google.com';
|
||||||
|
const DEFAULT_SERVER = '8.8.8.8';
|
||||||
|
|
||||||
|
let currentResults = { addresses: [], subnets: [], ASN: [] };
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListing(values) {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
.map(item => (item === null || item === undefined) ? '' : String(item))
|
||||||
|
.filter(item => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message, type) {
|
||||||
|
const $status = $(SELECTORS.status);
|
||||||
|
if (!$status.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
$status.hide().text('').removeClass('success-message error-message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$status
|
||||||
|
.removeClass('success-message error-message')
|
||||||
|
.addClass(type === 'success' ? 'success-message' : 'error-message')
|
||||||
|
.text(message)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFieldError(selector, message) {
|
||||||
|
const $error = $(selector);
|
||||||
|
if (!$error.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$error.text(message || '').toggle(!!message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFieldError(selector) {
|
||||||
|
showFieldError(selector, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDomain(value) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, message: 'Укажите домен.' };
|
||||||
|
}
|
||||||
|
const domainPattern = /^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$/;
|
||||||
|
if (!domainPattern.test(trimmed)) {
|
||||||
|
return { valid: false, message: 'Некорректный домен.' };
|
||||||
|
}
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateServer(value) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, message: 'Укажите DNS сервер.' };
|
||||||
|
}
|
||||||
|
const ipv4Pattern = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
|
||||||
|
const domainPattern = /^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$/;
|
||||||
|
if (!ipv4Pattern.test(trimmed) && !domainPattern.test(trimmed)) {
|
||||||
|
return { valid: false, message: 'Введите IP или домен DNS сервера.' };
|
||||||
|
}
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateListName(value) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { valid: false, message: 'Имя списка не может быть пустым.' };
|
||||||
|
}
|
||||||
|
const namePattern = /^[A-Za-z0-9.]+$/;
|
||||||
|
if (!namePattern.test(trimmed)) {
|
||||||
|
return { valid: false, message: 'Допустимы только символы A-Z, a-z, 0-9 и точка.' };
|
||||||
|
}
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListItems(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
return '<div class="hint-text" style="margin-top: 12px;">Список пуст.</div>';
|
||||||
|
}
|
||||||
|
return `<div style="margin-top: 12px; display: flex; flex-direction: column; gap: 6px;">${items.map(item => `<div>${escapeHtml(item)}</div>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAsnItems(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
return '<div class="hint-text" style="margin-top: 12px;">Список пуст.</div>';
|
||||||
|
}
|
||||||
|
return `<div style="margin-top: 12px;">${items.map(item => escapeHtml(item)).join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(domainValue) {
|
||||||
|
const addresses = normalizeListing(currentResults.addresses);
|
||||||
|
const subnets = normalizeListing(currentResults.subnets);
|
||||||
|
const ASN = normalizeListing(currentResults.ASN);
|
||||||
|
|
||||||
|
const addressesDisabled = addresses.length === 0;
|
||||||
|
const subnetsDisabled = subnets.length === 0;
|
||||||
|
const asnDisabled = ASN.length === 0;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="margin-top: 24px; display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<div class="card" style="padding: 20px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="checkbox" id="${FIELD_IDS.addressesCheckbox}" ${addressesDisabled ? 'disabled' : ''}>
|
||||||
|
<span>Добавить обнаруженные адреса</span>
|
||||||
|
</label>
|
||||||
|
${buildListItems(addresses)}
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding: 20px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="checkbox" id="${FIELD_IDS.subnetsCheckbox}" ${subnetsDisabled ? 'disabled' : ''}>
|
||||||
|
<span>Добавить подсети, содержащие обнаруженные адреса</span>
|
||||||
|
</label>
|
||||||
|
${buildListItems(subnets)}
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding: 20px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="checkbox" id="${FIELD_IDS.asnCheckbox}" ${asnDisabled ? 'disabled' : ''}>
|
||||||
|
<span>Добавить ASN, содержащие обнаруженные подсети</span>
|
||||||
|
</label>
|
||||||
|
${buildAsnItems(ASN)}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="${FIELD_IDS.nameInput}">Имя списка</label>
|
||||||
|
<input type="text" id="${FIELD_IDS.nameInput}" class="form-control" value="${escapeHtml(domainValue)}">
|
||||||
|
<div id="${FIELD_IDS.nameError}" class="error-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" id="${FIELD_IDS.addButton}" class="btn-primary" disabled>Добавить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const $results = $(SELECTORS.results);
|
||||||
|
$results.html(html).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAddButtonState() {
|
||||||
|
const $addButton = $(SELECTORS.addButton);
|
||||||
|
if (!$addButton.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const $nameInput = $(SELECTORS.nameInput);
|
||||||
|
const { valid } = validateListName($nameInput.val() || '');
|
||||||
|
const hasSelection = [
|
||||||
|
$(SELECTORS.addressesCheckbox),
|
||||||
|
$(SELECTORS.subnetsCheckbox),
|
||||||
|
$(SELECTORS.asnCheckbox)
|
||||||
|
].some($checkbox => $checkbox.length && !$checkbox.prop('disabled') && $checkbox.prop('checked'));
|
||||||
|
$addButton.prop('disabled', !(hasSelection && valid));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus('', '');
|
||||||
|
const $domainInput = $(SELECTORS.domainInput);
|
||||||
|
const $serverInput = $(SELECTORS.serverInput);
|
||||||
|
|
||||||
|
const domainValidation = validateDomain($domainInput.val() || '');
|
||||||
|
const serverValidation = validateServer($serverInput.val() || '');
|
||||||
|
|
||||||
|
if (!domainValidation.valid) {
|
||||||
|
showFieldError(SELECTORS.domainError, domainValidation.message);
|
||||||
|
} else {
|
||||||
|
clearFieldError(SELECTORS.domainError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverValidation.valid) {
|
||||||
|
showFieldError(SELECTORS.serverError, serverValidation.message);
|
||||||
|
} else {
|
||||||
|
clearFieldError(SELECTORS.serverError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domainValidation.valid || !serverValidation.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $button = $(SELECTORS.searchButton);
|
||||||
|
$button.prop('disabled', true).text('Поиск...');
|
||||||
|
$(SELECTORS.results).hide().empty();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await JSONRPC.NetworkManager.discoverBlockedDomainResources(
|
||||||
|
$domainInput.val().trim(),
|
||||||
|
$serverInput.val().trim()
|
||||||
|
);
|
||||||
|
currentResults = {
|
||||||
|
addresses: normalizeListing(result?.addresses),
|
||||||
|
subnets: normalizeListing(result?.subnets),
|
||||||
|
ASN: normalizeListing(result?.ASN)
|
||||||
|
};
|
||||||
|
renderResults($domainInput.val().trim());
|
||||||
|
updateAddButtonState();
|
||||||
|
clearFieldError(SELECTORS.nameError);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка поиска ресурсов домена:', error);
|
||||||
|
currentResults = { addresses: [], subnets: [], ASN: [] };
|
||||||
|
setStatus('Не удалось получить данные. Попробуйте позже.', 'error');
|
||||||
|
} finally {
|
||||||
|
$button.prop('disabled', false).text('Найти и разблокировать');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const $nameInput = $(SELECTORS.nameInput);
|
||||||
|
const validation = validateListName($nameInput.val() || '');
|
||||||
|
if (!validation.valid) {
|
||||||
|
showFieldError(SELECTORS.nameError, validation.message);
|
||||||
|
updateAddButtonState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showFieldError(SELECTORS.nameError, '');
|
||||||
|
|
||||||
|
const $addButton = $(SELECTORS.addButton);
|
||||||
|
$addButton.prop('disabled', true).text('Добавление...');
|
||||||
|
setStatus('', '');
|
||||||
|
|
||||||
|
const domain = ($(SELECTORS.domainInput).val() || '').trim();
|
||||||
|
const selectedAddresses = $(SELECTORS.addressesCheckbox).prop('checked') ? currentResults.addresses : [];
|
||||||
|
const selectedSubnets = $(SELECTORS.subnetsCheckbox).prop('checked') ? currentResults.subnets : [];
|
||||||
|
const selectedAsn = $(SELECTORS.asnCheckbox).prop('checked') ? currentResults.ASN : [];
|
||||||
|
|
||||||
|
JSONRPC.NetworkManager.createLocalResourceFile(
|
||||||
|
$nameInput.val().trim(),
|
||||||
|
domain,
|
||||||
|
selectedAsn,
|
||||||
|
selectedSubnets,
|
||||||
|
selectedAddresses
|
||||||
|
).then(() => {
|
||||||
|
setStatus('Ресурсы успешно добавлены.', 'success');
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Ошибка добавления ресурсов:', error);
|
||||||
|
setStatus('Не удалось добавить ресурсы.', 'error');
|
||||||
|
}).finally(() => {
|
||||||
|
$addButton.prop('disabled', false).text('Добавить');
|
||||||
|
updateAddButtonState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckboxChange() {
|
||||||
|
updateAddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameInputChange() {
|
||||||
|
const $nameInput = $(SELECTORS.nameInput);
|
||||||
|
const validation = validateListName($nameInput.val() || '');
|
||||||
|
if (!validation.valid) {
|
||||||
|
showFieldError(SELECTORS.nameError, validation.message);
|
||||||
|
} else {
|
||||||
|
clearFieldError(SELECTORS.nameError);
|
||||||
|
}
|
||||||
|
updateAddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEventHandlers() {
|
||||||
|
$(SELECTORS.form).on('submit', handleSearch);
|
||||||
|
$(SELECTORS.domainInput).on('input', () => clearFieldError(SELECTORS.domainError));
|
||||||
|
$(SELECTORS.serverInput).on('input', () => clearFieldError(SELECTORS.serverError));
|
||||||
|
$(SELECTORS.results).on('change', `input[type="checkbox"]`, handleCheckboxChange);
|
||||||
|
$(SELECTORS.results).on('input', SELECTORS.nameInput, handleNameInputChange);
|
||||||
|
$(SELECTORS.results).on('click', SELECTORS.addButton, handleAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachEventHandlers() {
|
||||||
|
$(SELECTORS.form).off('submit', handleSearch);
|
||||||
|
$(SELECTORS.domainInput).off('input');
|
||||||
|
$(SELECTORS.serverInput).off('input');
|
||||||
|
$(SELECTORS.results).off('change');
|
||||||
|
$(SELECTORS.results).off('input');
|
||||||
|
$(SELECTORS.results).off('click');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
currentResults = { addresses: [], subnets: [], ASN: [] };
|
||||||
|
setStatus('', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnlockSitePage = {
|
||||||
|
render: () => `
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Разблокировка сайта</h1>
|
||||||
|
<div id="${FIELD_IDS.container}" class="card" style="padding: 24px; max-width: 640px; margin: 0 auto;">
|
||||||
|
<form id="${FIELD_IDS.form}" style="display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="${FIELD_IDS.domainInput}">Домен</label>
|
||||||
|
<input type="text" id="${FIELD_IDS.domainInput}" class="form-control" value="${DEFAULT_DOMAIN}">
|
||||||
|
<div id="${FIELD_IDS.domainError}" class="error-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="${FIELD_IDS.serverInput}">Искать через DNS сервер</label>
|
||||||
|
<input type="text" id="${FIELD_IDS.serverInput}" class="form-control" value="${DEFAULT_SERVER}">
|
||||||
|
<div id="${FIELD_IDS.serverError}" class="error-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" id="${FIELD_IDS.searchButton}" class="btn-primary">Найти и разблокировать</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="${FIELD_IDS.results}" style="display: none;"></div>
|
||||||
|
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
mount: () => {
|
||||||
|
attachEventHandlers();
|
||||||
|
},
|
||||||
|
unmount: () => {
|
||||||
|
detachEventHandlers();
|
||||||
|
resetState();
|
||||||
|
$(SELECTORS.results).empty().hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue