mirror of
https://git.libssh.org/projects/libssh.git
synced 2026-02-04 12:20:42 +09:00
feat: implement proxy jump using libssh
tests: modify proxyjump tests to check for ssh_jump_info_struct tests: add proxyjump functionality test feat: add SSH_OPTIONS_PROXYJUMP tests: proxyjump, check authentication fix: ssh_socket_connect_proxyjump add exit label to exit on error feat: implement io forwarding using pthread feat: proxyjump: use threading instead of forking feat: proxyjump: cancel forwarding threads on ssh_disconnect fix: proxyjump remove ProxyJump bool and put pthread ifdefs feat: use ssh_event for io forwarding instead of threads reformat: tests to use assert_int_not_equal fix: link to pthread refactor: make function to free proxy jump list docs: add comment for proxy jump channel feat: add env variable to enable libssh proxy jump feat: open channel for proxyjump like OpenSSH feat: add more tests for proxy jump fix: use a global variable to close io forwarding, this prevents segfaults fix: handle proxy list in thread without creating copy Signed-off-by: Gauravsingh Sisodia <xaerru@gmail.com> Reviewed-by: Jakub Jelen <jjelen@redhat.com> Reviewed-by: Eshan Kelkar <eshankelkar@galorithm.com>
This commit is contained in:
committed by
Sahana Prasad
parent
fe53cdfabd
commit
6d1ed76c7a
@@ -18,6 +18,12 @@ set(TORTURE_LINK_LIBRARIES
|
||||
${CMOCKA_LIBRARY}
|
||||
ssh::static)
|
||||
|
||||
if (NOT WIN32)
|
||||
set(TORTURE_LINK_LIBRARIES
|
||||
${TORTURE_LINK_LIBRARIES}
|
||||
pthread)
|
||||
endif(NOT WIN32)
|
||||
|
||||
# create test library
|
||||
add_library(${TORTURE_LIBRARY}
|
||||
STATIC
|
||||
@@ -257,7 +263,7 @@ if (CLIENT_TESTING OR SERVER_TESTING)
|
||||
# ssh_ping
|
||||
add_executable(ssh_ping ssh_ping.c)
|
||||
target_compile_options(ssh_ping PRIVATE ${DEFAULT_C_COMPILE_FLAGS})
|
||||
target_link_libraries(ssh_ping ssh::static)
|
||||
target_link_libraries(ssh_ping ssh::static pthread)
|
||||
|
||||
# homedir will be used in passwd
|
||||
set(HOMEDIR ${CMAKE_CURRENT_BINARY_DIR}/home)
|
||||
|
||||
@@ -14,4 +14,4 @@ include_directories(${libssh_BINARY_DIR})
|
||||
|
||||
add_executable(benchmarks ${benchmarks_SRCS})
|
||||
|
||||
target_link_libraries(benchmarks ssh::static)
|
||||
target_link_libraries(benchmarks ssh::static pthread)
|
||||
|
||||
@@ -33,6 +33,12 @@ if (WITH_PKCS11_URI)
|
||||
torture_auth_pkcs11)
|
||||
endif()
|
||||
|
||||
if (HAVE_PTHREAD)
|
||||
set(LIBSSH_CLIENT_TESTS
|
||||
${LIBSSH_CLIENT_TESTS}
|
||||
torture_proxyjump)
|
||||
endif()
|
||||
|
||||
if (DEFAULT_C_NO_DEPRECATION_FLAGS)
|
||||
set_source_files_properties(torture_knownhosts.c
|
||||
PROPERTIES
|
||||
|
||||
@@ -93,7 +93,7 @@ static void torture_options_set_proxycommand(void **state)
|
||||
#ifdef WITH_EXEC
|
||||
assert_ssh_return_code(session, rc);
|
||||
fd = ssh_get_fd(session);
|
||||
assert_true(fd != SSH_INVALID_SOCKET);
|
||||
assert_int_not_equal(fd, SSH_INVALID_SOCKET);
|
||||
rc = fcntl(fd, F_GETFL);
|
||||
assert_int_equal(rc & O_RDWR, O_RDWR);
|
||||
#else
|
||||
@@ -145,7 +145,7 @@ static void torture_options_set_proxycommand_ssh(void **state)
|
||||
#ifdef WITH_EXEC
|
||||
assert_ssh_return_code(session, rc);
|
||||
fd = ssh_get_fd(session);
|
||||
assert_true(fd != SSH_INVALID_SOCKET);
|
||||
assert_int_not_equal(fd, SSH_INVALID_SOCKET);
|
||||
rc = fcntl(fd, F_GETFL);
|
||||
assert_int_equal(rc & O_RDWR, O_RDWR);
|
||||
#else
|
||||
@@ -176,7 +176,7 @@ static void torture_options_set_proxycommand_ssh_stderr(void **state)
|
||||
#ifdef WITH_EXEC
|
||||
assert_ssh_return_code(session, rc);
|
||||
fd = ssh_get_fd(session);
|
||||
assert_true(fd != SSH_INVALID_SOCKET);
|
||||
assert_int_not_equal(fd, SSH_INVALID_SOCKET);
|
||||
rc = fcntl(fd, F_GETFL);
|
||||
assert_int_equal(rc & O_RDWR, O_RDWR);
|
||||
#else
|
||||
|
||||
222
tests/client/torture_proxyjump.c
Normal file
222
tests/client/torture_proxyjump.c
Normal file
@@ -0,0 +1,222 @@
|
||||
#include "config.h"
|
||||
|
||||
#define LIBSSH_STATIC
|
||||
|
||||
#include "torture.h"
|
||||
#include <libssh/libssh.h>
|
||||
|
||||
#include <pwd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
static int
|
||||
sshd_setup(void **state)
|
||||
{
|
||||
torture_setup_sshd_server(state, false);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
sshd_teardown(void **state)
|
||||
{
|
||||
torture_teardown_sshd_server(state);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
session_setup(void **state)
|
||||
{
|
||||
struct torture_state *s = *state;
|
||||
int verbosity = torture_libssh_verbosity();
|
||||
struct passwd *pwd = NULL;
|
||||
int rc;
|
||||
bool b = false;
|
||||
|
||||
pwd = getpwnam("bob");
|
||||
assert_non_null(pwd);
|
||||
|
||||
rc = setuid(pwd->pw_uid);
|
||||
assert_return_code(rc, errno);
|
||||
|
||||
s->ssh.session = ssh_new();
|
||||
assert_non_null(s->ssh.session);
|
||||
|
||||
rc = ssh_options_set(s->ssh.session, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
|
||||
assert_ssh_return_code(s->ssh.session, rc);
|
||||
rc = ssh_options_set(s->ssh.session, SSH_OPTIONS_HOST, TORTURE_SSH_SERVER);
|
||||
assert_ssh_return_code(s->ssh.session, rc);
|
||||
rc = ssh_options_set(s->ssh.session,
|
||||
SSH_OPTIONS_USER,
|
||||
TORTURE_SSH_USER_ALICE);
|
||||
assert_ssh_return_code(s->ssh.session, rc);
|
||||
|
||||
/* Make sure no other configuration options from system will get used */
|
||||
rc = ssh_options_set(s->ssh.session, SSH_OPTIONS_PROCESS_CONFIG, &b);
|
||||
assert_ssh_return_code(s->ssh.session, rc);
|
||||
|
||||
unsetenv("SSH_AUTH_SOCK");
|
||||
unsetenv("SSH_AGENT_PID");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
session_teardown(void **state)
|
||||
{
|
||||
struct torture_state *s = *state;
|
||||
|
||||
ssh_disconnect(s->ssh.session);
|
||||
ssh_free(s->ssh.session);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void
|
||||
torture_proxyjump_single_jump(void **state)
|
||||
{
|
||||
struct torture_state *s = *state;
|
||||
ssh_session session = s->ssh.session;
|
||||
char proxyjump_buf[500] = {0};
|
||||
const char *address = torture_server_address(AF_INET);
|
||||
int rc;
|
||||
socket_t fd;
|
||||
|
||||
rc = snprintf(proxyjump_buf, sizeof(proxyjump_buf), "alice@%s:22", address);
|
||||
if (rc < 0 || rc >= (int)sizeof(proxyjump_buf)) {
|
||||
fail_msg("snprintf failed");
|
||||
}
|
||||
rc = ssh_options_set(session, SSH_OPTIONS_PROXYJUMP, proxyjump_buf);
|
||||
assert_ssh_return_code(session, rc);
|
||||
|
||||
rc = ssh_connect(session);
|
||||
assert_ssh_return_code(session, rc);
|
||||
|
||||
fd = ssh_get_fd(session);
|
||||
assert_int_not_equal(fd, SSH_INVALID_SOCKET);
|
||||
|
||||
rc = fcntl(fd, F_GETFL);
|
||||
assert_int_equal(rc & O_RDWR, O_RDWR);
|
||||
|
||||
rc = ssh_userauth_publickey_auto(session, NULL, NULL);
|
||||
assert_int_equal(rc, SSH_AUTH_SUCCESS);
|
||||
}
|
||||
|
||||
static int
|
||||
before_connection(ssh_session jump_session, void *user)
|
||||
{
|
||||
(void)jump_session;
|
||||
(void)user;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
verify_knownhost(ssh_session jump_session, void *user)
|
||||
{
|
||||
(void)jump_session;
|
||||
(void)user;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int
|
||||
authenticate(ssh_session jump_session, void *user)
|
||||
{
|
||||
(void)user;
|
||||
|
||||
return ssh_userauth_publickey_auto(jump_session, NULL, NULL);
|
||||
}
|
||||
|
||||
static void
|
||||
torture_proxyjump_multiple_jump(void **state)
|
||||
{
|
||||
struct torture_state *s = *state;
|
||||
ssh_session session = s->ssh.session;
|
||||
char proxyjump_buf[500] = {0};
|
||||
const char *address = torture_server_address(AF_INET);
|
||||
int rc;
|
||||
socket_t fd;
|
||||
|
||||
struct ssh_jump_callbacks_struct c = {
|
||||
.before_connection = before_connection,
|
||||
.verify_knownhost = verify_knownhost,
|
||||
.authenticate = authenticate
|
||||
};
|
||||
|
||||
rc = snprintf(proxyjump_buf,
|
||||
sizeof(proxyjump_buf),
|
||||
"alice@%s:22,alice@%s:22",
|
||||
address,
|
||||
address);
|
||||
if (rc < 0 || rc >= (int)sizeof(proxyjump_buf)) {
|
||||
fail_msg("snprintf failed");
|
||||
}
|
||||
rc = ssh_options_set(session, SSH_OPTIONS_PROXYJUMP, proxyjump_buf);
|
||||
assert_ssh_return_code(session, rc);
|
||||
rc = ssh_options_set(session, SSH_OPTIONS_PROXYJUMP_CB_LIST_APPEND, &c);
|
||||
assert_ssh_return_code(session, rc);
|
||||
rc = ssh_options_set(session, SSH_OPTIONS_PROXYJUMP_CB_LIST_APPEND, &c);
|
||||
assert_ssh_return_code(session, rc);
|
||||
|
||||
rc = ssh_connect(session);
|
||||
assert_ssh_return_code(session, rc);
|
||||
|
||||
fd = ssh_get_fd(session);
|
||||
assert_int_not_equal(fd, SSH_INVALID_SOCKET);
|
||||
|
||||
rc = fcntl(fd, F_GETFL);
|
||||
assert_int_equal(rc & O_RDWR, O_RDWR);
|
||||
|
||||
rc = ssh_userauth_publickey_auto(session, NULL, NULL);
|
||||
assert_int_equal(rc, SSH_AUTH_SUCCESS);
|
||||
}
|
||||
|
||||
static void
|
||||
torture_proxyjump_invalid_jump(void **state)
|
||||
{
|
||||
struct torture_state *s = *state;
|
||||
ssh_session session = s->ssh.session;
|
||||
char proxyjump_buf[500] = {0};
|
||||
const char *address = torture_server_address(AF_INET);
|
||||
int rc;
|
||||
|
||||
rc = snprintf(proxyjump_buf,
|
||||
sizeof(proxyjump_buf),
|
||||
"doesnotexist@%s:54",
|
||||
address);
|
||||
if (rc < 0 || rc >= (int)sizeof(proxyjump_buf)) {
|
||||
fail_msg("snprintf failed");
|
||||
}
|
||||
rc = ssh_options_set(session, SSH_OPTIONS_PROXYJUMP, proxyjump_buf);
|
||||
assert_ssh_return_code(session, rc);
|
||||
|
||||
rc = ssh_connect(session);
|
||||
assert_ssh_return_code_equal(session, rc, SSH_ERROR);
|
||||
}
|
||||
|
||||
int
|
||||
torture_run_tests(void)
|
||||
{
|
||||
int rc;
|
||||
struct CMUnitTest tests[] = {
|
||||
cmocka_unit_test_setup_teardown(torture_proxyjump_single_jump,
|
||||
session_setup,
|
||||
session_teardown),
|
||||
cmocka_unit_test_setup_teardown(torture_proxyjump_multiple_jump,
|
||||
session_setup,
|
||||
session_teardown),
|
||||
cmocka_unit_test_setup_teardown(torture_proxyjump_invalid_jump,
|
||||
session_setup,
|
||||
session_teardown),
|
||||
};
|
||||
|
||||
ssh_init();
|
||||
|
||||
torture_filter_tests(tests);
|
||||
rc = cmocka_run_group_tests(tests, sshd_setup, sshd_teardown);
|
||||
ssh_finalize();
|
||||
|
||||
return rc;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ macro(fuzzer name)
|
||||
add_executable(${name} ${name}.c)
|
||||
target_link_libraries(${name}
|
||||
PRIVATE
|
||||
ssh::static)
|
||||
ssh::static pthread)
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
|
||||
set_target_properties(${name}
|
||||
PROPERTIES
|
||||
|
||||
@@ -1653,6 +1653,9 @@ void torture_write_file(const char *filename, const char *data){
|
||||
void torture_reset_config(ssh_session session)
|
||||
{
|
||||
memset(session->opts.options_seen, 0, sizeof(session->opts.options_seen));
|
||||
if (ssh_libssh_proxy_jumps()) {
|
||||
ssh_proxyjumps_free(session->opts.proxy_jumps);
|
||||
}
|
||||
}
|
||||
|
||||
void torture_unsetenv(const char *variable)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "match.c"
|
||||
#include "config.c"
|
||||
#include "libssh/socket.h"
|
||||
#include "libssh/misc.h"
|
||||
|
||||
extern LIBSSH_THREAD int ssh_log_level;
|
||||
|
||||
@@ -691,6 +692,34 @@ static void torture_config_auth_methods_string(void **state)
|
||||
torture_config_auth_methods(state, NULL, LIBSSH_TESTCONFIG_STRING8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Helper for checking hostname, username and port of ssh_jump_info_struct
|
||||
*/
|
||||
static void
|
||||
helper_proxy_jump_check(struct ssh_iterator *jump,
|
||||
const char *hostname,
|
||||
const char *username,
|
||||
const char *port)
|
||||
{
|
||||
struct ssh_jump_info_struct *jis =
|
||||
ssh_iterator_value(struct ssh_jump_info_struct *, jump);
|
||||
|
||||
assert_string_equal(jis->hostname, hostname);
|
||||
|
||||
if (username != NULL) {
|
||||
assert_string_equal(jis->username, username);
|
||||
} else {
|
||||
assert_null(jis->username);
|
||||
}
|
||||
|
||||
if (port != NULL) {
|
||||
int iport = strtol(port, NULL, 10);
|
||||
assert_int_equal(jis->port, iport);
|
||||
} else {
|
||||
assert_int_equal(jis->port, 22);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Verify the configuration parser does not choke on unknown
|
||||
* or unsupported configuration options
|
||||
@@ -702,15 +731,18 @@ static void torture_config_unknown(void **state,
|
||||
int ret = 0;
|
||||
|
||||
/* test corner cases */
|
||||
/* Without libssh proxy jump */
|
||||
torture_setenv("OPENSSH_PROXYJUMP", "1");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
assert_string_equal(session->opts.ProxyCommand,
|
||||
"ssh -W '[%h]:%p' many-spaces.com");
|
||||
"ssh -W '[%h]:%p' many-spaces.com");
|
||||
assert_string_equal(session->opts.host, "equal.sign");
|
||||
|
||||
ret = ssh_config_parse_file(session, "/etc/ssh/ssh_config");
|
||||
assert_true(ret == 0);
|
||||
ret = ssh_config_parse_file(session, GLOBAL_CLIENT_CONFIG);
|
||||
assert_true(ret == 0);
|
||||
torture_unsetenv("OPENSSH_PROXYJUMP");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -994,8 +1026,89 @@ static void torture_config_proxyjump(void **state,
|
||||
const char *file, const char *string)
|
||||
{
|
||||
ssh_session session = *state;
|
||||
|
||||
const char *config;
|
||||
|
||||
|
||||
/* Tests for libssh based proxyjump */
|
||||
/* Simplest version with just a hostname */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "simple");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
/* With username */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "user");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
"user",
|
||||
NULL);
|
||||
|
||||
/* With port */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "port");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
NULL,
|
||||
"2222");
|
||||
|
||||
/* Two step jump */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "two-step");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"second",
|
||||
"u2",
|
||||
"33");
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root->next,
|
||||
"first",
|
||||
"u1",
|
||||
"222");
|
||||
|
||||
/* none */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "none");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
assert_int_equal(ssh_list_count(session->opts.proxy_jumps), 0);
|
||||
|
||||
/* If also ProxyCommand is specified, the first is applied */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "only-command");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
assert_string_equal(session->opts.ProxyCommand, PROXYCMD);
|
||||
assert_int_equal(ssh_list_count(session->opts.proxy_jumps), 0);
|
||||
|
||||
/* If also ProxyCommand is specified, the first is applied */
|
||||
torture_reset_config(session);
|
||||
SAFE_FREE(session->opts.ProxyCommand);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "only-jump");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
assert_null(session->opts.ProxyCommand);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
/* IPv6 address */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "ipv6");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"2620:52:0::fed",
|
||||
NULL,
|
||||
NULL);
|
||||
|
||||
torture_reset_config(session);
|
||||
|
||||
/* Tests for proxycommand based proxyjump */
|
||||
torture_setenv("OPENSSH_PROXYJUMP", "1");
|
||||
|
||||
/* Simplest version with just a hostname */
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "simple");
|
||||
@@ -1049,6 +1162,7 @@ static void torture_config_proxyjump(void **state,
|
||||
assert_string_equal(session->opts.ProxyCommand,
|
||||
"ssh -W '[%h]:%p' 2620:52:0::fed");
|
||||
|
||||
|
||||
/* Multiple @ is allowed in second jump */
|
||||
config = "Host allowed-hostname\n"
|
||||
"\tProxyJump localhost,user@principal.com@jumpbox:22\n";
|
||||
@@ -1076,8 +1190,44 @@ static void torture_config_proxyjump(void **state,
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
assert_string_equal(session->opts.ProxyCommand,
|
||||
"ssh -l user@principal.com -p 22 -W '[%h]:%p' jumpbox");
|
||||
torture_unsetenv("OPENSSH_PROXYJUMP");
|
||||
|
||||
/* Tests for libssh based proxyjump */
|
||||
/* Multiple @ is allowed in second jump */
|
||||
config = "Host allowed-hostname\n"
|
||||
"\tProxyJump localhost,user@principal.com@jumpbox:22\n";
|
||||
if (file != NULL) {
|
||||
torture_write_file(file, config);
|
||||
} else {
|
||||
string = config;
|
||||
}
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "allowed-hostname");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
"user@principal.com",
|
||||
"22");
|
||||
|
||||
/* Multiple @ is allowed */
|
||||
config = "Host allowed-hostname\n"
|
||||
"\tProxyJump user@principal.com@jumpbox:22\n";
|
||||
if (file != NULL) {
|
||||
torture_write_file(file, config);
|
||||
} else {
|
||||
string = config;
|
||||
}
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "allowed-hostname");
|
||||
_parse_config(session, file, string, SSH_OK);
|
||||
helper_proxy_jump_check(session->opts.proxy_jumps->root,
|
||||
"jumpbox",
|
||||
"user@principal.com",
|
||||
"22");
|
||||
torture_reset_config(session);
|
||||
|
||||
/* In this part, we try various other config files and strings. */
|
||||
torture_setenv("OPENSSH_PROXYJUMP", "1");
|
||||
|
||||
/* Try to create some invalid configurations */
|
||||
/* Non-numeric port */
|
||||
@@ -1223,6 +1373,8 @@ static void torture_config_proxyjump(void **state,
|
||||
torture_reset_config(session);
|
||||
ssh_options_set(session, SSH_OPTIONS_HOST, "no-port");
|
||||
_parse_config(session, file, string, SSH_ERROR);
|
||||
|
||||
torture_unsetenv("OPENSSH_PROXYJUMP");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user