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