From a8730a6997c9b4d583be68c4434cf3225854f8df Mon Sep 17 00:00:00 2001 From: Ivan Verbin Date: Sun, 15 Mar 2026 17:03:11 +0300 Subject: [PATCH] occtl: add terminate commands for session cookie invalidation Add 'terminate user', 'terminate id' and 'terminate session' commands to occtl that disconnect users and invalidate their session cookies, preventing reconnection with cached credentials. Short session IDs are resolved to full safe_id by fetching the cookie list from sec-mod via CTL_CMD_LIST_COOKIES with prefix matching and ambiguity detection. Active sessions trigger a warning before invalidation. Add integration tests for all three terminate commands. Signed-off-by: Ivan Verbin --- NEWS | 5 +- doc/design.md | 35 +++++ src/common/common.c | 8 ++ src/ctl.proto | 6 + src/defs.h | 4 + src/ipc.proto | 20 +++ src/main-ctl-unix.c | 245 ++++++++++++++++++++++++++++++++++ src/occtl/ctl.h | 8 +- src/occtl/occtl.c | 6 + src/occtl/occtl.h | 6 + src/occtl/unix.c | 282 +++++++++++++++++++++++++++++++++++++++ src/sec-mod-db.c | 84 ++++++++++++ src/sec-mod.c | 68 ++++++++++ src/sec-mod.h | 3 + tests/Makefile.am | 2 +- tests/terminate-commands | 184 +++++++++++++++++++++++++ 16 files changed, 963 insertions(+), 3 deletions(-) create mode 100755 tests/terminate-commands diff --git a/NEWS b/NEWS index 01723dfa..dc1c825a 100644 --- a/NEWS +++ b/NEWS @@ -1,9 +1,12 @@ * Version 1.4.2 (unreleased) - The bundled llhttp was updated to 9.3.1. +- occtl: Added 'terminate user', 'terminate id', and 'terminate session' + commands that disconnect users and invalidate their session cookies, + preventing automatic reconnection (#689) * Version 1.4.1 (released 2026-02-28) -- [SECURITY] Fixed authentication bypass (medium severity) when combined +- [SECURITY] Fixed authentication bypass (medium severity) when combined password with certificate authentication with cert-user-oid set to SAN(rfc822name): a client presenting a valid CA-signed certificate without the expected RFC822 SAN field could authenticate using password credentials alone, diff --git a/doc/design.md b/doc/design.md index c2109388..cee534f2 100644 --- a/doc/design.md +++ b/doc/design.md @@ -211,6 +211,41 @@ sequenceDiagram sm->>m: SECM_CLI_STATS (SID) ``` +## IPC Communication for session termination + +When an administrator issues a terminate command via occtl, the main process +disconnects the active worker (if any) and forwards the request to sec-mod +to invalidate the session cookie, preventing automatic reconnection. + +For `terminate session`, occtl first fetches the full cookie list from the +server to resolve a potentially shortened session ID prefix to the full +safe_id. If the prefix is ambiguous (matches multiple sessions), occtl +refuses the operation and lists the matching sessions. Scripts should use +the full session ID from `occtl --json show sessions valid` ("Full session" +field) to avoid ambiguity. + +```mermaid +sequenceDiagram + participant ctl as occtl + participant m as main + participant sm as sec-mod + + alt terminate session + ctl->>m: CTL_CMD_LIST_COOKIES + m->>ctl: cookie list (resolve short SID to full safe_id) + end + ctl->>m: CTL_CMD_TERMINATE_USER / ID / SESSION + Note over m: disconnect worker process(es) + alt terminate user + m->>sm: SECM_TERMINATE_USER_SESSIONS (username) + sm->>m: SECM_TERMINATE_SESSION_REPLY + else terminate id / session + m->>sm: SECM_TERMINATE_SESSION (safe_id) + sm->>m: SECM_TERMINATE_SESSION_REPLY + end + m->>ctl: CTL_CMD_TERMINATE_*_REP +``` + ## Cookies Cookies are valid for the value configured in `cookie-timeout` option, after diff --git a/src/common/common.c b/src/common/common.c index abd6aed0..2991b5a1 100644 --- a/src/common/common.c +++ b/src/common/common.c @@ -168,6 +168,14 @@ const char *cmd_request_to_str(unsigned int _cmd) return "sm: reload"; case CMD_SECM_RELOAD_REPLY: return "sm: reload reply"; + case CMD_SECM_TERMINATE_USER_SESSIONS: + return "sm: terminate user sessions"; + case CMD_SECM_TERMINATE_USER_SESSIONS_REPLY: + return "sm: terminate user sessions reply"; + case CMD_SECM_TERMINATE_SESSION: + return "sm: terminate session"; + case CMD_SECM_TERMINATE_SESSION_REPLY: + return "sm: terminate session reply"; default: snprintf(tmp, sizeof(tmp), "unknown (%u)", _cmd); return tmp; diff --git a/src/ctl.proto b/src/ctl.proto index 7e4e3924..a7b2e5c5 100644 --- a/src/ctl.proto +++ b/src/ctl.proto @@ -106,6 +106,12 @@ message id_req required sint32 id = 1; } +/* Used by 'terminate session' to transport the safe session ID */ +message safe_id_req +{ + required bytes safe_id = 1; +} + message ban_info_rep { required bytes ip = 1; diff --git a/src/defs.h b/src/defs.h index 0a88e146..4d8b94a3 100644 --- a/src/defs.h +++ b/src/defs.h @@ -101,6 +101,10 @@ typedef enum { CMD_SECM_STATS, /* sent periodically */ CMD_SECM_RELOAD, CMD_SECM_RELOAD_REPLY, + CMD_SECM_TERMINATE_USER_SESSIONS, + CMD_SECM_TERMINATE_USER_SESSIONS_REPLY, + CMD_SECM_TERMINATE_SESSION, + CMD_SECM_TERMINATE_SESSION_REPLY, MAX_SECM_CMD, } cmd_request_t; diff --git a/src/ipc.proto b/src/ipc.proto index c6144bb0..0cbf809b 100644 --- a/src/ipc.proto +++ b/src/ipc.proto @@ -382,6 +382,26 @@ message secm_list_cookies_reply_msg repeated cookie_int_msg cookies = 1; } +/* SECM_TERMINATE_USER_SESSIONS: sent from main to sec-mod to invalidate all + * session cookies for a given username. */ +message secm_terminate_user_sessions_msg +{ + required string username = 1; +} + +/* SECM_TERMINATE_SESSION: sent from main to sec-mod to invalidate a single + * session cookie by its safe_id. */ +message secm_terminate_session_msg +{ + required bytes safe_id = 1; +} + +/* Reply for both terminate commands */ +message secm_terminate_session_reply_msg +{ + required bool result = 1 [default = false]; +} + /* SECM_BAN_IP: sent from sec-mod to main */ /* same as: ban_ip_msg */ diff --git a/src/main-ctl-unix.c b/src/main-ctl-unix.c index 7c3fed94..7ef460eb 100644 --- a/src/main-ctl-unix.c +++ b/src/main-ctl-unix.c @@ -38,6 +38,7 @@ #include #include +#include #include typedef struct method_ctx { @@ -69,6 +70,12 @@ static void method_list_banned(method_ctx *ctx, int cfd, uint8_t *msg, unsigned int msg_size); static void method_list_cookies(method_ctx *ctx, int cfd, uint8_t *msg, unsigned int msg_size); +static void method_terminate_user(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size); +static void method_terminate_id(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size); +static void method_terminate_session(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size); typedef void (*method_func)(method_ctx *ctx, int cfd, uint8_t *msg, unsigned int msg_size); @@ -97,6 +104,9 @@ static const ctl_method_st methods[] = { ENTRY(CTL_CMD_UNBAN_IP, method_unban_ip), ENTRY(CTL_CMD_DISCONNECT_NAME, method_disconnect_user_name), ENTRY(CTL_CMD_DISCONNECT_ID, method_disconnect_user_id), + ENTRY(CTL_CMD_TERMINATE_USER, method_terminate_user), + ENTRY(CTL_CMD_TERMINATE_ID, method_terminate_id), + ENTRY(CTL_CMD_TERMINATE_SESSION, method_terminate_session), { NULL, 0, NULL } }; @@ -870,6 +880,241 @@ static void method_disconnect_user_id(method_ctx *ctx, int cfd, uint8_t *msg, } } +/* Invalidate all session cookies for a given username in sec-mod */ +static int terminate_user_sessions_in_secmod(method_ctx *ctx, + const char *username) +{ + SecmTerminateUserSessionsMsg req = + SECM_TERMINATE_USER_SESSIONS_MSG__INIT; + SecmTerminateSessionReplyMsg *reply = NULL; + int ret, result = 0; + unsigned int i; + + PROTOBUF_ALLOCATOR(pa, ctx->pool); + + req.username = (char *)username; + + for (i = 0; i < ctx->s->sec_mod_instance_count; i++) { + ret = send_msg( + ctx->pool, ctx->s->sec_mod_instances[i].sec_mod_fd_sync, + CMD_SECM_TERMINATE_USER_SESSIONS, &req, + (pack_size_func) + secm_terminate_user_sessions_msg__get_packed_size, + (pack_func)secm_terminate_user_sessions_msg__pack); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, + "error sending terminate to sec-mod!"); + continue; + } + + ret = recv_msg( + ctx->pool, ctx->s->sec_mod_instances[i].sec_mod_fd_sync, + CMD_SECM_TERMINATE_USER_SESSIONS_REPLY, (void *)&reply, + (unpack_func)secm_terminate_session_reply_msg__unpack, + MAIN_SEC_MOD_TIMEOUT); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, + "error receiving terminate reply"); + continue; + } + + if (reply && reply->result) + result = 1; + if (reply) + secm_terminate_session_reply_msg__free_unpacked(reply, + &pa); + reply = NULL; + if (result == 1) + break; + } + + return result; +} + +/* Invalidate a single session cookie by safe_id in sec-mod */ +static int terminate_session_in_secmod(method_ctx *ctx, const char *safe_id, + size_t safe_id_len) +{ + SecmTerminateSessionMsg req = SECM_TERMINATE_SESSION_MSG__INIT; + SecmTerminateSessionReplyMsg *reply = NULL; + int ret, result = 0; + unsigned int i; + + PROTOBUF_ALLOCATOR(pa, ctx->pool); + + req.safe_id.data = (uint8_t *)safe_id; + req.safe_id.len = safe_id_len; + + for (i = 0; i < ctx->s->sec_mod_instance_count; i++) { + ret = send_msg( + ctx->pool, ctx->s->sec_mod_instances[i].sec_mod_fd_sync, + CMD_SECM_TERMINATE_SESSION, &req, + (pack_size_func) + secm_terminate_session_msg__get_packed_size, + (pack_func)secm_terminate_session_msg__pack); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, + "error sending terminate to sec-mod!"); + continue; + } + + ret = recv_msg( + ctx->pool, ctx->s->sec_mod_instances[i].sec_mod_fd_sync, + CMD_SECM_TERMINATE_SESSION_REPLY, (void *)&reply, + (unpack_func)secm_terminate_session_reply_msg__unpack, + MAIN_SEC_MOD_TIMEOUT); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, + "error receiving terminate reply"); + continue; + } + + if (reply && reply->result) + result = 1; + if (reply) + secm_terminate_session_reply_msg__free_unpacked(reply, + &pa); + reply = NULL; + if (result == 1) + break; + } + + return result; +} + +static void method_terminate_user(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size) +{ + UsernameReq *req; + BoolMsg rep = BOOL_MSG__INIT; + struct proc_st *cpos; + struct proc_st *ctmp = NULL; + int ret; + + mslog(ctx->s, NULL, LOG_DEBUG, "ctl: terminate user"); + + req = username_req__unpack(NULL, msg_size, msg); + if (req == NULL) { + mslog(ctx->s, NULL, LOG_ERR, + "error parsing terminate user request"); + return; + } + + /* First disconnect all active sessions for this user */ + list_for_each_safe(&ctx->s->proc_list.head, ctmp, cpos, list) + { + if (strcmp(ctmp->username, req->username) == 0) { + disconnect_proc(ctx->s, ctmp); + rep.status = 1; + } + } + + /* Then invalidate all session cookies in sec-mod */ + if (terminate_user_sessions_in_secmod(ctx, req->username)) { + mslog(ctx->s, NULL, LOG_INFO, + "terminated session cookies for user '%s'", + req->username); + rep.status = 1; + } + + username_req__free_unpacked(req, NULL); + + ret = send_msg(ctx->pool, cfd, CTL_CMD_TERMINATE_USER_REP, &rep, + (pack_size_func)bool_msg__get_packed_size, + (pack_func)bool_msg__pack); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, "error sending ctl reply"); + } +} + +static void method_terminate_id(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size) +{ + IdReq *req; + BoolMsg rep = BOOL_MSG__INIT; + struct proc_st *cpos; + struct proc_st *ctmp = NULL; + int ret; + char safe_id[SAFE_ID_SIZE]; + int found = 0; + + mslog(ctx->s, NULL, LOG_DEBUG, "ctl: terminate id"); + + req = id_req__unpack(NULL, msg_size, msg); + if (req == NULL) { + mslog(ctx->s, NULL, LOG_ERR, + "error parsing terminate id request"); + return; + } + + /* Find and disconnect the process, save sid for cookie invalidation */ + list_for_each_safe(&ctx->s->proc_list.head, ctmp, cpos, list) + { + if (ctmp->pid == req->id) { + calc_safe_id(ctmp->sid, sizeof(ctmp->sid), safe_id, + SAFE_ID_SIZE); + found = 1; + disconnect_proc(ctx->s, ctmp); + rep.status = 1; + break; + } + } + + /* Invalidate session cookie for the specific connection */ + if (found) { + if (terminate_session_in_secmod(ctx, safe_id, + SAFE_ID_SIZE - 1)) { + mslog(ctx->s, NULL, LOG_INFO, + "terminated session cookie for ID %d", req->id); + } + } + + id_req__free_unpacked(req, NULL); + + ret = send_msg(ctx->pool, cfd, CTL_CMD_TERMINATE_ID_REP, &rep, + (pack_size_func)bool_msg__get_packed_size, + (pack_func)bool_msg__pack); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, "error sending ctl reply"); + } +} + +static void method_terminate_session(method_ctx *ctx, int cfd, uint8_t *msg, + unsigned int msg_size) +{ + SafeIdReq *req; + BoolMsg rep = BOOL_MSG__INIT; + int ret; + + mslog(ctx->s, NULL, LOG_DEBUG, "ctl: terminate session"); + + req = safe_id_req__unpack(NULL, msg_size, msg); + if (req == NULL) { + mslog(ctx->s, NULL, LOG_ERR, + "error parsing terminate session request"); + return; + } + + /* Invalidate session cookie by safe_id */ + if (req->safe_id.data != NULL && req->safe_id.len > 0 && + terminate_session_in_secmod(ctx, (const char *)req->safe_id.data, + req->safe_id.len)) { + mslog(ctx->s, NULL, LOG_INFO, + "terminated session (session: %.6s)", + (const char *)req->safe_id.data); + rep.status = 1; + } + + safe_id_req__free_unpacked(req, NULL); + + ret = send_msg(ctx->pool, cfd, CTL_CMD_TERMINATE_SESSION_REP, &rep, + (pack_size_func)bool_msg__get_packed_size, + (pack_func)bool_msg__pack); + if (ret < 0) { + mslog(ctx->s, NULL, LOG_ERR, "error sending ctl reply"); + } +} + struct ctl_watcher_st { int fd; struct ev_io ctl_cmd_io; diff --git a/src/occtl/ctl.h b/src/occtl/ctl.h index 9f2301bf..45ee00a2 100644 --- a/src/occtl/ctl.h +++ b/src/occtl/ctl.h @@ -16,6 +16,9 @@ enum { CTL_CMD_UNBAN_IP, CTL_CMD_TOP, CTL_CMD_LIST_COOKIES, + CTL_CMD_TERMINATE_USER, + CTL_CMD_TERMINATE_ID, + CTL_CMD_TERMINATE_SESSION, CTL_CMD_STATUS_REP = 101, CTL_CMD_RELOAD_REP, @@ -26,7 +29,10 @@ enum { CTL_CMD_UNBAN_IP_REP, CTL_CMD_LIST_BANNED_REP, CTL_CMD_TOP_UPDATE_REP, - CTL_CMD_LIST_COOKIES_REP + CTL_CMD_LIST_COOKIES_REP, + CTL_CMD_TERMINATE_USER_REP, + CTL_CMD_TERMINATE_ID_REP, + CTL_CMD_TERMINATE_SESSION_REP }; #endif diff --git a/src/occtl/occtl.c b/src/occtl/occtl.c index e4eb7b27..f9868c55 100644 --- a/src/occtl/occtl.c +++ b/src/occtl/occtl.c @@ -55,6 +55,12 @@ static const commands_st commands[] = { "Disconnect the specified user", 1, 1), ENTRY("disconnect id", "[ID]", handle_disconnect_id_cmd, "Disconnect the specified ID", 1, 1), + ENTRY("terminate user", "[NAME]", handle_terminate_user_cmd, + "Disconnect user and invalidate session cookies", 1, 1), + ENTRY("terminate id", "[ID]", handle_terminate_id_cmd, + "Disconnect ID and invalidate session cookies", 1, 1), + ENTRY("terminate session", "[SID]", handle_terminate_session_cmd, + "Invalidate the specified session of a disconnected user", 1, 1), ENTRY("unban ip", "[IP]", handle_unban_ip_cmd, "Unban the specified IP", 1, 1), ENTRY("reload", NULL, handle_reload_cmd, diff --git a/src/occtl/occtl.h b/src/occtl/occtl.h index 46b35294..9cead453 100644 --- a/src/occtl/occtl.h +++ b/src/occtl/occtl.h @@ -123,6 +123,12 @@ int handle_unban_ip_cmd(CONN_TYPE *conn, const char *arg, cmd_params_st *params); int handle_disconnect_id_cmd(CONN_TYPE *conn, const char *arg, cmd_params_st *params); +int handle_terminate_user_cmd(CONN_TYPE *conn, const char *arg, + cmd_params_st *params); +int handle_terminate_id_cmd(CONN_TYPE *conn, const char *arg, + cmd_params_st *params); +int handle_terminate_session_cmd(CONN_TYPE *conn, const char *arg, + cmd_params_st *params); int handle_reload_cmd(CONN_TYPE *conn, const char *arg, cmd_params_st *params); int handle_stop_cmd(CONN_TYPE *conn, const char *arg, cmd_params_st *params); int handle_events_cmd(struct unix_ctx *ctx, const char *arg, diff --git a/src/occtl/unix.c b/src/occtl/unix.c index c2cc8776..85f1f313 100644 --- a/src/occtl/unix.c +++ b/src/occtl/unix.c @@ -58,6 +58,8 @@ static int common_info_cmd(UserListRep *args, FILE *out, cmd_params_st *params); static int session_info_cmd(void *ctx, SecmListCookiesReplyMsg *args, FILE *out, cmd_params_st *params, const char *lsid, unsigned int all); +static char *shorten(void *cookie, unsigned int session_id_size, + unsigned int small); struct unix_ctx { int fd; @@ -78,6 +80,9 @@ static uint8_t msg_map[] = { [CTL_CMD_DISCONNECT_NAME] = CTL_CMD_DISCONNECT_NAME_REP, [CTL_CMD_DISCONNECT_ID] = CTL_CMD_DISCONNECT_ID_REP, [CTL_CMD_UNBAN_IP] = CTL_CMD_UNBAN_IP_REP, + [CTL_CMD_TERMINATE_USER] = CTL_CMD_TERMINATE_USER_REP, + [CTL_CMD_TERMINATE_ID] = CTL_CMD_TERMINATE_ID_REP, + [CTL_CMD_TERMINATE_SESSION] = CTL_CMD_TERMINATE_SESSION_REP, }; struct cmd_reply_st { @@ -669,6 +674,283 @@ cleanup: return ret; } +int handle_terminate_user_cmd(struct unix_ctx *ctx, const char *arg, + cmd_params_st *params) +{ + int ret; + struct cmd_reply_st raw; + BoolMsg *rep; + unsigned int status; + UsernameReq req = USERNAME_REQ__INIT; + + PROTOBUF_ALLOCATOR(pa, ctx); + + if (arg == NULL || need_help(arg)) { + check_cmd_help(rl_line_buffer); + return 1; + } + + init_reply(&raw); + + req.username = (void *)arg; + + ret = send_cmd(ctx, CTL_CMD_TERMINATE_USER, &req, + (pack_size_func)username_req__get_packed_size, + (pack_func)username_req__pack, &raw); + if (ret < 0) { + goto error; + } + + rep = bool_msg__unpack(&pa, raw.data_size, raw.data); + if (rep == NULL) + goto error; + + status = rep->status; + bool_msg__free_unpacked(rep, &pa); + + if (status != 0) { + printf("user '%s' was terminated (session invalidated)\n", arg); + ret = 0; + } else { + printf("could not terminate user '%s'\n", arg); + ret = 1; + } + + goto cleanup; + +error: + fprintf(stderr, ERR_SERVER_UNREACHABLE); + ret = 1; +cleanup: + free_reply(&raw); + + return ret; +} + +int handle_terminate_id_cmd(struct unix_ctx *ctx, const char *arg, + cmd_params_st *params) +{ + int ret; + struct cmd_reply_st raw; + BoolMsg *rep; + unsigned int status; + unsigned int id = 0; + IdReq req = ID_REQ__INIT; + + PROTOBUF_ALLOCATOR(pa, ctx); + + if (arg != NULL) + id = atoi(arg); + + if (arg == NULL || need_help(arg) || id == 0) { + check_cmd_help(rl_line_buffer); + return 1; + } + + init_reply(&raw); + + req.id = id; + + ret = send_cmd(ctx, CTL_CMD_TERMINATE_ID, &req, + (pack_size_func)id_req__get_packed_size, + (pack_func)id_req__pack, &raw); + if (ret < 0) { + goto error; + } + + rep = bool_msg__unpack(&pa, raw.data_size, raw.data); + if (rep == NULL) + goto error; + + status = rep->status; + bool_msg__free_unpacked(rep, &pa); + + if (status != 0) { + printf("connection ID '%s' was terminated (session invalidated)\n", + arg); + ret = 0; + } else { + printf("could not terminate ID '%s'\n", arg); + ret = 1; + } + + goto cleanup; + +error: + fprintf(stderr, ERR_SERVER_UNREACHABLE); + ret = 1; +cleanup: + free_reply(&raw); + + return ret; +} + +int handle_terminate_session_cmd(struct unix_ctx *ctx, const char *arg, + cmd_params_st *params) +{ + int ret; + struct cmd_reply_st raw; + struct cmd_reply_st raw_cookies; + BoolMsg *rep; + SecmListCookiesReplyMsg *crep = NULL; + unsigned int status; + SafeIdReq req = SAFE_ID_REQ__INIT; + unsigned int i; + size_t arg_len; + char resolved_safe_id[SAFE_ID_SIZE]; + unsigned int match_count = 0; + + PROTOBUF_ALLOCATOR(pa, ctx); + + if (arg == NULL || need_help(arg)) { + check_cmd_help(rl_line_buffer); + return 1; + } + + arg_len = strlen(arg); + if (arg_len > SAFE_ID_SIZE - 1) { + printf("invalid session ID '%s'\n", arg); + return 1; + } + + /* Fetch cookie list to resolve short SID to full safe_id */ + init_reply(&raw_cookies); + + ret = send_cmd(ctx, CTL_CMD_LIST_COOKIES, NULL, NULL, NULL, + &raw_cookies); + if (ret < 0) { + goto error_cookies; + } + + crep = secm_list_cookies_reply_msg__unpack(&pa, raw_cookies.data_size, + raw_cookies.data); + if (crep == NULL) + goto error_cookies; + + /* Find cookies matching the given prefix */ + for (i = 0; i < crep->n_cookies; i++) { + if (crep->cookies[i]->safe_id.len < arg_len) + continue; + if (memcmp(crep->cookies[i]->safe_id.data, arg, arg_len) == 0) { + match_count++; + memcpy(resolved_safe_id, crep->cookies[i]->safe_id.data, + MIN(crep->cookies[i]->safe_id.len, + SAFE_ID_SIZE - 1)); + resolved_safe_id[MIN(crep->cookies[i]->safe_id.len, + SAFE_ID_SIZE - 1)] = 0; + } + } + + if (match_count == 0) { + printf("no session matching '%s' was found\n", arg); + ret = 1; + goto cleanup_cookies; + } + + if (match_count > 1) { + printf("ambiguous session ID '%s' matches %u sessions:\n", arg, + match_count); + for (i = 0; i < crep->n_cookies; i++) { + if (crep->cookies[i]->safe_id.len < arg_len) + continue; + if (memcmp(crep->cookies[i]->safe_id.data, arg, + arg_len) == 0) { + const char *username = + crep->cookies[i]->username; + if (username == NULL || username[0] == 0) + username = NO_USER; + printf(" %s user: %s\n", + shorten(crep->cookies[i]->safe_id.data, + crep->cookies[i]->safe_id.len, + 0), + username); + } + } + printf("use the full session ID to terminate a specific session\n"); + ret = 1; + goto cleanup_cookies; + } + + /* Warn if the session has an active connection */ + for (i = 0; i < crep->n_cookies; i++) { + if (crep->cookies[i]->safe_id.len < arg_len) + continue; + if (memcmp(crep->cookies[i]->safe_id.data, arg, arg_len) == 0) { + if (crep->cookies[i]->in_use) { + const char *username = + crep->cookies[i]->username; + if (username == NULL || username[0] == 0) + username = NO_USER; + fprintf(stderr, + "warning: session '%.6s' (user: %s) has an active" + " connection; the connection will not be dropped\n", + (const char *)crep->cookies[i] + ->safe_id.data, + username); + } + break; + } + } + + secm_list_cookies_reply_msg__free_unpacked(crep, &pa); + crep = NULL; + free_reply(&raw_cookies); + + /* Reconnect - server closes the socket after each command */ + conn_posthandle(ctx); + if (conn_prehandle(ctx) < 0) { + fprintf(stderr, ERR_SERVER_UNREACHABLE); + return 1; + } + + /* Exactly one match - send the full safe_id */ + init_reply(&raw); + + req.safe_id.data = (uint8_t *)resolved_safe_id; + req.safe_id.len = strlen(resolved_safe_id); + + ret = send_cmd(ctx, CTL_CMD_TERMINATE_SESSION, &req, + (pack_size_func)safe_id_req__get_packed_size, + (pack_func)safe_id_req__pack, &raw); + if (ret < 0) { + goto error; + } + + rep = bool_msg__unpack(&pa, raw.data_size, raw.data); + if (rep == NULL) + goto error; + + status = rep->status; + bool_msg__free_unpacked(rep, &pa); + + if (status != 0) { + printf("session '%.6s' was terminated\n", arg); + ret = 0; + } else { + printf("could not terminate session '%.6s'\n", arg); + ret = 1; + } + + goto cleanup; + +error_cookies: + fprintf(stderr, ERR_SERVER_UNREACHABLE); + ret = 1; +cleanup_cookies: + if (crep != NULL) + secm_list_cookies_reply_msg__free_unpacked(crep, &pa); + free_reply(&raw_cookies); + return ret; + +error: + fprintf(stderr, ERR_SERVER_UNREACHABLE); + ret = 1; +cleanup: + free_reply(&raw); + + return ret; +} + static const char *fix_ciphersuite(char *txt) { if (txt != NULL && txt[0] != 0 && strlen(txt) > 16 && diff --git a/src/sec-mod-db.c b/src/sec-mod-db.c index 69f99e21..f9266e6a 100644 --- a/src/sec-mod-db.c +++ b/src/sec-mod-db.c @@ -240,3 +240,87 @@ void expire_client_entry(sec_mod_st *sec, client_entry_st *e) } } } + +/* Terminate all sessions for a given username, invalidating their cookies. + * Returns 1 if at least one session was terminated, 0 otherwise. + */ +int terminate_user_sessions(sec_mod_st *sec, const char *username) +{ + struct htable *db = sec->client_db; + client_entry_st *t; + struct htable_iter iter; + int terminated = 0; + + if (db == NULL) + return 0; + + if (username == NULL || username[0] == 0) { + seclog(sec, LOG_INFO, + "terminate session request without username"); + return 0; + } + + seclog(sec, LOG_DEBUG, "terminating sessions for user '%s'", username); + + t = htable_first(db, &iter); + while (t != NULL) { + if (strcmp(t->acct_info.username, username) == 0) { + seclog(sec, LOG_INFO, + "force terminating session of user '%s' " SESSION_STR, + t->acct_info.username, t->acct_info.safe_id); + htable_delval(db, &iter); + clean_entry(sec, t); + terminated = 1; + } + t = htable_next(db, &iter); + } + + return terminated; +} + +/* Terminate a session by its safe_id prefix. + * Returns 1 if the session was terminated, 0 otherwise. + */ +int terminate_session_by_sid(sec_mod_st *sec, const char *safe_id, + size_t safe_id_len) +{ + struct htable *db = sec->client_db; + client_entry_st *t; + struct htable_iter iter; + int terminated = 0; + + if (db == NULL) + return 0; + + if (safe_id == NULL || safe_id_len == 0) { + seclog(sec, LOG_INFO, + "terminate session request without session ID"); + return 0; + } + + if (safe_id_len != SAFE_ID_SIZE - 1) { + seclog(sec, LOG_INFO, + "terminate session request with invalid session ID length (%zu)", + safe_id_len); + return 0; + } + + seclog(sec, LOG_DEBUG, "terminating session with ID prefix '%.6s'", + safe_id); + + t = htable_first(db, &iter); + while (t != NULL) { + if (memcmp(t->acct_info.safe_id, safe_id, safe_id_len) == 0) { + seclog(sec, LOG_INFO, + "force terminating session of user '%s' " SESSION_STR, + t->acct_info.username, t->acct_info.safe_id); + htable_delval(db, &iter); + clean_entry(sec, t); + terminated = 1; + break; /* Session IDs should be unique */ + } + t = htable_next(db, &iter); + } + + return terminated; +} diff --git a/src/sec-mod.c b/src/sec-mod.c index f0e33a5b..50436154 100644 --- a/src/sec-mod.c +++ b/src/sec-mod.c @@ -548,6 +548,74 @@ static int process_packet_from_main(void *pool, int fd, sec_mod_st *sec, return ret; } + case CMD_SECM_TERMINATE_USER_SESSIONS: { + SecmTerminateUserSessionsMsg *msg; + SecmTerminateSessionReplyMsg reply = + SECM_TERMINATE_SESSION_REPLY_MSG__INIT; + + msg = secm_terminate_user_sessions_msg__unpack(&pa, data.size, + data.data); + if (msg == NULL) { + seclog(sec, LOG_INFO, + "error unpacking terminate user sessions"); + return ERR_BAD_COMMAND; + } + + if (msg->username != NULL) + reply.result = + terminate_user_sessions(sec, msg->username); + + ret = send_msg( + pool, fd, CMD_SECM_TERMINATE_USER_SESSIONS_REPLY, + &reply, + (pack_size_func) + secm_terminate_session_reply_msg__get_packed_size, + (pack_func)secm_terminate_session_reply_msg__pack); + + secm_terminate_user_sessions_msg__free_unpacked(msg, &pa); + + if (ret < 0) { + seclog(sec, LOG_ERR, + "could not send terminate session reply!"); + return ERR_BAD_COMMAND; + } + + return 0; + } + case CMD_SECM_TERMINATE_SESSION: { + SecmTerminateSessionMsg *msg; + SecmTerminateSessionReplyMsg reply = + SECM_TERMINATE_SESSION_REPLY_MSG__INIT; + + msg = secm_terminate_session_msg__unpack(&pa, data.size, + data.data); + if (msg == NULL) { + seclog(sec, LOG_INFO, + "error unpacking terminate session"); + return ERR_BAD_COMMAND; + } + + if (msg->safe_id.data != NULL && msg->safe_id.len > 0) + reply.result = terminate_session_by_sid( + sec, (const char *)msg->safe_id.data, + msg->safe_id.len); + + ret = send_msg( + pool, fd, CMD_SECM_TERMINATE_SESSION_REPLY, &reply, + (pack_size_func) + secm_terminate_session_reply_msg__get_packed_size, + (pack_func)secm_terminate_session_reply_msg__pack); + + secm_terminate_session_msg__free_unpacked(msg, &pa); + + if (ret < 0) { + seclog(sec, LOG_ERR, + "could not send terminate session reply!"); + return ERR_BAD_COMMAND; + } + + return 0; + } default: seclog(sec, LOG_WARNING, "unknown type 0x%.2x", cmd); return ERR_BAD_COMMAND; diff --git a/src/sec-mod.h b/src/sec-mod.h index 2e55be7c..090cf17c 100644 --- a/src/sec-mod.h +++ b/src/sec-mod.h @@ -160,6 +160,9 @@ int handle_secm_session_close_cmd(sec_mod_st *sec, int fd, const SecmSessionCloseMsg *req); int handle_sec_auth_stats_cmd(sec_mod_st *sec, const CliStatsMsg *req, pid_t pid); +int terminate_user_sessions(sec_mod_st *sec, const char *username); +int terminate_session_by_sid(sec_mod_st *sec, const char *safe_id, + size_t safe_id_len); void sec_auth_user_deinit(sec_mod_st *sec, client_entry_st *e); void sec_mod_server(void *main_pool, void *config_pool, diff --git a/tests/Makefile.am b/tests/Makefile.am index 7bc5c5c7..9906de6a 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -68,7 +68,7 @@ dist_check_SCRIPTS += haproxy-connect test-iroute test-multi-cookie test-pass-sc test-cookie-invalidation test-user-config test-append-routes test-ban \ multiple-routes json test-udp-listen-host test-max-same-1 test-script-multi-user \ apple-ios ipv6-iface test-namespace-listen disconnect-user disconnect-user2 \ - ping-leases test-ban-local test-client-bypass-protocol ipv6-small-net test-camouflage \ + terminate-commands ping-leases test-ban-local test-client-bypass-protocol ipv6-small-net test-camouflage \ test-camouflage-norealm vhost-traffic defvhost-traffic session-timeout test-occtl \ no-ipv6-ocv3 diff --git a/tests/terminate-commands b/tests/terminate-commands new file mode 100755 index 00000000..be296dd6 --- /dev/null +++ b/tests/terminate-commands @@ -0,0 +1,184 @@ +#!/bin/bash +# +# Copyright (C) 2026 Ivan Verbin +# +# This file is part of ocserv. +# +# ocserv is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# ocserv is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# This test validates that terminate commands invalidate cookies: +# - terminate user +# - terminate id +# - terminate session + +OCCTL="${OCCTL:-../src/occtl/occtl}" +SERV="${SERV:-../src/ocserv}" +srcdir=${srcdir:-.} +TMPFILE=ocfile.$$.tmp +PIDFILE=ocserv-pid.$$.tmp +CLIPID=oc-pid.$$.tmp +OUTFILE=occtl-terminate.$$.tmp +OCCTL_SOCKET=./occtl-$$.socket + +. `dirname $0`/common.sh + +eval "${GETPORT}" + +if test "$(id -u)" != "0";then + echo "This test must be run as root" + exit 77 +fi + +function finish { + set +e + echo " * Cleaning up..." + test -n "${PID}" && kill ${PID} >/dev/null 2>&1 + test -n "${PIDFILE}" && rm -f ${PIDFILE} >/dev/null 2>&1 + test -n "${CLIPID}" && test -f "${CLIPID}" && kill $(cat ${CLIPID}) >/dev/null 2>&1 + test -n "${CLIPID}" && rm -f ${CLIPID} >/dev/null 2>&1 + test -n "${CONFIG}" && rm -f ${CONFIG} >/dev/null 2>&1 + test -n "${TMPFILE}" && rm -f ${TMPFILE} >/dev/null 2>&1 + test -n "${OUTFILE}" && rm -f ${OUTFILE} >/dev/null 2>&1 +} +trap finish EXIT + +# server address +. `dirname $0`/random-net.sh +. `dirname $0`/ns.sh + +echo "Testing ocserv terminate commands via occtl... " + +# Run server +update_config disconnect-user.config +if test "$VERBOSE" = 1;then + DEBUG="-d 3" +fi + +${CMDNS2} ${SERV} -p ${PIDFILE} -f -c ${CONFIG} ${DEBUG} & PID=$! + +sleep 3 + +get_cookie() { + echo " * Getting cookie from ${ADDRESS}:${PORT}..." + ( echo "test" | ${CMDNS1} ${OPENCONNECT} ${ADDRESS}:${PORT} -u test --servercert=pin-sha256:xp3scfzy3rOQsv9NcOve/8YVVv+pHr4qNCXEXrNl5s8= --authenticate >${TMPFILE} ) + if test $? != 0;then + fail $PID "Could not get cookie from server" + fi + + unset COOKIE + eval $(cat ${TMPFILE}) + if test -z "${COOKIE}";then + fail $PID "Cookie was not returned by server" + fi +} + +connect_with_cookie() { + echo " * Connecting to ${ADDRESS}:${PORT}..." + rm -f ${CLIPID} + ( ${CMDNS1} ${OPENCONNECT} -q ${ADDRESS}:${PORT} -u test --servercert=pin-sha256:xp3scfzy3rOQsv9NcOve/8YVVv+pHr4qNCXEXrNl5s8= -s ${srcdir}/scripts/vpnc-script -C "${1}" --pid-file=${CLIPID} -b ) + if test $? != 0;then + fail $PID "Could not connect to server" + fi + + sleep 2 + if test ! -f "${CLIPID}";then + fail $PID "Connection did not create pid file" + fi +} + +assert_cookie_rejected() { + echo " * Re-connecting with old cookie (must fail)..." + rm -f ${CLIPID} + ( ${CMDNS1} ${OPENCONNECT} -q ${ADDRESS}:${PORT} -u test --servercert=pin-sha256:xp3scfzy3rOQsv9NcOve/8YVVv+pHr4qNCXEXrNl5s8= -s ${srcdir}/scripts/vpnc-script -C "${1}" --pid-file=${CLIPID} -b ) + if test $? = 0;then + fail $PID "Succeeded using invalidated cookie to reconnect" + fi + + sleep 2 + if test -f "${CLIPID}";then + fail $PID "Reconnection unexpectedly left an active client process" + fi +} + +# 1) terminate user +get_cookie +COOKIE_USER="${COOKIE}" +connect_with_cookie "${COOKIE_USER}" + +${OCCTL} -s ${OCCTL_SOCKET} terminate user test +if test $? != 0;then + fail $PID "terminate user failed" +fi + +assert_cookie_rejected "${COOKIE_USER}" + +# 2) terminate id +get_cookie +COOKIE_ID="${COOKIE}" +connect_with_cookie "${COOKIE_ID}" + +ID=$(${OCCTL} -s ${OCCTL_SOCKET} --json show user test 2>${OUTFILE} | jq -r '.[0].ID // empty') +if test -z "${ID}";then + fail $PID "Could not extract user ID from occtl output" +fi + +${OCCTL} -s ${OCCTL_SOCKET} terminate id ${ID} +if test $? != 0;then + fail $PID "terminate id failed" +fi + +assert_cookie_rejected "${COOKIE_ID}" + +# 3) terminate session +get_cookie +COOKIE_SESSION="${COOKIE}" +connect_with_cookie "${COOKIE_SESSION}" + +SID=$(${OCCTL} -s ${OCCTL_SOCKET} --json show sessions valid | jq -r '.[] | select((.Username // "") == "test") | .Session' | head -n 1) +if test -z "${SID}";then + SID=$(${OCCTL} -s ${OCCTL_SOCKET} --json show sessions valid | jq -r '.[0].Session // empty') +fi +if test -z "${SID}";then + fail $PID "Could not extract session ID from occtl output" +fi + +${OCCTL} -s ${OCCTL_SOCKET} terminate session ${SID} +if test $? != 0;then + fail $PID "terminate session (short SID) failed" +fi + +assert_cookie_rejected "${COOKIE_SESSION}" + +# 4) terminate session (for scripted use) +get_cookie +COOKIE_FULL="${COOKIE}" +connect_with_cookie "${COOKIE_FULL}" + +FULL_SID=$(${OCCTL} -s ${OCCTL_SOCKET} --json show sessions valid | jq -r '.[] | select((.Username // "") == "test") | ."Full session"' | head -n 1) +if test -z "${FULL_SID}";then + FULL_SID=$(${OCCTL} -s ${OCCTL_SOCKET} --json show sessions valid | jq -r '.[0]."Full session" // empty') +fi +if test -z "${FULL_SID}";then + fail $PID "Could not extract full session ID from occtl output" +fi + +${OCCTL} -s ${OCCTL_SOCKET} terminate session ${FULL_SID} +if test $? != 0;then + fail $PID "terminate session (full SID) failed" +fi + +assert_cookie_rejected "${COOKIE_FULL}" + +exit 0