mirror of
https://github.com/serokell/deploy-rs.git
synced 2024-11-22 13:22:10 +03:00
add support for entering password for sudo
This commit is contained in:
parent
1776009f1f
commit
5f694ef481
23
Cargo.lock
generated
23
Cargo.lock
generated
@ -139,6 +139,7 @@ dependencies = [
|
|||||||
"merge",
|
"merge",
|
||||||
"notify",
|
"notify",
|
||||||
"rnix",
|
"rnix",
|
||||||
|
"rpassword",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
@ -666,6 +667,27 @@ dependencies = [
|
|||||||
"thin-dst",
|
"thin-dst",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -859,6 +881,7 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
|
"memchr",
|
||||||
"mio 0.7.6",
|
"mio 0.7.6",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
@ -24,7 +24,7 @@ serde = { version = "1.0.104", features = [ "derive" ] }
|
|||||||
serde_json = "1.0.48"
|
serde_json = "1.0.48"
|
||||||
signal-hook = "0.3"
|
signal-hook = "0.3"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time" ] }
|
tokio = { version = "1.9.0", features = [ "process", "macros", "sync", "rt-multi-thread", "fs", "time", "io-util" ] }
|
||||||
toml = "0.5"
|
toml = "0.5"
|
||||||
whoami = "0.9.0"
|
whoami = "0.9.0"
|
||||||
yn = "0.1"
|
yn = "0.1"
|
||||||
@ -33,6 +33,7 @@ yn = "0.1"
|
|||||||
# 1.45.2 (shipped in nixos-20.09); it requires rustc 1.46.0. See
|
# 1.45.2 (shipped in nixos-20.09); it requires rustc 1.46.0. See
|
||||||
# <https://github.com/serokell/deploy-rs/issues/27>:
|
# <https://github.com/serokell/deploy-rs/issues/27>:
|
||||||
smol_str = "=0.1.16"
|
smol_str = "=0.1.16"
|
||||||
|
rpassword = "7.3.1"
|
||||||
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
sshOpts = [ "-p" "2221" ];
|
sshOpts = [ "-p" "2221" ];
|
||||||
hostname = "localhost";
|
hostname = "localhost";
|
||||||
fastConnection = true;
|
fastConnection = true;
|
||||||
|
interactiveSudo = true;
|
||||||
profiles = {
|
profiles = {
|
||||||
system = {
|
system = {
|
||||||
sshUser = "admin";
|
sshUser = "admin";
|
||||||
|
@ -35,6 +35,9 @@
|
|||||||
},
|
},
|
||||||
"tempPath": {
|
"tempPath": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"interactiveSudo": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
24
src/cli.rs
24
src/cli.rs
@ -103,6 +103,9 @@ pub struct Opts {
|
|||||||
/// Which sudo command to use. Must accept at least two arguments: user name to execute commands as and the rest is the command to execute
|
/// Which sudo command to use. Must accept at least two arguments: user name to execute commands as and the rest is the command to execute
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
sudo: Option<String>,
|
sudo: Option<String>,
|
||||||
|
/// Prompt for sudo password during activation.
|
||||||
|
#[clap(long)]
|
||||||
|
interactive_sudo: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns if the available Nix installation supports flakes
|
/// Returns if the available Nix installation supports flakes
|
||||||
@ -538,7 +541,25 @@ async fn run_deploy(
|
|||||||
log_dir.as_deref(),
|
log_dir.as_deref(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let deploy_defs = deploy_data.defs()?;
|
let mut deploy_defs = deploy_data.defs()?;
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
warn!("Interactive sudo is enabled! Using a sudo password is less secure than correctly configured SSH keys.\nPlease use keys in production environments.");
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.sudo.is_some() {
|
||||||
|
warn!("Custom sudo commands should be configured to accept password input from stdin when using the 'interactive sudo' option. Deployment may fail if the custom command ignores stdin.");
|
||||||
|
} else {
|
||||||
|
// this configures sudo to hide the password prompt and accept input from stdin
|
||||||
|
// at the time of writing, deploy_defs.sudo defaults to 'sudo -u root' when using user=root and sshUser as non-root
|
||||||
|
let original = deploy_defs.sudo.unwrap_or("sudo".to_string());
|
||||||
|
deploy_defs.sudo = Some(format!("{} -S -p \"\"", original));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("You will now be prompted for the sudo password for {}.", node.node_settings.hostname);
|
||||||
|
let sudo_password = rpassword::prompt_password(format!("(sudo for {}) Password: ", node.node_settings.hostname)).unwrap_or("".to_string());
|
||||||
|
|
||||||
|
deploy_defs.sudo_password = Some(sudo_password);
|
||||||
|
}
|
||||||
|
|
||||||
parts.push((deploy_flake, deploy_data, deploy_defs));
|
parts.push((deploy_flake, deploy_data, deploy_defs));
|
||||||
}
|
}
|
||||||
@ -665,6 +686,7 @@ pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> {
|
|||||||
dry_activate: opts.dry_activate,
|
dry_activate: opts.dry_activate,
|
||||||
remote_build: opts.remote_build,
|
remote_build: opts.remote_build,
|
||||||
sudo: opts.sudo,
|
sudo: opts.sudo,
|
||||||
|
interactive_sudo: opts.interactive_sudo
|
||||||
};
|
};
|
||||||
|
|
||||||
let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?;
|
let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?;
|
||||||
|
@ -35,6 +35,8 @@ pub struct GenericSettings {
|
|||||||
pub sudo: Option<String>,
|
pub sudo: Option<String>,
|
||||||
#[serde(default,rename(deserialize = "remoteBuild"))]
|
#[serde(default,rename(deserialize = "remoteBuild"))]
|
||||||
pub remote_build: Option<bool>,
|
pub remote_build: Option<bool>,
|
||||||
|
#[serde(rename(deserialize = "interactiveSudo"))]
|
||||||
|
pub interactive_sudo: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
109
src/deploy.rs
109
src/deploy.rs
@ -4,12 +4,12 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: MPL-2.0
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
use log::{debug, info};
|
use log::{debug, info, trace};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::process::Command;
|
use tokio::{io::AsyncWriteExt, process::Command};
|
||||||
|
|
||||||
use crate::{DeployDataDefsError, ProfileInfo};
|
use crate::{DeployDataDefsError, DeployDefs, ProfileInfo};
|
||||||
|
|
||||||
struct ActivateCommandData<'a> {
|
struct ActivateCommandData<'a> {
|
||||||
sudo: &'a Option<String>,
|
sudo: &'a Option<String>,
|
||||||
@ -242,6 +242,23 @@ fn test_revoke_command_builder() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_sudo_stdin(ssh_activate_child: &mut tokio::process::Child, deploy_defs: &DeployDefs) -> Result<(), std::io::Error> {
|
||||||
|
match ssh_activate_child.stdin.as_mut() {
|
||||||
|
Some(stdin) => {
|
||||||
|
let _ = stdin.write_all(format!("{}\n",deploy_defs.sudo_password.clone().unwrap_or("".to_string())).as_bytes()).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
Err(
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
"Failed to open stdin for sudo command",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum ConfirmProfileError {
|
pub enum ConfirmProfileError {
|
||||||
#[error("Failed to run confirmation command over SSH (the server should roll back): {0}")]
|
#[error("Failed to run confirmation command over SSH (the server should roll back): {0}")]
|
||||||
@ -259,7 +276,9 @@ pub async fn confirm_profile(
|
|||||||
ssh_addr: &str,
|
ssh_addr: &str,
|
||||||
) -> Result<(), ConfirmProfileError> {
|
) -> Result<(), ConfirmProfileError> {
|
||||||
let mut ssh_confirm_command = Command::new("ssh");
|
let mut ssh_confirm_command = Command::new("ssh");
|
||||||
ssh_confirm_command.arg(ssh_addr);
|
ssh_confirm_command
|
||||||
|
.arg(ssh_addr)
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
|
||||||
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
||||||
ssh_confirm_command.arg(ssh_opt);
|
ssh_confirm_command.arg(ssh_opt);
|
||||||
@ -277,9 +296,20 @@ pub async fn confirm_profile(
|
|||||||
confirm_command
|
confirm_command
|
||||||
);
|
);
|
||||||
|
|
||||||
let ssh_confirm_exit_status = ssh_confirm_command
|
let mut ssh_confirm_child = ssh_confirm_command
|
||||||
.arg(confirm_command)
|
.arg(confirm_command)
|
||||||
.status()
|
.spawn()
|
||||||
|
.map_err(ConfirmProfileError::SSHConfirm)?;
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
trace!("[confirm] Piping in sudo password");
|
||||||
|
handle_sudo_stdin(&mut ssh_confirm_child, deploy_defs)
|
||||||
|
.await
|
||||||
|
.map_err(ConfirmProfileError::SSHConfirm)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssh_confirm_exit_status = ssh_confirm_child
|
||||||
|
.wait()
|
||||||
.await
|
.await
|
||||||
.map_err(ConfirmProfileError::SSHConfirm)?;
|
.map_err(ConfirmProfileError::SSHConfirm)?;
|
||||||
|
|
||||||
@ -308,6 +338,9 @@ pub enum DeployProfileError {
|
|||||||
#[error("Waiting over SSH resulted in a bad exit code: {0:?}")]
|
#[error("Waiting over SSH resulted in a bad exit code: {0:?}")]
|
||||||
SSHWaitExit(Option<i32>),
|
SSHWaitExit(Option<i32>),
|
||||||
|
|
||||||
|
#[error("Failed to pipe to child stdin: {0}")]
|
||||||
|
SSHActivatePipe(std::io::Error),
|
||||||
|
|
||||||
#[error("Error confirming deployment: {0}")]
|
#[error("Error confirming deployment: {0}")]
|
||||||
Confirm(#[from] ConfirmProfileError),
|
Confirm(#[from] ConfirmProfileError),
|
||||||
#[error("Deployment data invalid: {0}")]
|
#[error("Deployment data invalid: {0}")]
|
||||||
@ -364,16 +397,29 @@ pub async fn deploy_profile(
|
|||||||
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
|
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
|
||||||
|
|
||||||
let mut ssh_activate_command = Command::new("ssh");
|
let mut ssh_activate_command = Command::new("ssh");
|
||||||
ssh_activate_command.arg(&ssh_addr);
|
ssh_activate_command
|
||||||
|
.arg(&ssh_addr)
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
|
||||||
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
||||||
ssh_activate_command.arg(&ssh_opt);
|
ssh_activate_command.arg(&ssh_opt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !magic_rollback || dry_activate || boot {
|
if !magic_rollback || dry_activate || boot {
|
||||||
let ssh_activate_exit_status = ssh_activate_command
|
let mut ssh_activate_child = ssh_activate_command
|
||||||
.arg(self_activate_command)
|
.arg(self_activate_command)
|
||||||
.status()
|
.spawn()
|
||||||
|
.map_err(DeployProfileError::SSHSpawnActivate)?;
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
trace!("[activate] Piping in sudo password");
|
||||||
|
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
|
||||||
|
.await
|
||||||
|
.map_err(DeployProfileError::SSHActivatePipe)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssh_activate_exit_status = ssh_activate_child
|
||||||
|
.wait()
|
||||||
.await
|
.await
|
||||||
.map_err(DeployProfileError::SSHActivate)?;
|
.map_err(DeployProfileError::SSHActivate)?;
|
||||||
|
|
||||||
@ -401,15 +447,24 @@ pub async fn deploy_profile(
|
|||||||
|
|
||||||
debug!("Constructed wait command: {}", self_wait_command);
|
debug!("Constructed wait command: {}", self_wait_command);
|
||||||
|
|
||||||
let ssh_activate = ssh_activate_command
|
let mut ssh_activate_child = ssh_activate_command
|
||||||
.arg(self_activate_command)
|
.arg(self_activate_command)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(DeployProfileError::SSHSpawnActivate)?;
|
.map_err(DeployProfileError::SSHSpawnActivate)?;
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
trace!("[activate] Piping in sudo password");
|
||||||
|
handle_sudo_stdin(&mut ssh_activate_child, deploy_defs)
|
||||||
|
.await
|
||||||
|
.map_err(DeployProfileError::SSHActivatePipe)?;
|
||||||
|
}
|
||||||
|
|
||||||
info!("Creating activation waiter");
|
info!("Creating activation waiter");
|
||||||
|
|
||||||
let mut ssh_wait_command = Command::new("ssh");
|
let mut ssh_wait_command = Command::new("ssh");
|
||||||
ssh_wait_command.arg(&ssh_addr);
|
ssh_wait_command
|
||||||
|
.arg(&ssh_addr)
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
|
||||||
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
||||||
ssh_wait_command.arg(ssh_opt);
|
ssh_wait_command.arg(ssh_opt);
|
||||||
@ -419,7 +474,7 @@ pub async fn deploy_profile(
|
|||||||
let (send_activated, recv_activated) = tokio::sync::oneshot::channel();
|
let (send_activated, recv_activated) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
let thread = tokio::spawn(async move {
|
let thread = tokio::spawn(async move {
|
||||||
let o = ssh_activate.wait_with_output().await;
|
let o = ssh_activate_child.wait_with_output().await;
|
||||||
|
|
||||||
let maybe_err = match o {
|
let maybe_err = match o {
|
||||||
Err(x) => Some(DeployProfileError::SSHActivate(x)),
|
Err(x) => Some(DeployProfileError::SSHActivate(x)),
|
||||||
@ -435,8 +490,21 @@ pub async fn deploy_profile(
|
|||||||
|
|
||||||
send_activated.send(()).unwrap();
|
send_activated.send(()).unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut ssh_wait_child = ssh_wait_command
|
||||||
|
.arg(self_wait_command)
|
||||||
|
.spawn()
|
||||||
|
.map_err(DeployProfileError::SSHWait)?;
|
||||||
|
|
||||||
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
trace!("[wait] Piping in sudo password");
|
||||||
|
handle_sudo_stdin(&mut ssh_wait_child, deploy_defs)
|
||||||
|
.await
|
||||||
|
.map_err(DeployProfileError::SSHActivatePipe)?;
|
||||||
|
}
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
x = ssh_wait_command.arg(self_wait_command).status() => {
|
x = ssh_wait_child.wait() => {
|
||||||
debug!("Wait command ended");
|
debug!("Wait command ended");
|
||||||
match x.map_err(DeployProfileError::SSHWait)?.code() {
|
match x.map_err(DeployProfileError::SSHWait)?.code() {
|
||||||
Some(0) => (),
|
Some(0) => (),
|
||||||
@ -498,18 +566,27 @@ pub async fn revoke(
|
|||||||
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
|
let ssh_addr = format!("{}@{}", deploy_defs.ssh_user, hostname);
|
||||||
|
|
||||||
let mut ssh_activate_command = Command::new("ssh");
|
let mut ssh_activate_command = Command::new("ssh");
|
||||||
ssh_activate_command.arg(&ssh_addr);
|
ssh_activate_command
|
||||||
|
.arg(&ssh_addr)
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
|
||||||
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
for ssh_opt in &deploy_data.merged_settings.ssh_opts {
|
||||||
ssh_activate_command.arg(&ssh_opt);
|
ssh_activate_command.arg(&ssh_opt);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ssh_revoke = ssh_activate_command
|
let mut ssh_revoke_child = ssh_activate_command
|
||||||
.arg(self_revoke_command)
|
.arg(self_revoke_command)
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(RevokeProfileError::SSHSpawnRevoke)?;
|
.map_err(RevokeProfileError::SSHSpawnRevoke)?;
|
||||||
|
|
||||||
let result = ssh_revoke.wait_with_output().await;
|
if deploy_data.merged_settings.interactive_sudo.unwrap_or(false) {
|
||||||
|
trace!("[revoke] Piping in sudo password");
|
||||||
|
handle_sudo_stdin(&mut ssh_revoke_child, deploy_defs)
|
||||||
|
.await
|
||||||
|
.map_err(RevokeProfileError::SSHRevoke)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = ssh_revoke_child.wait_with_output().await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Err(x) => Err(RevokeProfileError::SSHRevoke(x)),
|
Err(x) => Err(RevokeProfileError::SSHRevoke(x)),
|
||||||
|
@ -165,6 +165,7 @@ pub struct CmdOverrides {
|
|||||||
pub confirm_timeout: Option<u16>,
|
pub confirm_timeout: Option<u16>,
|
||||||
pub activation_timeout: Option<u16>,
|
pub activation_timeout: Option<u16>,
|
||||||
pub sudo: Option<String>,
|
pub sudo: Option<String>,
|
||||||
|
pub interactive_sudo: Option<bool>,
|
||||||
pub dry_activate: bool,
|
pub dry_activate: bool,
|
||||||
pub remote_build: bool,
|
pub remote_build: bool,
|
||||||
}
|
}
|
||||||
@ -334,6 +335,7 @@ pub struct DeployDefs {
|
|||||||
pub ssh_user: String,
|
pub ssh_user: String,
|
||||||
pub profile_user: String,
|
pub profile_user: String,
|
||||||
pub sudo: Option<String>,
|
pub sudo: Option<String>,
|
||||||
|
pub sudo_password: Option<String>,
|
||||||
}
|
}
|
||||||
enum ProfileInfo {
|
enum ProfileInfo {
|
||||||
ProfilePath {
|
ProfilePath {
|
||||||
@ -369,6 +371,7 @@ impl<'a> DeployData<'a> {
|
|||||||
ssh_user,
|
ssh_user,
|
||||||
profile_user,
|
profile_user,
|
||||||
sudo,
|
sudo,
|
||||||
|
sudo_password: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -448,6 +451,9 @@ pub fn make_deploy_data<'a, 's>(
|
|||||||
if let Some(activation_timeout) = cmd_overrides.activation_timeout {
|
if let Some(activation_timeout) = cmd_overrides.activation_timeout {
|
||||||
merged_settings.activation_timeout = Some(activation_timeout);
|
merged_settings.activation_timeout = Some(activation_timeout);
|
||||||
}
|
}
|
||||||
|
if let Some(interactive_sudo) = cmd_overrides.interactive_sudo {
|
||||||
|
merged_settings.interactive_sudo = Some(interactive_sudo);
|
||||||
|
}
|
||||||
|
|
||||||
DeployData {
|
DeployData {
|
||||||
node_name,
|
node_name,
|
||||||
|
Loading…
Reference in New Issue
Block a user