Merge pull request '33-lazy-ssh-agent' (#46) from 33-lazy-ssh-agent into master
Reviewed-on: #46
This commit is contained in:
commit
aa8a624360
10
Cargo.toml
10
Cargo.toml
@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "multi-ssh"
|
name = "multi-ssh"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.23", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
lazy-regex = "3.3.0"
|
lazy-regex = "3.6.1"
|
||||||
shell-words = "1.1.0"
|
shell-words = "1.1.1"
|
||||||
homedir = "0.3.4"
|
homedir = "0.3.6"
|
||||||
14
flake.lock
14
flake.lock
@ -2,16 +2,16 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1759580034,
|
"lastModified": 1787962033,
|
||||||
"narHash": "sha256-YWo57PL7mGZU7D4WeKFMiW4ex/O6ZolUS6UNBHTZfkI=",
|
"narHash": "sha256-u6z9VTZA4Kf3RkHQo9sQI7NI4Ei/uiU9vrMqOiwWP1Y=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3bcc93c5f7a4b30335d31f21e2f1281cba68c318",
|
"rev": "c5c4a43b0e8056328ec4529f735cabdb8f1942bb",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-25.05",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@ -43,11 +43,11 @@
|
|||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1759631821,
|
"lastModified": 1788165049,
|
||||||
"narHash": "sha256-V8A1L0FaU/aSXZ1QNJScxC12uP4hANeRBgI4YdhHeRM=",
|
"narHash": "sha256-en4IoUeCqvq9F66YhwOrUFw1nc70OBhrJwrzfalezvY=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "1d7cbdaad90f8a5255a89a6eddd8af24dc89cafe",
|
"rev": "d03cd474bd97389dcc2e8cd3b3bb6b8c6e346b1a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh";
|
description = "Flake of https://dev.stupstech.de/Mr_Steppy/multi-ssh";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use crate::shell_interface::{
|
use crate::shell_interface::{CommandOutput, CommandResult, ExitStatus, ShellCommand,
|
||||||
build_command_from_shell_command, CommandOutput, CommandResult, ExitStatus, ShellCommand,
|
|
||||||
ShellInterface, StartError,
|
ShellInterface, StartError,
|
||||||
|
build_command_from_shell_command,
|
||||||
};
|
};
|
||||||
use std::env::VarError;
|
use std::env::VarError;
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
@ -27,10 +27,14 @@ pub trait Environment {
|
|||||||
V: AsRef<OsStr>;
|
V: AsRef<OsStr>;
|
||||||
fn get_home_directory(&self) -> Option<PathBuf>;
|
fn get_home_directory(&self) -> Option<PathBuf>;
|
||||||
fn read_line(&mut self) -> Result<String, io::Error>;
|
fn read_line(&mut self) -> Result<String, io::Error>;
|
||||||
|
fn is_ssh_agent_started(&self) -> bool;
|
||||||
|
fn set_ssh_agent_started(&mut self, enabled: bool);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Prod;
|
pub struct Prod {
|
||||||
|
ssh_agent_started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Environment for Prod {
|
impl Environment for Prod {
|
||||||
fn args_os(&self) -> Vec<OsString> {
|
fn args_os(&self) -> Vec<OsString> {
|
||||||
@ -39,7 +43,7 @@ impl Environment for Prod {
|
|||||||
|
|
||||||
fn var_os<K>(&self, key: K) -> Option<OsString>
|
fn var_os<K>(&self, key: K) -> Option<OsString>
|
||||||
where
|
where
|
||||||
K: AsRef<OsStr>
|
K: AsRef<OsStr>,
|
||||||
{
|
{
|
||||||
env::var_os(key)
|
env::var_os(key)
|
||||||
}
|
}
|
||||||
@ -56,8 +60,11 @@ impl Environment for Prod {
|
|||||||
K: AsRef<OsStr>,
|
K: AsRef<OsStr>,
|
||||||
V: AsRef<OsStr>,
|
V: AsRef<OsStr>,
|
||||||
{
|
{
|
||||||
|
unsafe {
|
||||||
|
//multi-ssh is single threaded
|
||||||
env::set_var(key, value);
|
env::set_var(key, value);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_home_directory(&self) -> Option<PathBuf> {
|
fn get_home_directory(&self) -> Option<PathBuf> {
|
||||||
homedir::my_home().ok().flatten()
|
homedir::my_home().ok().flatten()
|
||||||
@ -68,6 +75,14 @@ impl Environment for Prod {
|
|||||||
io::stdin().read_line(&mut buffer)?;
|
io::stdin().read_line(&mut buffer)?;
|
||||||
Ok(buffer.trim().to_string())
|
Ok(buffer.trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_ssh_agent_started(&self) -> bool {
|
||||||
|
self.ssh_agent_started
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_ssh_agent_started(&mut self, enabled: bool) {
|
||||||
|
self.ssh_agent_started = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShellInterface for Prod {
|
impl ShellInterface for Prod {
|
||||||
|
|||||||
@ -74,6 +74,14 @@ impl Environment for TestEnvironment {
|
|||||||
fn read_line(&mut self) -> Result<String, Error> {
|
fn read_line(&mut self) -> Result<String, Error> {
|
||||||
self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared"))
|
self.stdin.pop_front().ok_or_else(|| Error::other("Unexpected call to read_line: No input prepared"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_ssh_agent_started(&self) -> bool {
|
||||||
|
self.ssh_agent_started
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_ssh_agent_started(&mut self, enabled: bool) {
|
||||||
|
self.ssh_agent_started = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShellInterface for TestEnvironment {
|
impl ShellInterface for TestEnvironment {
|
||||||
|
|||||||
106
src/main.rs
106
src/main.rs
@ -18,7 +18,7 @@ use crate::os_string_builder::ReplaceWithOsStr;
|
|||||||
use crate::server::{RelativeLocalPathAnker, ServerAddress};
|
use crate::server::{RelativeLocalPathAnker, ServerAddress};
|
||||||
use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface};
|
use crate::shell_interface::{ScpParam, ServerCommand, ShellCommand, ShellInterface};
|
||||||
use clap::{Parser, Subcommand, ValueEnum};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
use lazy_regex::{lazy_regex, Lazy, Regex};
|
use lazy_regex::{Lazy, Regex, lazy_regex};
|
||||||
use server::{Server, ServerReference};
|
use server::{Server, ServerReference};
|
||||||
use std::cell::LazyCell;
|
use std::cell::LazyCell;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
@ -170,6 +170,30 @@ where
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
macro_rules! start_ssh_agent {
|
||||||
|
() => {
|
||||||
|
self.start_ssh_agent(&logger)?;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! ssh {
|
||||||
|
($($field:ident $(: $value:expr)?$(,)? )*) => {{
|
||||||
|
start_ssh_agent!();
|
||||||
|
ShellCommand::Ssh {
|
||||||
|
$( $field $(: $value)? ),*
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! scp {
|
||||||
|
($($field:ident $(: $value:expr)?$(,)? )*) => {{
|
||||||
|
start_ssh_agent!();
|
||||||
|
ShellCommand::Scp {
|
||||||
|
$( $field $(: $value)? ),*
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env());
|
let mut configured_servers = LazyCell::new(|| self.parse_server_configuration_from_env());
|
||||||
let servers = args
|
let servers = args
|
||||||
.servers
|
.servers
|
||||||
@ -223,8 +247,6 @@ where
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.start_ssh_agent(&logger)?;
|
|
||||||
|
|
||||||
//make sure files exist
|
//make sure files exist
|
||||||
match &file_server {
|
match &file_server {
|
||||||
Some(file_server) => match &file_server.address {
|
Some(file_server) => match &file_server.address {
|
||||||
@ -233,7 +255,7 @@ where
|
|||||||
files = files
|
files = files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|file| {
|
.map(|file| {
|
||||||
let output = ShellCommand::Ssh {
|
let output = ssh! {
|
||||||
address: ssh_address.to_string(),
|
address: ssh_address.to_string(),
|
||||||
server_command: ServerCommand::Realpath {
|
server_command: ServerCommand::Realpath {
|
||||||
path: file_server.server_directory_path.join(file),
|
path: file_server.server_directory_path.join(file),
|
||||||
@ -313,7 +335,7 @@ where
|
|||||||
server,
|
server,
|
||||||
actions: {
|
actions: {
|
||||||
let present_file_names: Vec<OsString> = match &server.address {
|
let present_file_names: Vec<OsString> = match &server.address {
|
||||||
ServerAddress::Ssh { ssh_address } => ShellCommand::Ssh {
|
ServerAddress::Ssh { ssh_address } => ssh! {
|
||||||
address: ssh_address.to_string(),
|
address: ssh_address.to_string(),
|
||||||
server_command: ServerCommand::Ls {
|
server_command: ServerCommand::Ls {
|
||||||
dir: working_directory.clone(),
|
dir: working_directory.clone(),
|
||||||
@ -464,15 +486,27 @@ where
|
|||||||
for file_action in server_actions.actions {
|
for file_action in server_actions.actions {
|
||||||
match file_action.kind {
|
match file_action.kind {
|
||||||
Action::Add | Action::Replace => {
|
Action::Add | Action::Replace => {
|
||||||
|
//don't use scp on localhost
|
||||||
|
if file_server.is_none() && matches!(server.address, ServerAddress::Localhost) {
|
||||||
|
let file = file_action.file;
|
||||||
|
let dest = server_actions.working_directory.join(file_action.file_name);
|
||||||
|
fs::copy(&file, &dest).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to copy from {} to {}: {e}",
|
||||||
|
file.to_string_lossy(),
|
||||||
|
dest.to_string_lossy()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
let source = match &file_server {
|
let source = match &file_server {
|
||||||
Some(file_server) => ScpParam::from((
|
Some(file_server) => ScpParam::from((
|
||||||
file_server,
|
file_server,
|
||||||
file_server.server_directory_path.join(&file_action.file),
|
file_server.server_directory_path.join(&file_action.file),
|
||||||
)),
|
)),
|
||||||
None => ScpParam::from(file_action.file.as_path()),
|
None => ScpParam::from(&file_action.file),
|
||||||
};
|
};
|
||||||
let destination = ScpParam::from((server, &server_actions.working_directory));
|
let destination = ScpParam::from((server, &server_actions.working_directory));
|
||||||
ShellCommand::Scp {
|
scp! {
|
||||||
source,
|
source,
|
||||||
destination,
|
destination,
|
||||||
}
|
}
|
||||||
@ -482,9 +516,10 @@ where
|
|||||||
.into_result_with_error_logging(&logger)
|
.into_result_with_error_logging(&logger)
|
||||||
.map_err(|e| format!("upload failure: {e}"))?;
|
.map_err(|e| format!("upload failure: {e}"))?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Action::Delete => match &server.address {
|
Action::Delete => match &server.address {
|
||||||
ServerAddress::Ssh { ssh_address } => {
|
ServerAddress::Ssh { ssh_address } => {
|
||||||
ShellCommand::Ssh {
|
ssh! {
|
||||||
address: ssh_address.to_string(),
|
address: ssh_address.to_string(),
|
||||||
server_command: ServerCommand::Rm {
|
server_command: ServerCommand::Rm {
|
||||||
file: server_actions.working_directory.join(&file_action.file),
|
file: server_actions.working_directory.join(&file_action.file),
|
||||||
@ -503,7 +538,7 @@ where
|
|||||||
},
|
},
|
||||||
Action::Rename { new_name } => match &server.address {
|
Action::Rename { new_name } => match &server.address {
|
||||||
ServerAddress::Ssh { ssh_address } => {
|
ServerAddress::Ssh { ssh_address } => {
|
||||||
ShellCommand::Ssh {
|
ssh! {
|
||||||
address: ssh_address.to_string(),
|
address: ssh_address.to_string(),
|
||||||
server_command: ServerCommand::Mv {
|
server_command: ServerCommand::Mv {
|
||||||
source: server_actions.working_directory.join(&file_action.file),
|
source: server_actions.working_directory.join(&file_action.file),
|
||||||
@ -529,13 +564,12 @@ where
|
|||||||
log!(logger, "Done!");
|
log!(logger, "Done!");
|
||||||
}
|
}
|
||||||
Command::Command { command } => {
|
Command::Command { command } => {
|
||||||
self.start_ssh_agent(&logger)?;
|
|
||||||
Self::require_non_empty_servers(&servers)?;
|
Self::require_non_empty_servers(&servers)?;
|
||||||
for server in servers {
|
for server in servers {
|
||||||
log!(logger, "Running command on '{}'...", server.get_name());
|
log!(logger, "Running command on '{}'...", server.get_name());
|
||||||
match &server.address {
|
match &server.address {
|
||||||
ServerAddress::Ssh { ssh_address } => {
|
ServerAddress::Ssh { ssh_address } => {
|
||||||
ShellCommand::Ssh {
|
ssh! {
|
||||||
address: ssh_address.to_string(),
|
address: ssh_address.to_string(),
|
||||||
server_command: ServerCommand::Execute {
|
server_command: ServerCommand::Execute {
|
||||||
working_directory: server.server_directory_path.clone(),
|
working_directory: server.server_directory_path.clone(),
|
||||||
@ -625,28 +659,37 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
Self::require_non_empty_servers(&servers)?;
|
Self::require_non_empty_servers(&servers)?;
|
||||||
self.start_ssh_agent(&logger)?;
|
|
||||||
|
|
||||||
for server in servers {
|
for server in servers {
|
||||||
log!(logger, "Getting file from {}...", server.get_name());
|
log!(logger, "Getting file from {}...", server.get_name());
|
||||||
let source = ScpParam::from((&server, server.server_directory_path.join(&file)));
|
let file_path = server.server_directory_path.join(&file);
|
||||||
ShellCommand::Scp {
|
let downloaded_file_path = download_directory.join(file_name);
|
||||||
source: source.clone(),
|
if matches!(server.address, ServerAddress::Localhost) {
|
||||||
destination: ScpParam::from(download_directory.as_path()),
|
//no need to use scp on localhost
|
||||||
|
fs::copy(&file_path, &downloaded_file_path).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"failed to copy {} to {}: {e}",
|
||||||
|
file_path.to_string_lossy(),
|
||||||
|
downloaded_file_path.to_string_lossy()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
scp! {
|
||||||
|
source: ScpParam::from((&server, &file_path)),
|
||||||
|
destination: ScpParam::from(&download_directory),
|
||||||
}
|
}
|
||||||
.in_env(env!())
|
.in_env(env!())
|
||||||
.run_logged(&logger)
|
.run_logged(&logger)
|
||||||
.and_expect_success()
|
.and_expect_success()
|
||||||
.into_result_with_error_logging(&logger)
|
.into_result_with_error_logging(&logger)
|
||||||
.map_err(|e| format!("download failure: {e}"))?;
|
.map_err(|e| format!("download failure: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
//open file in editor
|
//open file in editor
|
||||||
let editor_command = shell_words::split(&editor)
|
let editor_command = shell_words::split(&editor)
|
||||||
.map_err(|e| format!("failed to parse editor command: {e}"))?
|
.map_err(|e| format!("failed to parse editor command: {e}"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|part| {
|
.map(|part| part.replace_with_os_str(FILE_PLACEHOLDER, &file_path))
|
||||||
part.replace_with_os_str(FILE_PLACEHOLDER, download_directory.join(file_name))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
ShellCommand::Editor(editor_command)
|
ShellCommand::Editor(editor_command)
|
||||||
@ -656,10 +699,19 @@ where
|
|||||||
.into_result_with_error_logging(&logger)
|
.into_result_with_error_logging(&logger)
|
||||||
.map_err(|e| format!("failed to open file in editor: {e}"))?;
|
.map_err(|e| format!("failed to open file in editor: {e}"))?;
|
||||||
|
|
||||||
//upload file again
|
//upload file again; don't use scp on localhost
|
||||||
ShellCommand::Scp {
|
if matches!(server.address, ServerAddress::Localhost) {
|
||||||
source: ScpParam::from(download_directory.join(file_name).as_path()),
|
fs::copy(&downloaded_file_path, &file_path).map_err(|e| {
|
||||||
destination: source,
|
format!(
|
||||||
|
"failed to copy {} to {}: {e}",
|
||||||
|
file_path.to_string_lossy(),
|
||||||
|
download_directory.to_string_lossy()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
scp! {
|
||||||
|
source: ScpParam::from(&downloaded_file_path),
|
||||||
|
destination: ScpParam::from((&server, &file_path)),
|
||||||
}
|
}
|
||||||
.in_env(env!())
|
.in_env(env!())
|
||||||
.run_logged(&logger)
|
.run_logged(&logger)
|
||||||
@ -667,6 +719,7 @@ where
|
|||||||
.into_result_with_error_logging(&logger)
|
.into_result_with_error_logging(&logger)
|
||||||
.map_err(|e| format!("failed to re-upload file: {e}"))?;
|
.map_err(|e| format!("failed to re-upload file: {e}"))?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log!(logger, "Done!");
|
log!(logger, "Done!");
|
||||||
}
|
}
|
||||||
@ -706,6 +759,10 @@ where
|
|||||||
fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> {
|
fn start_ssh_agent(&mut self, logger: &Logger) -> Result<(), String> {
|
||||||
let env = &mut self.environment;
|
let env = &mut self.environment;
|
||||||
|
|
||||||
|
if env.is_ssh_agent_started() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
//start the ssh agent
|
//start the ssh agent
|
||||||
let agent_output = ShellCommand::SshAgent
|
let agent_output = ShellCommand::SshAgent
|
||||||
.in_env(env)
|
.in_env(env)
|
||||||
@ -732,6 +789,9 @@ where
|
|||||||
.and_expect_success()
|
.and_expect_success()
|
||||||
.into_result_with_error_logging(logger)
|
.into_result_with_error_logging(logger)
|
||||||
.map_err(|e| format!("failed to add ssh-key: {e}"))?;
|
.map_err(|e| format!("failed to add ssh-key: {e}"))?;
|
||||||
|
|
||||||
|
env.set_ssh_agent_started(true);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ pub enum ShellCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ShellCommand {
|
impl ShellCommand {
|
||||||
pub fn in_env<E>(self, environment: &mut E) -> EnvCommand<E> {
|
pub fn in_env<E>(self, environment: &mut E) -> EnvCommand<'_, E> {
|
||||||
EnvCommand {
|
EnvCommand {
|
||||||
command: self,
|
command: self,
|
||||||
environment,
|
environment,
|
||||||
@ -129,6 +129,12 @@ impl From<&Path> for ScpParam {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<&PathBuf> for ScpParam {
|
||||||
|
fn from(value: &PathBuf) -> Self {
|
||||||
|
Self::from(value.as_path())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<&ScpParam> for OsString {
|
impl From<&ScpParam> for OsString {
|
||||||
fn from(value: &ScpParam) -> Self {
|
fn from(value: &ScpParam) -> Self {
|
||||||
let mut builder = osf!();
|
let mut builder = osf!();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user