first commit
This commit is contained in:
635
venv/lib/python3.12/site-packages/asyncssh/config.py
Normal file
635
venv/lib/python3.12/site-packages/asyncssh/config.py
Normal file
@@ -0,0 +1,635 @@
|
||||
# Copyright (c) 2020-2022 by Ron Frederick <ronf@timeheart.net> and others.
|
||||
#
|
||||
# This program and the accompanying materials are made available under
|
||||
# the terms of the Eclipse Public License v2.0 which accompanies this
|
||||
# distribution and is available at:
|
||||
#
|
||||
# http://www.eclipse.org/legal/epl-2.0/
|
||||
#
|
||||
# This program may also be made available under the following secondary
|
||||
# licenses when the conditions for such availability set forth in the
|
||||
# Eclipse Public License v2.0 are satisfied:
|
||||
#
|
||||
# GNU General Public License, Version 2.0, or any later versions of
|
||||
# that license
|
||||
#
|
||||
# SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-or-later
|
||||
#
|
||||
# Contributors:
|
||||
# Ron Frederick - initial implementation, API, and documentation
|
||||
|
||||
"""Parser for OpenSSH config files"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from hashlib import sha1
|
||||
from pathlib import Path, PurePath
|
||||
from subprocess import DEVNULL
|
||||
from typing import Callable, Dict, List, NoReturn, Optional, Sequence
|
||||
from typing import Set, Tuple, Union, cast
|
||||
|
||||
from .constants import DEFAULT_PORT
|
||||
from .logging import logger
|
||||
from .misc import DefTuple, FilePath, ip_address
|
||||
from .pattern import HostPatternList, WildcardPatternList
|
||||
|
||||
|
||||
ConfigPaths = Union[None, FilePath, Sequence[FilePath]]
|
||||
|
||||
|
||||
def _exec(cmd: str) -> bool:
|
||||
"""Execute a command and return if exit status is 0"""
|
||||
|
||||
return subprocess.run(cmd, check=False, shell=True, stdin=DEVNULL,
|
||||
stdout=DEVNULL, stderr=DEVNULL).returncode == 0
|
||||
|
||||
|
||||
class ConfigParseError(ValueError):
|
||||
"""Configuration parsing exception"""
|
||||
|
||||
|
||||
class SSHConfig:
|
||||
"""Settings from an OpenSSH config file"""
|
||||
|
||||
_conditionals = {'match'}
|
||||
_no_split: Set[str] = set()
|
||||
_percent_expand = {'AuthorizedKeysFile'}
|
||||
_handlers: Dict[str, Tuple[str, Callable]] = {}
|
||||
|
||||
def __init__(self, last_config: Optional['SSHConfig'], reload: bool):
|
||||
if last_config:
|
||||
self._last_options = last_config.get_options(reload)
|
||||
else:
|
||||
self._last_options = {}
|
||||
|
||||
self._default_path = Path('~', '.ssh').expanduser()
|
||||
self._path = Path()
|
||||
self._line_no = 0
|
||||
self._matching = True
|
||||
self._options = self._last_options.copy()
|
||||
self._tokens: Dict[str, str] = {}
|
||||
|
||||
self.loaded = False
|
||||
|
||||
def _error(self, reason: str, *args: object) -> NoReturn:
|
||||
"""Raise a configuration parsing error"""
|
||||
|
||||
raise ConfigParseError('%s line %s: %s' % (self._path, self._line_no,
|
||||
reason % args))
|
||||
|
||||
def _match_val(self, match: str) -> object:
|
||||
"""Return the value to match against in a match condition"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def _set_tokens(self) -> None:
|
||||
"""Set the tokens available for percent expansion"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def _expand_val(self, value: str) -> str:
|
||||
"""Perform percent token expansion on a string"""
|
||||
|
||||
last_idx = 0
|
||||
result: List[str] = []
|
||||
|
||||
for match in re.finditer(r'%', value):
|
||||
idx = match.start()
|
||||
|
||||
if idx < last_idx:
|
||||
continue
|
||||
|
||||
try:
|
||||
token = value[idx+1]
|
||||
result.extend([value[last_idx:idx], self._tokens[token]])
|
||||
last_idx = idx + 2
|
||||
except IndexError:
|
||||
raise ConfigParseError('Invalid token substitution') from None
|
||||
except KeyError:
|
||||
if token == 'd':
|
||||
raise ConfigParseError('Home directory is '
|
||||
'not available') from None
|
||||
elif token == 'i':
|
||||
raise ConfigParseError('User id not available') from None
|
||||
else:
|
||||
raise ConfigParseError('Invalid token substitution: %s' %
|
||||
value[idx+1]) from None
|
||||
|
||||
result.append(value[last_idx:])
|
||||
return ''.join(result)
|
||||
|
||||
def _include(self, option: str, args: List[str]) -> None:
|
||||
"""Read config from a list of other config files"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
old_path = self._path
|
||||
|
||||
for pattern in args:
|
||||
path = Path(pattern).expanduser()
|
||||
|
||||
if path.anchor:
|
||||
pattern = str(Path(*path.parts[1:]))
|
||||
path = Path(path.anchor)
|
||||
else:
|
||||
path = self._default_path
|
||||
|
||||
paths = list(path.glob(pattern))
|
||||
|
||||
if not paths:
|
||||
logger.debug1('Config pattern "%s" matched no files', pattern)
|
||||
|
||||
for path in paths:
|
||||
self.parse(path)
|
||||
|
||||
self._path = old_path
|
||||
args.clear()
|
||||
|
||||
def _match(self, option: str, args: List[str]) -> None:
|
||||
"""Begin a conditional block"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
while args:
|
||||
match = args.pop(0).lower()
|
||||
|
||||
if match == 'all':
|
||||
self._matching = True
|
||||
continue
|
||||
|
||||
match_val = self._match_val(match)
|
||||
|
||||
if match != 'exec' and match_val is None:
|
||||
self._error('Invalid match condition')
|
||||
|
||||
try:
|
||||
if match == 'exec':
|
||||
self._matching = _exec(args.pop(0))
|
||||
elif match in ('address', 'localaddress'):
|
||||
host_pat = HostPatternList(args.pop(0))
|
||||
ip = ip_address(cast(str, match_val)) \
|
||||
if match_val else None
|
||||
self._matching = host_pat.matches(None, match_val, ip)
|
||||
else:
|
||||
wild_pat = WildcardPatternList(args.pop(0))
|
||||
self._matching = wild_pat.matches(match_val)
|
||||
except IndexError:
|
||||
self._error('Missing %s match pattern', match)
|
||||
|
||||
if not self._matching:
|
||||
args.clear()
|
||||
break
|
||||
|
||||
def _set_bool(self, option: str, args: List[str]) -> None:
|
||||
"""Set a boolean config option"""
|
||||
|
||||
value_str = args.pop(0).lower()
|
||||
|
||||
if value_str in ('yes', 'true'):
|
||||
value = True
|
||||
elif value_str in ('no', 'false'):
|
||||
value = False
|
||||
else:
|
||||
self._error('Invalid %s boolean value: %s', option, value_str)
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = value
|
||||
|
||||
def _set_int(self, option: str, args: List[str]) -> None:
|
||||
"""Set an integer config option"""
|
||||
|
||||
value_str = args.pop(0)
|
||||
|
||||
try:
|
||||
value = int(value_str)
|
||||
except ValueError:
|
||||
self._error('Invalid %s integer value: %s', option, value_str)
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = value
|
||||
|
||||
def _set_string(self, option: str, args: List[str]) -> None:
|
||||
"""Set a string config option"""
|
||||
|
||||
value_str = args.pop(0)
|
||||
|
||||
if value_str.lower() == 'none':
|
||||
value = None
|
||||
else:
|
||||
value = value_str
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = value
|
||||
|
||||
def _append_string(self, option: str, args: List[str]) -> None:
|
||||
"""Append a string config option to a list"""
|
||||
|
||||
value_str = args.pop(0)
|
||||
|
||||
if value_str.lower() != 'none':
|
||||
if option in self._options:
|
||||
cast(List[str], self._options[option]).append(value_str)
|
||||
else:
|
||||
self._options[option] = [value_str]
|
||||
else:
|
||||
if option not in self._options:
|
||||
self._options[option] = []
|
||||
|
||||
def _set_string_list(self, option: str, args: List[str]) -> None:
|
||||
"""Set whitespace-separated string config options as a list"""
|
||||
|
||||
if option not in self._options:
|
||||
if len(args) == 1 and args[0].lower() == 'none':
|
||||
self._options[option] = []
|
||||
else:
|
||||
self._options[option] = args[:]
|
||||
|
||||
args.clear()
|
||||
|
||||
def _append_string_list(self, option: str, args: List[str]) -> None:
|
||||
"""Append whitespace-separated string config options to a list"""
|
||||
|
||||
if option in self._options:
|
||||
cast(List[str], self._options[option]).extend(args)
|
||||
else:
|
||||
self._options[option] = args[:]
|
||||
|
||||
args.clear()
|
||||
|
||||
def _set_address_family(self, option: str, args: List[str]) -> None:
|
||||
"""Set an address family config option"""
|
||||
|
||||
value_str = args.pop(0).lower()
|
||||
|
||||
if value_str == 'any':
|
||||
value = socket.AF_UNSPEC
|
||||
elif value_str == 'inet':
|
||||
value = socket.AF_INET
|
||||
elif value_str == 'inet6':
|
||||
value = socket.AF_INET6
|
||||
else:
|
||||
self._error('Invalid %s value: %s', option, value_str)
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = value
|
||||
|
||||
def _set_rekey_limits(self, option: str, args: List[str]) -> None:
|
||||
"""Set rekey limits config option"""
|
||||
|
||||
byte_limit: Union[str, Tuple[()]] = args.pop(0).lower()
|
||||
|
||||
if byte_limit == 'default':
|
||||
byte_limit = ()
|
||||
|
||||
if args:
|
||||
time_limit: Optional[Union[str, Tuple[()]]] = args.pop(0).lower()
|
||||
|
||||
if time_limit == 'none':
|
||||
time_limit = None
|
||||
else:
|
||||
time_limit = ()
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = byte_limit, time_limit
|
||||
|
||||
def parse(self, path: Path) -> None:
|
||||
"""Parse an OpenSSH config file and return matching declarations"""
|
||||
|
||||
self._path = path
|
||||
self._line_no = 0
|
||||
self._matching = True
|
||||
self._tokens = {'%': '%'}
|
||||
|
||||
logger.debug1('Reading config from "%s"', path)
|
||||
|
||||
with open(path) as file:
|
||||
for line in file:
|
||||
self._line_no += 1
|
||||
|
||||
line = line.strip()
|
||||
if not line or line[0] == '#':
|
||||
continue
|
||||
|
||||
try:
|
||||
split_args = shlex.split(line)
|
||||
except ValueError as exc:
|
||||
self._error(str(exc))
|
||||
|
||||
args = []
|
||||
|
||||
for arg in split_args:
|
||||
if arg.startswith('='):
|
||||
if len(arg) > 1:
|
||||
args.append(arg[1:])
|
||||
elif arg.endswith('='):
|
||||
args.append(arg[:-1])
|
||||
elif '=' in arg:
|
||||
arg, val = arg.split('=', 1)
|
||||
args.append(arg)
|
||||
args.append(val)
|
||||
else:
|
||||
args.append(arg)
|
||||
|
||||
option = args.pop(0)
|
||||
loption = option.lower()
|
||||
|
||||
if loption in self._no_split:
|
||||
args = [line.lstrip()[len(loption):].strip()]
|
||||
|
||||
if not self._matching and loption not in self._conditionals:
|
||||
continue
|
||||
|
||||
try:
|
||||
option, handler = self._handlers[loption]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if not args:
|
||||
self._error('Missing %s value', option)
|
||||
|
||||
handler(self, option, args)
|
||||
|
||||
if args:
|
||||
self._error('Extra data at end: %s', ' '.join(args))
|
||||
|
||||
self._set_tokens()
|
||||
|
||||
for option in self._percent_expand:
|
||||
try:
|
||||
value = self._options[option]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if isinstance(value, list):
|
||||
value = [self._expand_val(item) for item in value]
|
||||
elif isinstance(value, str):
|
||||
value = self._expand_val(value)
|
||||
|
||||
self._options[option] = value
|
||||
|
||||
def get_options(self, reload: bool) -> Dict[str, object]:
|
||||
"""Return options to base a new config object on"""
|
||||
|
||||
return self._last_options.copy() if reload else self._options.copy()
|
||||
|
||||
@classmethod
|
||||
def load(cls, last_config: Optional['SSHConfig'],
|
||||
config_paths: ConfigPaths, reload: bool,
|
||||
*args: object) -> 'SSHConfig':
|
||||
"""Load a list of OpenSSH config files into a config object"""
|
||||
|
||||
config = cls(last_config, reload, *args)
|
||||
|
||||
if config_paths:
|
||||
if isinstance(config_paths, (str, PurePath)):
|
||||
paths: Sequence[FilePath] = [config_paths]
|
||||
else:
|
||||
paths = config_paths
|
||||
|
||||
for path in paths:
|
||||
config.parse(Path(path))
|
||||
|
||||
config.loaded = True
|
||||
|
||||
return config
|
||||
|
||||
def get(self, option: str, default: object = None) -> object:
|
||||
"""Get the value of a config option"""
|
||||
|
||||
return self._options.get(option, default)
|
||||
|
||||
def get_compression_algs(self) -> DefTuple[str]:
|
||||
"""Return the compression algorithms to use"""
|
||||
|
||||
compression = self.get('Compression')
|
||||
|
||||
if compression is None:
|
||||
return ()
|
||||
elif compression:
|
||||
return 'zlib@openssh.com,zlib,none'
|
||||
else:
|
||||
return 'none,zlib@openssh.com,zlib'
|
||||
|
||||
|
||||
class SSHClientConfig(SSHConfig):
|
||||
"""Settings from an OpenSSH client config file"""
|
||||
|
||||
_conditionals = {'host', 'match'}
|
||||
_no_split = {'remotecommand'}
|
||||
_percent_expand = {'CertificateFile', 'IdentityAgent',
|
||||
'IdentityFile', 'ProxyCommand', 'RemoteCommand'}
|
||||
|
||||
def __init__(self, last_config: 'SSHConfig', reload: bool,
|
||||
local_user: str, user: str, host: str, port: int) -> None:
|
||||
super().__init__(last_config, reload)
|
||||
|
||||
self._local_user = local_user
|
||||
self._orig_host = host
|
||||
|
||||
if user != ():
|
||||
self._options['User'] = user
|
||||
|
||||
if port != ():
|
||||
self._options['Port'] = port
|
||||
|
||||
def _match_val(self, match: str) -> object:
|
||||
"""Return the value to match against in a match condition"""
|
||||
|
||||
if match == 'host':
|
||||
return self._options.get('Hostname', self._orig_host)
|
||||
elif match == 'originalhost':
|
||||
return self._orig_host
|
||||
elif match == 'localuser':
|
||||
return self._local_user
|
||||
elif match == 'user':
|
||||
return self._options.get('User', self._local_user)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _match_host(self, option: str, args: List[str]) -> None:
|
||||
"""Begin a conditional block matching on host"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
pattern = ','.join(args)
|
||||
self._matching = WildcardPatternList(pattern).matches(self._orig_host)
|
||||
args.clear()
|
||||
|
||||
def _set_hostname(self, option: str, args: List[str]) -> None:
|
||||
"""Set hostname config option"""
|
||||
|
||||
value = args.pop(0)
|
||||
|
||||
if option not in self._options:
|
||||
self._tokens['h'] = \
|
||||
cast(str, self._options.get(option, self._orig_host))
|
||||
self._options[option] = self._expand_val(value)
|
||||
|
||||
def _set_request_tty(self, option: str, args: List[str]) -> None:
|
||||
"""Set a pseudo-terminal request config option"""
|
||||
|
||||
value_str = args.pop(0).lower()
|
||||
|
||||
if value_str in ('yes', 'true'):
|
||||
value: Union[bool, str] = True
|
||||
elif value_str in ('no', 'false'):
|
||||
value = False
|
||||
elif value_str not in ('force', 'auto'):
|
||||
self._error('Invalid %s value: %s', option, value_str)
|
||||
else:
|
||||
value = value_str
|
||||
|
||||
if option not in self._options:
|
||||
self._options[option] = value
|
||||
|
||||
def _set_tokens(self) -> None:
|
||||
"""Set the tokens available for percent expansion"""
|
||||
|
||||
local_host = socket.gethostname()
|
||||
|
||||
idx = local_host.find('.')
|
||||
short_local_host = local_host if idx < 0 else local_host[:idx]
|
||||
|
||||
host = cast(str, self._options.get('Hostname', self._orig_host))
|
||||
port = str(self._options.get('Port', DEFAULT_PORT))
|
||||
user = cast(str, self._options.get('User') or self._local_user)
|
||||
home = os.path.expanduser('~')
|
||||
|
||||
conn_info = ''.join((local_host, host, port, user))
|
||||
conn_hash = sha1(conn_info.encode('utf-8')).hexdigest()
|
||||
|
||||
self._tokens.update({'C': conn_hash,
|
||||
'h': host,
|
||||
'L': short_local_host,
|
||||
'l': local_host,
|
||||
'n': self._orig_host,
|
||||
'p': port,
|
||||
'r': user,
|
||||
'u': self._local_user})
|
||||
|
||||
if home != '~':
|
||||
self._tokens['d'] = home
|
||||
|
||||
if hasattr(os, 'getuid'):
|
||||
self._tokens['i'] = str(os.getuid())
|
||||
|
||||
_handlers = {option.lower(): (option, handler) for option, handler in (
|
||||
('Host', _match_host),
|
||||
('Match', SSHConfig._match),
|
||||
('Include', SSHConfig._include),
|
||||
|
||||
('AddressFamily', SSHConfig._set_address_family),
|
||||
('BindAddress', SSHConfig._set_string),
|
||||
('CASignatureAlgorithms', SSHConfig._set_string),
|
||||
('CertificateFile', SSHConfig._append_string),
|
||||
('ChallengeResponseAuthentication', SSHConfig._set_bool),
|
||||
('Ciphers', SSHConfig._set_string),
|
||||
('Compression', SSHConfig._set_bool),
|
||||
('ConnectTimeout', SSHConfig._set_int),
|
||||
('EnableSSHKeySign', SSHConfig._set_bool),
|
||||
('ForwardAgent', SSHConfig._set_bool),
|
||||
('ForwardX11Trusted', SSHConfig._set_bool),
|
||||
('GlobalKnownHostsFile', SSHConfig._set_string_list),
|
||||
('GSSAPIAuthentication', SSHConfig._set_bool),
|
||||
('GSSAPIDelegateCredentials', SSHConfig._set_bool),
|
||||
('GSSAPIKeyExchange', SSHConfig._set_bool),
|
||||
('HostbasedAuthentication', SSHConfig._set_bool),
|
||||
('HostKeyAlgorithms', SSHConfig._set_string),
|
||||
('Hostname', _set_hostname),
|
||||
('HostKeyAlias', SSHConfig._set_string),
|
||||
('IdentitiesOnly', SSHConfig._set_bool),
|
||||
('IdentityAgent', SSHConfig._set_string),
|
||||
('IdentityFile', SSHConfig._append_string),
|
||||
('KbdInteractiveAuthentication', SSHConfig._set_bool),
|
||||
('KexAlgorithms', SSHConfig._set_string),
|
||||
('MACs', SSHConfig._set_string),
|
||||
('PasswordAuthentication', SSHConfig._set_bool),
|
||||
('PKCS11Provider', SSHConfig._set_string),
|
||||
('PreferredAuthentications', SSHConfig._set_string),
|
||||
('Port', SSHConfig._set_int),
|
||||
('ProxyCommand', SSHConfig._set_string_list),
|
||||
('ProxyJump', SSHConfig._set_string),
|
||||
('PubkeyAuthentication', SSHConfig._set_bool),
|
||||
('RekeyLimit', SSHConfig._set_rekey_limits),
|
||||
('RemoteCommand', SSHConfig._set_string),
|
||||
('RequestTTY', _set_request_tty),
|
||||
('SendEnv', SSHConfig._append_string_list),
|
||||
('ServerAliveCountMax', SSHConfig._set_int),
|
||||
('ServerAliveInterval', SSHConfig._set_int),
|
||||
('SetEnv', SSHConfig._append_string_list),
|
||||
('TCPKeepAlive', SSHConfig._set_bool),
|
||||
('User', SSHConfig._set_string),
|
||||
('UserKnownHostsFile', SSHConfig._set_string_list)
|
||||
)}
|
||||
|
||||
|
||||
class SSHServerConfig(SSHConfig):
|
||||
"""Settings from an OpenSSH server config file"""
|
||||
|
||||
def __init__(self, last_config: 'SSHConfig', reload: bool,
|
||||
local_addr: str, local_port: int, user: str,
|
||||
host: str, addr: str) -> None:
|
||||
super().__init__(last_config, reload)
|
||||
|
||||
self._local_addr = local_addr
|
||||
self._local_port = local_port
|
||||
self._user = user
|
||||
self._host = host or addr
|
||||
self._addr = addr
|
||||
|
||||
def _match_val(self, match: str) -> object:
|
||||
"""Return the value to match against in a match condition"""
|
||||
|
||||
if match == 'localaddress':
|
||||
return self._local_addr
|
||||
elif match == 'localport':
|
||||
return str(self._local_port)
|
||||
elif match == 'user':
|
||||
return self._user
|
||||
elif match == 'host':
|
||||
return self._host
|
||||
elif match == 'address':
|
||||
return self._addr
|
||||
else:
|
||||
return None
|
||||
|
||||
def _set_tokens(self) -> None:
|
||||
"""Set the tokens available for percent expansion"""
|
||||
|
||||
self._tokens.update({'u': self._user})
|
||||
|
||||
_handlers = {option.lower(): (option, handler) for option, handler in (
|
||||
('Match', SSHConfig._match),
|
||||
('Include', SSHConfig._include),
|
||||
|
||||
('AddressFamily', SSHConfig._set_address_family),
|
||||
('AuthorizedKeysFile', SSHConfig._set_string_list),
|
||||
('AllowAgentForwarding', SSHConfig._set_bool),
|
||||
('BindAddress', SSHConfig._set_string),
|
||||
('CASignatureAlgorithms', SSHConfig._set_string),
|
||||
('ChallengeResponseAuthentication', SSHConfig._set_bool),
|
||||
('Ciphers', SSHConfig._set_string),
|
||||
('ClientAliveCountMax', SSHConfig._set_int),
|
||||
('ClientAliveInterval', SSHConfig._set_int),
|
||||
('Compression', SSHConfig._set_bool),
|
||||
('GSSAPIAuthentication', SSHConfig._set_bool),
|
||||
('GSSAPIKeyExchange', SSHConfig._set_bool),
|
||||
('HostbasedAuthentication', SSHConfig._set_bool),
|
||||
('HostCertificate', SSHConfig._append_string),
|
||||
('HostKey', SSHConfig._append_string),
|
||||
('KbdInteractiveAuthentication', SSHConfig._set_bool),
|
||||
('KexAlgorithms', SSHConfig._set_string),
|
||||
('LoginGraceTime', SSHConfig._set_int),
|
||||
('MACs', SSHConfig._set_string),
|
||||
('PasswordAuthentication', SSHConfig._set_bool),
|
||||
('PermitTTY', SSHConfig._set_bool),
|
||||
('Port', SSHConfig._set_int),
|
||||
('PubkeyAuthentication', SSHConfig._set_bool),
|
||||
('RekeyLimit', SSHConfig._set_rekey_limits),
|
||||
('TCPKeepAlive', SSHConfig._set_bool),
|
||||
('UseDNS', SSHConfig._set_bool)
|
||||
)}
|
||||
Reference in New Issue
Block a user