Handle Redis protocol message separately

Closes #4504

Messages are not typical responses, so they need special handling. This
is different between RESP2 and 3, so this is the first instance where
the script layer needs to tell the difference.
This commit is contained in:
Evan Typanski 2025-06-10 16:00:22 -04:00
parent 8b914f4714
commit a4ce682bc9
13 changed files with 207 additions and 17 deletions

View file

@ -41,6 +41,11 @@ export {
end: count &optional;
};
type RESPVersion: enum {
RESP2,
RESP3
};
type State: record {
## Pending commands.
pending: table[count] of Info;
@ -52,14 +57,34 @@ export {
## Each range is one or two elements, one meaning it's unbounded, two meaning
## it begins at one and ends at the second.
no_reply_ranges: vector of NoReplyRange;
## The command indexes (from current_command and current_reply) that will
## not get responses no matter what.
skip_commands: set[count];
## We store if this analyzer had a violation to avoid logging if so.
## This should not be super necessary, but worth a shot.
violation: bool &default=F;
## If we are in "subscribed" mode
subscribed_mode: bool &default=F;
## The RESP version
resp_version: RESPVersion &default=RESP2;
};
# Redis specifically mentions 10k commands as a good pipelining threshold, so
# we'll piggyback on that.
option max_pending_commands = 10000;
# These commands enter subscribed mode
global enter_subscribed_mode = [KnownCommand_PSUBSCRIBE,
KnownCommand_SSUBSCRIBE, KnownCommand_SUBSCRIBE];
# These commands exit subscribed mode
global exit_subscribed_mode = [KnownCommand_RESET, KnownCommand_QUIT];
# These commands don't expect a response (ever) - their replies are out of band.
global no_response_commands = [KnownCommand_PSUBSCRIBE,
KnownCommand_PUNSUBSCRIBE, KnownCommand_SSUBSCRIBE,
KnownCommand_SUBSCRIBE, KnownCommand_SUNSUBSCRIBE,
KnownCommand_UNSUBSCRIBE];
}
redef record connection += {
@ -122,6 +147,15 @@ function is_last_interval_closed(c: connection): bool
c$redis_state$no_reply_ranges[-1]?$end;
}
event Redis::hello_command(c: connection, hello: HelloCommand)
{
if ( ! c?$redis_state )
make_new_state(c);
if ( hello?$requested_resp_version && hello$requested_resp_version == "3" )
c$redis_state$resp_version = RESP3;
}
event Redis::command(c: connection, cmd: Command)
{
if ( ! c?$redis_state )
@ -139,6 +173,26 @@ event Redis::command(c: connection, cmd: Command)
}
++c$redis_state$current_command;
if ( cmd?$known )
{
if ( c$redis_state$resp_version == RESP2 )
{
local should_enter = cmd$known in enter_subscribed_mode;
local should_exit = cmd$known in exit_subscribed_mode;
c$redis_state$subscribed_mode = should_enter && ! should_exit;
# It's weird if it's in both - in the future users may be able to add that
if ( should_enter && should_exit )
Reporter::conn_weird("Redis_command_enter_exit_subscribed_mode", c, cat(
cmd$known));
}
if ( cmd$known in no_response_commands || c$redis_state$subscribed_mode )
{
add c$redis_state$skip_commands[c$redis_state$current_command];
}
}
# CLIENT commands can skip a number of replies and may be used with
# pipelining. We need special logic in order to track the command/reply
# pairs.
@ -177,6 +231,7 @@ event Redis::command(c: connection, cmd: Command)
}
}
}
set_state(c, T);
c$redis$cmd = cmd;
@ -187,17 +242,24 @@ event Redis::command(c: connection, cmd: Command)
function reply_num(c: connection): count
{
local resp_num = c$redis_state$current_reply + 1;
local result = resp_num;
for ( i in c$redis_state$no_reply_ranges )
{
local range = c$redis_state$no_reply_ranges[i];
if ( ! range?$end && resp_num > range$begin )
{ } # TODO: This is necessary if not using pipelining
if ( range?$end && resp_num >= range$begin && resp_num < range$end )
return range$end;
result = range$end;
}
# Default: no disable/enable shenanigans
return resp_num;
# Account for commands that don't expect a response
while ( result in c$redis_state$skip_commands )
{
delete c$redis_state$skip_commands[result];
result += 1;
}
return result;
}
# Logs up to and including the last seen command from the last reply
@ -234,6 +296,11 @@ event Redis::reply(c: connection, data: ReplyData)
if ( ! c?$redis_state )
make_new_state(c);
if ( c$redis_state$subscribed_mode )
{
event server_push(c, data);
return;
}
local previous_reply_num = c$redis_state$current_reply;
c$redis_state$current_reply = reply_num(c);
set_state(c, F);
@ -241,6 +308,10 @@ event Redis::reply(c: connection, data: ReplyData)
c$redis$reply = data;
c$redis$success = T;
log_from(c, previous_reply_num);
# Tidy up the skip_commands when it's up to date
if ( c$redis_state$current_command == c$redis_state$current_reply )
clear_table(c$redis_state$skip_commands);
}
event Redis::error(c: connection, data: ReplyData)

View file

@ -37,6 +37,12 @@ export {
password: string;
};
## The Redis HELLO command (handshake).
type HelloCommand: record {
## The sent requested RESP version, such as "2" or "3"
requested_resp_version: string &optional;
};
## A generic Redis command from the client.
type Command: record {
## The raw command, exactly as parsed
@ -79,6 +85,13 @@ global get_command: event(c: connection, key: string);
## command: The AUTH command sent to the server and its data.
global auth_command: event(c: connection, command: AuthCommand);
## Generated for Redis HELLO commands sent to the Redis server.
##
## c: The connection.
##
## command: The HELLO command sent to the server and its data.
global hello_command: event(c: connection, command: HelloCommand);
## Generated for every command sent by the client to the Redis server.
##
## c: The connection.
@ -87,11 +100,15 @@ global auth_command: event(c: connection, command: AuthCommand);
global command: event(c: connection, cmd: Command);
## Generated for every successful response sent by the Redis server to the
## client.
## client. For RESP2, this includes "push" messages, which are out of band.
## These will also raise a server_push event. RESP3 push messages will only
## raise a server_push event.
##
## c: The connection.
##
## data: The server data sent to the client.
##
## .. zeek:see:: Redis::server_push
global reply: event(c: connection, data: ReplyData);
## Generated for every error response sent by the Redis server to the
@ -101,3 +118,11 @@ global reply: event(c: connection, data: ReplyData);
##
## data: The server data sent to the client.
global error: event(c: connection, data: ReplyData);
## Generated for out-of-band data, outside of the request-response
## model.
##
## c: The connection.
##
## data: The server data sent to the client.
global server_push: event(c: connection, data: ReplyData);