Added catalog discovery server, readmes and improved some server channel logic.

This commit is contained in:
Andrew Lalis 2021-08-26 11:22:24 +02:00
parent 31129cada2
commit e9fa0c13a7
17 changed files with 384 additions and 8 deletions

3
catalog/README.md Normal file
View File

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

36
catalog/pom.xml Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>concord</artifactId>
<groupId>nl.andrewl</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>concord-catalog</artifactId>
<properties>
<maven.compiler.source>16</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
<version>2.2.8.Final</version>
</dependency>
<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-servlet</artifactId>
<version>2.2.8.Final</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.4</version>
</dependency>
</dependencies>
</project>

View File

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

View File

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

View File

@ -0,0 +1,57 @@
package nl.andrewl.concord_catalog.servlet;
import java.util.List;
public class Page<T> {
private final List<T> 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<T> 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<T> 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;
}
}

View File

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

View File

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

View File

@ -0,0 +1 @@
port=25566

3
client/README.md Normal file
View File

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

View File

@ -22,8 +22,8 @@ public class ChatRenderer extends AbstractListBox.ListItemRenderer<Chat, ChatLis
}
graphics.putString(0, 0, chat.getSenderNickname());
Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp());
String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm:ss"));
String label = chat.getSenderNickname() + " @ " + timeStr + " : " + chat.getMessage();
String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm"));
String label = chat.getSenderNickname() + "@" + timeStr + " : " + chat.getMessage();
label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns());
while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) {
label += " ";

3
core/README.md Normal file
View File

@ -0,0 +1,3 @@
# Concord Core
This module contains the core resources that both the client and server depend on.

View File

@ -12,6 +12,7 @@
<module>server</module>
<module>core</module>
<module>client</module>
<module>catalog</module>
</modules>
<properties>

View File

@ -7,6 +7,7 @@ module concord_server {
requires java.base;
requires java.logging;
requires java.net.http;
requires concord_core;

View File

@ -23,6 +23,17 @@ public class ChannelManager {
return Set.copyOf(this.channelIdMap.values());
}
public Optional<Channel> 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);

View File

@ -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) {

View File

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

View File

@ -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<ChannelConfig> channels;
private List<String> 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)) {