diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..08fbe3a --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + ru.kirillius + pf-sdn + 0.1.0.0 + + + core + + + \ No newline at end of file diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthManager.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthManager.java new file mode 100644 index 0000000..b3e38ed --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthManager.java @@ -0,0 +1,72 @@ +package ru.kirillius.pf.sdn.core.Auth; + +import jakarta.servlet.http.HttpSession; +import ru.kirillius.pf.sdn.core.Context; +import ru.kirillius.pf.sdn.core.Util.HashUtil; + +import java.util.Objects; + +public class AuthManager { + private final Context context; + public final static String SESSION_AUTH_KEY = "auth"; + public final static String SESSION_TOKEN = "token"; + + public AuthManager(Context context) { + this.context = context; + } + + public boolean validatePassword(String pass) { + var config = context.getConfig(); + return HashUtil.hash(pass, config.getPasswordSalt()).equals(config.getPasswordHash()); + } + + public void updatePassword(String pass) { + var config = context.getConfig(); + config.setPasswordHash( + HashUtil.hash(pass, config.getPasswordSalt()) + ); + } + + public AuthToken createToken(String description) { + var config = context.getConfig(); + var token = new AuthToken(); + token.setDescripton(description); + config.getTokens().add(token); + config.update(); + return token; + } + + public boolean validateToken(AuthToken token) { + return context.getConfig().getTokens().contains(token); + } + + public void setSessionAuthState(HttpSession session, boolean state) { + session.setAttribute(SESSION_AUTH_KEY, state); + } + + public void setSessionToken(HttpSession session, AuthToken token) { + session.setAttribute(SESSION_TOKEN, token); + } + + public AuthToken getSessionToken(HttpSession session) { + return (AuthToken) session.getAttribute(SESSION_TOKEN); + } + + public boolean getSessionAuthState(HttpSession session) { + return Objects.equals(session.getAttribute(SESSION_AUTH_KEY), Boolean.TRUE); + } + + public void invalidateToken(AuthToken token) { + var config = context.getConfig(); + config.getTokens().remove(token); + config.update(); + } + + public boolean validateToken(String token) { + return validateToken(new AuthToken(token)); + } + + public void invalidateToken(String token) { + invalidateToken(new AuthToken(token)); + } +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthToken.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthToken.java new file mode 100644 index 0000000..6b992a6 --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Auth/AuthToken.java @@ -0,0 +1,40 @@ +package ru.kirillius.pf.sdn.core.Auth; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.kirillius.json.JSONProperty; +import ru.kirillius.json.JSONSerializable; + +import java.util.Objects; +import java.util.UUID; + +@NoArgsConstructor +@JSONSerializable +public class AuthToken { + public AuthToken(String token) { + this.token = token; + } + + @Setter + @Getter + @JSONProperty + private String descripton = "untitled"; + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + AuthToken authToken = (AuthToken) o; + return Objects.equals(token, authToken.token); + } + + @Override + public int hashCode() { + return Objects.hashCode(token); + } + + @Setter + @Getter + @JSONProperty + private String token = UUID.randomUUID().toString(); +} \ No newline at end of file diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java new file mode 100644 index 0000000..b0e5f9d --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java @@ -0,0 +1,84 @@ +package ru.kirillius.pf.sdn.core; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.json.JSONObject; +import org.json.JSONTokener; +import ru.kirillius.json.JSONArrayProperty; +import ru.kirillius.json.JSONProperty; +import ru.kirillius.json.JSONSerializable; +import ru.kirillius.json.JSONUtility; +import ru.kirillius.pf.sdn.core.Auth.AuthToken; + +import java.io.*; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@NoArgsConstructor +@JSONSerializable +public class Config { + @Getter + private File loadedConfigFile = null; + @Setter + @Getter + @JSONProperty + private String host = "0.0.0.0"; + + @Setter + @Getter + @JSONArrayProperty(type = AuthToken.class) + private List tokens = Collections.emptyList(); + + @Setter + @Getter + @JSONProperty + private String passwordSalt = UUID.randomUUID().toString(); + + @Setter + @Getter + @JSONProperty + private String passwordHash = null; + + @Setter + @Getter + @JSONProperty + private int httpPort = 8081; + + public void update() { + try { + store(this, loadedConfigFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void store(Config config, File file) throws IOException { + try (var fileInputStream = new FileOutputStream(file)) { + try (var writer = new BufferedWriter(new OutputStreamWriter(fileInputStream))) { + writer.write(serialize(config).toString()); + writer.flush(); + } + } + } + + public static JSONObject serialize(Config config) { + return JSONUtility.serializeStructure(config); + } + + public static Config deserialize(JSONObject object) { + return JSONUtility.deserializeStructure(object, Config.class); + } + + public static Config load(File file) throws IOException { + try (var stream = new FileInputStream(file)) { + var json = new JSONObject(new JSONTokener(stream)); + var config = deserialize(json); + config.loadedConfigFile = file; + return config; + } + } + + +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Context.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Context.java new file mode 100644 index 0000000..a029ce5 --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Context.java @@ -0,0 +1,13 @@ +package ru.kirillius.pf.sdn.core; + +import org.eclipse.jetty.server.Server; +import ru.kirillius.pf.sdn.core.Auth.AuthManager; + +public interface Context { + Config getConfig(); + + AuthManager getAuthManager(); + + Server getServer(); + +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java new file mode 100644 index 0000000..a007038 --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Networking/IPv4Subnet.java @@ -0,0 +1,56 @@ +package ru.kirillius.pf.sdn.core.Networking; + +import lombok.Getter; +import ru.kirillius.pf.sdn.core.Util.IPv4Util; + +import java.util.regex.Pattern; + + +public class IPv4Subnet { + + private final long address; + @Getter + private final int prefixLength; + + + 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); + + address = IPv4Util.ipAddressToLong(split[0]); + prefixLength = prefix; + } + + + public IPv4Subnet(String address, int prefixLength) { + IPv4Util.validatePrefix(prefixLength); + + this.address = IPv4Util.ipAddressToLong(address); + this.prefixLength = prefixLength; + } + + public String getAddress() { + return IPv4Util.longToIpAddress(address); + } + + @Override + public String toString() { + return getAddress() + '/' + prefixLength; + } + + public boolean overlaps(IPv4Subnet subnet) { + var minPrefixLength = Math.min(prefixLength, subnet.prefixLength); + var commonMask = IPv4Util.calculateMask(minPrefixLength); + if (commonMask != prefixLength) { + return false; //can't overlap larger prefix + } + + return (address & commonMask) == (subnet.address & commonMask); + } + + +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Util/BGPUtility.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/BGPUtility.java new file mode 100644 index 0000000..d5df553 --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/BGPUtility.java @@ -0,0 +1,5 @@ +package ru.kirillius.pf.sdn.core.Util; + +public class BGPUtility { + +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Util/HashUtil.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/HashUtil.java new file mode 100644 index 0000000..dd62def --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/HashUtil.java @@ -0,0 +1,30 @@ +package ru.kirillius.pf.sdn.core.Util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class HashUtil { + private HashUtil() { + } + + + public static String hash(String data, String salt) { + String generatedPassword = null; + MessageDigest md = null; + try { + md = MessageDigest.getInstance("SHA-512"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + md.update(salt.getBytes(StandardCharsets.UTF_8)); + var bytes = md.digest(data.getBytes(StandardCharsets.UTF_8)); + var sb = new StringBuilder(); + for (byte aByte : bytes) { + sb.append(Integer.toString((aByte & 0xff) + 0x100, 16).substring(1)); + } + generatedPassword = sb.toString(); + return generatedPassword; + + } +} diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Util/IPv4Util.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/IPv4Util.java new file mode 100644 index 0000000..358ee3c --- /dev/null +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Util/IPv4Util.java @@ -0,0 +1,53 @@ +package ru.kirillius.pf.sdn.core.Util; + +import lombok.SneakyThrows; + +import java.net.InetAddress; +import java.util.regex.Pattern; + + +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]?)$"); + + public static void validateAddress(String address) { + if (!pattern.matcher(address).matches()) { + throw new IllegalArgumentException("Invalid IPv4 address: " + address); + } + } + + public static void validatePrefix(int prefix) { + if (prefix < 0 || prefix > 32) { + throw new IllegalArgumentException("Invalid IPv4 prefix: " + prefix); + } + } + + + @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; + } + + public static long calculateMask(int prefixLength) { + validatePrefix(prefixLength); + return 0xFFFFFFFFL << (32 - prefixLength); + } + + + 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); + } +} diff --git a/core/src/test/java/ru/kirillius/pf/sdn/core/Networking/IPv4SubnetTest.java b/core/src/test/java/ru/kirillius/pf/sdn/core/Networking/IPv4SubnetTest.java new file mode 100644 index 0000000..078471c --- /dev/null +++ b/core/src/test/java/ru/kirillius/pf/sdn/core/Networking/IPv4SubnetTest.java @@ -0,0 +1,77 @@ +package ru.kirillius.pf.sdn.core.Networking; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class IPv4SubnetTest { + @SuppressWarnings("CatchMayIgnoreException") + @Test + void testInit() { + var validPrefixes = List.of(0, 5, 20, 32); + var invalidPrefixes = List.of(-1, 33, 800); + var validAddresses = List.of("1.2.3.4", "0.0.0.0", "255.255.255.255"); + var invalidAddresses = List.of("1.2.3.04", "0.0.0", "255.255.255.255.255", "1.2.3.256", "-1.0.0.0"); + + + validPrefixes.forEach(prefix -> { + validAddresses.forEach(address -> { + var subnet = new IPv4Subnet(address, prefix); + }); + }); + + invalidPrefixes.forEach(prefix -> { + validAddresses.forEach(address -> { + try { + var subnet = new IPv4Subnet(address, prefix); + throw new AssertionError(); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + }); + }); + + validPrefixes.forEach(prefix -> { + invalidAddresses.forEach(address -> { + try { + var subnet = new IPv4Subnet(address, prefix); + throw new AssertionError(); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + }); + }); + + invalidPrefixes.forEach(prefix -> { + invalidAddresses.forEach(address -> { + try { + var subnet = new IPv4Subnet(address, prefix); + throw new AssertionError(); + } catch (Exception e) { + assertThat(e).isInstanceOf(IllegalArgumentException.class); + } + }); + }); + + } + + @Test + void overlaps() { + checkOverlaps( + new IPv4Subnet("0.0.0.0", 0), + new IPv4Subnet("8.8.8.8", 32) + ); + + checkOverlaps( + new IPv4Subnet("192.168.0.0", 24), + new IPv4Subnet("192.168.0.128", 25) + ); + } + + private void checkOverlaps(IPv4Subnet larger, IPv4Subnet smaller) { + assertThat(larger.overlaps(smaller)).isTrue(); + assertThat(smaller.overlaps(larger)).isFalse(); + } +} \ No newline at end of file diff --git a/launcher/pom.xml b/launcher/pom.xml new file mode 100644 index 0000000..9126b2c --- /dev/null +++ b/launcher/pom.xml @@ -0,0 +1,14 @@ + + + 4.0.0 + + ru.kirillius + pf-sdn + 0.1.0.0 + + + launcher + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03009d9..268337f 100644 --- a/pom.xml +++ b/pom.xml @@ -7,11 +7,114 @@ ru.kirillius pf-sdn 0.1.0.0 + pom + + web-client + launcher + core + web-server + - 24 - 24 + 21 + 21 UTF-8 + + + + kirillius + kirillius + https://repo.kirillius.ru/maven + + true + always + fail + + default + + + + + + + org.junit.jupiter + junit-jupiter-api + 5.13.0-M2 + test + + + + + org.assertj + assertj-core + 4.0.0-M1 + test + + + + org.javatuples + javatuples + 1.2 + + + com.cronutils + cron-utils + 9.2.0 + + + + org.snmp4j + snmp4j + 3.7.7 + + + ru.kirillius + icmp4j + 1.0.0.0 + + + ru.kirillius.util + dynamic-types + 2.0.0.0 + + + ru.kirillius + json-rpc-servlet + 2.1.4.0 + + + ru.kirillius.utils + common-logging + 1.3.0.0 + + + ru.kirillius + hibernate-commons + 2.2.0.0 + + + + org.javassist + javassist + 3.29.2-GA + + + + + org.eclipse.jetty + jetty-server + 12.0.12 + + + + + org.projectlombok + lombok + 1.18.34 + provided + + + \ No newline at end of file diff --git a/web-client/pom.xml b/web-client/pom.xml new file mode 100644 index 0000000..3da5878 --- /dev/null +++ b/web-client/pom.xml @@ -0,0 +1,16 @@ + + + 4.0.0 + + ru.kirillius + pf-sdn + 0.1.0.0 + + + web-client + + + + \ No newline at end of file diff --git a/web-server/pom.xml b/web-server/pom.xml new file mode 100644 index 0000000..66ec4c9 --- /dev/null +++ b/web-server/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + ru.kirillius + pf-sdn + 0.1.0.0 + + + web-server + + + ru.kirillius + core + 0.1.0.0 + compile + + + + + \ No newline at end of file diff --git a/web-server/src/main/java/ru/kirillius/pf/sdn/web/HTTPServer.java b/web-server/src/main/java/ru/kirillius/pf/sdn/web/HTTPServer.java new file mode 100644 index 0000000..22713f5 --- /dev/null +++ b/web-server/src/main/java/ru/kirillius/pf/sdn/web/HTTPServer.java @@ -0,0 +1,91 @@ +package ru.kirillius.pf.sdn.web; + + +import org.eclipse.jetty.ee10.servlet.DefaultServlet; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.jetbrains.annotations.Nullable; +import ru.kirillius.json.rpc.Servlet.JSONRPCServlet; +import ru.kirillius.pf.sdn.core.Context; +import ru.kirillius.utils.logging.SystemLogger; +import ru.kirillius.pf.sdn.web.RPC.AuthRPC; +import ru.kirillius.pf.sdn.web.RPC.RPC; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Objects; +import java.util.Set; + +public class HTTPServer extends Server { + + public final static String ANY_HOST = "0.0.0.0"; + private final static String LOG_CONTEXT = HTTPServer.class.getSimpleName(); + private final static String TOKEN_HEADER = "X-Auth-token"; + + private final JSONRPCServlet JSONRPC = new JSONRPCServlet(); + + private String getResourceBase() throws MalformedURLException { + var resourceFile = getClass().getClassLoader().getResource("htdocs/index.html"); + return new URL(Objects.requireNonNull(resourceFile).getProtocol(), resourceFile.getHost(), resourceFile.getPath() + .substring(0, resourceFile.getPath().lastIndexOf("/"))) + .toString(); + } + + private final static Set> RPCHandlerTypes = Set.of(AuthRPC.class); + + + public HTTPServer(Context appContext, int port, @Nullable String host) throws Exception { + + var connector = new ServerConnector(this); + connector.setPort(port); + if (host != null && !host.equals(ANY_HOST)) { + connector.setHost(host); + } + + this.addConnector(connector); + ServletContextHandler servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS); + servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH); + var holder = servletContext.addServlet(DefaultServlet.class, "/"); + holder.setInitParameter("resourceBase", getResourceBase()); + this.setHandler(servletContext); + + start(); + + + JSONRPC.addRequestHandler((request, response, call) -> { + var authManager = appContext.getAuthManager(); + var authorized = authManager.getSessionAuthState(call.getContext().getSession()); + // Thread.sleep(100);//FIXME remove! debug only + + //auth by token + if (!authorized) { + var headerToken = request.getHeader(TOKEN_HEADER); + if (headerToken != null) { + authorized = authManager.validateToken(headerToken); + authManager.setSessionAuthState(call.getContext().getSession(), authorized); + } + } + + + var isProtectedAccess = call.getMethod().getAnnotation(ProtectedMethod.class); + if (isProtectedAccess != null) { + if (!authorized) throw new SecurityException("Forbidden"); + } + }); + + + for (var handlerClass : RPCHandlerTypes) { + var instance = RPC.instantiate(handlerClass, appContext); + //noinspection unchecked + JSONRPC.addTargetInstance((Class) handlerClass, instance); + } + + + JSONRPC.getErrorHandler().add(throwable -> { + SystemLogger.error("JRPC Request " + + (throwable.getRequestData() == null ? "" : throwable.getRequestData().toString()) + + " has failed with error", LOG_CONTEXT, throwable.getError()); + }); + } +} diff --git a/web-server/src/main/java/ru/kirillius/pf/sdn/web/ProtectedMethod.java b/web-server/src/main/java/ru/kirillius/pf/sdn/web/ProtectedMethod.java new file mode 100644 index 0000000..0eef9f4 --- /dev/null +++ b/web-server/src/main/java/ru/kirillius/pf/sdn/web/ProtectedMethod.java @@ -0,0 +1,11 @@ +package ru.kirillius.pf.sdn.web; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ProtectedMethod { +} diff --git a/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/AuthRPC.java b/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/AuthRPC.java new file mode 100644 index 0000000..49e81be --- /dev/null +++ b/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/AuthRPC.java @@ -0,0 +1,52 @@ +package ru.kirillius.pf.sdn.web.RPC; + +import ru.kirillius.json.rpc.Annotations.JRPCArgument; +import ru.kirillius.json.rpc.Annotations.JRPCContext; +import ru.kirillius.json.rpc.Annotations.JRPCMethod; +import ru.kirillius.json.rpc.Servlet.CallContext; +import ru.kirillius.pf.sdn.core.Auth.AuthToken; +import ru.kirillius.pf.sdn.core.Context; +import ru.kirillius.pf.sdn.web.ProtectedMethod; + +public class AuthRPC implements RPC { + private final Context context; + + public AuthRPC(Context context) { + this.context = context; + } + + @ProtectedMethod + @JRPCMethod + public AuthToken getAuthToken(@JRPCContext CallContext call) { + return context.getAuthManager().getSessionToken(call.getSession()); + } + + @ProtectedMethod + @JRPCMethod + public AuthToken rememberCurrentUser(@JRPCContext CallContext call) { + var UA = call.getRequest().getHeader("User-Agent"); + if (UA == null) { + UA = "Unknown user agent"; + } + var authManager = context.getAuthManager(); + var token = authManager.createToken(UA); + authManager.setSessionToken(call.getSession(), token); + return token; + } + + @JRPCMethod + public boolean auth(@JRPCArgument(name = "password") String password, @JRPCContext CallContext call) { + var authManager = context.getAuthManager(); + if (authManager.validatePassword(password)) { + authManager.setSessionAuthState(call.getSession(), true); + } + return false; + } + + + @JRPCMethod + public void logout(@JRPCContext CallContext call) { + var authManager = context.getAuthManager(); + authManager.setSessionAuthState(call.getSession(), false); + } +} diff --git a/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/RPC.java b/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/RPC.java new file mode 100644 index 0000000..2d2272e --- /dev/null +++ b/web-server/src/main/java/ru/kirillius/pf/sdn/web/RPC/RPC.java @@ -0,0 +1,16 @@ +package ru.kirillius.pf.sdn.web.RPC; + +import ru.kirillius.pf.sdn.core.Context; + +import java.lang.reflect.InvocationTargetException; + +public interface RPC { + static T instantiate(Class type, Context context) { + try { + return type.getConstructor(Context.class).newInstance(context); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | + IllegalAccessException e) { + throw new RuntimeException(e); + } + } +}