From e9fa0c13a7e969da229c9b0ffe452c154364eee0 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Thu, 26 Aug 2021 11:22:24 +0200 Subject: [PATCH] Added catalog discovery server, readmes and improved some server channel logic. --- catalog/README.md | 3 + catalog/pom.xml | 36 ++++++++ catalog/src/main/java/module-info.java | 10 +++ .../concord_catalog/CatalogServer.java | 65 +++++++++++++++ .../andrewl/concord_catalog/servlet/Page.java | 57 +++++++++++++ .../servlet/ServerMetaData.java | 82 +++++++++++++++++++ .../servlet/ServersServlet.java | 57 +++++++++++++ .../concord_catalog/defaults.properties | 1 + client/README.md | 3 + .../concord_client/gui/ChatRenderer.java | 4 +- core/README.md | 3 + pom.xml | 1 + server/src/main/java/module-info.java | 1 + .../concord_server/ChannelManager.java | 11 +++ .../andrewl/concord_server/ConcordServer.java | 52 ++++++++++-- .../cli/command/RemoveChannelCommand.java | 1 + .../concord_server/config/ServerConfig.java | 5 ++ 17 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 catalog/README.md create mode 100644 catalog/pom.xml create mode 100644 catalog/src/main/java/module-info.java create mode 100644 catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java create mode 100644 catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java create mode 100644 catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java create mode 100644 catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java create mode 100644 catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties create mode 100644 client/README.md create mode 100644 core/README.md diff --git a/catalog/README.md b/catalog/README.md new file mode 100644 index 0000000..efaa5ea --- /dev/null +++ b/catalog/README.md @@ -0,0 +1,3 @@ +# Concord Catalog + +The catalog is an HTTP server that is used as a "discovery" server that connects clients to the concord servers they might want to join. Clients will request a list of servers from the catalog, and servers are responsible for regularly sending their metadata to any catalogs they wish to be publicly visible in. diff --git a/catalog/pom.xml b/catalog/pom.xml new file mode 100644 index 0000000..fac9f56 --- /dev/null +++ b/catalog/pom.xml @@ -0,0 +1,36 @@ + + + + concord + nl.andrewl + 1.0-SNAPSHOT + + 4.0.0 + + concord-catalog + + + 16 + 16 + + + + + io.undertow + undertow-core + 2.2.8.Final + + + io.undertow + undertow-servlet + 2.2.8.Final + + + com.fasterxml.jackson.core + jackson-databind + 2.12.4 + + + \ No newline at end of file diff --git a/catalog/src/main/java/module-info.java b/catalog/src/main/java/module-info.java new file mode 100644 index 0000000..6be15f8 --- /dev/null +++ b/catalog/src/main/java/module-info.java @@ -0,0 +1,10 @@ +module concord_catalog { + requires com.fasterxml.jackson.databind; + requires undertow.core; + requires undertow.servlet; + requires java.servlet; + requires jdk.unsupported; + + exports nl.andrewl.concord_catalog.servlet to undertow.servlet; + opens nl.andrewl.concord_catalog.servlet to com.fasterxml.jackson.databind; +} \ No newline at end of file diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java b/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java new file mode 100644 index 0000000..b16188f --- /dev/null +++ b/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java @@ -0,0 +1,65 @@ +package nl.andrewl.concord_catalog; + +import io.undertow.Undertow; +import io.undertow.server.HttpHandler; +import io.undertow.servlet.Servlets; +import io.undertow.servlet.api.DeploymentInfo; +import io.undertow.servlet.api.DeploymentManager; +import nl.andrewl.concord_catalog.servlet.ServersServlet; + +import javax.servlet.ServletException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public class CatalogServer { + private static final String SETTINGS_FILE = "concord-catalog.properties"; + + public static void main(String[] args) throws ServletException, IOException { + var props = loadProperties(); + startServer(Integer.parseInt(props.getProperty("port"))); + } + + /** + * Starts the Undertow HTTP servlet container. + * @param port The port to bind to. + * @throws ServletException If the server could not be started. + */ + private static void startServer(int port) throws ServletException { + System.out.println("Starting server on port " + port + "."); + DeploymentInfo servletBuilder = Servlets.deployment() + .setClassLoader(CatalogServer.class.getClassLoader()) + .setContextPath("/") + .setDeploymentName("Concord Catalog") + .addServlets( + Servlets.servlet("ServersServlet", ServersServlet.class) + .addMapping("/servers") + ); + DeploymentManager manager = Servlets.defaultContainer().addDeployment(servletBuilder); + manager.deploy(); + HttpHandler servletHandler = manager.start(); + Undertow server = Undertow.builder() + .addHttpListener(port, "0.0.0.0") + .setHandler(servletHandler) + .build(); + server.start(); + } + + /** + * Loads properties from all necessary locations. + * @return The properties that were loaded. + * @throws IOException If an error occurs while reading properties. + */ + private static Properties loadProperties() throws IOException { + Properties props = new Properties(); + props.load(CatalogServer.class.getResourceAsStream("/nl/andrewl/concord_catalog/defaults.properties")); + Path settingsPath = Path.of(SETTINGS_FILE); + if (Files.exists(settingsPath)) { + props.load(Files.newBufferedReader(settingsPath)); + } else { + System.out.println("Using built-in default settings. Create a concord-catalog.properties file to configure."); + } + return props; + } +} diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java new file mode 100644 index 0000000..9a511f2 --- /dev/null +++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java @@ -0,0 +1,57 @@ +package nl.andrewl.concord_catalog.servlet; + +import java.util.List; + +public class Page { + private final List contents; + private final int elementCount; + private final int pageSize; + private final int currentPage; + private final boolean firstPage; + private final boolean lastPage; + private final String order; + private final String orderDirection; + + public Page(List contents, int currentPage, int pageSize, String order, String orderDirection) { + this.contents = contents; + this.elementCount = contents.size(); + this.pageSize = pageSize; + this.currentPage = currentPage; + this.firstPage = currentPage == 0; + this.lastPage = this.elementCount < this.pageSize; + this.order = order; + this.orderDirection = orderDirection; + } + + public List getContents() { + return contents; + } + + public int getElementCount() { + return elementCount; + } + + public int getPageSize() { + return pageSize; + } + + public int getCurrentPage() { + return currentPage; + } + + public boolean isFirstPage() { + return firstPage; + } + + public boolean isLastPage() { + return lastPage; + } + + public String getOrder() { + return order; + } + + public String getOrderDirection() { + return orderDirection; + } +} diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java new file mode 100644 index 0000000..e65e342 --- /dev/null +++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java @@ -0,0 +1,82 @@ +package nl.andrewl.concord_catalog.servlet; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.Objects; + +public class ServerMetaData implements Comparable { + private String name; + private String description; + private String host; + private int port; + + @JsonIgnore + private long lastUpdatedAt; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getAddress() { + return this.host + ":" + this.port; + } + + public long getLastUpdatedAt() { + return lastUpdatedAt; + } + + public void setLastUpdatedAt(long lastUpdatedAt) { + this.lastUpdatedAt = lastUpdatedAt; + } + + @Override + public int compareTo(ServerMetaData o) { + int result = this.name.compareTo(o.getName()); + if (result == 0) { + result = this.getAddress().compareTo(o.getAddress()); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (o.getClass().equals(this.getClass())) { + ServerMetaData other = (ServerMetaData) o; + return this.name.equals(other.getName()) && this.getAddress().equals(other.getAddress()); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hash(getName(), getAddress()); + } +} diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java new file mode 100644 index 0000000..f86a3d4 --- /dev/null +++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java @@ -0,0 +1,57 @@ +package nl.andrewl.concord_catalog.servlet; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * This servlet is the main HTTP endpoint for getting the list of servers and + * also uploading one's own server data. + */ +public class ServersServlet extends HttpServlet { + private static final SortedSet servers = new TreeSet<>(); + private static final ObjectMapper mapper = new ObjectMapper(); + private static final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + static { + executorService.scheduleAtFixedRate(() -> { + long now = System.currentTimeMillis(); + servers.removeIf(server -> server.getLastUpdatedAt() < now - (5 * 60000)); + }, 1, 1, TimeUnit.MINUTES); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + mapper.writeValue(resp.getWriter(), servers); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (servers.size() > 10000) { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + resp.setContentType("application/json"); + mapper.writeValue(resp.getWriter(), Map.of("message", "Too many servers registered at this time.")); + return; + } + ServerMetaData data = mapper.readValue(req.getReader(), ServerMetaData.class); + data.setHost(req.getRemoteHost()); + data.setLastUpdatedAt(System.currentTimeMillis()); + synchronized (servers) { + servers.remove(data); + servers.add(data); + } + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/json"); + mapper.writeValue(resp.getWriter(), data); + } +} diff --git a/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties b/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties new file mode 100644 index 0000000..ee45159 --- /dev/null +++ b/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties @@ -0,0 +1 @@ +port=25566 \ No newline at end of file diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..c3251b9 --- /dev/null +++ b/client/README.md @@ -0,0 +1,3 @@ +# Concord Client + +The concord client is the application which users will run to connect to and interact with various servers. It displays a GUI in the terminal that shows recent chat messages, channels, and the list of users in a channel. diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java index 36066b2..d3678e5 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java @@ -22,8 +22,8 @@ public class ChatRenderer extends AbstractListBox.ListItemRendererserver core client + catalog diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 9e45cf7..6552aac 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -7,6 +7,7 @@ module concord_server { requires java.base; requires java.logging; + requires java.net.http; requires concord_core; diff --git a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java index f07e714..90c672d 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java @@ -23,6 +23,17 @@ public class ChannelManager { return Set.copyOf(this.channelIdMap.values()); } + public Optional getDefaultChannel() { + var optionalGeneral = this.getChannelByName("general"); + if (optionalGeneral.isPresent()) { + return optionalGeneral; + } + for (var channel : this.getChannels()) { + return Optional.of(channel); + } + return Optional.empty(); + } + public void addChannel(Channel channel) { this.channelNameMap.put(channel.getName(), channel); this.channelIdMap.put(channel.getId(), channel); diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index 0ed6e67..5af63e6 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -1,5 +1,8 @@ package nl.andrewl.concord_server; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; @@ -12,18 +15,21 @@ import nl.andrewl.concord_server.config.ServerConfig; import org.dizitart.no2.Nitrite; import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; import java.io.IOException; +import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Path; +import java.time.Duration; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.*; import java.util.stream.Collectors; /** @@ -46,6 +52,11 @@ public class ConcordServer implements Runnable { @Getter private final ChannelManager channelManager; + // Components for communicating with discovery servers. + private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + public ConcordServer() { this.idProvider = new UUIDProvider(); this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); @@ -53,7 +64,6 @@ public class ConcordServer implements Runnable { .filePath("concord-server.db") .openOrCreate(); this.clients = new ConcurrentHashMap<>(32); - this.executorService = Executors.newCachedThreadPool(); this.eventManager = new EventManager(this); this.channelManager = new ChannelManager(this); @@ -83,7 +93,7 @@ public class ConcordServer implements Runnable { clientThread.setClientId(id); clientThread.setClientNickname(identification.getNickname()); // Immediately add the client to the default channel and send the initial welcome message. - var defaultChannel = this.channelManager.getChannelByName("general").orElseThrow(); + var defaultChannel = this.channelManager.getDefaultChannel().orElseThrow(); clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), this.getMetaData())); // It is important that we send the welcome message first. The client expects this as the initial response to their identification message. defaultChannel.addClient(clientThread); @@ -143,9 +153,38 @@ public class ConcordServer implements Runnable { } } + private void publishMetaDataToDiscoveryServers() { + if (this.config.getDiscoveryServers().isEmpty()) return; + ObjectNode node = this.mapper.createObjectNode(); + node.put("name", this.config.getName()); + node.put("description", this.config.getDescription()); + node.put("port", this.config.getPort()); + String json; + try { + json = this.mapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + var discoveryServers = List.copyOf(this.config.getDiscoveryServers()); + for (var discoveryServer : discoveryServers) { + System.out.println("Publishing this server's metadata to discovery server at " + discoveryServer); + var request = HttpRequest.newBuilder(URI.create(discoveryServer)) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(3)) + .build(); + try { + this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException | InterruptedException e) { + e.printStackTrace(); + } + } + } + @Override public void run() { this.running = true; + this.scheduledExecutorService.scheduleAtFixedRate(this::publishMetaDataToDiscoveryServers, 1, 1, TimeUnit.MINUTES); ServerSocket serverSocket; try { serverSocket = new ServerSocket(this.config.getPort()); @@ -163,6 +202,7 @@ public class ConcordServer implements Runnable { } catch (IOException e) { e.printStackTrace(); } + this.scheduledExecutorService.shutdown(); } public static void main(String[] args) { diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java index 19e0fb9..5ee7afd 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java @@ -39,5 +39,6 @@ public class RemoveChannelCommand implements ServerCliCommand { server.getConfig().getChannels().removeIf(channelConfig -> channelConfig.getName().equals(channelToRemove.getName())); server.getConfig().save(); server.broadcast(server.getMetaData()); + System.out.println("Removed the channel " + channelToRemove); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java index 1a8fc4a..ea76465 100644 --- a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java +++ b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java @@ -18,12 +18,15 @@ import java.util.List; @AllArgsConstructor public final class ServerConfig { private String name; + private String description; private int port; private int chatHistoryMaxCount; private int chatHistoryDefaultCount; private int maxMessageLength; private List channels; + private List discoveryServers; + /** * The path at which this config is stored. */ @@ -45,11 +48,13 @@ public final class ServerConfig { if (Files.notExists(filePath)) { config = new ServerConfig( "My Concord Server", + "A concord server for my friends and I.", 8123, 100, 50, 8192, List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")), + List.of(), filePath ); try (var out = Files.newOutputStream(filePath)) {