Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run>/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). |

Expand Down
3 changes: 2 additions & 1 deletion manager/guest/init
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions manager/payload_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
39 changes: 36 additions & 3 deletions manager/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -12,6 +15,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -129,7 +157,7 @@ func wrapperPayloadReady() bool {
if !wrapperPayloadInstalled() {
return false
}
want, err := releaseAssetURL()
want, err := payloadSource()
if err != nil {
return true
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading