fix(chv-muse-r12fix3-2): worker socket 组权限 + allowed_uids 含 chv(P6 缺口 ②)
R12 复测 2 钉死:即使 worker active,chv 客户端也永远连不上 socket——worker root + 目录 /run/chv-gitea 0700 root + socket 0600 root + allowed_uids=(0,) 三处锁死;chv ping 一律 EACCES (rc=2),root ping rc=0,privsep_worker_call 恒为 0,全部同步 走回落。 修法:三处配套改,只改一处仍锁死—— 1. chv-gitea-worker.py:DEFAULT_ALLOWED_UIDS=(0, 1000)(root + chv;round-11 authd 同款;环境变量 CHV_GITEA_WORKER_ALLOWED_UIDS 可覆盖);socket 用 group 权限 (目录 0750、socket 0660、组为第一个非 root 放行 uid 的主组, _socket_group_gid/_set_group_perms 与 authd 同款实现;gid 解析不出时回落 root-only 0700/0600,绝不放给全员)。 拒绝 0666 探针口径。 2. build.sh tmpfiles.d:d /run/chv-gitea 改为 0750 root 1000,与 新方案一致。gid 写数字 1000 而不用组名 chv——tmpfiles-setup 跑在 07:23:24,chv 用户由 cloud-init 在 07:23:40 才建,组名 当时解析不出;1000 是 chv 的 uid/gid(seed users 块未指定 uid/gid,cloud-init 取首个空闲 1000;authd 同款假设)。 worker 启动时再按 passwd 重新推导 gid 并重设权限,tmpfiles 行只是开机存在性保证。审计日志仍 0600 root,不动。 3. 服务单元 ReadWritePaths 不动(/run/chv-gitea 仍在列,存在性 由 tmpfiles 保证)。 注意:单元内 agent 同样映射为 chv uid 1000,故也能走到 socket, 但只能走类型化协议——操作白名单、仓库 confinement、安全扫描三 处仍在,worker 不持凭证(见 worker 模块注释)。 测试:WorkerSocketGroupPermsTest 新增 7 项——默认白名单含 (0,1000)、环境变量覆盖、gid 可推导时 0750/0660 + 组正确、显式 socket_group 时同样组权限、只有 root 时回落 0700/0600、gid 推导 函数四档、tmpfiles 行 0750 root 1000;WorkerUnitTmpfilesTest 的 /run/chv-gitea 期望改 0750。P6/round12/P2/P4 四模块 205 项全过。
This commit is contained in:
1 parent
e31354dc58
commit
7cba3c2b21
4 files changed
+243
-27
No files matched your search
+10
-1
@@ -407,7 +407,16 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
||||
# 跑、早于 multi-user 的服务启动,且与 P4 的 /run/chv 同款写法,
|
||||
# 不新增进镜像的文件(chv.conf 本就是 build.sh 内联生成),
|
||||
# lockfile 无需登记。模式与 worker 审计 0600 同款口径。
|
||||
--run-command 'printf "d /run/chv 2775 root chv-browser -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\nd /run/chv-browser 0755 chv-browser chv-browser -\nd /run/chv-gitea 0700 root root -\nf /var/log/chv-gitea-worker.log 0600 root root -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
|
||||
# round-12 修复轮第三轮(缺口 ②):/run/chv-gitea 改为组权限
|
||||
# 0750,与 worker 新方案一致(目录 0750 + socket 0660,组为
|
||||
# chv 主组;绝不用 0666)。gid 写数字 1000 而不用组名 chv——
|
||||
# tmpfiles-setup 跑在 07:23:24,chv 用户由 cloud-init 在
|
||||
# 07:23:40 才建,组名当时解析不出;1000 是 chv 的 uid/gid
|
||||
#(seed users 块未指定 uid/gid,cloud-init 取首个空闲 1000;
|
||||
# authd DEFAULT_ALLOWED_UIDS 同款假设)。worker 启动时再按
|
||||
# passwd 重新推导 gid 并重设目录/socket 权限,tmpfiles 行只是
|
||||
# 开机存在性保证。
|
||||
--run-command 'printf "d /run/chv 2775 root chv-browser -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\nd /run/chv-browser 0755 chv-browser chv-browser -\nd /run/chv-gitea 0750 root 1000 -\nf /var/log/chv-gitea-worker.log 0600 root root -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
|
||||
# P3 交接(Muse P3 的 authd/sentinel 叶子模块):文件在仓库里存在时
|
||||
# 拷入镜像(本分支已有,合入后自动生效);不存在时只警告、不失败——
|
||||
# M4 既定 build.sh 不硬要求 cloud/,见上。缺叶子模块时 chv-authd
|
||||
|
||||
@@ -9,8 +9,12 @@ chv-gitea-worker.service;P2 的 nspawn 单元落地后,codex 在单元内,
|
||||
socket)。它只做 Gitea 同步的 git 业务逻辑:
|
||||
|
||||
- 在 Unix socket /run/chv-gitea/worker.sock 上接受 broker/薄 CLI
|
||||
的类型化请求(SO_PEERCRED 只放行 root;agent 在单元内只能请
|
||||
broker 代办,连不上本 socket);
|
||||
的类型化请求(SO_PEERCRED 只放行 root 与 chv;socket 用 group
|
||||
权限——目录 0750、socket 0660、组为第一个非 root 放行 uid 的主
|
||||
组,round-12 修复轮第三轮缺口 ②,round-11 authd 同款口径,拒绝
|
||||
0666;chv-sync 薄客户端以 chv 身份连,单元内 agent 同样映射为
|
||||
chv uid 1000,故也只能走类型化协议——操作白名单、仓库
|
||||
confinement、安全扫描三处仍在,worker 不持凭证);
|
||||
- 操作白名单只有 pull/push/status/ping;仓库只限 ~/workspace 下;
|
||||
- git 以 chv 身份跑(runuser -u chv,文件归属不变),argv 里只配
|
||||
credential helper(P3 的 git-credential-chv-authd,见下),绝无
|
||||
@@ -40,6 +44,10 @@ DEFAULT_SOCKET_PATH = "/run/chv-gitea/worker.sock"
|
||||
DEFAULT_LOG_PATH = "/var/log/chv-gitea-worker.log"
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
SYNC_USER = "chv"
|
||||
# SO_PEERCRED 放行的 uid:root + chv(chv-sync 以 chv 身份跑薄客户
|
||||
# 端;round-11 authd 同款 DEFAULT_ALLOWED_UIDS)。可用环境变量
|
||||
# CHV_GITEA_WORKER_ALLOWED_UIDS 覆盖(逗号分隔)。
|
||||
DEFAULT_ALLOWED_UIDS = (0, 1000)
|
||||
# P3 的 credential helper(git 经它向 authd 按次取凭证)与 path
|
||||
# 配置(P3 修过的坑:git 默认不把 path 发给 helper)。
|
||||
HELPER_PATH = "/usr/local/bin/git-credential-chv-authd"
|
||||
@@ -124,6 +132,45 @@ def _truncate(text, limit=8192):
|
||||
return text
|
||||
|
||||
|
||||
def _socket_group_gid(allowed_uids, socket_group=None):
|
||||
"""socket 共享组的 gid:socket_group 显式给时优先(int 即 gid,
|
||||
str 即组名);否则取第一个非 root 放行 uid 的主组。解析不出
|
||||
(uid 无 passwd 条目、组名不存在)即 None——调用方回落
|
||||
root-only 权限(目录 0700、socket 0600),绝不把 socket 放给
|
||||
全员可读(round-11 authd 同款口径;0666 探针口径不上生产)。"""
|
||||
import grp
|
||||
import pwd
|
||||
if socket_group is not None:
|
||||
if isinstance(socket_group, int):
|
||||
return socket_group
|
||||
try:
|
||||
return grp.getgrnam(socket_group).gr_gid
|
||||
except (KeyError, OverflowError):
|
||||
return None
|
||||
for uid in sorted(allowed_uids or ()):
|
||||
if uid == 0:
|
||||
continue
|
||||
try:
|
||||
return pwd.getpwuid(uid).pw_gid
|
||||
except (KeyError, OverflowError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _set_group_perms(path, gid, mode):
|
||||
"""目录/socket 的组权限:先 chgrp(失败只放过,不中断启动),
|
||||
再 chmod。gid 为 None 时只 chmod(root-only 模式)。"""
|
||||
if gid is not None:
|
||||
try:
|
||||
os.chown(path, -1, gid)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class WorkerHandler(socketserver.BaseRequestHandler):
|
||||
"""单次请求处理(每连接一 JSON 行):鉴权 → 校验 → 跑 git →
|
||||
扫描输出 → 回包。任何一步拒绝都回 {ok:false},不含值。"""
|
||||
@@ -259,11 +306,13 @@ class WorkerServer(socketserver.ThreadingUnixStreamServer):
|
||||
allow_reuse_address = False
|
||||
|
||||
def __init__(self, socket_path, workspace, log_path,
|
||||
allowed_uids=(0,), run_git=None,
|
||||
helper_path=HELPER_PATH):
|
||||
allowed_uids=None, run_git=None,
|
||||
helper_path=HELPER_PATH, socket_group=None):
|
||||
self.workspace = workspace
|
||||
self.log_path = log_path
|
||||
self.allowed_uids = frozenset(allowed_uids)
|
||||
self.allowed_uids = frozenset(
|
||||
allowed_uids if allowed_uids is not None
|
||||
else DEFAULT_ALLOWED_UIDS)
|
||||
self.run_git = run_git
|
||||
self.helper_path = helper_path
|
||||
try:
|
||||
@@ -271,20 +320,27 @@ class WorkerServer(socketserver.ThreadingUnixStreamServer):
|
||||
self.cgroup_id = fh.readline().strip()
|
||||
except OSError:
|
||||
self.cgroup_id = ""
|
||||
# round-12 修复轮第三轮(缺口 ②):socket 用 group 权限。
|
||||
# gid 取 socket_group 显式值或第一个非 root 放行 uid 的
|
||||
# 主组;解析不出即 None,回落 root-only(0700/0600)。
|
||||
# tmpfiles.d 的 0750 行只是开机存在性保证,worker 启动时
|
||||
# 按 passwd 重新推导并重设权限,才是最终口径。
|
||||
self.socket_gid = _socket_group_gid(
|
||||
self.allowed_uids, socket_group)
|
||||
directory = os.path.dirname(socket_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
try:
|
||||
os.chmod(directory, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
if self.socket_gid is not None:
|
||||
_set_group_perms(directory, self.socket_gid, 0o750)
|
||||
else:
|
||||
_set_group_perms(directory, None, 0o700)
|
||||
if os.path.exists(socket_path):
|
||||
os.unlink(socket_path)
|
||||
super().__init__(socket_path, WorkerHandler)
|
||||
try:
|
||||
os.chmod(socket_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
if self.socket_gid is not None:
|
||||
_set_group_perms(socket_path, self.socket_gid, 0o660)
|
||||
else:
|
||||
_set_group_perms(socket_path, None, 0o600)
|
||||
|
||||
def audit(self, record):
|
||||
_audit(self.log_path, record)
|
||||
@@ -296,15 +352,23 @@ def build_server(socket_path=DEFAULT_SOCKET_PATH,
|
||||
lib_dir=DEFAULT_LIB_DIR,
|
||||
allowed_uids=None,
|
||||
run_git=None,
|
||||
helper_path=HELPER_PATH):
|
||||
helper_path=HELPER_PATH,
|
||||
socket_group=None):
|
||||
"""建服务(不启动):socket + 工作区 + 审计路径就绪即返回。
|
||||
helper 缺失不在建服务时报错——建服务时只备好 socket,git
|
||||
调用时再拒绝(fail-closed,见 _serve)。"""
|
||||
调用时再拒绝(fail-closed,见 _serve)。
|
||||
allowed_uids 不给时取环境变量 CHV_GITEA_WORKER_ALLOWED_UIDS
|
||||
(逗号分隔),再不给即默认 (0, 1000)(root + chv)。"""
|
||||
if lib_dir not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
uids = list(allowed_uids) if allowed_uids is not None else [0]
|
||||
return WorkerServer(socket_path, workspace, log_path, uids,
|
||||
run_git=run_git, helper_path=helper_path)
|
||||
if allowed_uids is None:
|
||||
raw = os.environ.get("CHV_GITEA_WORKER_ALLOWED_UIDS") or ""
|
||||
allowed_uids = ([int(p) for p in raw.split(",") if p.strip()]
|
||||
if raw.strip() else list(DEFAULT_ALLOWED_UIDS))
|
||||
return WorkerServer(socket_path, workspace, log_path,
|
||||
allowed_uids, run_git=run_git,
|
||||
helper_path=helper_path,
|
||||
socket_group=socket_group)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
|
||||
@@ -844,7 +844,7 @@ class WorkerUnitTmpfilesTest(unittest.TestCase):
|
||||
# /run 下的目录(tmpfs,开机不存在)须有 d 行;
|
||||
# 文件路径(worker 首次写审计才建)须有 f 行。
|
||||
self.assertEqual(entries.get("/run/chv-gitea"),
|
||||
("d", "0700"))
|
||||
("d", "0750"))
|
||||
self.assertEqual(entries.get("/var/log/chv-gitea-worker.log"),
|
||||
("f", "0600"))
|
||||
for path in paths:
|
||||
@@ -957,6 +957,146 @@ class WorkerUnitBootRaceTest(unittest.TestCase):
|
||||
(REPO_ROOT / "chv-guest" / name).read_text())
|
||||
|
||||
|
||||
# -- (7d) worker socket 组权限 ----------------------------------------------
|
||||
class WorkerSocketGroupPermsTest(unittest.TestCase):
|
||||
"""round-12 修复轮第三轮(缺口 ②,P6):R12 复测 2 钉死——worker
|
||||
root + 目录 0700 root + socket 0600 root + allowed_uids=(0,)
|
||||
三处锁死,chv 客户端永远 EACCES,全部同步走回落。
|
||||
|
||||
修法:三处配套改,只改一处仍锁死——目录 0750 + socket 0660
|
||||
(组为第一个非 root 放行 uid 的主组,round-11 authd 同款口径,
|
||||
拒绝 0666)+ allowed_uids 默认含 chv(0, 1000)。gid 解析不
|
||||
出时回落 root-only(0700/0600),绝不放给全员。
|
||||
"""
|
||||
|
||||
def _build(self, tmp, allowed_uids, socket_group=None):
|
||||
mod = _load_guest_worker()
|
||||
helper = os.path.join(tmp, "helper")
|
||||
Path(helper).write_text("#!/bin/sh\n")
|
||||
os.makedirs(os.path.join(tmp, "workspace"), exist_ok=True)
|
||||
server = mod.build_server(
|
||||
socket_path=os.path.join(tmp, "worker.sock"),
|
||||
workspace=os.path.join(tmp, "workspace"),
|
||||
log_path=os.path.join(tmp, "worker.log"),
|
||||
lib_dir=str(REPO_ROOT),
|
||||
allowed_uids=allowed_uids,
|
||||
run_git=lambda a, r: (0, "ok\n", ""),
|
||||
helper_path=helper,
|
||||
socket_group=socket_group)
|
||||
return mod, server
|
||||
|
||||
def _mode(self, path):
|
||||
return oct(os.stat(path).st_mode & 0o777)
|
||||
|
||||
def _first_non_root_uid(self):
|
||||
# 测试跑在 root 身份下(本 VM 的 unittest 即 root),
|
||||
# os.getuid() 不是可推导的非 root uid——从 passwd 里动态
|
||||
# 取第一个非 root 用户;没有即跳过(不在本机硬编 uid)。
|
||||
import pwd
|
||||
|
||||
for entry in pwd.getpwall():
|
||||
if entry.pw_uid != 0:
|
||||
return entry.pw_uid, entry.pw_gid
|
||||
return None, None
|
||||
|
||||
def test_default_allowed_uids_include_chv(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
mod = _load_guest_worker()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.dict(os.environ):
|
||||
os.environ.pop("CHV_GITEA_WORKER_ALLOWED_UIDS", None)
|
||||
_, server = self._build(tmp, None)
|
||||
try:
|
||||
self.assertEqual(server.allowed_uids,
|
||||
frozenset({0, 1000}))
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
def test_env_override_allowed_uids(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
mod = _load_guest_worker()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"CHV_GITEA_WORKER_ALLOWED_UIDS": "0"}):
|
||||
_, server = self._build(tmp, None)
|
||||
try:
|
||||
self.assertEqual(server.allowed_uids, frozenset({0}))
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
def test_group_perms_when_gid_resolvable(self):
|
||||
uid, gid = self._first_non_root_uid()
|
||||
if uid is None:
|
||||
self.skipTest("passwd 里无非 root 用户,推导无从测试")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_, server = self._build(tmp, [0, uid])
|
||||
try:
|
||||
self.assertEqual(server.socket_gid, gid)
|
||||
self.assertEqual(self._mode(tmp), "0o750")
|
||||
sock = os.path.join(tmp, "worker.sock")
|
||||
self.assertEqual(self._mode(sock), "0o660")
|
||||
self.assertEqual(os.stat(sock).st_gid, gid)
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
def test_group_perms_with_explicit_socket_group(self):
|
||||
# socket_group 显式给组名时不走 uid 推导:allowed 只有 root
|
||||
# 也能拿到组权限(0750/0660),gid 即该组。
|
||||
import grp
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_, server = self._build(tmp, [0], socket_group="root")
|
||||
try:
|
||||
self.assertEqual(server.socket_gid,
|
||||
grp.getgrnam("root").gr_gid)
|
||||
self.assertEqual(self._mode(tmp), "0o750")
|
||||
self.assertEqual(
|
||||
self._mode(os.path.join(tmp, "worker.sock")),
|
||||
"0o660")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
def test_root_only_fallback_when_gid_unresolvable(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
_, server = self._build(tmp, [0])
|
||||
try:
|
||||
self.assertIsNone(server.socket_gid)
|
||||
self.assertEqual(self._mode(tmp), "0o700")
|
||||
self.assertEqual(
|
||||
self._mode(os.path.join(tmp, "worker.sock")),
|
||||
"0o600")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
def test_socket_group_gid_derivation(self):
|
||||
import grp
|
||||
|
||||
mod = _load_guest_worker()
|
||||
uid, gid = self._first_non_root_uid()
|
||||
if uid is None:
|
||||
self.skipTest("passwd 里无非 root 用户,推导无从测试")
|
||||
self.assertEqual(mod._socket_group_gid([0, uid]), gid)
|
||||
self.assertEqual(
|
||||
mod._socket_group_gid([0, uid], socket_group="root"),
|
||||
grp.getgrnam("root").gr_gid)
|
||||
self.assertIsNone(mod._socket_group_gid([0]))
|
||||
self.assertIsNone(
|
||||
mod._socket_group_gid([0, uid],
|
||||
socket_group="no-such-group-xyz"))
|
||||
|
||||
def test_tmpfiles_worker_dir_group_perms(self):
|
||||
# tmpfiles 行须与新方案一致:0750 root + chv 的 gid。gid 写
|
||||
# 数字 1000 而不用组名——tmpfiles-setup 时 chv 用户还不存在,
|
||||
# 组名解析不出;worker 启动时再按 passwd 重设。
|
||||
text = (REPO_ROOT / "chv-guest" / "build.sh").read_text()
|
||||
match = re.search(r"d /run/chv-gitea (\S+) (\S+) (\S+)", text)
|
||||
self.assertIsNotNone(match, "chv.conf 里找不到 /run/chv-gitea 行")
|
||||
self.assertEqual(match.groups(), ("0750", "root", "1000"))
|
||||
|
||||
|
||||
class BuildInfoContentsTest(unittest.TestCase):
|
||||
"""round-12 修复轮(项 2,P6):worker 三件须进 BUILDINFO
|
||||
contents(无条件拷入,与 P2/P4 同款钉法)。"""
|
||||
|
||||
+10
-7
@@ -48,12 +48,13 @@ P3(authd/sentinel 本体——只读、不重写、不改接口)、不碰 P4
|
||||
(token/key/口令模式脱敏)+ `audit_inference_request/response`
|
||||
(只记指纹与 verdict,不记值;失败 best-effort)。
|
||||
- `chv-guest/chv-gitea-worker.py`(新,标准库守护进程):Unix socket
|
||||
`/run/chv-gitea/worker.sock`(0700 目录、0600 socket);SO_PEERCRED
|
||||
只放行 root(broker 走 root 连);操作白名单 pull/push/status/
|
||||
ping;仓库只限 `~/workspace` 下(realpath + commonpath);git 以
|
||||
chv 身份跑(`runuser -u chv`,文件归属不变),argv 里只配
|
||||
credential helper、绝无 token;输出先过 `safety` 扫描 + 来源标记
|
||||
再返回;超时 300 秒;异常只断本连接。
|
||||
`/run/chv-gitea/worker.sock`(目录 0750、socket 0660、组为 chv 主组,
|
||||
round-12 修复轮第三轮缺口 ②,round-11 authd 同款口径,拒绝
|
||||
0666);SO_PEERCRED 只放行 root 与 chv(chv-sync 薄客户端以 chv
|
||||
身份连);操作白名单 pull/push/status/ping;仓库只限 `~/workspace`
|
||||
下(realpath + commonpath);git 以 chv 身份跑(`runuser -u chv`,
|
||||
文件归属不变),argv 里只配 credential helper、绝无 token;输出先
|
||||
过 `safety` 扫描 + 来源标记再返回;超时 300 秒;异常只断本连接。
|
||||
- `chv-guest/chv-gitea-worker.service`(新):systemd 单元(root 运行,
|
||||
单元外;`ProtectSystem=strict`、`PrivateTmp`、`NoNewPrivileges`、
|
||||
`CapabilityBoundingSet=` 空、`RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
@@ -168,7 +169,9 @@ Base64、零宽字符)、藏在图片/附件/二进制里的指令、需要理
|
||||
- worker 的 systemd 沙箱在真机上生效(`systemctl status`、cgroup
|
||||
路径、受限调用被挡的表现)。
|
||||
- broker → worker → helper → authd → git 全链路在真机上跑通
|
||||
pull/push(真 Gitea、真 token 替身)。
|
||||
pull/push(真 Gitea、真 token 替身)。round-12 修复轮第三轮
|
||||
(缺口 ②)已改 socket 组权限 + allowed_uids 含 chv,待真机复测
|
||||
3 验证 chv ping rc=0 且 privsep_worker_call > 0。
|
||||
- worker 崩溃后 agent 侧只收到类型化失败、任务不炸(杀 worker
|
||||
进程复现)。
|
||||
- 安全扫描在真机 git 输出上的误报率抽查(真实 commit 信息样本)。
|
||||
Reference in new issue
Block a user