chv-cell: ns 内 setuid 修复 + 单元内 DNS(上游 resolv.conf 绑定)

- 中间进程不再先 setuid(1000) 再 unshare:真机对照实测那样
  后续 unshare(CLONE_NEWNS) 得 EPERM;改为保持 root 外层身份
  unshare(creator caps 在),孙进程在 ns 内 setgid/setuid(0)
  切到内 0(映射到外层 chv 1000)。
- 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元
  独立 netns 里自己的 lo 上没有 resolver;把上游
  /run/systemd/resolve/resolv.conf 绑到 /etc/resolv.conf
  (解析 symlink 后挂真实目标),只在单元私有 mount ns 里。
- 单测:CellResolvTest(bind 参数/symlink 解析/缺失回落)
  + 规约 dns_resolv_conf 字段;P2 + 阶段 2 共 73 项全过。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent afbb7772c5
commit 4b5f2f124f
2 files changed
+104 -13

No files matched your search

+58 -13
View File
@@ -72,6 +72,10 @@ MACHINE = "chv-cell"
UID_BASE = 1000
CELL_USER = "chv"
CELL_UID = 1000
# 单元内 DNS:客机 /etc/resolv.conf 是 127.0.0.53 stub,单元独立
# netns 里自己的 lo 上没有 resolver;上游文件里是真实上游。
RESOLV_UPSTREAM = "/run/systemd/resolve/resolv.conf"
RESOLV_CONF = "/etc/resolv.conf"
CELL_GID = 1000
WORKSPACE = "/home/chv/workspace"
CODEX_HOME = "/home/chv/.codex"
@@ -253,6 +257,7 @@ def build_sandbox_spec(command, network="veth",
"env": display_env,
"hide": list(INACCESSIBLE_BASE) + [playwright_package_dir],
"tmpfs": ["/tmp"],
"dns_resolv_conf": "upstream-bind",
"network": network,
"host_ip": HOST_IP,
"cell_ip": CELL_IP,
@@ -352,9 +357,12 @@ def _write_id_maps_for_pid(pid, uid=CELL_UID, gid=CELL_GID):
子进程自己写不进——unshare 后它的 euid 变成 overflow,
连 /proc/self/uid_map 的 open 都 EPERM(2026-11-07 真机
实测)。这是标准做法(unshare/bwrap 都是父进程写子进程
的映射)。注意:映射的外层 uid 必须是子进程当前的外层
uid——中间进程须先 setuid(1000) 再 unshare,否则映射盖
不住它、它在新 ns 里仍是 overflow。失败即抛 RuntimeError。"""
的映射)。注意:中间进程保持 root 外层身份 unshare,
映射写上后它在新 ns 里仍是 overflow uid,但 creator
caps 在、能继续 unshare 其它 ns;切内 0 由孙进程在
ns 内 setuid(0) 完成(见 _grandchild_main)。中间进程
绝不能先 setuid(1000) 再 unshare——实测那样后续
unshare(CLONE_NEWNS) 得 EPERM。失败即抛 RuntimeError。"""
try:
with open("/proc/%d/setgroups" % pid, "w") as fh:
fh.write("deny")
@@ -377,9 +385,35 @@ def _mount(args, what):
% (what, (proc.stderr or proc.stdout or "").strip()))
def _resolv_bind(source=RESOLV_UPSTREAM, target=RESOLV_CONF):
"""返回把上游 resolv.conf 绑到单元 /etc/resolv.conf 的 mount
参数,或 None(无上游文件/无 nameserver 时跳过,单元内
DNS 保持现状)。客机 /etc/resolv.conf 是 127.0.0.53 的
stub(systemd-resolved),单元有独立 netns、自己的 lo 上
没有 resolver,直接沿用则 DNS 全灭;上游文件
/run/systemd/resolve/resolv.conf 里是真实上游(8.8.8.8
等),经 veth NAT 可达。target 是 symlink 时挂到真实目标
(stub 文件),挂载只在单元的私有 mount ns 里,不动宿主。"""
if not os.path.isfile(source):
return None
try:
with open(source, "r", encoding="utf-8",
errors="replace") as f:
content = f.read()
except OSError:
return None
if "nameserver" not in content:
return None
real_target = os.path.realpath(target)
if not os.path.isfile(real_target):
return None
return ["--bind", source, real_target]
def _setup_mounts(playwright_package_dir=None):
"""单元内挂载:根 rprivate、fresh /proc、/tmp tmpfs、
敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)。
敏感路径遮蔽(目录盖 tmpfs,文件绑 /dev/null)、上游
resolv.conf 绑到 /etc/resolv.conf(单元内 DNS)。
只在新 mount ns 里做,不动宿主挂载表。"""
if playwright_package_dir is None:
playwright_package_dir = _playwright_package_dir()
@@ -394,6 +428,12 @@ def _setup_mounts(playwright_package_dir=None):
else:
print("chv-cell: 遮蔽目标不存在,跳过:%s" % path,
file=sys.stderr)
bind = _resolv_bind()
if bind:
_mount(bind, "DNS resolv.conf")
else:
print("chv-cell: 无上游 resolv.conf,单元内 DNS 可能不通",
file=sys.stderr)
def parse_default_route_iface(ip_route_output):
@@ -547,11 +587,17 @@ def _set_pdeathsig(sig):
def _grandchild_main(command, network, ready_w, go_r):
"""单元 init(新 pid ns 里的 pid 1):挂载、加固、报 READY、
等放行、exec payload。任何一步失败写 ERR 后退出(父进程
收到 ERR 即大声失败,不跑 payload)。"""
"""单元 init(新 pid ns 里的 pid 1):切内 0、挂载、加固、
报 READY、等放行、exec payload。任何一步失败写 ERR 后退出
(父进程收到 ERR 即大声失败,不跑 payload)。"""
try:
_set_pdeathsig(9) # SIGKILL:中间进程死即自杀,不留孤儿
# 在 ns 内切到内 0(映射到外层 chv 1000):此时进程是
# creator、有新 ns 的全部 caps,setgid/setuid(0) 合法;
# 必须先 setgid 再 setuid。绝不能在 unshare 之前以外层
# 身份 setuid——那会弄丢后续 unshare 的能力(见上)。
os.setgid(0)
os.setuid(0)
_setup_mounts()
_set_no_new_privileges()
_apply_seccomp_filter()
@@ -595,13 +641,12 @@ def _intermediate_main(command, network, pid_w, ready_r, ready_w,
只做命名空间搭建与收割。"""
try:
_set_pdeathsig(15) # SIGTERM:父进程死即退出,带走单元
# 先以外层 chv 身份再 unshare:映射按外层 uid 翻译,
# 外层不是 1000 时 “0 1000 1” 盖不住进程(2026-11-07
# 真机实测:root 外层 unshare 后映射写上了、进程在新
# ns 里仍是 overflow)。setuid 前须先清附加组、设 gid。
# 只设附加组(让它映射到内 0),不改 uid/gid:中间进程
# 保持 root 外层身份 unshare(creator caps 在,后续
# unshare 其它 ns 才通)。绝不能先 setuid(1000) 再
# unshare——2026-11-07 真机对照实测:那样后续
# unshare(CLONE_NEWNS) 得 EPERM;不 setuid 则通。
os.setgroups([CELL_GID])
os.setgid(CELL_GID)
os.setuid(CELL_UID)
os.unshare(CLONE_NEWUSER)
# 映射由父进程写(子进程自己写不进,见
# _write_id_maps_for_pid);先报“已 unshare”,再等放行。
+46
View File
@@ -111,6 +111,52 @@ class CellArgvTest(unittest.TestCase):
self.assertNotIn("GITEA_TOKEN", real)
def test_dns_resolv_conf_upstream_bind(self):
spec = CELL.build_sandbox_spec(["true"])
self.assertEqual(spec["dns_resolv_conf"], "upstream-bind")
class CellResolvTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="cell-resolv-")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_upstream_bind_args(self):
upstream = os.path.join(self.tmp, "resolv.conf")
with open(upstream, "w", encoding="utf-8") as f:
f.write("nameserver 8.8.8.8\nnameserver 1.1.1.1\n")
target = os.path.join(self.tmp, "target-resolv.conf")
with open(target, "w", encoding="utf-8") as f:
f.write("nameserver 127.0.0.53\n")
self.assertEqual(CELL._resolv_bind(upstream, target),
["--bind", upstream, target])
def test_symlink_target_resolved(self):
upstream = os.path.join(self.tmp, "resolv.conf")
with open(upstream, "w", encoding="utf-8") as f:
f.write("nameserver 8.8.8.8\n")
real = os.path.join(self.tmp, "stub-resolv.conf")
with open(real, "w", encoding="utf-8") as f:
f.write("nameserver 127.0.0.53\n")
link = os.path.join(self.tmp, "resolv-link")
os.symlink(real, link)
self.assertEqual(CELL._resolv_bind(upstream, link),
["--bind", upstream, real])
def test_missing_or_no_nameserver_returns_none(self):
target = os.path.join(self.tmp, "target-resolv.conf")
with open(target, "w", encoding="utf-8") as f:
f.write("nameserver 127.0.0.53\n")
self.assertIsNone(CELL._resolv_bind(
os.path.join(self.tmp, "no-such-file"), target))
empty = os.path.join(self.tmp, "empty-resolv.conf")
with open(empty, "w", encoding="utf-8") as f:
f.write("search .\n")
self.assertIsNone(CELL._resolv_bind(empty, target))
class CellSeccompTest(unittest.TestCase):
def test_filter_structure(self):
raw = CELL.build_seccomp_filter()