hi bro
your approach is good, especially because you are focusing on understanding the architecture instead of looking for ready-made source code.
the main thing i would recommend is to think of the filter as a protocol-aware layer between the client and the server, rather than just a tcp relay.
a useful architecture would look like this:
client
|
v
security filter / proxy
|
socket layer
session manager
packet framing
security layer
opcode validation
packet validation
rate limiting
heartbeat logging
|
v
gateway / agent / shard services
the first important part is the network layer.
on windows, iocp is a good choice when you want to handle many connections efficiently. the basic idea is to have asynchronous read and write operations associated with an iocp completion port instead of creating one blocking thread for every connection.
a simplified structure could be:
cpp
struct io_operation
{
overlapped overlapped{};
enum class type
{
read,
write,
accept
};
type operation_type;
wsabuf buffer;
};
the worker threads can then wait for completed operations:
cpp
dword bytes_transferred = 0;
ulong_ptr completion_key = 0;
lpoverlapped overlapped = nullptr;
bool result =
getqueuedcompletionstatus(
iocp,
&bytes_transferred,
&completion_key,
&overlapped,
infinite);
the important point is that the socket layer should not contain packet parsing or security logic. keep those responsibilities separated.
for example:
text
tcp socket
↓
receive buffer
↓
packet decoder
↓
security layer
↓
opcode parser
↓
validator
↓
forwarder
another important point is tcp framing.
you cannot assume that one recv call equals one silkroad packet.
for example, one packet may arrive in multiple tcp reads:
text
read 1
[packet part 1]
read 2:
[packet part 2]
or several packets may arrive together:
text
[packet a][packet b][packet c]
therefore the proxy needs a persistent receive buffer and a packet decoder that extracts complete frames from it.
a simple example:
cpp
class packet_buffer
{
public:
void append(
const uint8_t* data,
size_t size)
{
buffer.insert(
buffer.end(),
data,
data + size);
}
private:
std::vector<uint8_t> buffer;
};
after the framing layer, i would keep the silkroad security implementation separate.
for example:
cpp
class isecurity_layer
{
public:
virtual ~isecurity_layer() = default;
virtual bool process_incoming(
std::span<const uint8_t> input,
std::vector<uint8_t>& output) = 0;
virtual bool process_outgoing(
std::span<const uint8_t> input,
std::vector<uint8_t>& output) = 0;
};
then the silkroad-specific security implementation can implement this interface:
cpp
class silkroad_security :
public isecurity_layer
{
public:
bool process_incoming(
std::span<const uint8_t> input,
std::vector<uint8_t>& output) override
{
// security decoding
return true;
}
bool process_outgoing(
std::span<const uint8_t> input,
std::vector<uint8_t>& output) override
{
// security encoding
return true;
}
};
this separation is useful because the networking code does not need to know how the security algorithm works.
the next important part is the session state.
i would not allow every opcode at every stage of the connection.
for example:
cpp
enum class session_state
{
connected,
handshake,
authenticated,
in_game
};
then the filter can decide whether an opcode is valid for the current state:
cpp
bool is_opcode_allowed(
session_state state,
uint16_t opcode)
{
switch (state)
{
case session_state::connected:
return false;
case session_state::handshake:
return true;
case session_state::authenticated:
return true;
case session_state::in_game:
return true;
}
return false;
}
the exact opcode rules should come from the protocol analysis for the specific version being used.
i would also keep an opcode registry instead of putting opcode checks everywhere:
cpp
struct opcode_rule
{
uint16_t opcode;
uint16_t min_size;
uint16_t max_size;
bool client_to_server;
};
then:
cpp
bool validate_packet(
const opcode_rule& rule,
uint16_t size,
bool client_to_server)
{
if (size < rule.min_size)
return false;
if (size > rule.max_size)
return false;
if (rule.client_to_server != client_to_server)
return false;
return true;
}
this allows the filter to check more than just the opcode.
for example:
text
opcode
packet size
direction
session state
field ranges
packet frequency
sequence
security state
rate limiting is another important part.
instead of applying one global limit, i would normally keep limits per opcode or per packet category:
cpp
struct rate_limiter
{
uint64_t window_start = 0;
uint32_t packets = 0;
bool allow(
uint32_t maximum,
uint64_t now)
{
if (now - window_start >= 1000)
{
window_start = now;
packets = 0;
}
if (packets >= maximum)
return false;
++packets;
return true;
}
};
the filter can then detect abnormal packet rates without mixing that logic with the socket implementation.
for malformed packets, i would also avoid immediately treating every unexpected packet as a ban condition.
a better model is to keep a security state or violation counter:
cpp
struct security_score
{
uint32_t violations = 0;
void add_violation()
{
++violations;
}
bool should_disconnect() const
{
return violations >= 3;
}
};
this makes it easier to deal with protocol errors without creating unnecessary false positives.
for logging, i would record at least:
text
timestamp
session id
direction
opcode
packet size
session state
action
reason
for example:
text
session=1042
direction=c2s
opcode=0x1234
size=48
state=authenticated
action=forward
and for a rejected packet:
text
session=1042
direction=c2s
opcode=0x1234
size=4096
maximum=128
action=drop
reason=invalid_size
this becomes especially useful when analyzing disconnects, login errors, or unexpected server behavior.
for the client-side verification part, i would use a session-based challenge rather than relying only on a simple ping/pong.
the general flow can be:
text
client
|
| hello
v
filter
|
| challenge / nonce
v
client
|
| response
v
filter
|
v
session established
the response should be tied to the current session and challenge so that an old response cannot simply be reused.
a heartbeat can then be maintained independently:
cpp
struct heartbeat
{
uint32_t sequence;
uint64_t timestamp;
};
and the session can store:
cpp
struct client_security_context
{
uint64_t last_heartbeat;
uint32_t last_sequence;
bool authenticated;
};
the important architectural rule is to keep these components independent:
text
network
↓
session
↓
security
↓
framing
↓
opcode parsing
↓
validation
↓
rate limiting
↓
forwarding
for a vSRO-based project, i would also avoid assuming that every component communicates directly with the client. the exact path should be confirmed from the server configuration, packet captures, binaries, and the behavior of the specific client/server version.
for example, depending on the architecture, the path may be closer to:
text
client
↓
filter
↓
gateway / agent
↓
shard services
rather than directly connecting the client to every backend service.
i would therefore build the project incrementally:
text
1. tcp proxy
2. connection/session manager
3. packet framing
4. security layer
5. opcode registry
6. packet validation
7. state machine
8. rate limiting
9. heartbeat
10. client integrity checks
11. logging and packet capture
12. performance optimization with iocp
this approach makes debugging much easier because every layer has a clear responsibility.
the most important thing is not to start by writing a large filter and guessing the protocol. first establish the packet flow and security state for the exact version, then implement each layer around the observed protocol behavior.
that also makes it easier to compare different implementations such as stfilter-style filters, bfilter-style architectures, and proxy-based designs without depending on leaked source code or prebuilt binaries.
if you get stuck → on anything → ↓
←←←←←←←←←← ← ↓
↓
↓
don't hesitate to ask.