+ site unlocker
This commit is contained in:
parent
5e3666e899
commit
5bcc7d9549
|
|
@ -45,3 +45,4 @@ ovpn-connector.json
|
|||
/webui/src/json-rpc.js
|
||||
app/src/main/resources/htdocs/
|
||||
*.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.GitSubscription;
|
||||
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.Auth.AuthManager;
|
||||
import ru.kirillius.pf.sdn.core.Auth.TokenService;
|
||||
|
|
@ -62,7 +63,8 @@ public class App implements Context, Closeable {
|
|||
} catch (IOException e) {
|
||||
loadedConfig = new Config();
|
||||
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 {
|
||||
Config.store(loadedConfig, launcherConfig.getConfigFile());
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import java.net.http.HttpRequest;
|
|||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
@ -65,4 +89,50 @@ public class HEInfoProvider implements ASInfoProvider {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.json.JSONArray;
|
||||
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.rpc.Annotations.JRPCArgument;
|
||||
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.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.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 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.
|
||||
*/
|
||||
|
|
@ -47,4 +67,112 @@ public class NetworkManager implements RPC {
|
|||
public JSONObject 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;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import ru.kirillius.pf.sdn.core.Context;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
|
|
@ -14,6 +16,17 @@ public interface ASInfoProvider {
|
|||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ public class BGPInfoService extends AppService {
|
|||
return executor.submit(() -> provider.getPrefixes(as));
|
||||
}
|
||||
|
||||
public Future<ASInfoProvider.IPQueryInfo> getAddressInfo(String address) {
|
||||
return executor.submit(() -> provider.queryAddress(address));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { LogsPage } from '../pages/Logs.js';
|
||||
import { NetworkResourcesPage } from '../pages/NetworkResources.js';
|
||||
import { UnlockSitePage } from '../pages/UnlockSite.js';
|
||||
|
||||
|
||||
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
|
||||
|
|
@ -30,6 +31,7 @@ const allMenuItems = [
|
|||
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' },
|
||||
{ label: 'Журнал', path: 'logs', component: null },
|
||||
{ label: 'Сетевые ресурсы', path: 'network-resources', component: null },
|
||||
{ label: 'Разблокировка сайта', path: 'unlock-site', component: null },
|
||||
];
|
||||
|
||||
// 2. Определение страниц
|
||||
|
|
@ -83,6 +85,11 @@ const routes = {
|
|||
render: NetworkResourcesPage.render,
|
||||
mount: NetworkResourcesPage.mount,
|
||||
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