diff --git a/manager/README.md b/manager/README.md index d961383..1bb06c3 100644 --- a/manager/README.md +++ b/manager/README.md @@ -87,6 +87,7 @@ It is stored under `data/wrapper/`, with the URL it came from in | `-prepare` | `false` | Only download the required wrapper-lite payload, then exit. | | `-launcher` | `rootless` | `rootless` starts each wrapper-lite with `wrapper-lite-rootless` (user namespaces); `root` uses `wrapper-lite`, which needs root and suits containers that refuse a user namespace's `/proc` mount, such as Cloudflare Containers. | | `-payload-url` | *(empty)* | wrapper-lite payload zip to install instead of the newest `build-lite` artifact on `lite`, e.g. one run's `https://nightly.link/AMDL-Web/wrapper/actions/runs//wrapper-lite-linux-x86_64.zip`. | +| `-payload-sha256` | *(empty)* | Lowercase hex sha256 the `-payload-url` zip must have. A download that differs is refused, nothing is installed and the manager exits, even when another payload is already installed; the digest is part of `data/wrapper/.payload-source`, so a new digest reinstalls. Needs `-payload-url`. | | `-admin-port` | `0` (off) | Port of the [admin API](#admin-api) on `-host`. Never expose it. | | `-device-info` | `Music/5.0.2/Android/10/Pixel 8/7663314/en-US/en-US` | Optional Apple Music device-info string (`--device-info` pass-through to wrapper-lite). | diff --git a/manager/guest/init b/manager/guest/init index 1b10b75..3c2c69f 100755 --- a/manager/guest/init +++ b/manager/guest/init @@ -42,7 +42,8 @@ fi # --- optional host-provided manager args via fw_cfg --- # The launcher may pass an args file through qemu fw_cfg (name=manager_args); -# read it if present. Each line becomes one argv element. +# read it if present. The content is split on whitespace into arguments, so +# no argument may contain a space. MANAGER_ARGS="-host 0.0.0.0 -port 8080" /bin/busybox insmod /qemu_fw_cfg.ko 2>/dev/null || true if [ -f /sys/firmware/qemu_fw_cfg/by_name/manager_args/raw ]; then diff --git a/manager/main.go b/manager/main.go index adc6164..0cb7351 100644 --- a/manager/main.go +++ b/manager/main.go @@ -45,6 +45,7 @@ func main() { flag.StringVar(&PROXY, "proxy", "", "proxy for wrapper and manager") flag.StringVar(&Launcher, "launcher", "rootless", `wrapper-lite launcher: "rootless" (user namespaces) or "root" (needs root; for containers that refuse a user namespace's /proc mount)`) flag.StringVar(&PayloadURL, "payload-url", "", "wrapper-lite payload zip to install instead of the newest build-lite artifact on lite") + flag.StringVar(&PayloadSHA256, "payload-sha256", "", "lowercase hex sha256 the wrapper-lite payload zip must have; a download that differs is refused (needs -payload-url)") var adminPort = flag.Int("admin-port", 0, "port of the admin API (/snapshot, /restore, /stats) on the same host; 0 turns it off. Never expose it: a snapshot holds every account's session") flag.StringVar(&DeviceInfo, "device-info", "Music/5.0.2/Android/10/Pixel 8/7663314/en-US/en-US", "device info for wrapper-lite (--device-info pass-through, optional)") flag.Parse() @@ -53,6 +54,15 @@ func main() { os.Exit(2) } + if PayloadSHA256 != "" && !sha256Hex.MatchString(PayloadSHA256) { + fmt.Fprintln(os.Stderr, "-payload-sha256 must be 64 lowercase hex digits") + os.Exit(2) + } + if PayloadSHA256 != "" && PayloadURL == "" { + fmt.Fprintln(os.Stderr, "-payload-sha256 needs -payload-url: the newest build-lite artifact changes under a fixed digest") + os.Exit(2) + } + log.SetOutput(os.Stdout) if *debug { log.SetLevel(log.DebugLevel) diff --git a/manager/payload_source_test.go b/manager/payload_source_test.go index 2d61d54..e01d1c3 100644 --- a/manager/payload_source_test.go +++ b/manager/payload_source_test.go @@ -2,8 +2,15 @@ package main import ( "archive/zip" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -78,3 +85,84 @@ func TestPayloadFromAnotherSourceIsNotReady(t *testing.T) { t.Fatalf("account data lost: %v", err) } } + +// servePayload serves a payload zip and returns its URL and sha256. +func servePayload(t *testing.T) (url, sum string) { + t.Helper() + zipPath := filepath.Join(t.TempDir(), "p.zip") + writePayloadZip(t, zipPath) + body, err := os.ReadFile(zipPath) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) })) + t.Cleanup(srv.Close) + h := sha256.Sum256(body) + return srv.URL + "/p.zip", hex.EncodeToString(h[:]) +} + +func setPin(t *testing.T, url, sum string) { + t.Helper() + oldURL, oldSum := PayloadURL, PayloadSHA256 + PayloadURL, PayloadSHA256 = url, sum + t.Cleanup(func() { PayloadURL, PayloadSHA256 = oldURL, oldSum }) +} + +func TestPayloadSHA256AcceptsTheMatchingDownload(t *testing.T) { + t.Chdir(t.TempDir()) + url, sum := servePayload(t) + setPin(t, url, sum) + zipPath, err := downloadWrapperLite(false) + if err != nil { + t.Fatal(err) + } + if _, err := installWrapperLite(zipPath); err != nil { + t.Fatal(err) + } + if !wrapperPayloadReady() { + t.Fatal("payload not ready after a verified install") + } + // The same URL under another digest is a different pin. + setPin(t, url, strings.Repeat("0", 64)) + if wrapperPayloadReady() { + t.Fatal("a payload installed under another digest reported ready") + } +} + +func TestPayloadSHA256RefusesAMismatchAndInstallsNothing(t *testing.T) { + t.Chdir(t.TempDir()) + url, _ := servePayload(t) + setPin(t, url, strings.Repeat("0", 64)) + zipPath, err := downloadWrapperLite(false) + if !errors.Is(err, errPayloadSHA256) || zipPath != "" { + t.Fatalf("mismatch: %q, %v", zipPath, err) + } + if _, err := os.Stat(filepath.Join("data", "wrapper-lite-"+runtime.GOARCH+".zip")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("the refused download was written: %v", err) + } + if wrapperPayloadInstalled() { + t.Fatal("a payload was installed") + } +} + +func TestPayloadSHA256MismatchDoesNotKeepAnInstalledPayload(t *testing.T) { + t.Chdir(t.TempDir()) + // An installed payload from another source is normally kept when the + // replacement fails; a digest mismatch must panic instead. + for _, p := range []string{launcherPath(), filepath.Join(wrapperDir, "rootfs", "system", "bin", "lite")} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + } + url, _ := servePayload(t) + setPin(t, url, strings.Repeat("0", 64)) + defer func() { + if r := recover(); r == nil { + t.Fatal("PrepareWrapper kept the unverified payload") + } + }() + PrepareWrapper(false) +} diff --git a/manager/wrapper.go b/manager/wrapper.go index 192df36..7ed7c26 100644 --- a/manager/wrapper.go +++ b/manager/wrapper.go @@ -3,7 +3,10 @@ package main import ( "archive/zip" "bufio" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "github.com/gofrs/uuid/v5" log "github.com/sirupsen/logrus" @@ -12,6 +15,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "sync" @@ -54,6 +58,30 @@ func baseDirArg(id string) string { // as the wrapper-lite payload, for example to pin one build-lite run. var PayloadURL string +// PayloadSHA256, when set (-payload-sha256), is the lowercase hex sha256 the +// downloaded payload zip must have. A download that differs is refused and +// nothing is installed. +var PayloadSHA256 string + +// errPayloadSHA256 means a download did not match PayloadSHA256. Unlike a +// failed download it is never papered over by keeping an installed payload. +var errPayloadSHA256 = errors.New("wrapper-lite payload does not match -payload-sha256") + +var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// payloadSource is what payloadSourcePath records: the artifact URL, and the +// required digest when there is one, so that a different pin reinstalls. +func payloadSource() (string, error) { + src, err := releaseAssetURL() + if err != nil { + return "", err + } + if PayloadSHA256 != "" { + src += "#sha256=" + PayloadSHA256 + } + return src, nil +} + // releaseAssetURL returns the nightly.link URL of the wrapper-lite native // artifact for the current architecture. The artifact is produced by the // build-lite workflow on the `lite` branch of AMDL-Web/wrapper, a fork of @@ -129,7 +157,7 @@ func wrapperPayloadReady() bool { if !wrapperPayloadInstalled() { return false } - want, err := releaseAssetURL() + want, err := payloadSource() if err != nil { return true } @@ -176,6 +204,11 @@ func downloadWrapperLite(mirror bool) (string, error) { if err != nil { return "", fmt.Errorf("failed to read wrapper-lite download: %w", err) } + if PayloadSHA256 != "" { + if sum := sha256.Sum256(body); hex.EncodeToString(sum[:]) != PayloadSHA256 { + return "", fmt.Errorf("%w: downloaded %d bytes with sha256 %x, wanted %s", errPayloadSHA256, len(body), sum, PayloadSHA256) + } + } if err = os.WriteFile(zipPath, body, 0o777); err != nil { return "", err } @@ -200,7 +233,7 @@ func installWrapperLite(zipPath string) (installResult, error) { _ = os.Chmod(filepath.Join(wrapperDir, "wrapper-lite-rootless"), 0o777) _ = os.Chmod(liteBin, 0o777) _ = os.Chmod(filepath.Join(wrapperDir, "rootfs", "system", "bin", "linker64"), 0o777) - if src, err := releaseAssetURL(); err == nil { + if src, err := payloadSource(); err == nil { if err := os.WriteFile(payloadSourcePath(), []byte(src+"\n"), 0o644); err != nil { return installResult{}, err } @@ -236,7 +269,7 @@ func PrepareWrapper(mirror bool) { res, err = installWrapperLite(zipPath) } if err != nil { - if installed { + if installed && !errors.Is(err, errPayloadSHA256) { log.Warnf("keeping the installed wrapper-lite: %v", err) return }