Extend browser admin lifecycle actions
Support browser reboot and HTTPS stop through deferred control, plus exact `web certificate rotate --force` handoff to the dispatcher. Add typed request validation and focused boundary and lifecycle coverage.
This commit is contained in:
+82
-14
@@ -70,9 +70,17 @@ typedef struct {
|
||||
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
|
||||
} admin_session_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_REQUEST_SSH = 0,
|
||||
ADMIN_REQUEST_UART0,
|
||||
ADMIN_REQUEST_DEFERRED,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -80,16 +88,12 @@ typedef struct {
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
TaskHandle_t completion_task;
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
union {
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
admin_control_request_t deferred;
|
||||
};
|
||||
} admin_request_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
||||
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
|
||||
@@ -122,6 +126,12 @@ bool admin_ssh_console_dispatch_is_remote(void)
|
||||
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_web(void)
|
||||
{
|
||||
return admin_ssh_console_dispatch_is_remote() &&
|
||||
s_dispatch_token.transport == ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||
}
|
||||
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||
{
|
||||
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
|
||||
@@ -401,7 +411,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
bool valid = token_matches(session, &s_dispatch_token) &&
|
||||
!session->deferred_action_pending;
|
||||
const admin_console_owner_t *owner = valid ? session->owner : NULL;
|
||||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_SELF_CLOSE ||
|
||||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE ||
|
||||
!(owner->supported_actions & (1U << action)))) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
@@ -496,8 +506,12 @@ static bool remote_command_allowed(const admin_request_t *request)
|
||||
* User mutations remain available through UART0/SSH, subject to their policy.
|
||||
*/
|
||||
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
||||
if (strcmp(argv[0], "web") == 0 || strcmp(argv[0], "wifi") == 0 ||
|
||||
strcmp(argv[0], "mdns") == 0) {
|
||||
if (strcmp(argv[0], "web") == 0) {
|
||||
allowed = (argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0)) ||
|
||||
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
|
||||
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
|
||||
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
|
||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||
} else if (strcmp(argv[0], "user") == 0) {
|
||||
allowed = argc == 1U ||
|
||||
@@ -505,9 +519,9 @@ static bool remote_command_allowed(const admin_request_t *request)
|
||||
strcmp(argv[1], "list") == 0)) ||
|
||||
(argc == 3U && strcmp(argv[1], "show") == 0);
|
||||
} else if (strcmp(argv[0], "reboot") == 0) {
|
||||
allowed = false;
|
||||
allowed = argc == 1U;
|
||||
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
||||
/* These handlers defer for every remote; WEB supports SELF_CLOSE only. */
|
||||
/* SSH-specific deferred actions are not yet supported by WEB. */
|
||||
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0 ||
|
||||
(strcmp(argv[1], "host-key") == 0 &&
|
||||
@@ -598,6 +612,8 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
}
|
||||
}
|
||||
|
||||
static void dispatch_deferred_request(admin_request_t *request);
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -606,6 +622,11 @@ static void worker_task(void *context)
|
||||
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_DEFERRED) {
|
||||
dispatch_deferred_request(&request);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_UART0) {
|
||||
dispatch_registered_command(&request);
|
||||
if (request.completion_task != NULL) {
|
||||
@@ -683,6 +704,34 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void dispatch_deferred_request(admin_request_t *request)
|
||||
{
|
||||
const admin_control_request_t *action = &request->deferred;
|
||||
bool current = session_is_current(&action->token, &request->principal);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[action->token.slot_index];
|
||||
bool active = current && token_matches(session, &action->token) &&
|
||||
session->owner == action->owner && session->deferred_action_pending &&
|
||||
!session->command_pending && !session->executing;
|
||||
if (active) session->executing = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
/* Keep the slot reserved across lifecycle callbacks, including self-detach.
|
||||
* Recheck after reservation just as the canonical runner does. */
|
||||
esp_err_t result = ESP_ERR_NOT_FOUND;
|
||||
if (active && session_is_current(&action->token, &request->principal)) {
|
||||
result = action->owner->perform(&action->token, action->action, action->argument);
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[action->token.slot_index];
|
||||
if (active && token_identity_matches(session, &action->token)) {
|
||||
if (session->active) session->executing = false;
|
||||
else secure_wipe(session, sizeof(*session));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
finish_deferred_request(action, result, false);
|
||||
}
|
||||
|
||||
static void control_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -718,8 +767,26 @@ static void control_task(void *context)
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(200U));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool current = token_matches(&s_sessions[request.token.slot_index], &request.token);
|
||||
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||
bool current = token_matches(session, &request.token) &&
|
||||
session->owner == request.owner && session->deferred_action_pending;
|
||||
admin_request_t queued = {
|
||||
.origin = ADMIN_REQUEST_DEFERRED,
|
||||
.deferred = request,
|
||||
};
|
||||
if (current) queued.principal = session->principal;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (current && (request.owner->dispatcher_actions & (1U << request.action))) {
|
||||
/* Nonblocking handoff: a full dispatcher queue fails before mutation.
|
||||
* Pending remains set until execution completes, not merely enqueue. */
|
||||
if (xQueueSend(s_request_queue, &queued, 0U) != pdTRUE) {
|
||||
finish_deferred_request(&request, ESP_ERR_TIMEOUT, false);
|
||||
}
|
||||
secure_wipe(&queued, sizeof(queued));
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
secure_wipe(&queued, sizeof(queued));
|
||||
esp_err_t result = current ? request.owner->perform(
|
||||
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
|
||||
finish_deferred_request(&request, result, false);
|
||||
@@ -1266,6 +1333,7 @@ esp_err_t admin_ssh_console_get_session_snapshot(
|
||||
snapshot->command_pending = session->command_pending;
|
||||
snapshot->input_pending = session->input_length != 0U;
|
||||
snapshot->output_pending = session->output_length != 0U;
|
||||
snapshot->deferred_action_pending = session->deferred_action_pending;
|
||||
snapshot->input_length = session->input_length;
|
||||
snapshot->output_length = session->output_length;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
+14
-2
@@ -35,6 +35,8 @@ typedef enum {
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
ADMIN_CONSOLE_DEFER_WEB_STOP,
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
} admin_ssh_deferred_action_type_t;
|
||||
|
||||
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
||||
@@ -44,7 +46,10 @@ typedef enum {
|
||||
* An occupied or still-executing slot cannot be replaced by open_owned().
|
||||
*
|
||||
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
||||
* control task OUTSIDE console locks for drained/perform. Required is_current
|
||||
* control task OUTSIDE console locks for drained/perform, except perform actions
|
||||
* selected by dispatcher_actions run serialized on the existing 12KiB dispatcher
|
||||
* after drain/delay and queued identity/principal revalidation (no command replay).
|
||||
* Zero dispatcher_actions preserves legacy control-task execution. Required is_current
|
||||
* runs on the dispatcher outside console locks; it must be bounded and validate
|
||||
* full transport identity, originating-session liveness and principal binding,
|
||||
* without calling socket libraries or handlers. Core separately checks accounts.
|
||||
@@ -53,7 +58,9 @@ typedef enum {
|
||||
* its owner, never call socket libraries here. Neither callback may call console
|
||||
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
||||
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
||||
* SELF_CLOSE means this frontend, with argument ignored.
|
||||
* SELF_CLOSE means this frontend; WEB_STOP means HTTPS, not SSH.
|
||||
* WEB_CERTIFICATE_ROTATE replaces the HTTPS identity and restarts HTTPS.
|
||||
* These WEB actions and SELF_CLOSE ignore argument.
|
||||
*
|
||||
* One owner serializes feed calls per session; different owners may feed in
|
||||
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
||||
@@ -66,10 +73,13 @@ typedef enum {
|
||||
* Close wakes prompts; executing state is retained until the handler returns.
|
||||
* Output remains bounded (5s write backpressure); deferred work waits at most
|
||||
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
|
||||
* Dispatcher actions then wait behind queued commands/prompts, with input gated
|
||||
* until completion or cancellation; the drain bound is not an execution deadline.
|
||||
* No new tasks, queues, slots, or browser endpoint are provided by this API.
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t supported_actions;
|
||||
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
|
||||
bool (*is_current)(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
bool (*drained)(const admin_ssh_console_token_t *token);
|
||||
@@ -95,6 +105,7 @@ typedef struct {
|
||||
bool command_pending;
|
||||
bool input_pending;
|
||||
bool output_pending;
|
||||
bool deferred_action_pending;
|
||||
size_t input_length;
|
||||
size_t output_length;
|
||||
} admin_ssh_console_session_snapshot_t;
|
||||
@@ -108,6 +119,7 @@ esp_err_t admin_ssh_console_start_uart_frontend(void);
|
||||
|
||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
bool admin_ssh_console_dispatch_is_web(void);
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||
esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
const char *prompt, uint8_t *output, size_t capacity,
|
||||
|
||||
@@ -54,7 +54,7 @@ static int command_reboot(int argc, char **argv)
|
||||
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
|
||||
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||
return 0;
|
||||
}
|
||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_server.h"
|
||||
#include "web_security.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "secure_random.h"
|
||||
@@ -91,9 +94,34 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument)
|
||||
{
|
||||
(void)argument;
|
||||
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE) return ESP_ERR_NOT_SUPPORTED;
|
||||
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE && action != ADMIN_SSH_DEFER_REBOOT &&
|
||||
action != ADMIN_CONSOLE_DEFER_WEB_STOP &&
|
||||
action != ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) return ESP_ERR_NOT_SUPPORTED;
|
||||
/* Drain is only a delivery heuristic, not an authorization lease. The
|
||||
* execution task must recheck cookie/account binding after delay/queueing. */
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_slot.active && s_accepting;
|
||||
user_principal_t principal = s_slot.principal;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool current = owner_current(token, &principal);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (!current) return ESP_ERR_NOT_FOUND;
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) {
|
||||
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
|
||||
* Commit before restart; a failed stop retains HTTPD ownership and must
|
||||
* not be followed by start. No socket IO or console handler calls here. */
|
||||
esp_err_t error = web_security_rotate_certificate();
|
||||
if (error != ESP_OK) return error;
|
||||
error = web_server_stop();
|
||||
if (error != ESP_OK) return error;
|
||||
return web_server_start();
|
||||
}
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_slot.active && s_accepting && !s_slot.close_requested;
|
||||
if (valid) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (valid) admin_ssh_console_close(token);
|
||||
@@ -101,7 +129,10 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
|
||||
static const admin_console_owner_t s_owner = {
|
||||
.supported_actions = 1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
.supported_actions = (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE) |
|
||||
(1U << ADMIN_SSH_DEFER_REBOOT) | (1U << ADMIN_CONSOLE_DEFER_WEB_STOP) |
|
||||
(1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE),
|
||||
.dispatcher_actions = 1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
|
||||
};
|
||||
|
||||
@@ -152,11 +183,29 @@ static bool input_current(const admin_ssh_console_token_t *token,
|
||||
return false;
|
||||
}
|
||||
|
||||
static void discard_pending_input(void)
|
||||
{
|
||||
secure_wipe(s_payload->rx, sizeof(s_payload->rx));
|
||||
s_payload->rx_offset = s_payload->rx_length = 0;
|
||||
s_payload->input_deadline = 0;
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
}
|
||||
|
||||
static bool feed_pending(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (s_payload->rx_offset == s_payload->rx_length) return true;
|
||||
if (!input_current(token, principal)) return false;
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(token, &console) != ESP_OK || !console.active) {
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
if (console.deferred_action_pending) {
|
||||
/* Never replay buffered keystrokes if a deferred action fails/cancels. */
|
||||
discard_pending_input();
|
||||
return true;
|
||||
}
|
||||
if (esp_timer_get_time() >= s_payload->input_deadline) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
request_close();
|
||||
@@ -425,9 +474,19 @@ static esp_err_t frame_handler(httpd_req_t *request)
|
||||
count(&s_counts.protocol_errors, 1);
|
||||
goto failure;
|
||||
}
|
||||
if (s_payload->rx_length != s_payload->rx_offset) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
|
||||
goto failure;
|
||||
/* Latch before the potentially blocking receive: cancellation during receive
|
||||
* must not turn input observed during deferral into a new command. */
|
||||
bool discard_frame = console.deferred_action_pending;
|
||||
if (s_payload->rx_length != s_payload->rx_offset) {
|
||||
if (!discard_frame) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
goto failure;
|
||||
}
|
||||
/* Deferral may start before the next poll discards buffered trailing input. */
|
||||
discard_pending_input();
|
||||
}
|
||||
frame.payload = s_payload->rx;
|
||||
/* IDF treats len==0 as another header probe, not an empty payload read. */
|
||||
@@ -436,7 +495,8 @@ static esp_err_t frame_handler(httpd_req_t *request)
|
||||
s_payload->rx_length = frame.len;
|
||||
s_payload->rx_offset = 0;
|
||||
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
|
||||
if (!feed_pending(&token, &principal)) goto failure;
|
||||
if (discard_frame) discard_pending_input();
|
||||
else if (!feed_pending(&token, &principal)) goto failure;
|
||||
count(&s_counts.rx_bytes, (uint32_t)frame.len);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return ESP_OK;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
|
||||
|
||||
#include "web_console.h"
|
||||
#include "admin_ssh_console.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
@@ -394,6 +395,15 @@ static int command_web(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP, 0U);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS stop: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS stop scheduled after console output drains; both browser connections will close.\n");
|
||||
return 0;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
|
||||
@@ -439,6 +449,16 @@ static int command_web(int argc, char **argv)
|
||||
printf("Certificate rotation requires: web certificate rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0U);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
|
||||
return 0;
|
||||
}
|
||||
return rotate_certificate();
|
||||
}
|
||||
if (strcmp(argv[1], "reset") == 0) {
|
||||
|
||||
Reference in New Issue
Block a user