This commit is contained in:
kirillius 2026-06-26 18:12:32 +03:00
parent ee3f4f3fe5
commit e991e7f269
73 changed files with 3064 additions and 0 deletions

View File

@ -0,0 +1,31 @@
package ru.kirillius.pf.sdn;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
@SpringBootApplication
public class PfSdnApplication {
private final static String DATABASE_FILE = "pfsdn";
public static void main(String[] args) {
/*
ENV:
SERVER_PORT=8080
APP_DATA=/var/lib/pfsdn
ARGS:
-Dserver.port=8080
-app.data=/var/lib/pfsdn
*/
//System.setProperty("server.port", String.valueOf(configData.port()));
//System.setProperty("jwt.secret", configData.jwtSecret());
var appData = System.getProperty("app.data", ".");
System.setProperty("app.datasource.url", "jdbc:h2:file:" + appData + "/" + DATABASE_FILE);
new SpringApplicationBuilder(PfSdnApplication.class).run(args);
}
}

View File

@ -0,0 +1,22 @@
package ru.kirillius.pf.sdn.Util;
import java.util.Arrays;
/**
* Utility methods for parsing command-line arguments used by the launcher.
*/
public final class CommandLineUtils {
private CommandLineUtils() {
}
/**
* Returns the first command-line argument starting with the given flag name.
*/
public static String getArgument(String argname, String[] args) {
var first = Arrays.stream(args).filter(arg -> arg.startsWith("-" + argname)).findFirst();
if (first.isEmpty()) {
throw new IllegalArgumentException("Missing required argument: -" + argname);
}
return first.get().substring(argname.length() + 2);
}
}

View File

@ -0,0 +1,354 @@
package ru.kirillius.pf.sdn.Util;
import lombok.Getter;
import lombok.SneakyThrows;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import java.net.InetAddress;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Helper methods for IPv4 address manipulation and subnet aggregation logic.
*/
public class IPv4Util {
private IPv4Util() {
}
private static final Pattern pattern = Pattern.compile("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$");
/**
* Ensures the supplied string is a valid IPv4 address.
*/
public static void validateAddress(String address) {
if (!pattern.matcher(address).matches()) {
throw new IllegalArgumentException("Invalid IPv4 address: " + address);
}
}
/**
* Ensures the prefix length is within the allowed range 0..32.
*/
public static void validatePrefix(int prefix) {
if (prefix < 0 || prefix > 32) {
throw new IllegalArgumentException("Invalid IPv4 prefix: " + prefix);
}
}
/**
* Converts a dotted IPv4 address into its numeric representation.
*/
@SneakyThrows
public static long ipAddressToLong(String address) {
validateAddress(address);
var ip = InetAddress.getByName(address);
var bytes = ip.getAddress();
var result = 0L;
for (var b : bytes) {
result = (result << 8) | (b & 0xFF);
}
return result;
}
/**
* Returns a bit mask representing the provided prefix length.
*/
public static long calculateMask(int prefixLength) {
validatePrefix(prefixLength);
return 0xFFFFFFFFL << (32 - prefixLength);
}
/**
* Converts a numeric IPv4 address into dotted notation.
*/
public static String longToIpAddress(long ipLong) {
if (ipLong < 0 || ipLong > 0xFFFFFFFFL) {
throw new IllegalArgumentException("Address number should be in range 0 - 4294967295");
}
return ((ipLong >> 24) & 0xFF) + "." + ((ipLong >> 16) & 0xFF) + "." + ((ipLong >> 8) & 0xFF) + "." + (ipLong & 0xFF);
}
/**
* Formats a numeric mask into dotted notation.
*/
public static String maskToString(long maskLong) {
return String.format("%d.%d.%d.%d",
(maskLong >> 24) & 0xff,
(maskLong >> 16) & 0xff,
(maskLong >> 8) & 0xff,
maskLong & 0xff);
}
/**
* Result contract for subnet summarisation operations.
*/
public interface SummarisationResult {
/**
* Returns the resulting set of subnets after summarisation.
*/
List<IPv4Subnet> getResult();
/**
* Returns the original subnets that were merged during summarisation.
*/
Set<IPv4Subnet> getMergedSubnets();
}
/**
* Performs subnet merging strategies based on overlap and utilisation heuristics.
*/
private static class SubnetSummaryUtility implements SummarisationResult {
@Getter
private final List<IPv4Subnet> result;
private final Collection<IPv4Subnet> source;
@Getter
private final Set<IPv4Subnet> mergedSubnets = new HashSet<>();
/**
* Builds the summarisation utility with the supplied subnets and usage threshold.
*/
public SubnetSummaryUtility(Collection<IPv4Subnet> subnets, int usePercentage) {
source = subnets;
result = new ArrayList<>(subnets);
summaryOverlapped();
mergeNeighbours();
summaryWithUsage(usePercentage > 50 ? usePercentage : 51);
summaryOverlapped();
result.sort(Comparator.comparing(IPv4Subnet::getLongAddress));
}
/**
* Removes redundant subnets wholly covered by other subnets.
*/
private void summaryOverlapped() {
if (result.size() < 2) {
return;
}
//check subnets overlaps
var overlapped = new ArrayList<IPv4Subnet>();
var orderedByPrefix = result.stream().sorted(Comparator.comparing(IPv4Subnet::getPrefixLength)).toList();
orderedByPrefix.stream()
.filter(subnet -> subnet.getPrefixLength() > 32)
.forEach(parent -> orderedByPrefix.forEach(subnet -> {
if (subnet.equals(parent)) {
return;
}
if (parent.overlaps(subnet)) {
overlapped.add(subnet);
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
}
}));
overlapped.forEach(result::remove);
}
/**
* Attempts to merge adjacent subnets into larger ones while preserving coverage.
*/
private void mergeNeighbours() {
if (result.size() < 2) {
return;
}
var availableLengths = result.stream().map(IPv4Subnet::getPrefixLength).collect(Collectors.toSet());
for (var length = 32; length > 0; length--) {
if (!availableLengths.contains(length)) {
continue;
}
var finalLength = length;
var largerPrefixLength = length - 1;
var largerPrefixMask = IPv4Util.calculateMask(largerPrefixLength);
var selectedSubnets = result.stream().filter(subnet -> subnet.getPrefixLength() == finalLength).sorted(Comparator.comparing(IPv4Subnet::getLongAddress)).toList();
//проверяем является ли адрес подсети таким же как адрес подсети с перфиксом -1
for (var i = 0; i < selectedSubnets.size() - 1; i++) {
var subnet = selectedSubnets.get(i);
var next = selectedSubnets.get(i + 1);
var firstAddress = subnet.getLongAddress();
if (firstAddress != (firstAddress & largerPrefixMask)) {
continue;
}
var largerSubnet = new IPv4Subnet(subnet.getAddress(), largerPrefixLength);
if (largerSubnet.overlaps(next)) {
//если подсеть перекрывает соседнюю, то удаляем обе и добавляем новую
availableLengths.add(largerPrefixLength);
result.remove(subnet);
result.remove(next);
result.add(largerSubnet);
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
if (source.contains(next)) {
mergedSubnets.add(next);
}
i++;
}
}
}
}
/**
* Returns the smallest prefix length currently present in the result set.
*/
private int findMinPrefixLength() {
return result.stream().mapToInt(IPv4Subnet::getPrefixLength).min().getAsInt();
}
/**
* Returns the largest prefix length currently present in the result set.
*/
private int findMaxPrefixLength() {
return result.stream().mapToInt(IPv4Subnet::getPrefixLength).max().getAsInt();
}
/**
* Returns the smallest network address found in the result set.
*/
private long findMinAddress() {
return result.stream().mapToLong(IPv4Subnet::getLongAddress).min().getAsLong();
}
/**
* Returns the largest network address found in the result set.
*/
private long findMaxAddress() {
return result.stream().mapToLong(IPv4Subnet::getLongAddress).max().getAsLong();
}
/**
* Produces candidate subnets capable of covering the current set at the given prefix length.
*/
private List<IPv4Subnet> findMergeCandidatesForPrefixLength(int prefixLength) {
//создаём подсети-кандидаты, которые покроют наш список
var maxAddress = findMaxAddress();
var mask = IPv4Util.calculateMask(prefixLength);
var firstAddress = (findMinAddress() & mask);
var lastAddress = (maxAddress & mask);
var candidates = new ArrayList<IPv4Subnet>();
var candidateAddress = firstAddress;
do {
var candidate = new IPv4Subnet(candidateAddress, prefixLength);
candidates.add(candidate);
if (candidates.size() > result.size()) {
throw new IllegalStateException("Too many IPv4 addresses when trying to summary " + result.size() + " subnets");
}
//поиск следующего адреса кандидата
var nextAddress = candidateAddress + candidate.count();
var nextSubnet = result.stream().filter(subnet -> {
var address = subnet.getLongAddress();
return subnet.getPrefixLength() > prefixLength && address >= nextAddress && address <= maxAddress;
}).min(Comparator.comparingLong(IPv4Subnet::getLongAddress)).stream().findFirst();
if (nextSubnet.isEmpty()) {
break;
}
candidateAddress = IPv4Util.createSubnetOverlapping(nextSubnet.get().getLongAddress(), prefixLength).getLongAddress();
} while (candidateAddress <= lastAddress);
return candidates;
}
/**
* Tests whether the candidate subnet meets utilisation requirements and performs the merge.
*/
private boolean testCandidate(IPv4Subnet candidate, int usePercentage) {
if (result.contains(candidate)) {
return false;
}
var min = candidate.getLongAddress();
var max = candidate.getLongAddress() + candidate.count() - 1;
var overlapped = new ArrayList<IPv4Subnet>();
var used = new AtomicLong(0L);
result.forEach(child -> {
if (child.getLongAddress() < min || child.getLongAddress() > max) {
return;
}
if (candidate.overlaps(child)) {
overlapped.add(child);
used.addAndGet(child.count());
}
});
if (100.0 * used.get() / candidate.count() >= usePercentage) {
//подсеть подходит под критерий
overlapped.forEach(subnet -> {
if (source.contains(subnet)) {
mergedSubnets.add(subnet);
}
});
result.removeAll(overlapped);
result.add(candidate);
return true;
}
return false;
}
/**
* Iteratively merges subnets that satisfy the utilisation threshold.
*/
private void summaryWithUsage(int usePercentage) {
if (result.isEmpty() || usePercentage >= 100 || usePercentage <= 0) {
return;
}
var found = new AtomicBoolean();
do {
found.set(false);
if (result.size() < 2) {
break;
}
var prefixMin = findMinPrefixLength();
var prefixMax = findMaxPrefixLength();
for (var testPrefixLength = prefixMin - 1; testPrefixLength < prefixMax; testPrefixLength++) {
//создаём подсети-кандидаты, которые покроют наш список
var candidates = findMergeCandidatesForPrefixLength(testPrefixLength);
candidates.forEach(candidate -> {
if (testCandidate(candidate, usePercentage)) {
found.set(true);
}
});
if (candidates.stream().anyMatch(result::contains)) {
//если был добавлен хотя бы 1 кандидат, то нужно пересчитать maxPrefix
prefixMax = result.stream().mapToInt(IPv4Subnet::getPrefixLength).max().getAsInt();
}
}
} while (found.get());
}
}
/**
* Summaries the provided subnets using merging heuristics and utilisation thresholds.
*/
public static SummarisationResult summarySubnets(Collection<IPv4Subnet> subnets, int usePercentage) {
return new SubnetSummaryUtility(subnets, usePercentage);
}
/**
* Creates a subnet covering the given address at the specified prefix length.
*/
private static IPv4Subnet createSubnetOverlapping(long address, int prefixLength) {
return new IPv4Subnet(address & IPv4Util.calculateMask(prefixLength), prefixLength);
}
/**
* Returns the number of addresses represented by a prefix length.
*/
public static long calculateCountForPrefixLength(long prefixLength) {
return 1L << (32L - prefixLength);
}
}

View File

@ -0,0 +1,41 @@
package ru.kirillius.pf.sdn.Util;
import java.time.Duration;
import java.util.function.Supplier;
/**
* Provides blocking wait utilities for polling boolean conditions with interruption support.
*/
public final class Wait {
private Wait() {
throw new AssertionError();
}
/**
* Blocks until the supplied condition becomes true or the thread is interrupted.
*/
public static void until(Supplier<Boolean> condition) throws InterruptedException {
if (condition == null) {
return;
}
while (!condition.get() && !Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofSeconds(1));
}
}
/**
* Blocks while the supplied condition remains true or until interrupted.
*/
public static void when(Supplier<Boolean> condition) throws InterruptedException {
if (condition == null) {
return;
}
while (condition.get() && !Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofSeconds(1));
}
}
}

View File

@ -0,0 +1,39 @@
package ru.kirillius.pf.sdn.Util;
import java.time.Duration;
import java.time.Instant;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
public class WorkerUtils {
@SuppressWarnings("BusyWait")
public static void waitForCondition(BooleanSupplier condition, long checkInterval) {
while (!Thread.interrupted() && !condition.getAsBoolean()) {
try {
Thread.sleep(checkInterval);
} catch (InterruptedException e) {
return;
}
}
}
public static void waitForCondition(BooleanSupplier condition) {
waitForCondition(condition, 1000);
}
public static void waitForTimer(Supplier<Duration> duration) {
var start = Instant.now();
waitForCondition(() -> Duration.between(start, Instant.now()).compareTo(duration.get()) > 0, 1000);
}
public abstract static class LoopedWorker implements Runnable {
protected abstract void doWork();
@Override
public final void run() {
while (!Thread.interrupted()) {
doWork();
}
}
}
}

View File

@ -0,0 +1,16 @@
package ru.kirillius.pf.sdn.api.Networking;
import ru.kirillius.pf.sdn.api.dto.IPQueryInfo;
import java.net.Inet4Address;
import java.util.List;
import java.util.concurrent.Future;
public interface AutonomousSystemResolver {
void registerApi(AutonomousSystemResolverApi api);
Future<List<IPv4Subnet>> getPrefixes(int as);
Future<IPQueryInfo> getAddressInfo(Inet4Address address);
}

View File

@ -0,0 +1,19 @@
package ru.kirillius.pf.sdn.api.Networking;
import ru.kirillius.pf.sdn.api.dto.IPQueryInfo;
import java.net.Inet4Address;
import java.util.List;
/**
* Abstraction for retrieving prefixes announced by a specific autonomous system.
*/
public interface AutonomousSystemResolverApi {
/**
* Returns IPv4 subnets originated by the provided autonomous system number.
*/
List<IPv4Subnet> getPrefixes(int as);
IPQueryInfo queryAddress(Inet4Address address);
}

View File

@ -0,0 +1,30 @@
package ru.kirillius.pf.sdn.api.Networking;
import java.net.Inet4Address;
import java.util.List;
import java.util.concurrent.Future;
public interface DomainResolver {
/**
* Resolve domain on specified server
* @param domain
* @param server
* @return
*/
Future<List<Inet4Address>> getAddresses(String domain, Inet4Address server);
/**
* Resolve domain using default servers
* @param domain
* @return
*/
Future<List<Inet4Address>> getAddresses(String domain);
/**
* Resolve domain using specified servers
* @param domain
* @param servers
* @return
*/
Future<List<Inet4Address>> getAddresses(String domain, List<Inet4Address> servers);
}

View File

@ -0,0 +1,122 @@
package ru.kirillius.pf.sdn.api.Networking;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import ru.kirillius.pf.sdn.Util.IPv4Util;
import java.net.Inet4Address;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Immutable representation of an IPv4 subnet serialized as a CIDR string.
*/
public class IPv4Subnet {
@Getter
private final long longAddress;
@Getter
private final int prefixLength;
/**
* Compares two subnets for equality based on address and prefix length.
*/
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
IPv4Subnet that = (IPv4Subnet) o;
return longAddress == that.longAddress && prefixLength == that.prefixLength;
}
/**
* Computes a hash code using address and prefix length.
*/
@Override
public int hashCode() {
return Objects.hash(longAddress, prefixLength);
}
/**
* Parses a subnet from textual CIDR notation.
*/
@JsonCreator
public IPv4Subnet(String subnet) {
var split = subnet.split(Pattern.quote("/"));
if (split.length != 2) {
throw new IllegalArgumentException("Invalid subnet: " + subnet);
}
var prefix = Integer.parseInt(split[1]);
IPv4Util.validatePrefix(prefix);
longAddress = IPv4Util.ipAddressToLong(split[0]);
prefixLength = prefix;
}
public IPv4Subnet(Inet4Address address, int prefixLength) {
IPv4Util.validatePrefix(prefixLength);
longAddress = IPv4Util.ipAddressToLong(address.getHostAddress());
this.prefixLength = prefixLength;
}
/**
* Creates a subnet from a numeric address and prefix length.
*/
public IPv4Subnet(long longAddress, int prefixLength) {
this.longAddress = longAddress;
this.prefixLength = prefixLength;
}
/**
* Creates a subnet from a dotted address string and prefix length.
*/
public IPv4Subnet(String address, int prefixLength) {
IPv4Util.validatePrefix(prefixLength);
this.longAddress = IPv4Util.ipAddressToLong(address);
this.prefixLength = prefixLength;
}
/**
* Calculates the number of addresses within the subnet.
*/
public long count() {
return IPv4Util.calculateCountForPrefixLength(prefixLength);
}
/**
* Returns the dotted-decimal representation of the subnet's network address.
*/
public String getAddress() {
return IPv4Util.longToIpAddress(longAddress);
}
/**
* Formats the subnet using CIDR notation.
*/
@JsonValue
@Override
public String toString() {
return getAddress() + '/' + prefixLength;
}
/**
* Determines whether this subnet overlaps with another.
*/
public boolean overlaps(IPv4Subnet subnet) {
var minPrefixLength = Math.min(prefixLength, subnet.prefixLength);
var commonMask = IPv4Util.calculateMask(minPrefixLength);
if (minPrefixLength != prefixLength) {
return false; //can't overlap larger prefix
}
return (longAddress & commonMask) == (subnet.longAddress & commonMask);
}
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,62 @@
package ru.kirillius.pf.sdn.api.Networking;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Container for grouped network identifiers used to configure filtering and subscriptions.
*/
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class NetworkScope {
@Getter
@Setter
private String description = "";
@Getter
@Setter
private List<Integer> ASN = new ArrayList<>();
@Getter
@Setter
private List<IPv4Subnet> subnets = new ArrayList<>();
@Getter
@Setter
private List<String> domains = new ArrayList<>();
@Getter
@Setter
private boolean resolveDomains = false;
@Override
public boolean equals(Object o) {
if (!(o instanceof NetworkScope that)) return false;
return Objects.equals(ASN, that.ASN) && Objects.equals(subnets, that.subnets) && Objects.equals(domains, that.domains);
}
@Override
public int hashCode() {
return Objects.hash(ASN, subnets, domains);
}
/**
* Clears all stored network identifiers.
*/
public void clear() {
ASN.clear();
subnets.clear();
domains.clear();
}
/**
* Adds all resources from the provided bundle into this bundle.
*/
public void add(NetworkScope networkScope) {
ASN.addAll(networkScope.getASN());
subnets.addAll(networkScope.getSubnets());
domains.addAll(networkScope.getDomains());
}
}

View File

@ -0,0 +1,28 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import lombok.RequiredArgsConstructor;
import ru.kirillius.pf.sdn.entity.SubscriptionProviderConfig;
import ru.kirillius.pf.sdn.repository.SubscriptionCacheRepository;
import java.util.Collections;
@RequiredArgsConstructor
public class CacheFallbackProviderProtocol implements SubscriptionProviderProtocol {
@Override
public String getIdentifier() {
return "Fallback:cache";
}
@Override
public PropertyCollection getProperties() {
return new PropertyCollection();
}
private final SubscriptionCacheRepository cacheRepository;
@Override
public SubscriptionQueryResult query(SubscriptionProviderConfig config) {
var found = cacheRepository.getAllByNameStartsWith(config.getName());
return new SubscriptionQueryResult(Collections.unmodifiableList(found), Collections.emptyList());
}
}

View File

@ -0,0 +1,4 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
public record Descriptor(PropertyType type, boolean list, String description) {
}

View File

@ -0,0 +1,7 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import java.util.HashMap;
public class PropertyCollection extends HashMap<String, Descriptor> {
}

View File

@ -0,0 +1,9 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
public enum PropertyType {
Integer,
String,
Boolean,
Float,
Subscription
}

View File

@ -0,0 +1,9 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
public interface Subscription {
String getName();
NetworkScope getScope();
}

View File

@ -0,0 +1,7 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
public interface SubscriptionFactory {
Subscription create(String name, NetworkScope scope);
}

View File

@ -0,0 +1,32 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import lombok.Builder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import ru.kirillius.pf.sdn.entity.SubscriptionProviderConfig;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
@Builder
public class SubscriptionProvider {
private final SubscriptionProviderProtocol protocol;
private final SubscriptionProviderConfig config;
public List<Subscription> getSubscriptions() {
return Collections.unmodifiableList(subscriptions);
}
private final List<Subscription> subscriptions = new ArrayList<>();
public List<Subscription> update() {
var result = protocol.query(config);
subscriptions.clear();
subscriptions.addAll(result.subscriptions());
return result.updated();
}
}

View File

@ -0,0 +1,11 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import ru.kirillius.pf.sdn.entity.SubscriptionProviderConfig;
public interface SubscriptionProviderProtocol {
String getIdentifier();
PropertyCollection getProperties();
SubscriptionQueryResult query(SubscriptionProviderConfig config);
}

View File

@ -0,0 +1,6 @@
package ru.kirillius.pf.sdn.api.Networking.Subscriptions;
import java.util.List;
public record SubscriptionQueryResult(List<Subscription> subscriptions, List<Subscription> updated) {
}

View File

@ -0,0 +1,15 @@
package ru.kirillius.pf.sdn.api.dto;
import lombok.Builder;
import lombok.Getter;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import java.util.List;
@Builder
public class IPQueryInfo {
@Getter
private List<Integer> ASN;
@Getter
private List<IPv4Subnet> prefixes;
}

View File

@ -0,0 +1,44 @@
package ru.kirillius.pf.sdn.api.pipeline;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
import lombok.SneakyThrows;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import java.util.Properties;
import java.util.function.Function;
@Builder
public class Action implements Function<NetworkScope, NetworkScope> {
private PipelineFunction function;
public Action(PipelineFunction function, Class<? extends PipelineFunction> functionClass, Properties properties) {
this.function = function;
this.functionClass = functionClass;
this.properties = properties;
instantiateFunction();
}
@SneakyThrows
private void instantiateFunction() {
function = functionClass.getConstructor().newInstance();
}
public void setFunctionClass(Class<? extends PipelineFunction> functionClass) {
this.functionClass = functionClass;
instantiateFunction();
}
@Getter
private Class<? extends PipelineFunction> functionClass;
@Getter
@Setter
private Properties properties;
@Override
public NetworkScope apply(NetworkScope source) {
return function.apply(source, properties);
}
}

View File

@ -0,0 +1,5 @@
package ru.kirillius.pf.sdn.api.pipeline;
public interface InputResource extends PipelineFunction {
}

View File

@ -0,0 +1,7 @@
package ru.kirillius.pf.sdn.api.pipeline;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
public interface OutputResource extends PipelineFunction {
void apply(NetworkScope config);
}

View File

@ -0,0 +1,9 @@
package ru.kirillius.pf.sdn.api.pipeline;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import java.util.Properties;
public interface PipelineFunction {
NetworkScope apply(NetworkScope source, Properties properties);
}

View File

@ -0,0 +1,52 @@
package ru.kirillius.pf.sdn.api.pipeline;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@AllArgsConstructor
@NoArgsConstructor
public class ProcessingPipeline {
@Getter
@Setter
private List<StartCondition> startConditions;
private final AtomicBoolean running = new AtomicBoolean(false);
@Getter
@Setter
private List<Action> actions;
private AtomicInteger currentStep = new AtomicInteger(0);
public boolean isRunning() {
return running.get();
}
public int getStepCount() {
return actions.size();
}
public int getCurrentStep() {
return currentStep.get();
}
public void execute() {
running.set(true);
currentStep.set(0);
try {
var config = new AtomicReference<>(new NetworkScope());
actions.forEach(action -> {
currentStep.incrementAndGet();
config.set(action.apply(config.get()));
});
} finally {
running.set(false);
}
}
}

View File

@ -0,0 +1,8 @@
package ru.kirillius.pf.sdn.api.pipeline;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
public interface ResourceProcessor extends PipelineFunction {
NetworkScope apply(NetworkScope config);
}

View File

@ -0,0 +1,13 @@
package ru.kirillius.pf.sdn.api.pipeline;
import lombok.Getter;
import lombok.Setter;
import java.util.Properties;
@Getter
@Setter
public class StartCondition {
private TriggerType trigger;
private Properties properties;
}

View File

@ -0,0 +1,8 @@
package ru.kirillius.pf.sdn.api.pipeline;
public enum TriggerType {
Manual,
Interval,
OnSubscriptionUpdate,
OnStart
}

View File

@ -0,0 +1,23 @@
package ru.kirillius.pf.sdn.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Configuration
public class BeanConfiguration {
@Bean
public ExecutorService executor() {
return Executors.newFixedThreadPool(Math.max(Runtime.getRuntime().availableProcessors() * 2, 10));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -0,0 +1,19 @@
package ru.kirillius.pf.sdn.config;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import ru.kirillius.pf.sdn.service.AuthService;
@Component
@RequiredArgsConstructor
public class DataInitializer {
private final AuthService authService;
@EventListener(ApplicationReadyEvent.class)
public void init() {
authService.createDefaultUserIfAbsent();
}
}

View File

@ -0,0 +1,50 @@
package ru.kirillius.pf.sdn.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Optional;
@Component
public class JwtService {
private final SecretKey key;
private final long expirationMs;
public JwtService(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration-ms:2592000000}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
public String generateToken(Long userId) {
Date now = new Date();
return Jwts.builder()
.subject(String.valueOf(userId))
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationMs))
.signWith(key)
.compact();
}
public Optional<Long> validateToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
return Optional.of(Long.parseLong(claims.getSubject()));
} catch (JwtException | NumberFormatException e) {
return Optional.empty();
}
}
}

View File

@ -0,0 +1,16 @@
package ru.kirillius.pf.sdn.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(String.class, IPv4Subnet.class, IPv4Subnet::new);
registry.addConverter(IPv4Subnet.class, String.class, IPv4Subnet::toString);
}
}

View File

@ -0,0 +1,76 @@
package ru.kirillius.pf.sdn.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.kirillius.pf.sdn.dto.ApiTokenResponse;
import ru.kirillius.pf.sdn.dto.CreateApiTokenRequest;
import ru.kirillius.pf.sdn.dto.ErrorResponse;
import ru.kirillius.pf.sdn.service.AuthService;
import ru.kirillius.pf.sdn.service.SessionService;
import java.util.List;
@RestController
@RequestMapping("/api/tokens")
@RequiredArgsConstructor
public class ApiTokenController {
private final AuthService authService;
private final SessionService sessionService;
@PostMapping
public ResponseEntity<?> create(
@RequestHeader("Authorization") String authHeader,
@RequestBody CreateApiTokenRequest request) {
var maybeUserId = sessionService.resolveUserId(authHeader);
if (maybeUserId.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid or missing token"));
}
if (request.getName() == null || request.getName().isBlank()) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("Name is required"));
}
String rawToken = authService.generateApiToken(maybeUserId.get(), request.getName().trim());
return ResponseEntity.ok(new ApiTokenResponse(null, request.getName().trim(), rawToken, true, null, null));
}
@GetMapping
public ResponseEntity<?> list(@RequestHeader("Authorization") String authHeader) {
var maybeUserId = sessionService.resolveUserId(authHeader);
if (maybeUserId.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid or missing token"));
}
var tokens = authService.getUserApiTokens(maybeUserId.get()).stream()
.map(t -> new ApiTokenResponse(t.getId(), t.getName(), null, t.isEnabled(), t.getCreatedAt(), t.getLastUsedAt()))
.toList();
return ResponseEntity.ok(tokens);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(
@RequestHeader("Authorization") String authHeader,
@PathVariable Long id) {
var maybeUserId = sessionService.resolveUserId(authHeader);
if (maybeUserId.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid or missing token"));
}
boolean deleted = authService.deleteApiToken(id, maybeUserId.get());
if (!deleted) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Token not found"));
}
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,68 @@
package ru.kirillius.pf.sdn.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.kirillius.pf.sdn.dto.AuthResponse;
import ru.kirillius.pf.sdn.dto.ChangePasswordRequest;
import ru.kirillius.pf.sdn.dto.ErrorResponse;
import ru.kirillius.pf.sdn.dto.LoginRequest;
import ru.kirillius.pf.sdn.service.AuthService;
import ru.kirillius.pf.sdn.service.SessionService;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final SessionService sessionService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
return authService.login(request.getUsername(), request.getPassword())
.<ResponseEntity<?>>map(token -> ResponseEntity.ok(new AuthResponse(token, request.getUsername())))
.orElse(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid username or password")));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) {
var maybeToken = sessionService.extractToken(authHeader);
if (maybeToken.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Missing or invalid Authorization header"));
}
sessionService.invalidate(maybeToken.get());
return ResponseEntity.ok().build();
}
@PostMapping("/change-password")
public ResponseEntity<?> changePassword(
@RequestHeader("Authorization") String authHeader,
@RequestBody ChangePasswordRequest request) {
var maybeToken = sessionService.extractToken(authHeader);
if (maybeToken.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Missing or invalid Authorization header"));
}
String token = maybeToken.get();
var maybeUserId = authService.validateToken(token);
if (maybeUserId.isEmpty()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid token"));
}
boolean changed = authService.changePassword(maybeUserId.get(), request.getOldPassword(), request.getNewPassword());
if (changed) {
sessionService.invalidate(token);
return ResponseEntity.ok(new AuthResponse(null, null));
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("Old password is incorrect"));
}
}

View File

@ -0,0 +1,19 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
public class ApiTokenResponse {
private Long id;
private String name;
private String token;
private boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime lastUsedAt;
}

View File

@ -0,0 +1,13 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class AuthResponse {
private String token;
private String username;
}

View File

@ -0,0 +1,15 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChangePasswordRequest {
private String oldPassword;
private String newPassword;
}

View File

@ -0,0 +1,14 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CreateApiTokenRequest {
private String name;
}

View File

@ -0,0 +1,12 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@AllArgsConstructor
public class ErrorResponse {
private String error;
}

View File

@ -0,0 +1,15 @@
package ru.kirillius.pf.sdn.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String username;
private String password;
}

View File

@ -0,0 +1,18 @@
package ru.kirillius.pf.sdn.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Getter
@Setter
@NoArgsConstructor
public class ResolverCacheEntry {
private Instant lastUpdate = Instant.now();
private Map<IPv4Subnet, Instant> addresses = new HashMap<>();
}

View File

@ -0,0 +1,42 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_tokens")
@Getter
@Setter
@NoArgsConstructor
public class ApiToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false)
private String tokenHash;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
private LocalDateTime lastUsedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,30 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.*;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import java.util.List;
@Entity
@Table(name = "as_cache_entry")
@Getter
@Setter
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class AutonomousSystemCacheEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private int number;
@ElementCollection
@CollectionTable(name = "as_cache_entry_prefixes", joinColumns = @JoinColumn(name = "entry_id"))
@Column(name = "prefix", nullable = false)
@Convert(converter = IPv4SubnetConverter.class)
private List<IPv4Subnet> prefixes;
}

View File

@ -0,0 +1,32 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.*;
import java.net.Inet4Address;
import java.time.Instant;
@Entity
@Table(name = "domain_cache_entry")
@Getter
@Setter
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class DomainCacheEntry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String domain;
@Column(nullable = false)
private Instant lastUpdate;
@Column(nullable = false)
private Instant lastSeen;
@Column(nullable = false)
private Inet4Address address;
}

View File

@ -0,0 +1,19 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
@Converter
public class IPv4SubnetConverter implements AttributeConverter<IPv4Subnet, String> {
@Override
public String convertToDatabaseColumn(IPv4Subnet attribute) {
return attribute == null ? null : attribute.toString();
}
@Override
public IPv4Subnet convertToEntityAttribute(String dbData) {
return dbData == null ? null : new IPv4Subnet(dbData);
}
}

View File

@ -0,0 +1,33 @@
package ru.kirillius.pf.sdn.entity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
@Converter
public class NetworkScopeConverter implements AttributeConverter<NetworkScope, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(NetworkScope attribute) {
if (attribute == null) return null;
try {
return MAPPER.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize NetworkScope", e);
}
}
@Override
public NetworkScope convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
try {
return MAPPER.readValue(dbData, NetworkScope.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize NetworkScope", e);
}
}
}

View File

@ -0,0 +1,26 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.*;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.Subscription;
@Entity
@Table(name = "subscription_cache")
@Getter
@Setter
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class SubscriptionCacheEntry implements Subscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@Column(nullable = false, columnDefinition = "TEXT")
@Convert(converter = NetworkScopeConverter.class)
private NetworkScope scope;
}

View File

@ -0,0 +1,40 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.*;
import java.util.Properties;
@Entity
@Table(name = "subscription_provider_config")
@Getter
@Setter
@NoArgsConstructor
@Builder
@AllArgsConstructor
public class SubscriptionProviderConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@Column(nullable = false)
private String protocolId;
@ElementCollection
@CollectionTable(
name = "subscription_provider_config_properties",
joinColumns = @JoinColumn(name = "subscription_provider_config_entry_id")
)
@MapKeyColumn(name = "prop_key")
@Column(name = "prop_value", nullable = false)
private Properties properties;
@Column(nullable = false)
private String shellScriptBefore;
@Column(nullable = false)
private String shellScriptAfter;
}

View File

@ -0,0 +1,37 @@
package ru.kirillius.pf.sdn.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String passwordHash;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,47 @@
package ru.kirillius.pf.sdn.pipeline;
import lombok.RequiredArgsConstructor;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import ru.kirillius.pf.sdn.api.pipeline.InputResource;
import ru.kirillius.pf.sdn.service.DomainUpdaterService;
import ru.kirillius.pf.sdn.service.SubscriptionService;
import java.net.Inet4Address;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Properties;
import java.util.regex.Pattern;
@RequiredArgsConstructor
public class SubscriptionInput implements InputResource {
private final SubscriptionService subscriptionService;
private final DomainUpdaterService domainCacheService;
@Override
public NetworkScope apply(NetworkScope source, Properties properties) {
var property = properties.getProperty("property", null);
var subscriptions = subscriptionService.getSubscriptions();
if (property != null) {
var filter = Arrays.stream(property.split(Pattern.quote(","))).filter(s -> !s.isBlank()).toList();
subscriptions = subscriptions.stream().filter(s -> filter.contains(s.getName())).toList();
}
var bundle = new NetworkScope();
bundle.add(source);
subscriptions.forEach(subscription -> {
var scope = subscription.getScope();
bundle.add(scope);
var resolved = new ArrayList<Inet4Address>();
if (scope.isResolveDomains()) {
scope.getDomains().forEach(domain -> resolved.addAll(domainCacheService.getActualAddresses(domain)));
}
if (!resolved.isEmpty()) {
bundle.getSubnets().addAll(resolved.stream().map(ip -> new IPv4Subnet(ip, 32)).toList());
}
});
return bundle;
}
}

View File

@ -0,0 +1,30 @@
package ru.kirillius.pf.sdn.plugins.LocalSubscriptionProvider;
import lombok.Getter;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.*;
import ru.kirillius.pf.sdn.entity.SubscriptionProviderConfig;
public class LocalSubscriptionProvider implements SubscriptionProviderProtocol {
@Override
public String getIdentifier() {
return "local-filesystem";
}
@Getter
private final PropertyCollection properties;
public LocalSubscriptionProvider() {
properties = new PropertyCollection();
properties.put("path", new Descriptor(
PropertyType.String,
false,
"Path to files is local filesystem"
));
}
@Override
public SubscriptionQueryResult query(SubscriptionProviderConfig config) {
return null;
}
}

View File

@ -0,0 +1,6 @@
package ru.kirillius.pf.sdn.plugins;
public interface Plugin {
void onLoad();
void onUnload();
}

View File

@ -0,0 +1,4 @@
package ru.kirillius.pf.sdn.plugins;
public class PluginLoader {
}

View File

@ -0,0 +1,16 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.ApiToken;
import java.util.List;
import java.util.Optional;
public interface ApiTokenRepository extends JpaRepository<ApiToken, Long> {
List<ApiToken> findByUserId(Long userId);
Optional<ApiToken> findByIdAndUserId(Long id, Long userId);
List<ApiToken> findByEnabledTrue();
}

View File

@ -0,0 +1,12 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.AutonomousSystemCacheEntry;
import java.util.Optional;
public interface AutonomousSystemCacheRepository extends JpaRepository<AutonomousSystemCacheEntry, Long> {
Optional<AutonomousSystemCacheEntry> findByNumber(int number);
boolean existsByNumber(int number);
}

View File

@ -0,0 +1,14 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.DomainCacheEntry;
import java.util.List;
public interface DomainCacheRepository extends JpaRepository<DomainCacheEntry, Long> {
boolean existsByDomain(String domain);
List<DomainCacheEntry> findAllByDomain(String domain);
List<DomainCacheEntry> getAllByDomain(String domain);
}

View File

@ -0,0 +1,11 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.SubscriptionCacheEntry;
import java.util.List;
public interface SubscriptionCacheRepository extends JpaRepository<SubscriptionCacheEntry, Long> {
List<SubscriptionCacheEntry> getAllByNameStartsWith(String s);
}

View File

@ -0,0 +1,8 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.SubscriptionProviderConfig;
public interface SubscriptionProviderConfigRepository extends JpaRepository<SubscriptionProviderConfig, Long> {
}

View File

@ -0,0 +1,13 @@
package ru.kirillius.pf.sdn.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kirillius.pf.sdn.entity.User;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@ -0,0 +1,115 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.kirillius.pf.sdn.config.JwtService;
import ru.kirillius.pf.sdn.entity.ApiToken;
import ru.kirillius.pf.sdn.entity.User;
import ru.kirillius.pf.sdn.repository.ApiTokenRepository;
import ru.kirillius.pf.sdn.repository.UserRepository;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final ApiTokenRepository apiTokenRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private static final int API_TOKEN_BYTES = 32;
@Transactional
public void createDefaultUserIfAbsent() {
if (!userRepository.existsByUsername("admin")) {
User user = new User();
user.setUsername("admin");
user.setPasswordHash(passwordEncoder.encode("admin"));
userRepository.save(user);
}
}
public Optional<String> login(String username, String rawPassword) {
return userRepository.findByUsername(username)
.filter(User::isEnabled)
.filter(user -> passwordEncoder.matches(rawPassword, user.getPasswordHash()))
.map(user -> jwtService.generateToken(user.getId()));
}
@Transactional
public boolean changePassword(Long userId, String oldPassword, String newPassword) {
return userRepository.findById(userId)
.filter(user -> passwordEncoder.matches(oldPassword, user.getPasswordHash()))
.map(user -> {
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
return true;
})
.orElse(false);
}
public Optional<Long> validateToken(String token) {
return jwtService.validateToken(token);
}
public void invalidateToken(String token) {
// JWT is stateless client just discards the token
}
@Transactional
public String generateApiToken(Long userId, String name) {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[API_TOKEN_BYTES];
random.nextBytes(bytes);
String rawToken = HexFormat.of().formatHex(bytes);
ApiToken apiToken = new ApiToken();
apiToken.setName(name);
apiToken.setTokenHash(passwordEncoder.encode(rawToken));
apiToken.setUserId(userId);
apiTokenRepository.save(apiToken);
return apiToken.getId() + "." + rawToken;
}
public Optional<Long> validateApiToken(String rawToken) {
for (ApiToken stored : apiTokenRepository.findByEnabledTrue()) {
if (passwordEncoder.matches(rawToken, stored.getTokenHash())) {
updateLastUsedAt(stored);
return Optional.of(stored.getUserId());
}
}
return Optional.empty();
}
public Optional<Long> resolveUserId(String token) {
return validateToken(token)
.or(() -> validateApiToken(token));
}
@Transactional
public boolean deleteApiToken(Long tokenId, Long userId) {
return apiTokenRepository.findByIdAndUserId(tokenId, userId)
.map(token -> {
apiTokenRepository.delete(token);
return true;
})
.orElse(false);
}
public List<ApiToken> getUserApiTokens(Long userId) {
return apiTokenRepository.findByUserId(userId);
}
private void updateLastUsedAt(ApiToken token) {
token.setLastUsedAt(java.time.LocalDateTime.now());
apiTokenRepository.save(token);
}
}

View File

@ -0,0 +1,37 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.springframework.stereotype.Service;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import ru.kirillius.pf.sdn.entity.AutonomousSystemCacheEntry;
import ru.kirillius.pf.sdn.repository.AutonomousSystemCacheRepository;
import java.util.List;
import java.util.Optional;
@Setter
@RequiredArgsConstructor
@Service
public class AutonomousSystemCacheService {
private final AutonomousSystemCacheRepository repository;
public void store(int number, List<IPv4Subnet> subnets) {
synchronized (repository) {
if (repository.existsByNumber(number)) {
repository.findByNumber(number).ifPresent(entry -> {
entry.setPrefixes(subnets);
repository.save(entry);
});
} else {
repository.save(AutonomousSystemCacheEntry.builder().number(number).prefixes(subnets).build());
}
}
}
public Optional<AutonomousSystemCacheEntry> load(int number) {
synchronized (repository) {
return repository.findByNumber(number);
}
}
}

View File

@ -0,0 +1,83 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import ru.kirillius.pf.sdn.api.Networking.AutonomousSystemResolver;
import ru.kirillius.pf.sdn.api.Networking.AutonomousSystemResolverApi;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import ru.kirillius.pf.sdn.api.dto.IPQueryInfo;
import java.io.IOException;
import java.net.Inet4Address;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicReference;
@Setter
@RequiredArgsConstructor
public class AutonomousSystemResolverService implements AutonomousSystemResolver {
private final ExecutorService executor;
private final List<AutonomousSystemResolverApi> apis = new CopyOnWriteArrayList<>();
private final AtomicReference<AutonomousSystemResolverApi> currentApi = new AtomicReference<>();
private synchronized void selectNextApi() {
if (apis.isEmpty()) {
return;
}
var index = apis.indexOf(currentApi.get());
if (index == -1 || index == apis.size() - 1) {
currentApi.set(apis.getFirst());
return;
}
currentApi.set(apis.get(index + 1));
}
@Override
public void registerApi(AutonomousSystemResolverApi api) {
apis.add(api);
if (currentApi.get() == null) {
currentApi.set(api);
}
}
@Override
public Future<List<IPv4Subnet>> getPrefixes(int as) {
return executor.submit(() -> {
checkApis();
return executeWithFallbackSelection(() -> currentApi.get().getPrefixes(as));
});
}
private void checkApis() throws IOException {
if (apis.isEmpty()) {
throw new IOException("You're trying to resolve an AS without any resolvers");
}
if (currentApi.get() == null) {
selectNextApi();
}
}
private <T> T executeWithFallbackSelection(Callable<T> callable) throws IOException {
var retries = 0;
while (retries < apis.size()) {
try {
return callable.call();
} catch (Exception e) {
retries++;
selectNextApi();
}
}
throw new IOException("Unable to perform operation on every AS resolver");
}
@Override
public Future<IPQueryInfo> getAddressInfo(Inet4Address address) {
return executor.submit(() -> {
checkApis();
return executeWithFallbackSelection(() -> currentApi.get().queryAddress(address));
});
}
}

View File

@ -0,0 +1,63 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.springframework.stereotype.Service;
import ru.kirillius.pf.sdn.entity.DomainCacheEntry;
import ru.kirillius.pf.sdn.repository.DomainCacheRepository;
import java.net.Inet4Address;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@Setter
@RequiredArgsConstructor
@Service
public class DomainCacheService {
private final DomainCacheRepository repository;
private static final int DOMAIN_TTL_HOURS = 12;
public void update(String domain, List<Inet4Address> addresses) {
var minDate = Instant.now().minus(Duration.ofHours(DOMAIN_TTL_HOURS));
synchronized (repository) {
var existing = repository.getAllByDomain(domain);
var existingAddresses = existing.stream().map(DomainCacheEntry::getAddress).toList();
existing.forEach(entry -> {
if (addresses.contains(entry.getAddress())) {
entry.setLastUpdate(Instant.now());
entry.setLastSeen(Instant.now());
} else {
if (entry.getLastSeen().isBefore(minDate)) {
repository.delete(entry);
} else {
entry.setLastUpdate(Instant.now());
}
}
});
addresses.stream()
.filter(address -> !existingAddresses.contains(address))
.forEach(address -> repository.save(
DomainCacheEntry.builder()
.domain(domain)
.address(address)
.lastUpdate(Instant.now())
.lastSeen(Instant.now())
.build()
)
);
}
}
public List<Inet4Address> load(String domain, boolean filterOutdated) {
var minDate = Instant.now().minus(Duration.ofHours(DOMAIN_TTL_HOURS));
synchronized (repository) {
var found = repository.getAllByDomain(domain);
if (filterOutdated && !found.isEmpty()) {
found = found.stream().filter(entry -> entry.getLastSeen().isAfter(minDate)).toList();
}
return found.stream().map(DomainCacheEntry::getAddress).toList();
}
}
}

View File

@ -0,0 +1,139 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import ru.kirillius.pf.sdn.Util.IPv4Util;
import ru.kirillius.pf.sdn.api.Networking.DomainResolver;
import ru.kirillius.pf.sdn.config.BeanConfiguration;
import java.io.IOException;
import java.io.InputStream;
import java.net.Inet4Address;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class DomainResolverService implements DomainResolver {
private final static int SUCCESS_CODE = 0;
private final BeanConfiguration config;
private final ExecutorService executor;
private final RuntimeConfigService configService;
/**
* Returns a list of IPv4 addresses resolved for the provided domain.
*/
private static List<Inet4Address> lookup(String domain, Inet4Address server) throws IOException, InterruptedException {
if (domain == null || domain.isBlank()) {
throw new IllegalArgumentException("Domain must not be null or blank");
}
var processBuilder = new ProcessBuilder("nslookup", domain.trim(), server.getHostAddress().trim());
processBuilder.redirectErrorStream(true);
var process = processBuilder.start();
try {
if (!process.waitFor(10, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new InterruptedException("Request timed out for domain: " + domain);
}
var output = readStream(process.getInputStream());
if (process.exitValue() != SUCCESS_CODE) {
throw new IOException("nslookup failed for domain " + domain + ": " + output);
}
return extractIPv4(output);
} finally {
process.destroy();
}
}
private static List<Inet4Address> extractIPv4(String output) {
var addresses = new LinkedHashSet<Inet4Address>();
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((Inet4Address) Inet4Address.getByName(token));
} catch (IllegalArgumentException ignored) {
// skip invalid addresses such as IPv6
} catch (UnknownHostException e) {
throw new RuntimeException(e);
}
}
}
}
return new ArrayList<>(addresses);
}
private static String readStream(InputStream stream) throws IOException {
try (stream) {
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
@Override
public Future<List<Inet4Address>> getAddresses(String domain, Inet4Address server) {
return getAddresses(domain, Collections.singletonList(server));
}
@Override
public Future<List<Inet4Address>> getAddresses(String domain) {
return getAddresses(domain, configService.getConfig().getDomainResolvers());
}
@Override
public Future<List<Inet4Address>> getAddresses(String domain, List<Inet4Address> servers) {
return executor.submit(() -> {
var resolvers = configService.getConfig().getDomainResolvers();
if (resolvers.isEmpty()) {
throw new UnknownHostException("You're trying to resolve a domain without any resolvers");
}
for (var server : resolvers) {
try {
return lookup(domain, server);
} catch (IOException e) {
throw new UnknownHostException("Failed to resolve domain: " + domain);
} catch (InterruptedException e) {
continue; //network error
}
}
throw new UnknownHostException("Failed to resolve domain " + domain + " due to network error or something nasty");
});
}
}

View File

@ -0,0 +1,89 @@
package ru.kirillius.pf.sdn.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import ru.kirillius.pf.sdn.Util.WorkerUtils;
import ru.kirillius.pf.sdn.api.Networking.DomainResolver;
import java.net.Inet4Address;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
@Service
@RequiredArgsConstructor
@Slf4j
public class DomainUpdaterService {
private final DomainCacheService domainCacheService;
private final DomainResolver domainResolver;
private final ExecutorService executor;
private final SubscriptionService subscriptionService;
public List<Inet4Address> getActualAddresses(String domain) {
var cached = domainCacheService.load(domain, true);
if (!cached.isEmpty()) {
var resolved = domainResolver.getAddresses(domain);
while (!resolved.isDone() && !resolved.isCancelled()) {
Thread.yield();
}
if (resolved.isDone()) {
try {
domainCacheService.update(domain, resolved.get());
cached.addAll(resolved.get());
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
}
return cached;
}
private Future<?> worker;
private final RuntimeConfigService configService;
@PostConstruct
private void initialize() {
worker = executor.submit(() -> new WorkerUtils.LoopedWorker() {
@Override
protected void doWork() {
subscriptionService.getSubscriptions().forEach(subscription -> {
var scope = subscription.getScope();
if (!scope.isResolveDomains()) {
return;
}
scope.getDomains().forEach(domain -> {
var future = domainResolver.getAddresses(domain);
while (!future.isDone() && !future.isCancelled()) {
Thread.yield();
}
if (future.isDone()) {
try {
domainCacheService.update(domain, future.get());
} catch (InterruptedException | ExecutionException e) {
log.error("Domain {} resolve failed", domain);
}
}
});
});
WorkerUtils.waitForTimer(() -> Duration.ofMinutes(configService.getConfig().getDomainResolveInterval()));
}
});
}
@PreDestroy
private void destroy() {
if (worker != null) {
worker.cancel(true);
worker = null;
}
}
}

View File

@ -0,0 +1,341 @@
package ru.kirillius.pf.sdn.service;
import lombok.Getter;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.springframework.stereotype.Service;
import ru.kirillius.java.utils.events.EventListener;
import ru.kirillius.json.JSONUtility;
import ru.kirillius.pf.sdn.api.Networking.IPv4Subnet;
import ru.kirillius.pf.sdn.api.Networking.NetworkScope;
import ru.kirillius.pf.sdn.dto.ResolverCacheEntry;
import ru.kirillius.pf.sdn.core.AppService;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.ContextEventsHandler;
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionService;
import ru.kirillius.pf.sdn.core.Util.IPv4Util;
import ru.kirillius.utils.logging.SystemLogger;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Builds the effective set of network resources by combining subscriptions, caches, and filters.
*/
@Service
public class NetworkingService extends AppService {
private final static String CTX = NetworkingService.class.getSimpleName();
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final File domainCacheFile;
private final File asCacheFile;
private final EventListener<NetworkResourceConfig> resourceUpdateSubscription;
private final EventListener<ContextEventsHandler.ConfigChangeContext> configChangeSubscription;
private final AtomicReference<Future<?>> updateProcess = new AtomicReference<>();
@Getter
private final NetworkResourceConfig inputResources = new NetworkResourceConfig();
@Getter
private final NetworkResourceConfig outputResources = new NetworkResourceConfig();
private final Map<Integer, List<IPv4Subnet>> prefixCache = new ConcurrentHashMap<>();
private final Map<String, ResolverCacheEntry> domainCache = new ConcurrentHashMap<>();
/**
* Creates the networking service, wiring subscriptions and restoring cached state.
*/
public NetworkingService(Context context) {
super(context);
inputResources.clear();
inputResources.add(context.getConfig().getCustomResources());
resourceUpdateSubscription = context.getEventsHandler().getSubscriptionsUpdateEvent().add(bundle -> rebuildInputs());
configChangeSubscription = context.getEventsHandler().getConfigChangeEvent().add(changeContext -> {
var filtersChanges = !changeContext.getCurrent().getFilteredResources().equals(changeContext.getInitial().getFilteredResources());
var resChanges = !changeContext.getCurrent().getCustomResources().equals(changeContext.getInitial().getCustomResources());
if (resChanges || filtersChanges) {
NetworkingService.this.rebuildInputs();
}
});
domainCacheFile = new File(context.getConfig().getCacheDirectory(), "domain-cache.json");
asCacheFile = new File(context.getConfig().getCacheDirectory(), "as-cache.json");
if (asCacheFile.exists() && context.getConfig().isCachingAS()) {
SystemLogger.message("Loading as cache file", CTX);
try (var is = new FileInputStream(asCacheFile)) {
var json = new JSONObject(new JSONTokener(is));
json.keySet().forEach(key -> {
var as = Integer.parseInt(key);
prefixCache.put(as, JSONUtility.deserializeCollection(json.getJSONArray(key), IPv4Subnet.class, null).toList());
});
} catch (Exception e) {
SystemLogger.error("Failed to load as cache file " + asCacheFile.getPath(), CTX, e);
}
}
if (domainCacheFile.exists() && context.getConfig().isCachingAS()) {
SystemLogger.message("Loading domain cache file", CTX);
try (var is = new FileInputStream(domainCacheFile)) {
var json = new JSONObject(new JSONTokener(is));
json.keySet().forEach(host -> {
domainCache.put(host, JSONUtility.deserializeStructure(json.getJSONObject(host), ResolverCacheEntry.class));
});
} catch (Exception e) {
SystemLogger.error("Failed to load domain cache file " + asCacheFile.getPath(), CTX, e);
}
}
}
public void performAutoresolve() {
var current = new HashSet<IPv4Subnet>();
domainCache.forEach((host, entry) -> current.addAll(entry.getAddresses().keySet()));
resolveDomains(List.copyOf(context.getServiceManager().getService(SubscriptionService.class).getAutoResolvingDomains()));
var resolved = new HashSet<IPv4Subnet>();
domainCache.forEach((host, entry) -> resolved.addAll(entry.getAddresses().keySet()));
if (resolved.size() != current.size()) {
rebuildInputs();
} else {
var updated = false;
for (var subnet : resolved) {
if (!current.contains(subnet)) {
updated = true;
break;
}
}
if (updated) {
rebuildInputs();
}
}
if(!context.getConfig().isCachingDomains()){
domainCache.clear();
}
}
private void rebuildInputs() {
inputResources.clear();
inputResources.add(context.getConfig().getCustomResources());
inputResources.add(context.getServiceManager().getService(SubscriptionService.class).getOutputResources());
triggerUpdate(false);
}
/**
* Indicates whether an update job is currently executing.
*/
public boolean isUpdatingNow() {
var future = updateProcess.get();
return future != null && !future.isDone() && !future.isCancelled();
}
/**
* Schedules an update of network resources, optionally ignoring cached prefixes.
*/
public void triggerUpdate(boolean ignoreCache) {
if (isUpdatingNow()) {
return;
}
SystemLogger.message("Updating network manager", CTX);
updateProcess.set(executor.submit(() -> {
try {
SystemLogger.message("Update is started", CTX);
var config = context.getConfig();
var filteredResources = config.getFilteredResources();
var domains = new HashSet<>(inputResources.getDomains());
filteredResources.getDomains().forEach(domains::remove);
//check domain overlaps
var domainsToRemove = new HashSet<String>();
for (var domainToMatch : domains) {
var pattern = "." + domainToMatch;
for (var domain : domains) {
if (domain.endsWith(pattern)) {
domainsToRemove.add(domain);
}
}
}
domains.removeAll(domainsToRemove);
var asn = new ArrayList<>(inputResources.getASN());
asn.removeAll(filteredResources.getASN());
var asnToFetch = new ArrayList<>(asn);
if (!ignoreCache) {
asnToFetch.removeAll(prefixCache.keySet());
}
fetchPrefixes(asnToFetch);
if (config.isCachingAS()) {
try (var os = new FileOutputStream(asCacheFile)) {
var json = new JSONObject();
prefixCache.forEach((key, asnList) -> {
json.put(String.valueOf(key), JSONUtility.serializeCollection(asnList, IPv4Subnet.class, null));
});
os.write(json.toString().getBytes());
} catch (IOException e) {
SystemLogger.error("Unable to write file " + asCacheFile.getPath(), CTX, e);
}
}
resolveDomains(List.copyOf(context.getServiceManager().getService(SubscriptionService.class).getAutoResolvingDomains()));
if (config.isCachingDomains()) {
try (var os = new FileOutputStream(domainCacheFile)) {
var json = new JSONObject();
domainCache.forEach((key, entry) -> {
var serialized = JSONUtility.serializeStructure(entry);
json.put(String.valueOf(key), serialized);
});
os.write(json.toString().getBytes());
} catch (IOException e) {
SystemLogger.error("Unable to write file " + domainCacheFile.getPath(), CTX, e);
}
}
var subnets = new HashSet<>(inputResources.getSubnets());
asn.forEach(n -> {
var cached = prefixCache.get(n);
if (cached == null) {
return;
}
subnets.addAll(cached);
SystemLogger.message("Using " + cached.size() + " subnets from AS" + n, CTX);
});
//добавляем отрезолвенные домены
domains.forEach(domain -> {
var entry = domainCache.get(domain);
if (entry != null) {
subnets.addAll(entry.getAddresses().keySet());
}
});
filteredResources.getSubnets().forEach(subnets::remove);
SystemLogger.message("Trying to summary " + subnets.size() + " subnets...", CTX);
var merged = IPv4Util.summarySubnets(subnets, config.getMergeSubnetsWithUsage());
var unmerged = new AtomicInteger();
subnets.forEach(subnet -> {
if (!merged.getMergedSubnets().contains(subnet)) {
unmerged.getAndIncrement();
}
});
SystemLogger.message(subnets.size() + " subnets has been summarized and merged to " + merged.getResult().size() + " new subnets. Unmerged: " + unmerged.get(), CTX);
outputResources.setASN(Collections.unmodifiableList(asn));
outputResources.setSubnets(merged.getResult());
outputResources.setDomains(domains.stream().toList());
SystemLogger.message("Update is complete", CTX);
try {
context.getEventsHandler().getNetworkManagerUpdateEvent().invoke(outputResources);
} catch (Exception e) {
SystemLogger.error("Unable to invoke update event", CTX, e);
}
} catch (Exception e) {
SystemLogger.error("Something went wrong on update", CTX, e);
}
}));
}
private void resolveDomains(List<String> domains) {
var resolvedSubnets = new ArrayList<IPv4Subnet>();
var resolver = context.getServiceManager().getService(DomainResolverService.class);
for (var domain : domains) {
var task = resolver.resolve(domain);
while (!task.isDone() && !task.isCancelled()) {
Thread.yield();
}
try {
var subnets = task.get();
var entry = domainCache.get(domain);
if(entry == null) {
entry = new ResolverCacheEntry();
domainCache.put(domain, entry);
}
var addresses = entry.getAddresses();
entry.setLastUpdate(Instant.now());
subnets.forEach(subnet -> addresses.put(subnet, Instant.now()));
resolvedSubnets.addAll(domainCache.get(domain).getAddresses().keySet());
} catch (InterruptedException | ExecutionException e) {
SystemLogger.error("Error happened while resolving domain " + domain, CTX, e);
}
}
//remove old entries
for (var domain : domainCache.keySet()) {
var entry = domainCache.get(domain);
var addresses = entry.getAddresses();
for (var subnet : addresses.keySet()) {
var time = addresses.get(subnet);
if (time.isBefore(Instant.now().minus(context.getConfig().getDomainsTimeToLive(), ChronoUnit.HOURS))) {
addresses.remove(subnet);
}
}
if (addresses.isEmpty()) {
domainCache.remove(domain);
}
}
}
/**
* Fetches prefixes for the given autonomous systems and stores them in the cache.
*/
private void fetchPrefixes(List<Integer> systems) {
var service = context.getServiceManager().getService(BGPInfoService.class);
systems.forEach(as -> {
var currentProvider = service.getProvider();
var done = false;
do {
SystemLogger.message("Fetching AS" + as + " prefixes...", CTX);
var future = service.getPrefixes(as);
while (!future.isDone() && !future.isCancelled()) {
Thread.yield();
}
try {
var iPv4Subnets = future.get();
prefixCache.put(as, iPv4Subnets);
done = true;
break;
} catch (InterruptedException | ExecutionException e) {
service.fallbackNextProvider();
SystemLogger.error("Error happened while fetching AS" + as + " prefixes. Trying to use fallback BGP info provider:" + service.getProvider().getClass().getSimpleName(), CTX, e);
}
} while (service.getProvider() != currentProvider);
if (!done) {
SystemLogger.error("Unable to fetch AS" + as + " prefixes from all providers. Trying to use cache...", CTX);
}
});
}
/**
* Removes event subscriptions and shuts down the executor.
*/
@Override
public void close() throws IOException {
context.getEventsHandler().getSubscriptionsUpdateEvent().remove(resourceUpdateSubscription);
context.getEventsHandler().getConfigChangeEvent().remove(configChangeSubscription);
executor.shutdown();
}
}

View File

@ -0,0 +1,61 @@
package ru.kirillius.pf.sdn.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.stereotype.Service;
import ru.kirillius.pf.sdn.api.pipeline.ProcessingPipeline;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
@Service
@RequiredArgsConstructor
public class PipelineExecutorService {
private final ExecutorService executor;
private final Queue<ProcessingPipeline> executionQueue = new ConcurrentLinkedQueue<>();
public void triggerExecute(ProcessingPipeline pipeline) {
if (executionQueue.contains(pipeline)) {
return;
}
executionQueue.add(pipeline);
}
@PostConstruct
private void initialize() {
worker = executor.submit(new PipelineWorker());
}
@PreDestroy
private void destroy() {
if (worker != null) {
worker.cancel(true);
}
}
private Future<?> worker;
private class PipelineWorker implements Runnable {
@SuppressWarnings("BusyWait")
@SneakyThrows
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
while (!executionQueue.isEmpty()) {
var pipeline = executionQueue.poll();
try {
pipeline.execute();
} catch (Exception e) {
//TODO write log
}
}
Thread.sleep(100L);
Thread.yield();
}
}
}
}

View File

@ -0,0 +1,23 @@
package ru.kirillius.pf.sdn.service;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
import java.net.Inet4Address;
import java.util.List;
@Service
public class RuntimeConfigService {
@Getter
@Setter
public class Config {
private int domainResolveInterval = 1;
private int subscriptionUpdateInterval = 1;
private List<Inet4Address> domainResolvers;
}
@Getter
private final Config config = new Config();
}

View File

@ -0,0 +1,30 @@
package ru.kirillius.pf.sdn.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class SessionService {
private final AuthService authService;
public Optional<Long> resolveUserId(String authHeader) {
return extractToken(authHeader)
.flatMap(authService::resolveUserId);
}
public Optional<String> extractToken(String authHeader) {
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
return Optional.of(authHeader.substring(7));
}
return Optional.empty();
}
public void invalidate(String token) {
authService.invalidateToken(token);
}
}

View File

@ -0,0 +1,149 @@
package ru.kirillius.pf.sdn.service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import ru.kirillius.java.utils.events.ConcurrentEventHandler;
import ru.kirillius.java.utils.events.EventHandler;
import ru.kirillius.pf.sdn.Util.WorkerUtils;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.CacheFallbackProviderProtocol;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.Subscription;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.SubscriptionProvider;
import ru.kirillius.pf.sdn.api.Networking.Subscriptions.SubscriptionProviderProtocol;
import ru.kirillius.pf.sdn.repository.SubscriptionProviderConfigRepository;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
@Service
@RequiredArgsConstructor
@Slf4j
public class SubscriptionService {
private final SubscriptionProviderConfigRepository configRepository;
private final ExecutorService executorService;
private final RuntimeConfigService runtimeConfigService;
private final Map<String, SubscriptionProviderProtocol> protocols = new ConcurrentHashMap<>();
private final Map<String, SubscriptionProvider> providers = new ConcurrentHashMap<>();
private final AtomicInteger updateCounter = new AtomicInteger(0);
private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>();
private Future<?> worker;
public List<Subscription> getSubscriptions() {
return Collections.unmodifiableList(subscriptions);
}
public void update() {
updateCounter.incrementAndGet();
}
@PreDestroy
private void destroy() {
if (worker != null) {
worker.cancel(true);
worker = null;
}
}
private final ApplicationContext context;
public void reloadProviders() {
var beanFactory = context.getAutowireCapableBeanFactory();
synchronized (providers) {
providers.clear();
configRepository.findAll().forEach(config -> {
var protocol = protocols.get(config.getProtocolId());
if (protocol == null) {
log.error("Provider protocol with id {} not found. Using fallback from cache.", config.getProtocolId());
protocol = beanFactory.createBean(CacheFallbackProviderProtocol.class);
}
providers.put(config.getName(), SubscriptionProvider.builder()
.config(config)
.protocol(protocol)
.build()
);
});
}
}
@Getter
private final EventHandler<Subscription> updateEvent = new ConcurrentEventHandler<>();
private void reloadSubscriptions() {
var retrieved = new ArrayList<Subscription>();
var updated = new ArrayList<Subscription>();
providers.values().forEach(provider -> {
updated.addAll(provider.update());
retrieved.addAll(provider.getSubscriptions());
});
synchronized (subscriptions) {
subscriptions.clear();
subscriptions.addAll(retrieved);
}
updated.forEach(s -> {
try {
updateEvent.invoke(s);
} catch (Exception e) {
log.error("Failed to invoke subscription update event because of error {}:{}", e.getClass().getSimpleName(), e.getMessage());
}
});
}
@PostConstruct
private void initialize() {
reloadProviders();
reloadSubscriptions();
worker = executorService.submit(new WorkerUtils.LoopedWorker() {
private Instant lastUpdate = Instant.now();
@Override
protected void doWork() {
if (updateCounter.get() > 0) {
updateCounter.set(0);
}
reloadSubscriptions();
lastUpdate = Instant.now();
var config = runtimeConfigService.getConfig();
WorkerUtils.waitForCondition(() -> Duration.between(lastUpdate, Instant.now())
.compareTo(Duration.ofMinutes(config.getSubscriptionUpdateInterval())) > 0
|| updateCounter.get() > 0, 1000L);
}
});
}
public void registerProvider(SubscriptionProviderProtocol subscriptionProviderProtocol) {
if (subscriptionProviderProtocol == null) {
throw new IllegalArgumentException();
}
var identifier = subscriptionProviderProtocol.getIdentifier();
if (identifier == null || identifier.isBlank()) {
throw new IllegalArgumentException("Identifier is null or blank");
}
if (protocols.containsKey(identifier)) {
return;
}
protocols.put(identifier, subscriptionProviderProtocol);
}
}

View File

@ -0,0 +1 @@
jwt.secret=very-strong1234568ds7f7df6g7fd5df67g5ds78g6ds75gdf678g78g623r432r3213few

View File

@ -0,0 +1,24 @@
spring:
application:
name: pf-sdn
datasource:
url: ${app.datasource.url}
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect

View File

@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>pf-sdn Auth Test</title>
<style>
body { font-family: sans-serif; padding: 2rem; background: #f5f5f5; }
.card { background: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.15); margin-bottom: 1.5rem; }
h2 { margin-top: 0; }
label { display: block; margin-bottom: .25rem; font-weight: 600; }
input { width: 100%; padding: .5rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
button { padding: .5rem 1.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
.btn-primary { background: #007bff; color: #fff; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-success { background: #28a745; color: #fff; }
pre { background: #f0f0f0; padding: 1rem; border-radius: 4px; overflow-x: auto; }
.hidden { display: none; }
</style>
</head>
<body>
<h1>pf-sdn Auth Test</h1>
<div class="card">
<h2>Login</h2>
<label for="loginUsername">Username</label>
<input id="loginUsername" value="admin">
<label for="loginPassword">Password</label>
<input id="loginPassword" type="password" value="admin">
<button class="btn-primary" onclick="login()">Login</button>
<pre id="loginResult"></pre>
</div>
<div id="authSection" class="hidden">
<div class="card">
<h2>Logout</h2>
<button class="btn-danger" onclick="logout()">Logout</button>
<pre id="logoutResult"></pre>
</div>
<div class="card">
<h2>Change Password</h2>
<label for="oldPassword">Old Password</label>
<input id="oldPassword" type="password">
<label for="newPassword">New Password</label>
<input id="newPassword" type="password">
<button class="btn-success" onclick="changePassword()">Change Password</button>
<pre id="changePasswordResult"></pre>
</div>
</div>
<script>
let token = localStorage.getItem("token");
function updateAuthSection() {
document.getElementById("authSection").classList.toggle("hidden", !token);
}
async function login() {
const username = document.getElementById("loginUsername").value;
const password = document.getElementById("loginPassword").value;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({username, password})
});
const data = await res.json();
document.getElementById("loginResult").textContent = JSON.stringify(data, null, 2);
if (res.ok) {
token = data.token;
localStorage.setItem("token", token);
updateAuthSection();
}
} catch (e) {
document.getElementById("loginResult").textContent = e;
}
}
async function logout() {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
headers: { "Authorization": "Bearer " + token }
});
document.getElementById("logoutResult").textContent = "Status: " + res.status;
token = null;
localStorage.removeItem("token");
updateAuthSection();
} catch (e) {
document.getElementById("logoutResult").textContent = e;
}
}
async function changePassword() {
const oldPassword = document.getElementById("oldPassword").value;
const newPassword = document.getElementById("newPassword").value;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify({oldPassword, newPassword})
});
const data = await res.json();
document.getElementById("changePasswordResult").textContent = JSON.stringify(data, null, 2);
if (res.ok) {
token = null;
localStorage.removeItem("token");
updateAuthSection();
}
} catch (e) {
document.getElementById("changePasswordResult").textContent = e;
}
}
updateAuthSection();
</script>
</body>
</html>