diff --git a/main.go b/main.go index aefcc53..4b3276b 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,37 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "syscall" ) +// ——— bridge ——— + +const bridgeName = "mdbr0" +const bridgeIP = "10.100.0.1" +const bridgeSubnet = "10.100.0.0/24" + +func ensureBridge() { + if _, err := os.Stat("/sys/class/net/" + bridgeName); err == nil { + return + } + runCmd("ip", "link", "add", bridgeName, "type", "bridge") + runCmd("ip", "addr", "add", bridgeIP+"/24", "dev", bridgeName) + runCmd("ip", "link", "set", bridgeName, "up") + fmt.Printf("[bridge] %s created at %s\n", bridgeName, bridgeIP) +} + +func allocateIP() string { + data, _ := os.ReadFile("/tmp/mdbr0-ip-last") + last := 1 + if len(data) > 0 { + last, _ = strconv.Atoi(string(data)) + } + next := last + 1 + os.WriteFile("/tmp/mdbr0-ip-last", []byte(strconv.Itoa(next)), 0644) + return fmt.Sprintf("10.100.0.%d", next) +} + // 这个 demo 没有加 CLONE_NEWUSER(user namespace)。 // 加了之后容器里的"root"会映射成宿主机上一个无权限的普通用户, // mount/mknod 这类系统调用会被内核直接拒绝——从根上堵死设备逃逸这条路。 @@ -34,11 +62,43 @@ func main() { func run() { args := os.Args[2:] if len(args) == 0 { - args = []string{"/bin/sh"} + fmt.Fprintf(os.Stderr, "Usage: %s run [:tag] [command...]\n", os.Args[0]) + os.Exit(1) + } + + // 解析 "alpine" 或 "python:3.11" + image := args[0] + tag := "latest" + if idx := strings.Index(image, ":"); idx != -1 { + tag = image[idx+1:] + image = image[:idx] + } + cmdArgs := args[1:] + + // --no-entry: 跳过镜像的 entrypoint,直接跑用户命令 + skipEntrypoint := false + if len(cmdArgs) > 0 && cmdArgs[0] == "--no-entry" { + skipEntrypoint = true + cmdArgs = cmdArgs[1:] + } + + result, err := pullImage(image, tag) + must(err) + + // 用户没指定命令,用镜像默认的 + if len(cmdArgs) == 0 { + cmdArgs = result.Cmd + if len(cmdArgs) == 0 { + cmdArgs = []string{"/bin/sh"} + } + } + // 镜像有 entrypoint 且不跳过,就排在命令前面 + if !skipEntrypoint { + cmdArgs = append(result.Entrypoint, cmdArgs...) } hostPID := os.Getpid() - fmt.Printf("[run] Running %v as PID %d\n", args, hostPID) + fmt.Printf("[run] %s:%s → %v as PID %d\n", image, tag, cmdArgs, hostPID) overlayBase := fmt.Sprintf("/tmp/maingo-%d", hostPID) upperDir := overlayBase + "/upper" @@ -48,50 +108,52 @@ func run() { vethCtr := fmt.Sprintf("veth1-%d", hostPID) cgroupName := fmt.Sprintf("maingo-%d", hostPID) - ipSuffix := hostPID % 253 - hostIP := fmt.Sprintf("10.0.%d.1", ipSuffix) - ctrIP := fmt.Sprintf("10.0.%d.2", ipSuffix) - subnet := fmt.Sprintf("10.0.%d.0/24", ipSuffix) + ctrIP := allocateIP() must(os.MkdirAll(upperDir, 0755)) must(os.MkdirAll(workDir, 0755)) must(os.MkdirAll(mergedDir, 0755)) - // 用 defer 而不是"cmd.Wait() 之后顺序执行": - // must(cmd.Wait()) 在子进程非 0 退出时会 panic, - // 之前的写法一旦这里 panic,后面的清理代码一行都不会跑, - // 会在宿主机上留下孤儿 veth / iptables 规则 / cgroup / 临时目录。 - // defer 保证不管中间从哪里 panic,清理都会按 LIFO 顺序跑完。 defer safeRemoveAll(overlayBase) defer cleanupCgroup(cgroupName) defer cleanupVeth(vethHost) - defer cleanupNAT(subnet) r, w, err := os.Pipe() must(err) - cmd := exec.Command("/proc/self/exe", append([]string{"child"}, args...)...) + cmd := exec.Command("/proc/self/exe", append([]string{"child"}, cmdArgs...)...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.ExtraFiles = []*os.File{r} cmd.SysProcAttr = &syscall.SysProcAttr{ - Cloneflags: syscall.CLONE_NEWUTS | + Cloneflags: syscall.CLONE_NEWUSER | + syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET, } - cmd.Env = append(os.Environ(), + cmd.SysProcAttr.UidMappings = []syscall.SysProcIDMap{ + {ContainerID: 0, HostID: 0, Size: 65536}, + } + cmd.SysProcAttr.GidMappings = []syscall.SysProcIDMap{ + {ContainerID: 0, HostID: 0, Size: 65536}, + } + // 先加镜像的环境变量,再拼我们自己的——镜像的 Env 可能覆盖宿主机的 + cmd.Env = append(os.Environ(), result.Env...) + cmd.Env = append(cmd.Env, "MAINGO_MERGED="+mergedDir, "MAINGO_UPPER="+upperDir, "MAINGO_WORK="+workDir, + "MAINGO_LOWER="+result.LowerDirs, "MAINGO_CGROUP="+cgroupName) must(cmd.Start()) r.Close() - setupVeth(cmd.Process.Pid, vethHost, vethCtr, hostIP, ctrIP, subnet) - setupNAT(cmd.Process.Pid, ctrIP, subnet) + ensureBridge() + setupVeth(cmd.Process.Pid, vethHost, vethCtr, ctrIP) + setupNAT() w.Write([]byte{1}) w.Close() @@ -120,7 +182,7 @@ func child() { upperDir := os.Getenv("MAINGO_UPPER") workDir := os.Getenv("MAINGO_WORK") - lowerDir := "/home/mingdax/alpine-rootfs" + lowerDir := os.Getenv("MAINGO_LOWER") opts := fmt.Sprintf("lowerdir=%s,upperdir=%s,workdir=%s", lowerDir, upperDir, workDir) must(syscall.Mount("overlay", mergedDir, "overlay", 0, opts)) fmt.Printf("[overlay] mounted\n") @@ -140,7 +202,7 @@ func child() { must(syscall.Unmount("proc", 0)) } -func setupVeth(childPID int, vethHost, vethCtr, hostIP, ctrIP, subnet string) { +func setupVeth(childPID int, vethHost, vethCtr, ctrIP string) { pid := strconv.Itoa(childPID) runCmdSilent("ip", "link", "del", vethHost) @@ -148,37 +210,35 @@ func setupVeth(childPID int, vethHost, vethCtr, hostIP, ctrIP, subnet string) { runCmd("ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethCtr) runCmd("ip", "link", "set", vethCtr, "netns", pid) - runCmd("ip", "addr", "add", hostIP+"/24", "dev", vethHost) + // 宿主端插进网桥,不配 IP——bridge 自己有一个 IP 当网关 + runCmd("ip", "link", "set", vethHost, "master", bridgeName) runCmd("ip", "link", "set", vethHost, "up") + // 容器端:配 IP、设默认网关、启 lo runCmd("nsenter", "-t", pid, "-n", "ip", "addr", "add", ctrIP+"/24", "dev", vethCtr) runCmd("nsenter", "-t", pid, "-n", "ip", "link", "set", vethCtr, "up") runCmd("nsenter", "-t", pid, "-n", "ip", "link", "set", "lo", "up") + runCmd("nsenter", "-t", pid, "-n", "ip", "route", "add", "default", "via", bridgeIP) - fmt.Printf("[veth] pair ready: host=%s, container=%s\n", hostIP, ctrIP) + fmt.Printf("[veth] %s → %s via %s\n", ctrIP, bridgeName, bridgeIP) } -func setupNAT(childPID int, ctrIP, subnet string) { - pid := strconv.Itoa(childPID) - +// setupNAT 确保共享子网能出公网。规则只加一次,多个容器复用。 +func setupNAT() { must(os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644)) - runCmd("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", subnet, "-j", "MASQUERADE") - runCmd("nsenter", "-t", pid, "-n", "ip", "route", "add", "default", "via", - ctrIP[:len(ctrIP)-1]+"1") - fmt.Printf("[nat] NAT enabled: %s → internet\n", subnet) + if _, err := os.Stat("/tmp/mdbr0-nat-ready"); err == nil { + return + } + runCmd("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", bridgeSubnet, "-j", "MASQUERADE") + os.WriteFile("/tmp/mdbr0-nat-ready", []byte("1"), 0644) + fmt.Printf("[nat] MASQUERADE %s → internet\n", bridgeSubnet) } func cleanupVeth(vethHost string) { runCmdSilent("ip", "link", "del", vethHost) } -func cleanupNAT(subnet string) { - runCmdSilent("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-j", - "MASQUERADE") - fmt.Printf("[nat] cleaned up\n") -} - func waitForNetwork() { f := os.NewFile(3, "syncpipe") buf := make([]byte, 1) diff --git a/pull.go b/pull.go new file mode 100644 index 0000000..92739ca --- /dev/null +++ b/pull.go @@ -0,0 +1,294 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// ——— 1. 数据结构:把 curl 看到的 JSON 翻译成 Go struct ——— +// +// curl /manifests/latest 返回的 JSON: +// +// { "manifests": [ { "digest": "sha256:xxx", "platform": {"architecture":"amd64","os":"linux"} }, ... ] } +// +// 这个叫 manifest list(多架构索引)。 +type ManifestList struct { + SchemaVersion int `json:"schemaVersion"` + Manifests []ManifestEntry `json:"manifests"` +} + +type ManifestEntry struct { + Digest string `json:"digest"` // 指向真正 manifest 的 sha256 + Platform Platform `json:"platform"` // 比如 amd64/linux +} + +type Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` +} + +// curl /manifests/sha256:79ff19... 返回的 JSON: +// +// { "config": {"digest":"sha256:xxx"}, "layers": [{"digest":"sha256:yyy"}] } +// +// 这个是单架构的 manifest——里面有 config 和 layers。 +type ImageManifest struct { + SchemaVersion int `json:"schemaVersion"` + Config BlobRef `json:"config"` // 指向 config blob + Layers []BlobRef `json:"layers"` // 文件系统层,索引 0 是最底层 +} + +// BlobRef 是个"指针"——用 sha256 digest 指向 registry 上的一个文件。 +// config blob 和每一层 layer blob 都用这个表示。 +type BlobRef struct { + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// curl /blobs/sha256:d529dd0c...(config blob)返回的 JSON: +// +// { "config": { "Env": ["PATH=..."], "Cmd": ["/bin/sh"] } } +// +// 只有 Env 和 Cmd 是我们需要的。 +// ImageConfig 是 config blob 的 Go 表示。 +// Docker 里实际执行的命令 = Entrypoint(如果有)+ Cmd +// 比如 redis: Entrypoint=["docker-entrypoint.sh"], Cmd=["redis-server"] +// +// → 实际执行: docker-entrypoint.sh redis-server +type ImageConfig struct { + Config struct { + Env []string `json:"Env"` + Cmd []string `json:"Cmd"` + Entrypoint []string `json:"Entrypoint"` + } `json:"config"` +} + +// PullResult 是 pullImage() 的返回值——run() 只需要这三样 +type PullResult struct { + LowerDirs string // 冒号分隔:/tmp/layers/.../sha1:/tmp/layers/.../sha2 + Env []string // PATH, PYTHON_VERSION... 直接拼进 cmd.Env + Cmd []string // 镜像默认命令,用户没指定时用 + Entrypoint []string // 镜像入口脚本,如果有就排在 Cmd 前面 +} + +// ——— 2. 注册表 HTTP 客户端 ——— + +// getToken 向 Docker Hub 要一个临时 token。 +// 免费公开镜像不需要登录——传 repo 名就行,返回一个 Bearer token。 +// 所有后续请求都要带 Authorization: Bearer +func getToken(repo string) (string, error) { + url := fmt.Sprintf( + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", + repo, + ) + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + var out struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("token decode: %w", err) + } + return out.Token, nil +} + +// doRequest 封装了"带 token 和 Accept 头发一个 GET,把 JSON 解析到 target"。 +// manifest list、manifest、config blob 共用这个函数——它们都是 JSON。 +// layer blob 不走这个(layer 是 tar.gz 二进制)。 +func doRequest(url, token, accept string, target interface{}) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + if accept != "" { + req.Header.Set("Accept", accept) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("%s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s: status %d", url, resp.StatusCode) + } + return json.NewDecoder(resp.Body).Decode(target) +} + +// ——— 3. pullImage —— +// 输入 "alpine" + "latest" → 输出 PullResult{LowerDirs, Env, Cmd} +// +// 流程: +// 1. 解析镜像名("alpine" → "library/alpine") +// 2. 拿 token +// 3. GET /manifests/latest → ManifestList → 挑 amd64/linux +// 4. GET /manifests/sha256:xxx → ImageManifest → config.digest + layers[].digest +// 5. GET /blobs/config.digest → ImageConfig → Env + Cmd +// 6. 遍历 layers,每个 GET /blobs/digest → tar.gz → 解压到 /tmp/layers/ +// 7. 返回 +func pullImage(image, tag string) (*PullResult, error) { + if tag == "" { + tag = "latest" + } + repo := image + if !strings.Contains(image, "/") { + repo = "library/" + image // "alpine" → "library/alpine" + } + + token, err := getToken(repo) + if err != nil { + return nil, err + } + + baseURL := fmt.Sprintf("https://registry-1.docker.io/v2/%s", repo) + + // 1. manifest list → 挑 amd64 + var ml ManifestList + if err := doRequest( + fmt.Sprintf("%s/manifests/%s", baseURL, tag), + token, + "application/vnd.docker.distribution.manifest.v2+json", + &ml, + ); err != nil { + return nil, fmt.Errorf("manifest list: %w", err) + } + + var targetDigest string + for _, e := range ml.Manifests { + if e.Platform.Architecture == "amd64" && e.Platform.OS == "linux" { + targetDigest = e.Digest + break + } + } + if targetDigest == "" { + return nil, fmt.Errorf("no amd64/linux manifest for %s:%s", image, tag) + } + + // 2. 真正的 manifest → config.digest + layers + var im ImageManifest + if err := doRequest( + fmt.Sprintf("%s/manifests/%s", baseURL, targetDigest), + token, + "application/vnd.oci.image.manifest.v1+json", + &im, + ); err != nil { + return nil, fmt.Errorf("image manifest: %w", err) + } + + // 3. config blob → Env + Cmd + var cfg ImageConfig + if err := doRequest( + fmt.Sprintf("%s/blobs/%s", baseURL, im.Config.Digest), + token, + "", + &cfg, + ); err != nil { + return nil, fmt.Errorf("config blob: %w", err) + } + + // 4. 逐层下载 + layersBase := fmt.Sprintf("/tmp/layers/%s/%s", strings.ReplaceAll(repo, "/", "_"), tag) + var lowerDirs []string + for _, layer := range im.Layers { + safeName := strings.Replace(layer.Digest, ":", "_", 1) + layerDir := filepath.Join(layersBase, safeName) + + // 目录已存在 = 之前下载过,跳过 + if _, err := os.Stat(layerDir); os.IsNotExist(err) { + fmt.Printf("[pull] downloading %s (%d MB)...\n", + layer.Digest[:19], layer.Size/1024/1024) + if err := extractLayer(baseURL, layer.Digest, token, layerDir); err != nil { + fmt.Printf("[pull] WARNING: layer %s unavailable (%v), skipping\n", layer.Digest[:19], err) + continue // 404 或其他 registry 错误——跳过这一层 + } + } else { + fmt.Printf("[pull] %s (cached)\n", layer.Digest[:19]) + } + // OCI layers[0] = 基底层,overlay lowerdir 第一个 = 最上层 + // 所以要反转——最新的层放在最前面 + lowerDirs = append([]string{layerDir}, lowerDirs...) + } + + return &PullResult{ + LowerDirs: strings.Join(lowerDirs, ":"), + Env: cfg.Config.Env, + Cmd: cfg.Config.Cmd, + Entrypoint: cfg.Config.Entrypoint, + }, nil +} + +// extractLayer 下载一个 blob(tar.gz 格式)并解压到 destDir。 +// 处理普通文件、目录、符号链接。不处理硬链接(容器镜像很少用)。 +func extractLayer(baseURL, digest, token, destDir string) error { + url := fmt.Sprintf("%s/blobs/%s", baseURL, digest) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + + // 第一层:gzip 解压 + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + + // 第二层:tar 遍历 + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + + target := filepath.Join(destDir, hdr.Name) + + switch hdr.Typeflag { + case tar.TypeDir: + os.MkdirAll(target, 0755) + os.Chmod(target, os.FileMode(hdr.Mode)) + case tar.TypeReg: + os.MkdirAll(filepath.Dir(target), 0755) + f, err := os.Create(target) + if err != nil { + return fmt.Errorf("create %s: %w", target, err) + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + os.Chmod(target, os.FileMode(hdr.Mode)) + case tar.TypeSymlink: + os.Symlink(hdr.Linkname, target) + } + } + return nil +}