envd 深度阅读 · 第 1 讲:心智模型与启动编排

代码仓库:e2b-dev/infra 本文基于:commit b8ca332f4 · envd v0.5.13 阅读时间:25–30 分钟(跟着源码走)


TL;DR

  • envd 是跑在每个 Firecracker microVM 内部的守护进程,监听 49983 端口,对外提供 Process/Filesystem 两套 Connect RPC + 一组 REST API(/init/files/health)。
  • main.go 不到 250 行就把整个启动链串了起来,它的顺序不是随便写的 —— MMDS 轮询先起、logger 等 MMDS、cgroup 在 HTTP 服务前建好,都是为了避免后续路径拿不到依赖。
  • /init 是 envd 的灵魂。它一口气完成"鉴权 + 对时 + 装环境变量 + 注入 access token + 改 /etc/hosts + 装 CA 证书 + 挂 NFS",并且用 memguard + SecureToken 抽象把敏感数据锁在加密内存里。
  • 鉴权是双通道X-Access-Token 走常量时间比较(热路径),/init 额外用 MMDS 里的 token hash 做旁路授权 —— 这让 orchestrator 在 Resume 时可以远程轮换 token,又不泄漏 token 明文给任何参与者。

一、背景:envd 在 E2B 里的位置

E2B 给 LLM agent 提供"一个可以安全跑代码的云沙箱"。架构大致是三层:

Client SDK → Client-Proxy (边缘路由) → Orchestrator (控制面) → Firecracker microVM
                                                                      └─ envd (VM 内守护进程)

Orchestrator 用 gRPC 调用 Firecracker 创建 VM,但它不伸到 VM 里面。VM 里所有的事(起用户进程、读写文件、开端口、改 hosts、装证书)都由 envd 来做。更关键的是:Client SDK 可以绕过 Orchestrator 直接打 envd(通过 Client-Proxy 路由),所以 envd 自己就是安全边界 —— 鉴权、路径校验、权限隔离都得它自己扛。

三个关键设计约束(理解后面代码都离不开这三点):

  1. envd 在用户代码同一个 VM 里,没有 cgroup 隔离它就可能被用户代码抢 CPU / OOM 掉,所以它给自己设了 Nice=-20OOMScoreAdjust=-1000,还要自己管 cgroup。
  2. VM 可以 Resume(冷启动一次、之后多次唤醒),所以 /init 会被调用多次,必须幂等,还要支持 access token 的远程轮换。
  3. 版本号是能力契约packages/envd/pkg/version.goVersion 字符串每次行为变更都要 bump,Orchestrator 用它来 feature-gate。当前 v0.5.13
// packages/envd/pkg/version.go
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/pkg/version.go
package pkg

const Version = "0.5.13"

📍 下一步:如果你对"为什么一个常量值得上整套流程"感到好奇,先跳到本文第三节看 PR #2207。


二、关键设计

2.1 main.go 的启动链(16 步)

main.go 只有 293 行,但它几乎是 envd 的"施工图"。把它读懂,后面所有模块在哪里拼进来就清晰了。

先看 main() 函数的骨架(我给每一步加了编号注释):

// packages/envd/main.go:132-242
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/main.go#L132-L242
func main() {
    parseFlags()                                          // 1. CLI 参数

    if versionFlag { fmt.Printf("%s\n", pkg.Version); return }
    if commitFlag  { fmt.Printf("%s\n", commitSHA); return } // 2. 打版本/commit 后退出

    ctx, cancel := context.WithCancel(context.Background()) // 3. 根 context
    defer cancel()

    os.MkdirAll(host.E2BRunDir, 0o755)                    // 4. 创建 /run/e2b/

    defaults := &execcontext.Defaults{                    // 5. 全局默认用户/环境变量
        User:    defaultUser,                             //    "root"
        EnvVars: utils.NewMap[string, string](),
    }
    isFCBoolStr := strconv.FormatBool(!isNotFC)
    defaults.EnvVars.Store("E2B_SANDBOX", isFCBoolStr)
    os.WriteFile(filepath.Join(host.E2BRunDir, ".E2B_SANDBOX"), ...)

    mmdsChan := make(chan *host.MMDSOpts, 1)              // 6. MMDS 管道
    defer close(mmdsChan)
    if !isNotFC {
        go host.PollForMMDSOpts(ctx, mmdsChan, defaults.EnvVars) // 6a. 后台开始 50ms 轮询
    }

    l := logs.NewLogger(ctx, isNotFC, mmdsChan)            // 7. Logger (会等 MMDS 给它 collector 地址)

    m := chi.NewRouter()                                   // 8. HTTP 路由
    fsLogger := l.With().Str("logger", "filesystem").Logger()
    filesystemRpc.Handle(m, &fsLogger, defaults)           // 9. 挂 Filesystem RPC

    cgroupManager := createCgroupManager()                 // 10. cgroup v2 (含 noop 回退)
    defer cgroupManager.Close()

    processLogger := l.With().Str("logger", "process").Logger()
    processService := processRpc.Handle(m, &processLogger, defaults, cgroupManager) // 11. 挂 Process RPC

    service := api.New(&envLogger, defaults, mmdsChan, isNotFC) // 12. REST API store
    handler := api.HandlerFromMux(service, m)
    middleware := authn.NewMiddleware(permissions.AuthenticateUsername) // BasicAuth → user.Lookup

    s := &http.Server{                                     // 13. HTTP Server
        Handler: withCORS(
            service.WithAuthorization(                     //   ← token 校验中间件
                middleware.Wrap(handler),                  //   ← BasicAuth 中间件
            ),
        ),
        Addr:         fmt.Sprintf("0.0.0.0:%d", port),
        ReadTimeout:  0, WriteTimeout: 0,                  //   流式 RPC 不超时
        IdleTimeout:  idleTimeout,                         //   640s — 见 2.2
    }

    if startCmdFlag != "" { /* 14. 已弃用的启动命令路径 */ }

    portScanner   := publicport.NewScanner(portScannerInterval)                 // 15. 端口扫描
    portForwarder := publicport.NewForwarder(&portLogger, portScanner, cgroupManager)
    go portForwarder.StartForwarding(ctx)
    go portScanner.ScanAndBroadcast()

    err := s.ListenAndServe()                              // 16. 开始服务
    if err != nil { log.Fatalf("error starting server: %v", err) }
}

MMDS 是什么

MMDS = Microvm Metadata Service,是 Firecracker 提供的一个只对 microVM 内部可读、只对 host 侧可写的元数据通道,模仿 AWS EC2 IMDS。沙箱里的 envd 通过 HTTP 请求 http://169.254.169.254(IMDS 标准链路本地地址)就能拿到 host 写进去的内容——典型字段包括 SandboxIDTemplateIDLogsCollectorAddressAccessTokenHash

它在 envd 里同时承担三个角色:

  • 身份下发:告诉沙箱"你叫什么、你是哪个 template、Resume 后你的新 ID 是什么"
  • 配置下发:日志收集器地址等运行时参数
  • 旁路授权信道:Orchestrator 写 AccessTokenHash 到 MMDS,envd 拿请求 token 算 sha512 比对 hash,从而实现 token 远程轮换(详见 §2.5

之所以选 MMDS 而不是直接走 HTTP 调用,是因为 host 写 MMDS 不需要任何网络认证——它本身就是物理机层面的特权操作,只有 Firecracker 的拥有者(orchestrator)能写,构成了天然的信任根。

实现细节见 internal/host/mmds.go,envd 用 IMDSv2 两步协议拉取(先 PUT 拿 token,再 GET 带 token 拉 JSON)。

读这段代码时,注意两组"隐式依赖"

  1. MMDS 必须先起(第 6 步):因为 Logger(第 7 步)会等 MMDS 里的 LogsCollectorAddress 才能把日志推出去。Logger 初始化本身不阻塞,但拿到地址前日志只到 stdout。
  2. cgroupManager 必须在 Process RPC 之前(第 10 步在第 11 步之前):因为 processRpc.Handle 把 cgroupManager 作为参数注入到每个新生成的进程里。如果 cgroup 创建失败,会回退成 NoopManager,让 envd 在老内核上也能跑,不过进程没有资源隔离。

noop 回退是什么

NoopManager 是 cgroup Manager 接口的空壳实现——GetFileDescriptor 永远返回 (0, false)Close 永远返回 nil。当真正的 cgroup v2 manager 创建失败时(老内核、本地开发、权限不够),envd 不会 log.Fatal,而是塞一个 noop 进去让程序继续跑,只是失去 cgroup 资源隔离能力

这个模式三层意义:

  • 优雅降级:cgroup 是优化不是核心,没有它 envd 仍能提供 RPC,本地开发环境也能起
  • Null Object 模式:调用方代码 mgr.GetFileDescriptor(...) 拿到 (0, false) 跳过 attach 即可,不需要写 if mgr != nil 的 nil 检查
  • defer 集中兜底createCgroupManager 有多个 return nil 失败路径,用 defer 在最末尾统一把 nil 替换成 noop——失败路径再多也不会漏处理
// 简化形式
func createCgroupManager() (m cgroups.Manager) {
    defer func() {
        if m == nil { m = cgroups.NewNoopManager() }
    }()
    // ...各种可能 return nil 的失败路径
}

一句话:“宁可没 cgroup,也别死给你看”——核心功能不被非核心子系统拖死。

📍 下一步

  • 打开 main.go:244-293createCgroupManager,注意 defer 里的 noop 回退逻辑 —— 它用 defer 实现"任何失败路径都回退到 noop",很优雅。
  • 如果你想看 cgroup 三档(pty/socat/user)的权重配置,也在这个函数里,但细节留到第 3 讲讲。

2.2 为什么 IdleTimeout = 640s

// packages/envd/main.go:33-36
const (
    // Downstream timeout should be greater than upstream (in orchestrator proxy).
    idleTimeout = 640 * time.Second
    maxAge      = 2 * time.Hour
    defaultPort = 49983
)

这个 640s 不是拍脑袋定的。envd 前面有 Orchestrator 的代理层,代理层有自己的上游 (upstream) 空闲超时。如果下游 envd 的 IdleTimeout 比上游短,会发生:

  1. 代理层还以为连接活着,继续往 envd 转包
  2. envd 这边已经把 connection idle kick 掉了
  3. 代理层收到 RST,用户看到"连接被重置"

反过来,envd 等久一点,哪怕代理先 close,对用户体验就是"连接正常断开"。所以下游总是要比上游久。

同理,ReadTimeoutWriteTimeout 都设为 0(不超时)。为什么?因为 envd 有流式 RPC —— 比如 process.Start 返回一个服务端流,进程跑几小时输出都在同一个连接上,期间可能很长时间没新数据。任何固定超时都会误杀这种合法连接。

📍 下一步:想深入?搜 idleTimeout 在整个项目有几个引用,你会发现 Orchestrator 侧的 proxy 也有类似常量,两者是配对的。

2.3 /init:envd 的灵魂

一个 VM 冷启动或 Resume 时,Orchestrator 做的第一件事就是调 POST /init。这个接口一口气做了七件事:鉴权 → 对时 → 装环境变量 → 转移 access token → 改 /etc/hosts(Hyperloop)→ 装 CA 证书 → 挂 NFS

先看入口:

// packages/envd/internal/api/init.go:97-164
// https://github.com/e2b-dev/infra/blob/b8ca332f435370397bf42be614b2a5b620d65d39/packages/envd/internal/api/init.go#L97-L164
func (a *API) PostInit(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    ctx := r.Context()

    operationID := logs.AssignOperationID()
    logger := a.logger.With().Str(string(logs.OperationIDKey), operationID).Logger()

    if r.Body != nil {
        // 读原始 body,读完要能擦掉
        body, err := io.ReadAll(r.Body)
        defer memguard.WipeBytes(body)    // ★ 关键:出作用域前抹掉 body
        if err != nil {
            w.WriteHeader(http.StatusBadRequest); return
        }

        var initRequest PostInitJSONBody
        if len(body) > 0 {
            if err := json.Unmarshal(body, &initRequest); err != nil {
                w.WriteHeader(http.StatusBadRequest); return
            }
        }

        // ★ 关键:如果 token 没被 TakeFrom 走(即没被"接手"),这里兜底销毁
        defer initRequest.AccessToken.Destroy()

        a.initLock.Lock()
        defer a.initLock.Unlock()

        // 只在新请求时间戳更大或无时间戳时才落地 —— 防止乱序请求覆盖新状态
        if initRequest.Timestamp == nil || a.lastSetTime.SetToGreater(initRequest.Timestamp.UnixNano()) {
            if err := a.SetData(ctx, logger, initRequest); err != nil {
                // ...401/400 分支...
            }
        }
    }

    // /init 成功后,顺手让 MMDS 再轮询一轮(60s cap)
    go func() {
        ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
        defer cancel()
        host.PollForMMDSOpts(ctx, a.mmdsChan, a.defaults.EnvVars)
    }()

    w.WriteHeader(http.StatusNoContent)
}

两个关键的 defer

  1. defer memguard.WipeBytes(body) —— body 里有明文 access token,JSON 解析完必须擦掉,否则 GC 前它一直躺在 heap 上,core dump 能 grep 到。
  2. defer initRequest.AccessToken.Destroy() —— 如果中间某一步失败返回了,initRequest.AccessToken 还没被 TakeFrom 转移走,它的 memguard buffer 要主动 Destroy,不然内存锁定的页不会被还。

📍 下一步:看 SetData()(同文件 166–232 行),它是真正的"七步编排"。下面一节我们挑其中最值得看的鉴权和 token 转移细讲。

2.4 鉴权:三层叠加

envd 的鉴权其实有 三层,大多数读者会忽略第一层:

第 1 层 — HTTP Basic Auth 的 username:不是用来校验密码的(basic auth 的 password 字段 envd 根本没读),而是把 username 当作"以谁的身份执行后续操作"的 hint

// packages/envd/internal/permissions/authenticate.go:14-28
func AuthenticateUsername(_ context.Context, req authn.Request) (any, error) {
    username, _, ok := req.BasicAuth()     // ← 只取 username
    if !ok {
        // 没给 username 的话,让后续 endpoint 各自处理
        return nil, nil
    }

    u, err := GetUser(username)            // ← os/user.Lookup()
    if err != nil {
        return nil, authn.Errorf("invalid username: '%s'", username)
    }

    return u, nil
}

这个 *user.User 对象会被 Connect 框架存到 context 里,后面生成子进程时通过 GetAuthUser(ctx, defaultUser) 取出来决定以哪个 UID 启动 —— 这就是"username 即身份"。

第 2 层 — X-Access-Token header:真正的 token 校验,WithAuthorization 中间件:

// packages/envd/internal/api/auth.go:26-53
var authExcludedPaths = []string{
    "GET/health",
    "GET/files",     // 走签名 URL 而不是 token
    "POST/files",
    "POST/init",     // 走 MMDS hash
}

func (a *API) WithAuthorization(handler http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
        if a.accessToken.IsSet() {
            authHeader := req.Header.Get(accessTokenHeader)

            allowedPath := slices.Contains(authExcludedPaths, req.Method+req.URL.Path)

            if !a.accessToken.Equals(authHeader) && !allowedPath {
                err := fmt.Errorf("unauthorized access, ...")
                jsonError(w, http.StatusUnauthorized, err)
                return
            }
        }
        handler.ServeHTTP(w, req)
    })
}

读这段代码时注意几点:

  • a.accessToken.IsSet() 返回 false 时所有请求都放行:这就是"沙箱还没初始化"的安全假设 —— 没人告诉我 token 是什么,那我只能相信最先来调用我的人。
  • a.accessToken.Equals 是常量时间比较(memguard 实现),防时序攻击。
  • 4 条 authExcludedPaths 各有各的理由/health 要给 LB 探活;/files 改走签名 URL;/init 改走 MMDS hash。

第 3 层 — /init 的 MMDS hash 分支:只在 /init 用。下面 2.5 节专门讲。

三层各自的使用时机

光看代码很容易觉得"三层都一样在每个请求上跑",但其实三层的触发场景、调用频率、设计目的完全不同。把它们的生命周期画清楚,后面读 validateInitAccessTokenProcess.Start 才不会反复迷路。

触发场景频率用途
第 1 层 usernameProcess / Filesystem RPC每次进程相关调用选身份(uid/gid)
第 2 层 X-Access-Token几乎所有 RPC每个请求防外部直接调用
第 3 层 MMDS hash/init冷启动 + 每次 Resumetoken 远程轮换

第 2 层是日常态。客户端 SDK 跑用户代码时,每秒可能几十次 RPC,全都带 X-Access-Token。所以 accessToken.Equals 必须是常量时间比较——既防时序攻击,也避免鉴权层成为热点。

第 1 层只在"要消费 Linux 身份"的 RPC 里有意义。虽然每个请求都可能带 BasicAuth,但 username 真正被读出来的地方就两类:

  • Process.Start —— 决定 fork 时的 uid/gid
  • Filesystem.* —— 决定以哪个用户身份做 stat/read/write

/init/health 不消费 username。客户端没传时 fallback 到 defaults.User典型用法:同一个沙箱里既要以 user 身份跑用户代码,又要以 root 装包,客户端在不同请求里换不同 BasicAuth,一个 envd 实例多重身份分时复用。

第 3 层一辈子被调用的次数很少

  • 冷启动一次:沙箱第一次起来,envd 自己还没 token,走"首次 setup 放行"分支
  • 每次 Resume 一次:snapshot 复活,orchestrator 可能想轮换 token,但旧 orchestrator 实例可能都不在了——这时新 orchestrator 把 sha512(新token) 写进 MMDS,调 /init 带新 token,envd 算 hash 比对通过

频率低(一个沙箱生命周期 1~N 次,N = Resume 次数),所以这一层可以"重"——做完整 hash 计算 + MMDS 拉取都不会成为瓶颈。

一个常被忽略的点:三层是叠加不是替代

/init 走 MMDS hash 不代表它绕过了 username——它只是绕过了第 2 层的 token 校验。但因为 /init 是 root 上下文,username 实际上没被使用,所以看起来像"只有第 3 层"。

普通 RPC 也不是"只用第 2 层"——第 1 层 username 同样在每次 RPC 里被解析,只是大部分 RPC 不消费它而已。

一句话:第 2 层是"每个请求的护城河",第 1 层是"以谁的身份开始执行",第 3 层是"orchestrator 远程改 envd 状态时的旁路通道"。三层各管各的,不重叠也不冲突。

📍 下一步

  • /files 签名 URL 怎么生成?跳到 auth.go:55-72generateSignature,是 HMAC-SHA256(path:op:username:token[:expiration])。这一段第 3 讲会细讲。
  • 想知道 token 是什么时候"第一次被 Set 到 envd"?搜 a.accessToken.TakeFrom —— 只有一处调用,就在 SetData 里。

2.5 validateInitAccessToken:token 轮换的小机关

这是 /init 最精妙的一段:如何让 Orchestrator 在 Resume 时"改 token",却不让任意人都能改?

// packages/envd/internal/api/init.go:40-61
// validateInitAccessToken 校验 /init 请求的 access token。
// 满足以下任一条件即视为合法:与已存在的 token 相同 或 命中 MMDS hash。
// 两者都不存在时,视为首次冷启动,放行。
func (a *API) validateInitAccessToken(ctx context.Context, requestToken *SecureToken) error {
    requestTokenSet := requestToken.IsSet()

    // 快路径:与当前 token 完全一致(常量时间比较,防时序攻击)
    if a.accessToken.IsSet() && requestTokenSet && a.accessToken.EqualsSecure(requestToken) {
        return nil
    }

    // 与当前 token 不匹配时,再去查 MMDS 里的 hash
    matchesMMDS, mmdsExists := a.checkMMDSHash(ctx, requestToken)

    switch {
    case matchesMMDS:
        return nil
    case !a.accessToken.IsSet() && !mmdsExists:
        return nil // 首次冷启动:envd 没 token,MMDS 也没写过 hash
    case !requestTokenSet:
        return ErrAccessTokenResetNotAuthorized // 想重置 token 却没带任何凭证
    default:
        return ErrAccessTokenMismatch // token 既不匹配当前值,也对不上 MMDS hash
    }
}

四条放行/拒绝规则:

条件结果语义
request token == 当前 token✅ 放行正常调用(热路径)
sha512(request token) == MMDS hash✅ 放行Resume 场景,Orchestrator 提前在 MMDS 里写了新 token 的 hash
当前无 token + MMDS 也没 hash✅ 放行第一次冷启动
其他❌ 401拒绝

机关在 MMDS 那一条。MMDS (Firecracker Metadata Service) 是 Firecracker 提供的一个 169.254.169.254 的只读 metadata 服务 —— 但只对 orchestrator 可写(写 MMDS 必须有 host 侧权限)。所以流程是:

Orchestrator 要轮换 token
    ↓
先把 hash(newToken) 写进 MMDS
    ↓
再调 POST /init 带上 newToken
    ↓
envd 看到当前 token ≠ newToken,检查 MMDS hash
    ↓
hash(newToken) == MMDS 里写的 hash → 放行
    ↓
`a.accessToken.TakeFrom(data.AccessToken)` → 替换掉本地 token

为什么这安全:攻击者就算拿到了 MMDS 里的 hash,也反推不出 token(SHA-512 单向);就算拿到了旧 token,也无法覆盖新 token(因为 MMDS hash 已经是新 token 的 hash)。

澄清一个常见误解:MMDS 里存的不是 token,是 token 的指纹

容易把 MMDS 想成"token 的真正存储地"——其实MMDS 里只有 sha512(token),永远没有明文。token 明文真正住的地方是 orchestrator 的内存、客户端 SDK 的内存、envd 的 memguard buffer。MMDS 这条通道在 envd 看来只能用来"验证",不能用来"读取" ——它是单向凭据,不是 secret store。

这个区分重要在哪?回到 Resume 场景:新 orchestrator 要发新 token,但它没法把新 token 明文留在 MMDS 里(MMDS 在沙箱里可读,token 一旦放进去等于明文落地),所以它放 hash。envd 收到请求 body 里的明文新 token 时,自己算一次 hash 比对。MMDS 的角色是"等会儿会有一个明文 token 来,如果它的 sha512 等于这个值就接受"——这是一个"延迟验证凭据",不是密钥本身。

📍 下一步

  • checkMMDSHash(同文件 70–95 行)—— 里面有个"当前 token 为 nil 怎么办"的 edge case,用 hash("") 表达"orchestrator 授权把 token 清零"。是个用 hash 编码"意图"的小技巧。
  • SetDataa.accessToken.TakeFrom(data.AccessToken) 这一行的前后,理解验证通过后 token 是怎么"零拷贝"接管的。

2.6 SecureToken:把 token 锁进内存保险箱

前面出现了好几次 SecureToken。这是一个专门为 access token 设计的类型,用 memguard 库把 token 放进受物理保护的内存页

先澄清一个容易踩的坑:memguard 不是"加密",是"内存物理保护 + 生命周期管理"

SecureToken 内部存的始终是明文——a.accessToken.EqualsSecure(other) 比对的也是明文字节。memguard 的"安全"完全来自对内存物理位置和生命周期的控制:mlock 不让换 swap、guard pages 防越界读、Destroy 时手动清零。

换句话说,如果把 envd 的 memguard buffer 想象成"加密保险箱"是错的——它更像**“放在带门禁的物理保险室里、用完立刻碎纸的明文文件”**:纸上的字一直可读,但谁能进保险室、纸何时被销毁都被严格控制。

这个区分重要在哪?密码学加密会改变字节本身(密文 ≠ 明文),但 memguard 不动字节内容。所以接 token 比对时仍然要走"拿出明文做常量时间比较"(见后面的 EqualsSecure)——你不可能让客户端发一个加密后的 token 来"密文比对",客户端发的就是明文,服务端就是用明文比的。

// packages/envd/internal/api/secure_token.go:16-46
// SecureToken wraps memguard for secure token storage.
// It uses LockedBuffer which provides memory locking, guard pages,
// and secure zeroing on destroy.
type SecureToken struct {
    mu     sync.RWMutex
    buffer *memguard.LockedBuffer
}

// Set securely replaces the token, destroying the old one first.
// ...
func (s *SecureToken) Set(token []byte) error {
    if len(token) == 0 {
        return ErrTokenEmpty
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    // Destroy old token first (zeros memory)
    if s.buffer != nil {
        s.buffer.Destroy()
        s.buffer = nil
    }

    // Create new LockedBuffer from bytes (source slice is wiped by memguard)
    s.buffer = memguard.NewBufferFromBytes(token)

    return nil
}

memguard 提供的三层保护

机制防范的风险
mlock() 内存锁定敏感数据被 swap 到磁盘(重启后还能读到)
前后 guard 页缓冲区溢出读(邻近内存被读出 token)
Destroy 时显式清零GC 后 heap 残留,core dump 暴露

但真正值得学习的是这个 API 的设计Set 有一个反直觉的细节:先 Destroy 旧的,再装新的。为什么?如果先装新的再 Destroy 旧的,中间那一瞬间同时有两份明文在内存里。Set 的语义强制保证"任一时刻只有一份"。

再来看一个更精妙的方法 —— TakeFrom零拷贝移交

// packages/envd/internal/api/secure_token.go:94-115
// TakeFrom transfers the token from src to this SecureToken, destroying any
// existing token. The source token is cleared after transfer.
// This avoids copying the underlying bytes.
func (s *SecureToken) TakeFrom(src *SecureToken) {
    if src == nil || s == src {
        return
    }

    // Extract buffer from source
    src.mu.Lock()
    buffer := src.buffer
    src.buffer = nil
    src.mu.Unlock()

    // Install buffer in destination
    s.mu.Lock()
    if s.buffer != nil {
        s.buffer.Destroy()
    }
    s.buffer = buffer
    s.mu.Unlock()
}

这里没有复制任何字节。只是把底层的 *memguard.LockedBuffer 指针从 src “搬"到 s。这意味着:

  • 解析 /init body 时 token 进了 initRequest.AccessToken 这个 SecureToken
  • SetDataa.accessToken.TakeFrom(data.AccessToken) 把它接管走
  • 然后 defer initRequest.AccessToken.Destroy() 看到 buffer == nil,什么都不做

整个流程 token 明文在 memguard buffer 里只存在过一份。这是 PR #1823 引入 SecureToken 的最大收益。

最后看 EqualsSecure,它解决了一个不太明显的死锁问题:

// packages/envd/internal/api/secure_token.go:134-160
// EqualsSecure compares this token with another SecureToken using constant-time comparison.
func (s *SecureToken) EqualsSecure(other *SecureToken) bool {
    if s == nil || other == nil {
        return false
    }

    if s == other {
        return s.IsSet()
    }

    // Get a copy of other's bytes (avoids holding two locks simultaneously)
    otherBytes, err := other.Bytes()
    if err != nil {
        return false
    }
    defer memguard.WipeBytes(otherBytes)

    s.mu.RLock()
    defer s.mu.RUnlock()

    if s.buffer == nil || !s.buffer.IsAlive() {
        return false
    }

    return s.buffer.EqualTo(otherBytes)
}

为什么要 “先拿副本再加锁”?朴素实现会是:先锁 self 的 mu,再锁 other 的 mu,然后比较。但是想象两个 goroutine 同时比较 A 和 B:

  • G1:A.EqualsSecure(B) → 锁 A → 试图锁 B
  • G2:B.EqualsSecure(A) → 锁 B → 试图锁 A

经典 AB-BA 死锁。作者的解法:先通过 other.Bytes() 拿到 other 的明文副本(Bytes 内部短锁一下就释放),再只锁 self 做比较。副本用完 memguard.WipeBytes 擦掉。

📍 下一步

  • 打开 secure_token_test.go,461 行测试,专门有一节覆盖 deadlock prevention,用 runtime.Gosched() 构造调度窗口。
  • memguard.WipeBytes 在整个 envd 有多少处调用 —— 这是敏感数据出 memguard 边界时"必须擦"的一个纪律。

附:token 在系统里的几种形态

读完 §2.5 + §2.6 后,可能仍会困惑"那 token 到底在哪儿”。下面这张表把整套生命周期里 token 的所有形态收拢:

位置存的是什么形态防护手段谁能读
Orchestrator 内存token 明文普通字符串OS 进程隔离控制面服务自己
Client SDK 内存token 明文SDK 内部变量客户端进程隔离调用方自己
HTTP Header X-Access-Tokentoken 明文字节流TLS 在传输层加密服务端解析后立即放进 memguard
HTTP Body(/init 时)token 明文JSON 字段TLS + body 用完立即 WipeBytesenvd 解析时短暂在普通堆上
envd 的 a.accessTokentoken 明文memguard LockedBuffermlock + guard pages + 常量时间比较同进程其他代码理论可读,但被物理保护提高门槛
MMDS AccessTokenHashsha512(token)字符串host 才能写,VM 内只读VM 内任何进程,但反推不出明文

三个关键观察:

  1. token 明文同时存在于多个位置(orchestrator、client、envd 内存、传输流),每个位置的防护手段不同,但没有"加密存储"——都是明文 + 物理隔离
  2. MMDS 是唯一不存明文的位置,它存 hash。这是因为 MMDS 在 VM 内是公开可读的,放明文等于直接泄漏。
  3. token 明文每次跨边界(Header→memguard,body→memguard,memguard→比对) 都会有一段在普通内存里短暂存活的窗口,所以代码里到处是 defer WipeBytes —— 这是 envd 处理敏感数据的纪律:任何明文从受保护区域逃出来,都必须有 defer 抹零兜底。

📍 延伸阅读:

  • §2.5 讲 hash 验证流程,§2.6 讲明文保护机制,合起来才是完整的 token 安全模型——单看任一节都会形成"token 就在 X"的片面印象。
  • memguard.WipeBytes 的所有调用点,你会发现它们恰好对应这张表里"明文短暂跨边界"的每一处。

三、为什么这么设计(PR 溯源)

上面讲的 SecureToken + MMDS hash 是同一批改动的两半。代码里看不出"为什么这样",PR 讲得最清楚。

3.1 PR #1823 — SecureToken 内存保护

合入:2026-02-04 · 规模:+832 / −87

原来是什么样type API struct { ... accessToken *string ... } —— 就一个普通的字符串指针。Go 的字符串放在 heap 上,三个风险:

  • swap 泄漏:内存压力大时 Linux 把 heap 页换到 swap 分区,重启也还能 grep 到
  • GC 残留:string 不可变,被替换的旧 string 可能要等好几轮 GC 才被清,core dump 期间暴露
  • 时序攻击:常规 s == t 字符串比较在第一个不同字节就短路,攻击者可以测时间逐位猜

PR 做的:新建 SecureToken,底层 memguard.LockedBuffer。上面 2.6 节讲的 API 细节都是这个 PR 设计的。

为什么值得学:它不是简单"包一下就完事",而是重新设计了一套 API(Set / TakeFrom / EqualsSecure / UnmarshalJSON 拒 \ 转义),每一个方法都针对一类具体攻击面。这是"把隐式安全假设变成显式机制"的典型例子。

3.2 PR #1822 — MMDS hash 做 Resume 时 token 轮换

合入:2026-02-03 · 规模:+637 / −17

要解决的问题:Resume 时 Orchestrator 要给一个运行中的沙箱换 token(租户切换、template 复用)。可是:

  • 走普通 API 需要先持旧 token 才能认证
  • 新 Orchestrator 实例可能根本不知道旧 token
  • 不能让任意人都能改

PR 的方案:让 MMDS 成为"旁路授权信道"。

  1. packages/shared/pkg/keys/sha512.goHashAccessToken
  2. Orchestrator 在 FC Resume 流程中把 accessTokenHash 写进 MMDS(MMDS 只能 host 侧写)
  3. /init 加到 authExcludedPaths,鉴权改走 validateInitAccessToken
  4. envd 比对 sha512(request.token) 是否等于 MMDS 里的 hash

两个巧思

  • hash("") 表达"我要你清零 token":Orchestrator 可以写一个空 hash + 发 nil token → envd 认为 hash 匹配空字符串 → 主动 Destroy 当前 token。这是"写 MMDS"来表达"我要 envd 做什么动作"的副通信机制。
  • 状态码从 409 改 401:原来 token 更改冲突返回 409 Conflict,PR 改成 401 Unauthorized —— 语义上这是鉴权失败,不是资源冲突。

这对 PR 必须一起读:SecureToken + MMDS hash 共同构成了新的鉴权模型。单看任一个都不完整。


四、值得留意的问题 & 开放讨论

4.1 MMDS 不可达时的 50ms 忙轮询

host.PollForMMDSOpts 每 50ms 打一次 MMDS。如果 Orchestrator 由于某种原因忘了给这个沙箱配 MMDS:

  • envd 启动完成后会持续 CPU 打 stderr(每次失败都 log 一条)
  • 没有指数退避
  • 没有 circuit breaker

影响范围:通常不会触发,因为 Orchestrator 正常流程都会配 MMDS。但自建部署 / 本地调试模式 (-isnotfc) 时要小心 —— 好在 -isnotfc 分支里这个 goroutine 不会启动。

讨论点:要不要加个指数退避?还是 50ms 故意做得激进(因为 Resume 刚唤醒时 MMDS 可能还没就绪)?

4.2 Hyperloop 的异步失败静默

// init.go:206-208
if data.HyperloopIP != nil {
    go a.SetupHyperloop(*data.HyperloopIP)    // ← 异步,无返回
}

这里 fire-and-forget。如果 /etc/hosts 写失败(磁盘满、权限问题),用户完全感知不到 —— /init 会返回 204,但 events.e2b.local 这个域名仍然解析不到。

触发条件:罕见,但 OOM 重启窗口里比较危险。

缓解手段:日志有打,但没有上报给 client。可以考虑用 Hyperloop 状态做一个 readiness 指标。

4.3 initRequest.AccessToken.Destroy() 的 defer 位置

这是 PR #1823 后续 bug fix 才加的兜底。原本 SetData 内部 TakeFrom 后就把 token 接管走了,看似不需要 Destroy。但任何"SetData 之前"的早期返回(validation 失败、时间戳过期跳过等)都会让 token 留在 initRequest 里没人清 —— memguard 的 mlock 不释放就一直占着内存锁定页。

教训:memguard 这种"用完必须主动释放"的资源,最稳妥是在构造它的作用域defer Destroy(),哪怕后面会转移。

4.4 首次 /init 的 TOCTOU 窗口

checkMMDSHashSetData 中间是有时间差的。极端情况下:

  1. t=0:envd 启动,MMDS 还没写好 hash
  2. t=1:有人(非 orchestrator)打 /init —— 这时 a.accessToken 为 nil,MMDS hash 也是空,走"首次 setup"分支放行
  3. t=2:attacker 把自己的 token 装进去了

前提:VM 的网络是隔离的,只有 orchestrator 能访问 envd。目前这个"前提"由基础设施保证。但如果将来 VM 网络拓扑变化,这里会变成脆弱点。


五、延伸阅读

推荐阅读顺序

如果你想完整吃透本讲内容:

1. main.go 完整读一遍(~250 行)
2. internal/api/init.go 的 PostInit + SetData
3. internal/api/secure_token.go 全文
4. internal/api/auth.go 的 WithAuthorization
5. internal/permissions/authenticate.go(简短,但重要)
6. 最后看 PR #1822 + #1823 的 description(放一起读)

相关 PR

  • #1822 MMDS hash token 轮换
  • #1823 SecureToken 内存保护
  • #2207 envd 版本 feature gate(理解版本号为什么重要)

本讲涉及的源码文件

文件关键内容
packages/envd/main.go启动链、CLI、HTTP Server、cgroup 配置
packages/envd/pkg/version.go能力版本
packages/envd/internal/api/store.goAPI struct、New 构造器
packages/envd/internal/api/init.go/init 的编排(PostInit + SetData
packages/envd/internal/api/auth.goWithAuthorization 中间件、签名 URL
packages/envd/internal/api/secure_token.goSecureToken 全套 API
packages/envd/internal/permissions/authenticate.goBasicAuth username → OS user

下一讲预告

第 2 讲:命令执行与进程管理

将覆盖:

  • 用户命令的进入路径(Connect-RPC + chi router 路径分流)
  • pre-exec shell wrapper 如何消除 OOM/nice race(PR #2028)
  • CLONE_INTO_CGROUP + Credential 一次 syscall 完成隔离 + 降权
  • Start vs Connect 为什么在客户端断网后进程还能活
  • MultiplexedChannel 的广播模式和慢消费者隐患
  • 为什么同时提供流式 WatchDir 和轮询式 CreateWatcher
  • 路径安全:~ 展开、Abs、依赖 home 目录位置的弱前提