Skip to content

Commit bba9090

Browse files
committed
feat: v4.0 managed-user image, egress IP auto-correction, and FK fix
- managed-user image: bump to v4.0.0, use variable user/group names, remove v3 version labels from log messages, fix DEV_MODE detection - egress IP auto-correction: when SOCKS5 probe returns a different IP than user-configured ExpectedIP, automatically update egress_ips via UpdateEgressIPAddress callback so host creation doesn't fail just because the user entered the proxy server IP instead of the actual egress IP - egress-ip-drawer: simplify IP placeholder logic, always use 0.0.0.0 when user leaves IP blank — backend probe corrects it - fix: drop FK constraints on host_bypass_snapshots.created_by and host_bypass_audit_log.actor_id — audit columns referencing a stale user_id (e.g. after DB rebuild) caused all bypass apply/rollback/ rule operations to fail with 500 - cleanup: normalize comments across entrypoint.sh, Makefile, firewall_helpers, container_proxy_provider, and network types
1 parent 7eb9d41 commit bba9090

13 files changed

Lines changed: 94 additions & 47 deletions

File tree

.planning/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"research_before_questions": false,
2727
"discuss_mode": "discuss",
2828
"skip_discuss": false,
29-
"_auto_chain_active": true
29+
"_auto_chain_active": false
3030
},
3131
"hooks": {
3232
"context_warnings": true

Makefile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ dev: ## Start backend + frontend (auto-starts PostgreSQL if needed)
2020
@# Auto-start PostgreSQL if not running
2121
@nc -z 127.0.0.1 $(POSTGRES_PORT) > /dev/null 2>&1 || \
2222
{ echo "PostgreSQL not running, starting it now..."; $(MAKE) db; }
23-
@# v4.0 (Phase 54): sing-box 同容器化后不再需要 sidecar gateway image;
24-
@# managed-user 镜像内置 sing-box(Phase 53),非 Linux host 直接跑无需 build。
23+
@# managed-user 镜像内置 sing-box,非 Linux host 直接跑无需 build。
2524
@trap 'kill $$CP_PID $$VITE_PID 2>/dev/null; wait' INT EXIT; \
2625
bash scripts/dev-backend.sh & CP_PID=$$!; \
2726
cd web/admin && pnpm dev & VITE_PID=$$!; \

deploy/docker/managed-user/entrypoint.sh

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,12 @@ detect_platform() {
88
local testfile="/tmp/.dev_mode_test_$$"
99
if ! touch "$testfile" 2>/dev/null || ! chattr +i "$testfile" 2>/dev/null; then
1010
DEV_MODE=true
11+
rm -f "$testfile"
1112
else
1213
chattr -i "$testfile" 2>/dev/null || true
1314
rm -f "$testfile"
1415
DEV_MODE=false
1516
fi
16-
if [ "$DEV_MODE" = false ] && [ ! -d /sys/class/net/tun0 ] && [ -f /.dockerenv ]; then
17-
DEV_MODE=true
18-
fi
1917
readonly DEV_MODE
2018
echo "[entrypoint] platform: DEV_MODE=$DEV_MODE"
2119
}
@@ -77,11 +75,10 @@ wait_for_x_display() {
7775
return 1
7876
}
7977

80-
# ===== v3.0 stages — D-09 / PITFALLS M4 串行快速失败 =====
8178

82-
prepare_v3_dirs() {
83-
echo "[entrypoint] v3: chown /home/claude /workspace-hot /workspace-cold /var/lib/claude-persist"
84-
chown -R 1000:1000 \
79+
prepare_workspace_dirs() {
80+
echo "[entrypoint] chown workspace dirs"
81+
chown -R "${RUN_USER}:${RUN_USER}" \
8582
/home/claude \
8683
/workspace-hot \
8784
/workspace-cold \
@@ -99,16 +96,16 @@ prepare_persistent_state() {
9996
cp -an /home/claude/.cache/claude/. "$root/.cache/claude/" 2>/dev/null || true
10097
fi
10198

102-
chown -R 1000:1000 "$root"
99+
chown -R "${RUN_USER}:${RUN_USER}" "$root" 2>/dev/null || true
103100

104101
rm -rf /home/claude/.claude /home/claude/.cache/claude
105102
ln -sfn "$root/.claude" /home/claude/.claude
106103
mkdir -p /home/claude/.cache
107104
ln -sfn "$root/.cache/claude" /home/claude/.cache/claude
108105

109-
chown -h 1000:1000 /home/claude/.claude /home/claude/.cache/claude
106+
chown -h "${RUN_USER}:${RUN_USER}" /home/claude/.claude /home/claude/.cache/claude 2>/dev/null || true
110107

111-
echo "[entrypoint] v3: persistent state ready (volume=/var/lib/claude-persist)"
108+
echo "[entrypoint] persistent state ready (volume=/var/lib/claude-persist)"
112109
}
113110

114111
prepare_container_disguise() {
@@ -143,30 +140,29 @@ ENVTELEM
143140

144141
prepare_mergerfs_check() {
145142
if ! command -v mergerfs >/dev/null 2>&1; then
146-
echo "[entrypoint] v3: FATAL mergerfs binary missing" >&2
143+
echo "[entrypoint] FATAL mergerfs binary missing" >&2
147144
exit 1
148145
fi
149146
local ver
150147
ver="$(mergerfs --version 2>&1 | head -n1 || true)"
151-
echo "[entrypoint] v3: mergerfs available ($ver) — mount deferred to cloud-claude (Phase 31)"
152-
# SC1 / C1 / C2 — documented params for Phase 31 (static traceability):
148+
echo "[entrypoint] mergerfs available ($ver) — mount deferred to cloud-claude"
149+
# mergerfs params (static traceability):
153150
# func.readdir=cor:4,cache.attr=30,cache.entry=30,cache.readdir=true,cache.files=off
154151
# category.create=ff, inodecalc=path-hash
155152
# 2-way: /workspace-hot=RW:/workspace-cold=NC,RO
156-
# Q10: CLOUD_CLAUDE_MERGERFS_BRANCHES reserved for Phase 31
157-
echo "[entrypoint] v3: mergerfs params (Phase 31): func.readdir=cor:4 category.create=ff inodecalc=path-hash"
153+
echo "[entrypoint] mergerfs params: func.readdir=cor:4 category.create=ff inodecalc=path-hash"
158154
}
159155

160156
assert_tmux_version() {
161157
local tmux_ver
162158
tmux_ver="$(tmux -V 2>/dev/null | awk '{print $2}' || true)"
163159
case "$tmux_ver" in
164160
3.4*|3.5*|3.6*|3.7*|3.8*|3.9*|[4-9].*)
165-
echo "[entrypoint] v3: tmux ${tmux_ver} >= 3.4 ok"
161+
echo "[entrypoint] tmux ${tmux_ver} >= 3.4 ok"
166162
echo "$tmux_ver" >/etc/cloud-claude/tmux.version
167163
;;
168164
*)
169-
echo "[entrypoint] v3: FATAL tmux ${tmux_ver} < 3.4" >&2
165+
echo "[entrypoint] FATAL tmux ${tmux_ver} < 3.4" >&2
170166
exit 1
171167
;;
172168
esac
@@ -303,7 +299,7 @@ apply_nft_or_die() {
303299
if [ -n "$SING_BOX_PID" ]; then kill "$SING_BOX_PID" 2>/dev/null || true; fi
304300
exit 1
305301
fi
306-
# 清理 v3.x 残留的 ip cloudproxy 表,避免双表 shadow 导致规则不生效
302+
# TODO(v5): 清理 v3.x 残留的 ip cloudproxy 表。所有 v3.x 部署迁移完毕后移除此行。
307303
nft delete table ip cloudproxy 2>/dev/null || true
308304
if ! nft -f "$NFT_RULESET"; then
309305
echo "[entrypoint] FATAL: nft 应用失败" >&2
@@ -382,14 +378,13 @@ fi
382378
if [ "$CONTAINER_USER" != "workspace" ] && id workspace >/dev/null 2>&1; then
383379
usermod -l "$CONTAINER_USER" workspace
384380
groupmod -n "$CONTAINER_USER" workspace 2>/dev/null || true
385-
# v4.0 (D-53-4): 不再 sed sudoers — v4.0 移除了用户 sudo NOPASSWD 路径。
381+
# v4.0: 用户无 sudo 权限(安全基线)
386382
fi
387383

388384
RUN_USER="${CONTAINER_USER:-workspace}"
389385

390-
# v4.0 (D-53-4): 删除 v3.x 的 sudoers NOPASSWD 写入。
391-
# 用户拿到 shell 后不再有任何 sudo / root 提权路径。
392-
# 兜底清理(防御历史镜像残留 / volume 挂载的 sudoers.d):
386+
# v4.0: 用户无 sudo 权限。兜底清理历史镜像残留的 sudoers.d:
387+
# TODO(v5): 移除此 v3.x sudoers 兜底清理。
393388
rm -f /etc/sudoers.d/workspace 2>/dev/null || true
394389
if [ "${RUN_USER}" != "workspace" ]; then
395390
rm -f "/etc/sudoers.d/${RUN_USER}" 2>/dev/null || true
@@ -408,7 +403,7 @@ fi
408403

409404
echo "${RUN_USER}:${CONTAINER_PASSWORD}" | chpasswd
410405

411-
# Phase 29.1: 验证密码已被成功设置,避免"密码退化为 workspace"的静默失败复现。
406+
# 验证密码已被成功设置,避免"密码退化为 workspace"的静默失败复现。
412407
# passwd -S 第 2 列语义:P / PS = 已设置密码;L / LK = 已锁定;NP = 无密码;UNSET = 读取失败。
413408
status="$(passwd -S "${RUN_USER}" 2>/dev/null | awk '{print $2}' || echo UNSET)"
414409
case "$status" in
@@ -575,19 +570,13 @@ su "${RUN_USER}" -c 'DISPLAY=:99 HOME=/workspace pcmanfm --desktop --profile def
575570
# 预热一遍 Chromium 检测,方便排查图标点击失败。
576571
su "${RUN_USER}" -c 'HOME=/workspace /usr/local/bin/launch-chromium.sh --version' >>"${CHROMIUM_LOG}" 2>&1 || true
577572

578-
# ===== v3.0 stages (serialized fail-fast, D-09 order) =====
579-
prepare_v3_dirs
573+
prepare_workspace_dirs
580574
prepare_persistent_state
581575
prepare_container_disguise
582576
prepare_mergerfs_check
583577
assert_tmux_version
584578

585579
fi # end MODE != "local"
586580

587-
# v4.0 (Phase 53): 旧的 MODE=local sing-box 分支已删除。
588-
# 出网现在由顶部 start_singbox_or_die / apply_nft_or_die 序列统一处理,
589-
# 所有 MODE 都强制走 sing-box tun + nft default-deny,没有 proxy fallback。
590-
# 用户的 ALL_PROXY / HTTP_PROXY 环境变量也不再注入(应用应直接走 tun)。
591-
592581
# Foreground: sshd
593582
exec /usr/sbin/sshd -D -e

deploy/docker/managed-user/image.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ default_user: workspace
88
rebuild_mode_default: preserve-home
99
factory_reset_mode: wipe-/workspace
1010
# v3.0 baseline — Phase 29 追加
11-
image_version: v3.3.0
11+
image_version: v4.0.0
1212
mergerfs_version: 2.41.1
1313
tmux_version_min: "3.4"
1414
supports_mergerfs: true

internal/controlplane/http/admin_egress_ip_probe.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ import (
2222
"golang.org/x/net/proxy"
2323
)
2424

25-
// probeImage 固定 sing-box 版本,与项目自建 gateway 镜像保持一致(v1.13.3),
26-
// 避免 latest 版本配置格式不兼容导致探针失败。
25+
// probeImage 固定 sing-box 版本(v1.13.3),避免 latest 版本配置格式不兼容导致探针失败。
2726
const probeImage = "ghcr.io/sagernet/sing-box:v1.13.3"
2827

2928
type ProbeResult struct {

internal/network/container_proxy_provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func NewContainerProxyProvider(logger *slog.Logger, verifier NetworkVerifier) *C
3838
// v4.0 (Phase 54) 改造(54-01):
3939
// - 不再创建 cloudproxy-net-* 自定义 bridge(删除 dockerNetworkCreate 调用)
4040
// - 不再启动 sidecar gateway 容器(删除 dockerRunGateway / waitGatewayHealthy)
41-
// - 不再写 v3.5 容器 DNS 入口锁占位(resolv.conf / nsswitch.conf 由容器内 sing-box
41+
// - 不再写容器 DNS 入口锁占位(resolv.conf / nsswitch.conf 由容器内 sing-box
4242
// 接管,删除 WriteContainerDNSConfig 调用)
4343
// - 不再写 v4 sing-box 路径下的 cidrs / domains placeholder(由 sing-box config
4444
// 的 route.rule_set 直接拉取,54-02 决定具体格式)

internal/network/firewall_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func addIifTCPDportAcceptRule(conn *nftables.Conn, table *nftables.Table, chain
169169
})
170170
}
171171

172-
// ----- Phase 47 Plan 02: v3.5 bypass-firewall helpers ----------------------
172+
// ----- bypass-firewall helpers ---------------------------------------------
173173
//
174174
// 这一组 helper 把 4 类新规则的 expr 序列构造抽成纯函数(buildXxxExprs),
175175
// 再由配套的 addXxxRule wrapper 负责 conn.AddRule(...) 真实下发。

internal/network/types.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package network
22

33
import (
4+
"context"
45
"encoding/json"
56
)
67

@@ -20,6 +21,11 @@ type EgressConfig struct {
2021
ExpectedIP string // expected egress IP address (e.g. "1.2.3.4")
2122
TunnelType string // "proxy"
2223
Proxy *ProxySpec // proxy config
24+
25+
// UpdateExpectedIP 为可选的出口 IP 自动纠正回调。
26+
// 当 SOCKS5 探测一致返回与 ExpectedIP 不同的 IP 时,verifier 调用此回调
27+
// 更新 egress_ips.ip_address,避免因用户填错代理服务器 IP 导致创建失败。
28+
UpdateExpectedIP func(ctx context.Context, newIP string) error
2329
}
2430

2531
// HostNetworkSpec carries everything the network Provider needs to wire a container.
@@ -29,13 +35,9 @@ type HostNetworkSpec struct {
2935
Egress *EgressConfig // nil when Provider should skip network setup
3036
}
3137

32-
// BypassSingboxUID 是 gateway 容器内 sing-box 进程的 uid(与 sing-gateway Dockerfile 对齐)。
33-
// 在 worker netns 的 nft output 链做 uid 锁:仅该 uid 能 TCP 连到代理服务器 IP:443。
34-
// 注意:worker netns 与 gateway netns 是两个 namespace,meta skuid 匹配的是
35-
// worker 容器内的进程 uid。v3.5 设计下 sing-box 跑在 gateway 容器,worker 容器内
36-
// 没有 sing-box,故 uid 锁实际作用是:worker 容器内任何 uid 都不能直连代理服务器
37-
// (除非通过 sb-tun0),这是 fail-closed 的最强形态。暴露该常量是为了未来如果切到
38-
// sidecar 同 netns 模式时保持单点变更。
38+
// BypassSingboxUID 是容器内 sing-box 进程的 uid(与 Dockerfile 对齐)。
39+
// uid 锁确保仅 sing-box 能直连代理服务器,其余进程 uid 一律走 tun0。
40+
// 暴露该常量是为了未来如果切到同 netns 模式时保持单点变更。
3941
const BypassSingboxUID uint32 = 1000
4042

4143
// BypassNftSetName 是 worker netns nft inet 表内白名单 set 的名字。

internal/network/verify.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ func VerifyNetworkIntegrityDocker(ctx context.Context, containerName string, exp
179179
result.BypassEgressOK = true
180180
result.NonBypassEgressOK = true
181181

182+
if result.ActualEgressIP != "" && result.ActualEgressIP != expected.ExpectedIP && expected.UpdateExpectedIP != nil {
183+
if err := expected.UpdateExpectedIP(ctx, result.ActualEgressIP); err == nil {
184+
expected.ExpectedIP = result.ActualEgressIP
185+
verifyEgressIPMultiSOCKS(ctx, prefix, expected.ExpectedIP, egressIPSources, &result)
186+
}
187+
}
182188
lastErr = firstNetworkError(expected, result)
183189
if result.AllPassed() {
184190
return result, nil

internal/runtime/tasks/ssh_inject_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ func (r *fakeWorkerRepo) GetLatestAppliedBypassSnapshot(_ context.Context, _ str
107107
return repository.BypassSnapshot{}, nil
108108
}
109109

110+
func (r *fakeWorkerRepo) UpdateEgressIPAddress(_ context.Context, _ string, _ string) error {
111+
return nil
112+
}
113+
110114
// setupInjectTest 装配 fake 容器、fake repo、代理公钥与 execInContainer 注入点,
111115
// 返回 worker + 容器 + repo,供测试断言。
112116
func setupInjectTest(t *testing.T, proxyPub string) (*Worker, *fakeContainer, *fakeWorkerRepo) {

0 commit comments

Comments
 (0)