Compare commits

..

24 Commits

Author SHA1 Message Date
528d9c1c39 implement SIP 2025-06-24 15:17:41 +03:00
83cd1def66 brainrot 2025-06-24 15:16:53 +03:00
6551cf5130 better multithreading 2025-06-23 22:07:43 +03:00
7c3f25dd2e grammar 2025-06-23 21:36:12 +03:00
8f77d2a511 Minor fixes 2025-02-12 16:30:59 +03:00
e6c41073b0 Merge pull request 'Add support for PROXY protocol.' (#6) from bedohswe/crab:master into master
Reviewed-on: #6
2025-02-12 13:08:33 +00:00
3b68d8a097 Add support for PROXY protocol. 2025-02-12 12:12:22 +05:00
c0db9ff1a1 Merge pull request 'master' (#1) from pixtaded/crab:master into master
Reviewed-on: bedohswe/crab#1
2025-02-11 18:05:00 +00:00
b8d4ab90f2 Add RACv2 protocol specification. (I hate it) 2025-02-11 21:04:18 +03:00
12e595bb73 prepare for racv2 final update (I hate PID collisions) 2025-02-11 20:38:58 +03:00
933af5c5e0 Mefedroniy color support 2025-02-11 15:06:34 +03:00
f03666bead AntiClear (protection against /clear in bRAC) 2025-02-09 20:15:17 +03:00
72074ca117 Add client-specific colors (No Mefedroniy support 'cause no parsing :( ) 2025-02-09 19:01:10 +03:00
57d5f5ea1e Port the server to RACv1.99.2 and handle logs erasing in client 2025-02-09 15:32:22 +03:00
61ddfc86d1 Port the client to RACv1.99.2 2025-02-09 14:47:01 +03:00
3e6aefd4d2 Add RACv1.99.2 protocol specification 2025-02-09 14:19:44 +03:00
389d7c4868 Update README.md 2025-02-08 22:04:01 +03:00
6d21c9bccd Bump version to 2.0.0-SNAPSHOT 2025-02-08 21:42:05 +03:00
a4bf914c41 Server functionality restored 2025-02-08 21:41:03 +03:00
30d90f22f2 Add RACv1.99 protocol specification 2025-02-08 20:42:38 +03:00
74589d48b0 Port the client to RACv2 beta (the server is still not functional) 2025-02-08 20:34:47 +03:00
7ba5bfbf9e Add shadowJar instead of application, bump the project's version to 1.0.5 2025-01-12 18:07:15 +03:00
e96b02357d Add serverside caching and improve sanitizing. 2025-01-12 17:26:21 +03:00
c2e8927c42 Fix server throwing an ArrayOutOfBoundsException when someone tries to leave 2025-01-12 15:06:43 +03:00
14 changed files with 385 additions and 87 deletions

View File

@ -24,12 +24,12 @@ To get started with CRAB, follow these steps:
```
2. **Build the project:**
Ensure you have Java Development Kit (JDK) of version 17 or higher installed. You can build the project using Maven:
Ensure you have Java Development Kit (JDK) of version 17 or higher installed. You can build the project using Gradle:
```bash
./gradlew clean build
```
3. **Run the bundle**: You will have the built .tar and .zip packages in ./build/distributions directory.
3. **Run the bundle**: You will have the built .jar package in ./build/libs directory.
## Usage
@ -37,4 +37,46 @@ Once the server is running, clients can connect to it and send messages accordin
## RAC Protocol
You can see the RAC protocol documentation [here](https://bedohswe.eu.org/text/rac/protocol.md.html).
1. Message Retrieval
a. The client initiates a message retrieval session by sending the byte `0x00` to the server.
b. In response, the server transmits the size of the available messages as an ASCII-encoded string.
c. After receiving the size, the client must send one of the following bytes or close the connection:
i. Sending `0x01` instructs the server to transmit all messages in full.
ii. Sending `0x02` followed by the clients cached messages length (as an ASCII string, e.g., `0x02"1024"`) instructs the server to transmit only new messages added since the cached length. The server sends messages starting from the cached length offset, and the client updates its cached length to the total size received in step 1b after processing the new messages.
2. Message Transmission
a. To send a message, the client issues a request in one of the following formats:
i. Unauthenticated Message: The client sends the byte `0x01` followed immediately by the message content. The server does not send a response.
ii. Authenticated Message: The client sends the byte `0x02` followed by the username, a newline character (`\n`), the password, a newline character and the message content. The server responds with a single byte:
- `0x01` indicates the user does not exist.
- `0x02` indicates the password is incorrect.
- A successful authentication results in the server accepting the message without sending a response.
3. User Registration
a. To register a new user, the client sends a request formatted as:
- The byte `0x03`.
- The username, followed by a newline character (`\n`).
- The password.
b. The server processes the request and responds with a single byte:
- `0x01` if the username already exists.
- A successful registration is assumed if no error byte (`0x01`) is received. The client should close the connection after handling the response.
### Additional Notes:
- The current specification of RACv2 is implemented in `lRACd` version 2.0.0 and `clRAC` version 2.0.0.
- When using `0x02` for incremental retrieval, the client must ensure the cached length is synchronized with the servers total message length (retrieved via `0x00`). The server sends messages from the cached length onward, and the client calculates the read size as `(total_length - cached_length)`.
- After receiving incremental messages, the client must update its cached length to the total length provided in step 1b to maintain consistency in subsequent requests.
- For authenticated message transmission (`0x02`) or user registration (`0x03`), the client must follow the specified format precisely. The server validates the structure of the request and responds with error codes only for specific failure conditions (e.g., invalid credentials or duplicate usernames).

View File

@ -1,19 +1,31 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id 'java'
id 'application'
id 'com.gradleup.shadow' version '9.0.0-beta4'
}
group = 'net.pixtaded'
version = '1.0.4'
version = projectVersion
repositories {
mavenCentral()
}
application {
mainClass = 'net.pixtaded.crab.Main'
}
dependencies {
implementation('org.xerial:sqlite-jdbc:3.47.1.0')
}
tasks.build.dependsOn tasks.shadowJar
tasks.named('jar', Jar) {
manifest {
attributes 'Main-Class': 'net.pixtaded.crab.Main'
}
}
tasks.named('shadowJar', ShadowJar) {
archiveBaseName = 'crab'
archiveClassifier = ''
archiveVersion = projectVersion
}

1
gradle.properties Normal file
View File

@ -0,0 +1 @@
projectVersion=2.0.0-SNAPSHOT

View File

@ -17,7 +17,7 @@ public class Main {
case "help" -> {
System.out.println("crab help - print this message.");
System.out.println("crab client <ip> <port> [nick] - connect to a server.");
System.out.println("crab server <port> - start a server.");
System.out.println("crab server <port> [PROXY protocol off/on] - start a server.");
}
case "client" -> {
CrabClient client;
@ -31,18 +31,23 @@ public class Main {
}
case "server" -> {
CrabServer server;
if (args.length > 1) {
boolean isProxied = false;
if (args.length > 2)
isProxied = args[2].equals("on");
try {
server = new CrabServer(Integer.parseInt(args[1]));
server = new CrabServer(Integer.parseInt(args[1]), isProxied);
} catch (NumberFormatException e) {
System.err.println("Port is not a number.");
return;
}
server.run();
}
default -> {
System.err.println("Unknown argument");
} else {
System.err.println("Not enough arguments.");
return;
}
server.run();
}
default -> System.err.println("Unknown argument");
}
}

View File

@ -0,0 +1,4 @@
package net.pixtaded.crab.client;
public record ClientColor(String regex, String color) {
}

View File

@ -0,0 +1,28 @@
package net.pixtaded.crab.client;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ClientUtil {
public static final String COLOR_KEY = "\u2550\u2550\u2550";
public static final ClientColor[] colors = {
new ClientColor( COLOR_KEY + "(<.*?>)", "\033[0;31m$1\033[0m"),
new ClientColor("\uB9AC\u3E70(<.*?>)", "\033[0;32m$1\033[0m"),
new ClientColor(" (<.*?>)", " \033[0;34m$1\033[0m"),
new ClientColor("\u00B0\u0298(<.*?>)", "\033[0;35m$1\033[0m")
};
public static String clientColors(String s) {
for (ClientColor color : colors) s = matchClientKey(s, color);
return s;
}
private static String matchClientKey(String s, ClientColor color) {
Pattern p = Pattern.compile(color.regex());
Matcher m = p.matcher(s);
return m.replaceAll(color.color());
}
}

View File

@ -1,7 +1,9 @@
package net.pixtaded.crab.client;
import net.pixtaded.crab.common.Crab;
import net.pixtaded.crab.common.Logs;
import net.pixtaded.crab.common.Sanitizer;
import net.pixtaded.crab.common.Util;
import java.io.*;
import java.net.InetSocketAddress;
@ -28,7 +30,7 @@ public class CrabClient implements Crab {
this.serverAddress = serverAddress;
this.port = port;
if (nickname != null)
this.nickname = "<" + nickname + "> ";
this.nickname = ClientUtil.COLOR_KEY + "<" + nickname + "> ";
else
this.nickname = "";
}
@ -69,7 +71,7 @@ public class CrabClient implements Crab {
System.out.print("Enter your nickname (leave empty for no nickname): ");
nickname = scanner.nextLine();
if (!nickname.isEmpty())
nickname = "<" + nickname + "> ";
nickname = ClientUtil.COLOR_KEY + "<" + nickname + "> ";
}
private void connect() throws IOException {
@ -82,44 +84,82 @@ public class CrabClient implements Crab {
private void communicate() throws IOException {
Scanner scanner = new Scanner(System.in);
String message;
boolean preserveScreen = false;
while (true) {
if (!preserveScreen) {
getLogs();
} else {
preserveScreen = false;
}
System.out.print("Enter a message (or type '/exit' to exit): ");
message = scanner.nextLine();
if (message.equalsIgnoreCase("/exit")) {
break;
} else if (message.equalsIgnoreCase("/info")) {
sendPacket(SERVER_INFO, "", true);
preserveScreen = true;
} else {
if (!message.isEmpty()) sendMessage(message);
}
if (!message.isEmpty()) sendPacket(MESSAGE, this.nickname + message);
}
}
private void sendPacket(byte PID, String argument) throws IOException {
connect();
String formattedMessage = String.valueOf((char) PID) + argument + "\n";
private void sendPacket(byte PID, String argument, boolean receiveResponse) throws IOException {
if (socket == null || socket.isClosed()) connect();
String formattedMessage = (char) PID + argument;
out.print(formattedMessage);
out.flush();
receiveResponse(PID);
if (receiveResponse) receiveResponse(PID);
}
private void printLogs() {
clearScreen();
System.out.print(ClientUtil.clientColors(Sanitizer.sanitizeString(cache.content(), false)));
}
private void sendMessage(String msg) throws IOException {
sendPacket(MESSAGE, this.nickname + msg, false);
closeConnection();
}
private void receiveResponse(byte PID) throws IOException {
switch (PID) {
case LOGS_SIZE -> {
char[] buffer = new char[10];
int response = in.read(buffer);
lastBufferLength = Integer.parseInt(new String(buffer).trim());
String convertedString = Util.readAsciiNumber(in);
if (!convertedString.isEmpty()) lastBufferLength = Integer.parseInt(convertedString);
} case CACHED_LOGS -> {
byte[] bytes = socket.getInputStream().readNBytes(lastBufferLength - cache.sizeInBytes());
cache = new Logs(lastBufferLength, cache.content() + new String(bytes, StandardCharsets.UTF_8));
} case LOGS -> {
if (cache.sizeInBytes() != lastBufferLength) {
byte[] bytes = socket.getInputStream().readNBytes(lastBufferLength);
cache = new Logs(lastBufferLength, new String(bytes, StandardCharsets.UTF_8));
} case SERVER_INFO -> {
InputStream input = socket.getInputStream();
int versionByte = input.read();
if (versionByte == -1) {
System.out.println("The server did not provide any information...");
return;
}
clearScreen();
System.out.print(Sanitizer.sanitizeString(cache.content(), false));
} default -> {
String protocolVersion;
switch (versionByte) {
case 0x01 -> protocolVersion = "RACv1";
case 0x02 -> protocolVersion = "RACv1.99";
case 0x03 -> protocolVersion = "RACv2";
default -> protocolVersion = "Unknown RAC version";
}
byte[] nameBytes = input.readAllBytes();
String serverName = new String(nameBytes, StandardCharsets.UTF_8);
System.out.println("\n\033[36mServer Information:\033[0m");
System.out.println("\033[33mProtocol:\033[0m " + protocolVersion);
System.out.println("\033[33mName:\033[0m " + serverName + "\n");
}
}
}
@ -135,8 +175,14 @@ public class CrabClient implements Crab {
}
private void getLogs() throws IOException {
sendPacket(LOGS_SIZE, "");
sendPacket(LOGS, "");
sendPacket(LOGS_SIZE, "", true);
if (this.cache.sizeInBytes() < lastBufferLength) {
sendPacket(CACHED_LOGS, String.valueOf(cache.sizeInBytes()), true);
} else if (this.cache.sizeInBytes() != lastBufferLength) {
sendPacket(LOGS, "", true);
}
closeConnection();
printLogs();
}
private void clearScreen() {

View File

@ -1,4 +1,4 @@
package net.pixtaded.crab.client;
package net.pixtaded.crab.common;
public record Logs(int sizeInBytes, String content) {
}

View File

@ -1,7 +1,11 @@
package net.pixtaded.crab.common;
public class PID {
public static final byte MESSAGE = 0x30;
public static final byte LOGS_SIZE = 0x31;
public static final byte LOGS = 0x32;
public static final byte LOGS_SIZE = 0x00;
public static final byte LOGS = 0x01;
public static final byte MESSAGE = 0x01;
public static final byte CACHED_LOGS = 0x02;
public static final byte AUTHENTICATED_MESSAGE = 0x02;
public static final byte REGISTER = 0x03;
public static final byte SERVER_INFO = 0x69;
}

View File

@ -2,11 +2,17 @@ package net.pixtaded.crab.common;
public class Sanitizer {
public static String sanitizeString(String s, boolean sanitizeNewlines) {
String sanitized = s.replaceAll("\033", "");
String sanitized = s.replaceAll("[\010\015\033]", "");
if (sanitizeNewlines) {
sanitized = sanitized.replaceAll("\n", "\\\\n");
if (!s.endsWith("\n")) sanitized += '\n';
} else {
sanitized = sanitized.replaceAll("\n\n+", "\n");
}
return sanitized;
}
public static String formatMessage(long timeMillis, String address, String content) {
return String.format("[%td.%1$tm.%1$tY %1$tR] {%s} %s", timeMillis, address, content);
}
}

View File

@ -0,0 +1,12 @@
package net.pixtaded.crab.common;
import java.io.BufferedReader;
import java.io.IOException;
public class Util {
public static String readAsciiNumber(BufferedReader in) throws IOException {
char[] buffer = new char[10];
int response = in.read(buffer);
return new String(buffer).trim();
}
}

View File

@ -1,26 +1,35 @@
package net.pixtaded.crab.server;
import net.pixtaded.crab.common.Crab;
import net.pixtaded.crab.common.Logs;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.sql.SQLException;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CrabServer implements Crab {
private ServerSocket serverSocket;
private Socket socket;
private boolean isStopped = false;
private boolean isProxied = false;
private int port;
private final Database db;
private Logs cache = new Logs(0, "");
public static final byte RAC_VERSION = 0x02;
public final ExecutorService threadPool = Executors.newCachedThreadPool();
public CrabServer() {
this.db = new Database("data.db");
}
public CrabServer(int port) {
public CrabServer(int port, boolean isProxied) {
this.db = new Database("data.db");
this.port = port;
this.isProxied = isProxied;
}
@Override
@ -28,6 +37,7 @@ public class CrabServer implements Crab {
try {
if (this.port == 0)
setup();
this.cache = getDb().getLogs();
listen();
} catch (IOException e) {
if (!isStopped) System.err.println("Error starting server: " + e.getMessage());
@ -51,34 +61,63 @@ public class CrabServer implements Crab {
System.out.println("Enter a correct port number: ");
}
}
System.out.print("Enable PROXY protocol? (on/off): ");
while (true) {
String s = scanner.nextLine();
if (s.equals("on")) {
this.isProxied = true;
break;
}
if (s.equals("off")) {
this.isProxied = false;
break;
}
System.out.println("Enter either \"on\" or \"off\".");
}
}
private void listen() throws IOException {
Scanner scanner = new Scanner(System.in);
if (this.isProxied) {
serverSocket = new ServerSocket(port, 0, InetAddress.getLoopbackAddress());
} else {
serverSocket = new ServerSocket(port);
}
System.out.printf("Server successfully started! Listening on port %s.\nTo stop the server, type 'q'.\n", port);
ServerCLI cli = new ServerCLI(scanner, this);
new Thread(cli).start();
threadPool.submit(new ServerCLI(scanner, this));
while (!isStopped) {
Socket socket = serverSocket.accept();
ServerThread thread = new ServerThread(socket, this);
new Thread(thread).start();
threadPool.submit(new ServerThread(socket, this));
}
}
public synchronized void stop() {
isStopped = true;
try {
if (socket != null) socket.close();
if (serverSocket != null) serverSocket.close();
getDb().close();
} catch (IOException e) {
System.err.println("An error occured while closing the socket: " + e.getMessage());
} catch (SQLException e) {
throw new RuntimeException(e);
} finally {
System.exit(0);
}
}
public synchronized Logs getCache() {
return cache;
}
public synchronized void setCache(Logs cache) {
this.cache = cache;
}
public Database getDb() {
return db;
}
public boolean isProxied() {
return this.isProxied;
}
}

View File

@ -1,15 +1,14 @@
package net.pixtaded.crab.server;
import net.pixtaded.crab.common.Logs;
import net.pixtaded.crab.common.Sanitizer;
import java.sql.*;
import java.util.Date;
import java.util.Locale;
public class Database implements AutoCloseable {
public class Database {
private Connection connection;
private String logs = "";
public Database(String dbName) {
try {
@ -50,28 +49,20 @@ public class Database implements AutoCloseable {
}
}
public String getLogs() {
public Logs getLogs() {
StringBuilder s = new StringBuilder();
try (ResultSet rs = query("SELECT time, address, msg FROM messages")) {
while (rs.next()) {
s.append(String.format("[%td.%1$tm.%1$tY %1$tR] {%s} %s", rs.getLong(1), rs.getString(2), rs.getString(3)));
s.append(Sanitizer.formatMessage(rs.getLong(1), rs.getString(2), rs.getString(3)));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
logs = s.toString();
return logs;
String logsString = s.toString();
return new Logs(logsString.isEmpty() ? 0 : logsString.getBytes().length, logsString);
}
public int getLogSize() {
if (logs.isEmpty()) return 0;
else return logs.getBytes().length;
}
@Override
public void close() throws SQLException {
if (connection != null && !connection.isClosed()) {
connection.close();
}
}
}

View File

@ -1,21 +1,25 @@
package net.pixtaded.crab.server;
import net.pixtaded.crab.common.Logs;
import net.pixtaded.crab.common.Sanitizer;
import net.pixtaded.crab.common.Util;
import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Date;
import static net.pixtaded.crab.common.PID.*;
public class ServerThread implements Runnable {
private Socket socket;
private PrintWriter out;
private BufferedReader in;
private OutputStream output;
private InputStream input;
private byte PID;
private CrabServer server;
private final Socket socket;
private final PrintWriter out;
private final BufferedReader in;
private final OutputStream output;
private final InputStream input;
private final CrabServer server;
public ServerThread(Socket socket, CrabServer server) throws IOException {
this.socket = socket;
@ -29,31 +33,135 @@ public class ServerThread implements Runnable {
@Override
public void run() {
try {
byte[] PID = input.readNBytes(1);
byte[] PID = readPID();
if (PID.length == 0) {
socket.close();
return;
}
String address = socket.getInetAddress().getHostAddress();
if (PID[0] == 'P') {
if (!this.server.isProxied()) {
System.err.println(address + " tried to use PROXY despite it being off.");
socket.close();
return;
}
if (Arrays.equals(readUntilChar(' '),"ROXY".getBytes())) {
readUntilChar(' '); // proto
byte[] source = readUntilChar(' ');
address = new String(source);
readUntilChar(' '); // destination IP
readUntilChar(' '); // source port
readUntilChar('\r'); // destination port
if (input.read() != '\n') {
System.err.println("Invalid PROXY packet.");
socket.close();
return;
}
} else {
System.err.println("Invalid PROXY packet header.");
socket.close();
return;
}
PID = readPID();
if (PID.length == 0) {
socket.close();
return;
}
}
switch (PID[0]) {
case MESSAGE -> {
server.getDb().logMessage(new Date(), socket.getInetAddress().getHostAddress(), new String(input.readNBytes(4096), StandardCharsets.UTF_8).trim());
socket.close();
} case LOGS -> {
respond(server.getDb().getLogs());
String msg = new String(input.readNBytes(4096), StandardCharsets.UTF_8).trim();
Date date = new Date();
String s = Sanitizer.sanitizeString(msg, true);
String newContent = server.getCache().content() + Sanitizer.formatMessage(date.getTime(), address, s);
server.setCache(new Logs(newContent.getBytes().length, newContent));
server.threadPool.submit(new LogDBThread(date, address, msg));
} case LOGS_SIZE -> {
respond(String.valueOf(server.getDb().getLogSize()));
} default -> {
System.out.println("PID not implemented: " + PID[0]);
respond(String.valueOf(server.getCache().sizeInBytes()));
byte[] logPID = readPID();
if (logPID.length == 0) {
socket.close();
return;
}
sendLogs(logPID[0]);
} case SERVER_INFO -> {
byte protocolVersion = CrabServer.RAC_VERSION;
String serverName = "CRAB";
byte[] nameBytes = serverName.getBytes(StandardCharsets.UTF_8);
byte[] response = new byte[1 + nameBytes.length];
response[0] = protocolVersion;
System.arraycopy(nameBytes, 0, response, 1, nameBytes.length);
respond(response);
} default -> System.out.println("PID not implemented: " + PID[0]);
}
socket.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private byte[] readPID() throws IOException {
return input.readNBytes(1);
}
private byte[] readUntilChar(char c) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
int b;
while ((b = input.read()) != -1) {
if (b == (byte) c) {
break;
}
buffer.write(b);
}
if (b == -1) {
throw new EOFException("Delimiter not found before EOF");
}
return buffer.toByteArray();
}
private void sendLogs(byte PID) throws IOException {
if (PID == LOGS) {
respond(server.getCache().content());
} else if (PID == CACHED_LOGS) {
String clientSize = Util.readAsciiNumber(in);
int clientSizeNum = Integer.parseInt(clientSize);
byte[] serverLogs = server.getCache().content().getBytes(StandardCharsets.UTF_8);
int logPartSize = Math.max(0, serverLogs.length - clientSizeNum);
byte[] logPart = new byte[logPartSize];
if (logPartSize > 0) {
System.arraycopy(serverLogs, clientSizeNum, logPart, 0, logPartSize);
}
respond(logPart);
}
}
private void respond(byte[] data) throws IOException {
socket.getOutputStream().write(data);
socket.getOutputStream().flush();
socket.close();
}
private void respond(String utf8) throws IOException {
respond(utf8.getBytes(StandardCharsets.UTF_8));
}
private class LogDBThread implements Runnable {
Date date;
String msg;
String address;
public LogDBThread(Date date, String address, String msg) {
this.date = date;
this.msg = msg;
this.address = address;
}
@Override
public void run() {
server.getDb().logMessage(date, address, msg);
}
}
}