This commit is contained in:
Minecon724 2024-08-21 18:01:37 +02:00
parent f7f2d781bc
commit eec5450159
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
9 changed files with 182 additions and 90 deletions

View file

@ -3,7 +3,8 @@ This file documents the websocket /api/ws
## Format ## Format
Packet id then arguments \ Packet id then arguments \
Some packets don't have arguments so send just the packet id \ Some packets don't have arguments so send just the packet id \
There can be multiple packets in one message, those with variable length arguments have to be terminated There can be multiple packets in one message, those with variable length arguments have to be terminated \
Numbers are signed
## Authentication ## Authentication
The first message from the server is: The first message from the server is:
@ -16,33 +17,35 @@ The client should reply, in a single message:
4. access key, decoded from base64 4. access key, decoded from base64
Authentication complete, the server will send a disconnect if something's wrong, otherwise it will pong. \ Authentication complete, the server will send a disconnect if something's wrong, otherwise it will pong. \
If the client version is incorrect, the server sends a 0x01 disconnect, and doesn't verify the access key. No commands are handled during this time
## Commands # Commands
### Client -> Server ### Server bound (Client -> Server)
- `0x00` - Ping | Code | Name | Data | Notes |
* no body |--------|----------|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
* the server replies with 0x00 Pong | `0x00` | Ping | | the server replies with pong<br/>must be sent by client every at most 30 seconds, otherwise the server disconnects |
* also a keepalive, sent by client in at most 30 second intervals otherwise the server disconnects | `0x01` | Settings | 1. byte: setting id<br/>2. value<br/>3. and so on (terminated by 0xFF) | see below for IDs, value type varies |
- `0x01` - Disconnect
* first there's the reason as byte, see below
* second argument is a signed byte - message length in bytes
* then the utf8 encoded message
* after that the server doesn't wait for a reply it just closes the connection
- `0x02` - Settings
* setting id followed by value (length varies) terminated with 0x00
### Server -> Client ### Client bound (Server -> Client)
- `0x00` - Pong
* the body is a single signed long (8 bytes) which the current unix time | Code | Name | Data | Notes |
* a response to client's command of the same id |--------|------|----------------------|----------------------------------------------------------------------|
| `0x00` | Pong | 1. long: unix millis | A response to ping, also sent by server on successful authentication |
## Disconnect reasons ## Disconnect reasons
- `0x00` - unspecified On every disconnect there's a human-readable message the client should display
- `0x01` - incompatible client
- `0x02` - timeout (client didn't send ping) | Code | Name | Notes |
- `0x03` - access key revoked |--------|--------------------|-------------------------------------------------|
- `0x04` - server error | `3000` | reserved | |
| `3001` | unauthorized | used during authentication phase |
| `3002` | version mismatch | incompatible client |
| `3003` | timeout | client wasn't sending pings |
| `3004` | access key revoked | when the access key was revoked while connected |
| `3005` | server error | |
## Settings ## Settings
- `0x00` - not used | Code | Name | Notes |
|--------|------------|-------------------------------------------------|
| `0x00` | reserved | |
| `0xFF` | terminator | not a setting, just used to terminate sequences |

View file

@ -1,10 +1,13 @@
# rwws - RealWeather Web Server # rwws - RealWeather Web Server
This contacts APIs instead of the servers because we scrape some and we don't want to draw attention This contacts APIs instead of the servers because we scrape some, and we don't want to draw attention
### Usage ### Usage
See `RUNNING.md`[^1] See `RUNNING.md`[^1]
### Websocket message format
See `PROTOCOL.md`
--- ---
[^1]: This is the default Quarkus readme that I wish was available online [^1]: This is the default Quarkus readme that I wish was available online

View file

@ -1,9 +1,12 @@
package eu.m724.websocket; package eu.m724.websocket;
import io.smallrye.mutiny.Uni; import eu.m724.websocket.packet.DisconnectReason;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.websocket.*; import jakarta.websocket.OnClose;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint; import jakarta.websocket.server.ServerEndpoint;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@ -25,7 +28,11 @@ public class WebsocketResource {
@OnClose @OnClose
public void onClose(Session session) { public void onClose(Session session) {
websocketService.removeConnection(session.getId()); websocketService.removeConnection(session.getId());
System.out.printf("WS [%s]: disconnected, authenticated: %b\n", session.getId(), websocketService.isAuthenticated(session.getId())); System.out.printf(
"WS [%s]: disconnected, authenticated: %b\n",
session.getId(),
websocketService.isAuthenticated(session.getId())
);
} }
@OnMessage @OnMessage
@ -36,13 +43,29 @@ public class WebsocketResource {
if (!websocketService.isAuthenticated(session.getId())) { if (!websocketService.isAuthenticated(session.getId())) {
if (command.get() == (byte)0xb6 && command.get() == (byte)0xc4) { if (command.get() == (byte)0xb6 && command.get() == (byte)0xc4) {
byte clientVersion = command.get(); byte clientVersion = command.get();
if (clientVersion == websocketService.protocolVersion) { if (clientVersion == websocketService.protocolVersion) {
byte keyLength = command.get(); byte keyLength = command.get();
byte[] accessKey = new byte[keyLength]; byte[] accessKey = new byte[keyLength];
command.get(accessKey); command.get(accessKey);
boolean success = websocketService.authenticate(sessionId, accessKey); boolean success = websocketService.authenticate(sessionId, accessKey);
} else {
session if (success) {
websocketService.pong(session);
} else { // wrong key
websocketService.disconnect(
session,
DisconnectReason.UNAUTHORIZED,
"Invalid access key"
);
}
} else { // wrong version
websocketService.disconnect(
session,
DisconnectReason.VERSION_MISMATCH,
"Expected %d, got %d".formatted(websocketService.protocolVersion, clientVersion)
);
} }
} }
return; return;
@ -50,19 +73,13 @@ public class WebsocketResource {
switch (message[0]) { switch (message[0]) {
case 0x00: case 0x00:
pong(session); websocketService.pong(session);
break;
case 0x01:
break; break;
} }
} }
private void pong(Session session) {
ByteBuffer byteBuffer = ByteBuffer.allocate(9);
byteBuffer.put((byte)0);
byteBuffer.putLong(System.currentTimeMillis());
session.getAsyncRemote().sendBinary(byteBuffer);
}
/*private void broadcast(String message) { /*private void broadcast(String message) {
sessions.values().forEach(s -> { sessions.values().forEach(s -> {
@ -71,6 +88,6 @@ public class WebsocketResource {
System.out.println("Unable to send message: " + result.getException()); System.out.println("Unable to send message: " + result.getException());
} }
}); });
});*/ });*
} }*/
} }

View file

@ -2,14 +2,14 @@ package eu.m724.websocket;
import eu.m724.auth.master.AccountService; import eu.m724.auth.master.AccountService;
import eu.m724.orm.AccessKey; import eu.m724.orm.AccessKey;
import eu.m724.orm.Account; import eu.m724.websocket.packet.DisconnectReason;
import eu.m724.websocket.packet.clientbound.PongPacket;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.websocket.Session; import jakarta.websocket.Session;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.nio.ByteBuffer; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -21,7 +21,7 @@ public class WebsocketService {
@Inject @Inject
AccountService accountService; AccountService accountService;
private final Map<String, Account> accounts = new ConcurrentHashMap<>(); private final Map<String, AccessKey> accounts = new ConcurrentHashMap<>();
void addSession(String sessionId) { void addSession(String sessionId) {
accounts.put(sessionId, null); accounts.put(sessionId, null);
@ -32,22 +32,18 @@ public class WebsocketService {
} }
void disconnect(Session session, DisconnectReason reason, String message) { void disconnect(Session session, DisconnectReason reason, String message) {
byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); try {
ByteBuffer byteBuffer = ByteBuffer.allocate(3 + messageBytes.length); session.close(reason.asCloseReason(message));
byteBuffer.put(0x0) } catch (IOException ignored) { }
session.getAsyncRemote().sendBinary(
ByteBuffer.wrap(new byte[] { reason });
)
} }
boolean authenticate(String sessionId, byte[] bytes) { boolean authenticate(String sessionId, byte[] bytes) {
AccessKey accessKey = accountService.findByAccessKey(byte); AccessKey accessKey = accountService.findByAccessKey(bytes);
if (ac == null) if (accessKey == null)
return false; return false;
accounts.put(sessionId, account); accounts.put(sessionId, accessKey);
return true; return true;
} }
@ -55,29 +51,7 @@ public class WebsocketService {
return accounts.containsKey(sessionId); return accounts.containsKey(sessionId);
} }
public enum Packet { void pong(Session session) {
PING((byte)0x00), new PongPacket().send(session);
DISCONNECT((byte)0x01),
SETTINGS((byte)0x02);
public final byte value;
Packet(byte value) {
this.value = value;
}
}
public enum DisconnectReason {
UNSPECIFIED((byte)0x00),
VERSION_MISMATCH((byte)0x01),
TIMEOUT((byte)0x02),
ACCESS_KEY_REVOKED((byte)0x03),
SERVER_ERROR((byte)0x04);
public final byte value;
DisconnectReason(byte value) {
this.value = value;
}
} }
} }

View file

@ -0,0 +1,31 @@
package eu.m724.websocket.packet;
import java.nio.ByteBuffer;
public abstract class ArgumentsPacket<T extends ArgumentsPacket<T>> implements Packet<T> {
private final byte packetId;
private final ByteBuffer byteBuffer;
private final Object[] arguments;
public ArgumentsPacket(byte packetId, Object... arguments) {
this.packetId = packetId;
this.arguments = arguments;
this.byteBuffer = compose(packetId);
}
@Override
public byte packetId() {
return packetId;
}
@Override
public Object[] arguments() {
return arguments;
}
public ByteBuffer byteBuffer() {
return byteBuffer;
}
public abstract ByteBuffer compose(byte packetId, Object... arguments);
}

View file

@ -0,0 +1,25 @@
package eu.m724.websocket.packet;
import jakarta.websocket.CloseReason;
public enum DisconnectReason {
UNAUTHORIZED((byte)3001),
VERSION_MISMATCH((byte)3002),
TIMEOUT((byte)3003),
ACCESS_KEY_REVOKED((byte)3004),
SERVER_ERROR((byte)3005);
public final int code;
DisconnectReason(int code) {
this.code = code;
}
public CloseReason.CloseCode closeCode() {
return CloseReason.CloseCodes.getCloseCode(code);
}
public CloseReason asCloseReason(String message) {
return new CloseReason(closeCode(), message);
}
}

View file

@ -2,9 +2,9 @@ package eu.m724.websocket.packet;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
public class EmptyPacket implements Packet { public class EmptyPacket<T extends EmptyPacket<T>> implements Packet<T> {
private final byte packetId; private final byte packetId;
public final ByteBuffer byteBuffer; private final ByteBuffer byteBuffer;
public EmptyPacket(byte packetId) { public EmptyPacket(byte packetId) {
this.packetId = packetId; this.packetId = packetId;
@ -17,7 +17,12 @@ public class EmptyPacket implements Packet {
} }
@Override @Override
public ByteBuffer compose() { public ByteBuffer byteBuffer() {
return byteBuffer; return byteBuffer;
} }
@Override
public T read(ByteBuffer buffer) {
return null;
}
} }

View file

@ -1,16 +1,20 @@
package eu.m724.websocket.packet; package eu.m724.websocket.packet;
import java.nio.ByteBuffer; import jakarta.websocket.Session;
import java.util.Collections;
import java.util.List;
public interface Packet { import java.nio.ByteBuffer;
public interface Packet<T extends Packet<T>> {
byte packetId(); byte packetId();
default List<Object> arguments() { default Object[] arguments() {
return Collections.emptyList(); return new Object[0];
} }
ByteBuffer compose(); ByteBuffer byteBuffer();
T read(ByteBuffer buffer);
default void send(Session session) { // TODO should this be here?
session.getAsyncRemote().sendBinary(byteBuffer());
}
} }

View file

@ -0,0 +1,30 @@
package eu.m724.websocket.packet.clientbound;
import eu.m724.websocket.packet.ArgumentsPacket;
import java.nio.ByteBuffer;
public class PongPacket extends ArgumentsPacket<PongPacket> {
public PongPacket(long time) {
super((byte)0x00, time);
}
public PongPacket() {
super((byte)0x00, System.currentTimeMillis());
}
@Override
public ByteBuffer compose(byte packetId, Object... arguments) {
ByteBuffer byteBuffer = ByteBuffer.allocate(9);
byteBuffer.put(packetId);
byteBuffer.putLong((long)arguments[0]);
return byteBuffer;
}
@Override
public PongPacket read(ByteBuffer buffer) {
return new PongPacket(buffer.getLong());
}
}