+ site unlocker

This commit is contained in:
kirillius 2025-10-14 09:58:46 +03:00
parent 5e3666e899
commit 5bcc7d9549
11 changed files with 688 additions and 15 deletions

1
.gitignore vendored
View File

@ -45,3 +45,4 @@ ovpn-connector.json
/webui/src/json-rpc.js
app/src/main/resources/htdocs/
*.pfapp
cache/

View File

@ -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());

View File

@ -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>");
}

View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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.
*/

View File

@ -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.
*/

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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
}
};

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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();
}
};