From 5bcc7d9549e336e581f078b7f8ea31790a2c770c Mon Sep 17 00:00:00 2001 From: kirillius Date: Tue, 14 Oct 2025 09:58:46 +0300 Subject: [PATCH] + site unlocker --- .gitignore | 1 + .../main/java/ru/kirillius/pf/sdn/App.java | 4 +- .../pf/sdn/External/API/HEInfoProvider.java | 70 ++++ .../pf/sdn/web/RPC/NetworkManager.java | 128 +++++++ .../kirillius/pf/sdn/External/API/TDNS.java | 14 - .../sdn/core/Networking/ASInfoProvider.java | 13 + .../sdn/core/Networking/BGPInfoService.java | 4 + .../pf/sdn/core/Networking/IPv4Subnet.java | 7 + .../pf/sdn/core/Util/DomainUtil.java | 106 ++++++ webui/src/modules/router.js | 7 + webui/src/pages/UnlockSite.js | 349 ++++++++++++++++++ 11 files changed, 688 insertions(+), 15 deletions(-) delete mode 100644 app/src/test/java/ru/kirillius/pf/sdn/External/API/TDNS.java create mode 100644 core/src/main/java/ru/kirillius/pf/sdn/core/Util/DomainUtil.java create mode 100644 webui/src/pages/UnlockSite.js diff --git a/.gitignore b/.gitignore index b036f1a..6ef8757 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ ovpn-connector.json /webui/src/json-rpc.js app/src/main/resources/htdocs/ *.pfapp +cache/ diff --git a/app/src/main/java/ru/kirillius/pf/sdn/App.java b/app/src/main/java/ru/kirillius/pf/sdn/App.java index 6b32ae6..ea111ad 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/App.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/App.java @@ -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()); diff --git a/app/src/main/java/ru/kirillius/pf/sdn/External/API/HEInfoProvider.java b/app/src/main/java/ru/kirillius/pf/sdn/External/API/HEInfoProvider.java index 0973bbd..9664037 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/External/API/HEInfoProvider.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/External/API/HEInfoProvider.java @@ -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(); + var prefixes = new ArrayList(); + + 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("(.*?)", Pattern.DOTALL); + private static final Pattern AS_PATTERN = Pattern.compile("\\s*AS\\d+\\s*"); + private static final Pattern PREFIX_PATTERN = Pattern.compile("\\s*([0-9.]+/\\d+)\\s*"); } diff --git a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java index b059628..9805272 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java @@ -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) 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(); + var discoveredSubnets = new HashSet(); + + 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 addresses; + @JSONArrayProperty(type = IPv4Subnet.class, serializer = IPv4Subnet.Serializer.class) + @Getter + private List subnets; + @Getter + @JSONArrayProperty(type = Integer.class) + private List ASN; + } } diff --git a/app/src/test/java/ru/kirillius/pf/sdn/External/API/TDNS.java b/app/src/test/java/ru/kirillius/pf/sdn/External/API/TDNS.java deleted file mode 100644 index c3a5c8a..0000000 --- a/app/src/test/java/ru/kirillius/pf/sdn/External/API/TDNS.java +++ /dev/null @@ -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(); - } - } -} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/ASInfoProvider.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/ASInfoProvider.java index af6316b..2093415 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/ASInfoProvider.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/ASInfoProvider.java @@ -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 getPrefixes(int as); + IPQueryInfo queryAddress(String address); + + + @Builder + class IPQueryInfo { + @Getter + private List ASN; + @Getter + private List prefixes; + } + /** * Instantiates a provider class using the context-aware constructor. */ diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/BGPInfoService.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/BGPInfoService.java index 3bead9b..0ac8dad 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/BGPInfoService.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/BGPInfoService.java @@ -38,6 +38,10 @@ public class BGPInfoService extends AppService { return executor.submit(() -> provider.getPrefixes(as)); } + public Future getAddressInfo(String address) { + return executor.submit(() -> provider.queryAddress(address)); + } + /** * Shuts down the background executor. */ diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java index a0d125e..6a8069a 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java @@ -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); + } + + } diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Util/DomainUtil.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/DomainUtil.java new file mode 100644 index 0000000..f21e3a2 --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/DomainUtil.java @@ -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 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 extractIPv4(String output) { + var addresses = new LinkedHashSet(); + 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); + } + } +} diff --git a/webui/src/modules/router.js b/webui/src/modules/router.js index e61332a..5642531 100644 --- a/webui/src/modules/router.js +++ b/webui/src/modules/router.js @@ -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 } }; diff --git a/webui/src/pages/UnlockSite.js b/webui/src/pages/UnlockSite.js new file mode 100644 index 0000000..303df5d --- /dev/null +++ b/webui/src/pages/UnlockSite.js @@ -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, '''); +} + +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 '
Список пуст.
'; + } + return `
${items.map(item => `
${escapeHtml(item)}
`).join('')}
`; +} + +function buildAsnItems(items) { + if (!items.length) { + return '
Список пуст.
'; + } + return `
${items.map(item => escapeHtml(item)).join(', ')}
`; +} + +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 = ` +
+
+ + ${buildListItems(addresses)} +
+
+ + ${buildListItems(subnets)} +
+
+ + ${buildAsnItems(ASN)} +
+
+ + + +
+
+ +
+
+ `; + + 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: () => ` +
+

Разблокировка сайта

+
+
+
+ + + +
+
+ + + +
+
+ +
+
+ + +
+
+ `, + mount: () => { + attachEventHandlers(); + }, + unmount: () => { + detachEventHandlers(); + resetState(); + $(SELECTORS.results).empty().hide(); + } +};