From fbf9e545af4b35c78607111ac58ba5d5a6dc4481 Mon Sep 17 00:00:00 2001 From: LYJW131 <153256373+LYJW131@users.noreply.github.com> Date: Tue, 3 Nov 2026 16:18:37 +0800 Subject: [PATCH] fix(manager): the guest keeps its writes when stopped, and a login can be canceled A host that stopped the QEMU guest killed it, and ext4's delayed allocation kept ~30 s of written data in guest memory: an installed payload, or an instances.json and the tokens of a fresh login, were lost with it. - init mounts /data with commit=1,nodelalloc, falls back to the defaults, formats only a disk with no ext4 magic, and syncs and unmounts before powering off. - POST /shutdown, served only in the guest (init sets WRAPPER_MANAGER_GUEST=1), syncs and stops the manager, so the host can power the guest off instead of killing it. - instances.json and .payload-source are written with fsync and a synced rename; the payload's files and a login's tokens are synced before they are counted on. - With -payload-sha256 the digest alone names the installed payload, so a host may serve the same zip from a new URL each boot without a reinstall. A login honours the request's context: when the client goes away, or the 90 s login wait passes, its child is killed and its instance dir removed before the answer, and POST /login gets a write deadline past that wait instead of the server's 60 s. A new login of an account replaces one still in flight, and a replaced login's late failure no longer removes the new login's dir. Co-Authored-By: Claude Opus 5.5 Signed-off-by: LYJW131 <153256373+LYJW131@users.noreply.github.com> --- manager/README.md | 7 ++ manager/guest/README.md | 12 +- manager/guest/init | 36 +++++- manager/instance.go | 40 +++++-- manager/login.go | 127 ++++++++++++++++++++-- manager/login_cancel_test.go | 193 +++++++++++++++++++++++++++++++++ manager/main.go | 43 +++++++- manager/multiaccount_test.go | 5 +- manager/payload_source_test.go | 28 +++++ manager/wrapper.go | 19 +++- 10 files changed, 476 insertions(+), 34 deletions(-) create mode 100644 manager/login_cancel_test.go diff --git a/manager/README.md b/manager/README.md index 1d4bcae5..bbbe1ab1 100644 --- a/manager/README.md +++ b/manager/README.md @@ -146,6 +146,7 @@ instance"` (retry later). |---|---|---|---| | `POST` | `/login` | `{"username","password"}` or `{"username","password","code"}` | Two-phase login, see below. | | `POST` | `/logout` | `{"username"}` or `{"id"}` | Kill the account's service process, delete its data dir, remove it from `instances.json`, return `code:0`. `id` is the instance id and wins when both are sent. Unknown account → `code:-1, "no such account"`. | +| `POST` | `/shutdown` | — | QEMU guest only (its init sets `WRAPPER_MANAGER_GUEST=1`): sync the data disk, answer `code:0`, then stop as on SIGTERM; init syncs again and powers the guest off. | **Two-phase 2FA login:** @@ -171,6 +172,12 @@ Notes: - Sending a `code` with no pending login returns `code:-1` with a message telling you to restart the login without a code. - Failed logins clean up the instance data directory before reporting. +- A login request waits up to 90 seconds (its write deadline is 105). When + that passes, or the client closes the connection, the login child is killed + and its instance dir removed before the answer (`"login timeout"`, or + `"login canceled"` to a client that is gone), so a retry starts clean. +- A login without a code replaces one of the same account still in flight, + such as one waiting for a code: the old child is killed and reaped first. - The instance id is a deterministic UUIDv5 derived from the username, so the `loginId` is stable across attempts. diff --git a/manager/guest/README.md b/manager/guest/README.md index e5240d99..b84a18af 100644 --- a/manager/guest/README.md +++ b/manager/guest/README.md @@ -43,13 +43,17 @@ from the kernel's version. 1. Mounts proc/sys/dev, loads `e1000.ko`, configures static NAT networking (10.0.2.15, DNS 10.0.2.3) and writes `/etc/resolv.conf`. -2. Mounts the ext4 data disk (`/dev/vda` → `/data`); formats it with - `mke2fs` on first boot when it is a raw empty image. +2. Mounts the ext4 data disk (`/dev/vda` → `/data`) with `commit=1,nodelalloc`, + so a guest that is killed loses at most the last second of writes; formats + it with `mke2fs` only when it holds no ext4 at all, and powers off rather + than format one that does not mount. 3. Runs `/wrapper-manager -host 0.0.0.0 -port 8080` from `/data`, so all manager state (wrapper-lite payload, accounts, tokens) persists in `data.img`. Optional manager args can be supplied via qemu fw_cfg (`manager_args`) or the kernel cmdline (`manager_args_b64=`). -4. Powers the guest off when the manager exits, so QEMU exits with it rather - than hanging on a kernel panic. The manager itself retries a failed +4. Syncs, unmounts and powers the guest off when the manager exits, so QEMU + exits with it rather than hanging on a kernel panic. The host stops the + guest with `POST /shutdown`, which only the guest serves; killing QEMU is + its fallback. The manager itself retries a failed wrapper-lite download with backoff instead of exiting; only a download that fails its pinned digest ends it. diff --git a/manager/guest/init b/manager/guest/init index 7903febb..81ebc425 100755 --- a/manager/guest/init +++ b/manager/guest/init @@ -34,11 +34,29 @@ for _ in 1 2 3 4 5 6 7 8 9 10; do [ -e /dev/vda ] && break /bin/busybox sleep 1 done -if ! /bin/busybox mount -t ext4 /dev/vda /data 2>/dev/null; then - echo "[init] data disk mount failed - formatting as ext4" +# commit=1 commits the journal every second and nodelalloc allocates blocks +# as data is written, so data=ordered writes the data before that commit: a +# host that has to kill QEMU loses at most the last second, not the ~30 s of +# dirty pages delayed allocation keeps. The host normally powers the guest off +# through POST /shutdown, which syncs first. +mount_data() { + /bin/busybox mount -t ext4 -o commit=1,nodelalloc /dev/vda /data 2>/dev/null && return 0 + echo "[init] mounting /data with commit=1,nodelalloc failed, mounting with the defaults" + /bin/busybox mount -t ext4 /dev/vda /data 2>/dev/null +} +if ! mount_data; then + # Format only a disk that holds no ext4 at all (magic 0xEF53 at byte + # 1080): one that holds the accounts but does not mount is left alone. + magic=$(/bin/busybox dd if=/dev/vda bs=1 skip=1080 count=2 2>/dev/null | /bin/busybox od -An -tx1 | /bin/busybox tr -d ' \n') + if [ "$magic" = "53ef" ]; then + echo "[init] /dev/vda holds an ext4 filesystem that does not mount; leaving it untouched and powering off" + /bin/busybox poweroff -f + fi + echo "[init] data disk holds no filesystem - formatting as ext4" /bin/busybox mke2fs -q -t ext4 /dev/vda 2>/dev/null || /bin/busybox echo "[init] mkfs.ext4 unavailable" - /bin/busybox mount -t ext4 /dev/vda /data 2>/dev/null && echo "[init] data disk formatted and mounted" + mount_data && echo "[init] data disk formatted and mounted" fi +echo "[init] data: $(/bin/busybox grep ' /data ' /proc/mounts)" # --- optional host-provided manager args via fw_cfg --- # The launcher may pass an args file through qemu fw_cfg (name=manager_args); @@ -76,7 +94,15 @@ echo "[init] dns test: $(/bin/busybox nslookup nightly.link 10.0.2.3 2>&1 | head cd /data # Not exec: the kernel panics when init exits, and a panicked guest keeps # QEMU running. Powering off ends QEMU, so the host sees the manager stop. +# WRAPPER_MANAGER_GUEST turns on POST /shutdown, which only this guest serves: +# the host asks for it to stop the guest without losing writes. # shellcheck disable=SC2086 -/wrapper-manager $MANAGER_ARGS -echo "[init] wrapper-manager exited with status $?, powering off" +WRAPPER_MANAGER_GUEST=1 /wrapper-manager $MANAGER_ARGS +status=$? +echo "[init] wrapper-manager exited with status $status, syncing and powering off" +# poweroff -f does not sync; unmounting fails while a wrapper-lite still has a +# file open, and the sync before it is what keeps the data. +/bin/busybox sync +/bin/busybox umount /data 2>/dev/null || /bin/busybox mount -o remount,ro /data 2>/dev/null +/bin/busybox sync /bin/busybox poweroff -f diff --git a/manager/instance.go b/manager/instance.go index cfa9326c..3e5e8ff6 100644 --- a/manager/instance.go +++ b/manager/instance.go @@ -5,6 +5,7 @@ import ( log "github.com/sirupsen/logrus" "os" "os/exec" + "path/filepath" "sync" "time" ) @@ -169,16 +170,41 @@ func SaveInstances() { log.Errorf("failed to marshal instances: %v", err) return } - // Atomic write: temp file + rename avoids a truncated registry on crash. - tmp := "data/instances.json.tmp" - if err = os.WriteFile(tmp, data, 0777); err != nil { + if err := writeFileDurable("data/instances.json", data, 0777); err != nil { log.Errorf("failed to write instances.json: %v", err) - return } - if err = os.Rename(tmp, "data/instances.json"); err != nil { - log.Errorf("failed to rename instances.json: %v", err) - return +} + +// writeFileDurable replaces path with data so that a crash or a killed guest +// leaves either the old file or the new one: it writes a temp file, syncs it, +// renames it over path and syncs the directory, so the rename is on disk +// before this returns. +func writeFileDurable(path string, data []byte, perm os.FileMode) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err } + defer func() { _ = dir.Close() }() + return dir.Sync() } func LoadInstance() []WrapperInstance { diff --git a/manager/login.go b/manager/login.go index 3900f6fe..f5c72661 100644 --- a/manager/login.go +++ b/manager/login.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "fmt" "github.com/gofrs/uuid/v5" @@ -9,9 +10,28 @@ import ( "os/exec" "strings" "sync" + "syscall" "time" ) +// loginWait bounds one POST /login: the wait for the lite login child to ask +// for a code or to finish. It ends before the request's write deadline +// (loginWriteTimeout), so the client always gets an answer, and before +// amdl's own limit for a login, so the timeout it reports is this one. +const loginWait = 90 * time.Second + +// loginWriteTimeout is POST /login's write deadline, in place of the +// server's WriteTimeout, which is shorter than a login. +const loginWriteTimeout = loginWait + 15*time.Second + +// abortWait bounds how long an abort waits for the killed login child to be +// reaped and its half-made instance dir removed. +const abortWait = 10 * time.Second + +// errLoginCanceled ends a login whose client went away or that a new login of +// the same account replaced. +var errLoginCanceled = errors.New("login canceled") + // LoginResult describes the outcome of a login attempt for the HTTP layer. type LoginResult struct { Code int // 0 = success, 2 = 2FA required, -1 = failure @@ -43,8 +63,14 @@ type LoginState struct { pending2FA bool reported2FA bool cmd *exec.Cmd + // aborted means the login was canceled: its child is killed, and its + // exit cleans up instead of counting as a failure of the account. + aborted bool done chan error // nil => success; error => failure + // exited is closed once runLoginChild has returned: the child is reaped + // and a failed login's instance dir is gone. + exited chan struct{} } // loginRegistry tracks in-flight logins keyed by instance id. @@ -58,6 +84,7 @@ func newLoginState(id string) *LoginState { Id: id, Username: "", done: make(chan error, 1), + exited: make(chan struct{}), } } @@ -81,6 +108,55 @@ func removeLogin(id string) { loginRegistry.Unlock() } +// releaseLogin drops st from the registry and reports whether it was still +// the account's login: a login that a newer one replaced owns nothing there, +// not even the instance dir. +func releaseLogin(id string, st *LoginState) bool { + loginRegistry.Lock() + defer loginRegistry.Unlock() + cur, ok := loginRegistry.m[id] + if ok && cur != st { + return false + } + delete(loginRegistry.m, id) + return true +} + +// abortLogin kills an in-flight login's child and waits, up to abortWait, +// until runLoginChild has reaped it and removed its instance dir. A child +// that has already finished the login is left to finish. +func abortLogin(st *LoginState) { + st.mu.Lock() + st.aborted = true + cmd := st.cmd + st.mu.Unlock() + if cmd != nil { + killGroup(cmd) + } + select { + case <-st.exited: + case <-time.After(abortWait): + log.Warnf("[wrapper %s] the canceled login's child did not exit within %s", shortID(st.Id), abortWait) + } +} + +func (s *LoginState) isAborted() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.aborted +} + +// killGroup kills a launcher's process group: the launcher and the lite it +// forked. +func killGroup(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { + _ = cmd.Process.Kill() + } +} + // hasTokenFiles reports whether a successful login artifact exists in the // instance data dir (lite writes STOREFRONT_ID and MUSIC_TOKEN there). func hasTokenFiles(id string) bool { @@ -118,7 +194,9 @@ func reserveLogin(id string) (*LoginState, bool) { return st, true } -// startLogin begins (or continues) a login for an account. +// startLogin begins (or continues) a login for an account. It gives up when +// ctx ends, the request's context: the client went away, so the login child is +// killed and its half-made instance dir removed. // // When req.Code == "" and no login is in flight, it launches the one-shot // lite --login child and returns as soon as the outcome is known: @@ -128,7 +206,11 @@ func reserveLogin(id string) (*LoginState, bool) { // // When req.Code != "", the code is written to 2fa.txt and the pending child // finishes the login; this call blocks (polling) until the child exits. -func startLogin(req LoginRequest) LoginResult { +// +// A login without a code replaces one of the same account still in flight, +// such as one waiting for a code that will not come: the old child is killed +// and reaped first, so two children never share the account's dir. +func startLogin(ctx context.Context, req LoginRequest) LoginResult { id := InstanceID(req.Username) // Same account already registered as a ready instance -> already login. @@ -142,12 +224,16 @@ func startLogin(req LoginRequest) LoginResult { if err := provide2FACode(id, req.Code); err != nil { return LoginResult{Code: -1, Msg: err.Error(), LoginId: id} } - return waitForLoginFinish(id, st) + return waitForLoginFinish(ctx, id, st) } return LoginResult{Code: -1, Msg: "no pending login for this account, restart login without code", LoginId: id} } // No code: (re)start from scratch. + if old := getLogin(id); old != nil { + log.Infof("[wrapper %s] a new login replaces the one in flight", shortID(id)) + abortLogin(old) + } st, ok := reserveLogin(id) if !ok { return LoginResult{Code: -1, Msg: fmt.Sprintf("account limit reached: this manager holds at most %d accounts, log one out first", MaxAccounts), LoginId: id} @@ -157,12 +243,13 @@ func startLogin(req LoginRequest) LoginResult { _ = RemoveWrapperDataQuiet(id) go runLoginChild(id, req.Username, req.Password, st) - return waitForLoginFinish(id, st) + return waitForLoginFinish(ctx, id, st) } // runLoginChild launches the one-shot lite --login child, monitors its exit, // and drives the state machine to completion. func runLoginChild(id, username, password string, st *LoginState) { + defer close(st.exited) log.Infof("[wrapper %s] login started for %s", shortID(id), username) cmd, err := startLiteLogin(id, username, password) if err != nil { @@ -171,7 +258,11 @@ func runLoginChild(id, username, password string, st *LoginState) { } st.mu.Lock() st.cmd = cmd + aborted := st.aborted st.mu.Unlock() + if aborted { + killGroup(cmd) // canceled while the child started + } waitCh := make(chan error, 1) go func() { waitCh <- cmd.Wait() }() @@ -183,6 +274,10 @@ func runLoginChild(id, username, password string, st *LoginState) { select { case werr := <-waitCh: // Child exited. Token files present => success; else classify. + if st.isAborted() { + loginFailed(id, st, errLoginCanceled) + return + } if hasTokenFiles(id) { finishLoginSuccess(id, username, st) return @@ -221,10 +316,14 @@ func classifyLoginFailure(werr error) error { // waitForLoginFinish blocks until the in-flight login resolves: the child // exits (success/failure), or 2FA becomes required. When 2FA is required it // is reported once (code 2); a second call (the client resubmits with a code) -// blocks until the child exits with the final outcome. -func waitForLoginFinish(id string, st *LoginState) LoginResult { - timeout := time.NewTimer(150 * time.Second) +// blocks until the child exits with the final outcome. When ctx ends or +// loginWait passes first, the login is aborted: its child is killed and its +// instance dir removed before this returns, so a retry starts clean. +func waitForLoginFinish(ctx context.Context, id string, st *LoginState) LoginResult { + timeout := time.NewTimer(loginWait) defer timeout.Stop() + tick := time.NewTicker(200 * time.Millisecond) + defer tick.Stop() for { select { @@ -233,9 +332,14 @@ func waitForLoginFinish(id string, st *LoginState) LoginResult { return LoginResult{Code: 0, Msg: "SUCCESS", LoginId: id} } return LoginResult{Code: -1, Msg: err.Error(), LoginId: id} + case <-ctx.Done(): + log.Infof("[wrapper %s] the client went away; canceling the login", shortID(id)) + abortLogin(st) + return LoginResult{Code: -1, Msg: errLoginCanceled.Error(), LoginId: id} case <-timeout.C: + abortLogin(st) return LoginResult{Code: -1, Msg: "login timeout", LoginId: id} - case <-time.After(200 * time.Millisecond): + case <-tick.C: if st.claimPending2FA() { return LoginResult{Code: 2, Msg: "2fa code require", LoginId: id} } @@ -289,6 +393,7 @@ func provide2FACode(id string, code string) error { // is recorded as the instance's Name. func finishLoginSuccess(id, username string, st *LoginState) { go func() { + syscall.Sync() // the tokens the child wrote, before anything counts on them instance := getInstanceOrNew(id) instance.NoRestart = false instance.Name = username @@ -307,7 +412,7 @@ func finishLoginSuccess(id, username string, st *LoginState) { // resolveLogin completes the pending login. func resolveLogin(id string, st *LoginState, cause error) { - removeLogin(id) + releaseLogin(id, st) select { case st.done <- cause: default: @@ -318,7 +423,9 @@ func resolveLogin(id string, st *LoginState, cause error) { // an error. func loginFailed(id string, st *LoginState, cause error) { log.Warnf("[wrapper %s] login failed: %v", shortID(id), cause) - _ = RemoveWrapperDataQuiet(id) + if releaseLogin(id, st) { + _ = RemoveWrapperDataQuiet(id) + } resolveLogin(id, st, cause) } diff --git a/manager/login_cancel_test.go b/manager/login_cancel_test.go new file mode 100644 index 00000000..ae228231 --- /dev/null +++ b/manager/login_cancel_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// fakeLauncher installs a wrapper-lite-rootless that behaves as script says, +// and returns the file each run appends its pid to. +func fakeLauncher(t *testing.T, script string) string { + t.Helper() + inTempDir(t) + runs := filepath.Join(mustAbs("."), "runs") + body := "#!/bin/sh\necho $$ >> " + runs + "\n" + script + "\n" + if err := os.MkdirAll(filepath.Dir(launcherPath()), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(launcherPath(), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + withInstances(t) + return runs +} + +// pids returns the pids the fake launcher recorded, oldest first. +func pids(t *testing.T, runs string) []int { + t.Helper() + raw, _ := os.ReadFile(runs) + var out []int + for _, f := range strings.Fields(string(raw)) { + n, err := strconv.Atoi(f) + if err != nil { + t.Fatal(err) + } + out = append(out, n) + } + return out +} + +func alive(pid int) bool { return syscall.Kill(pid, 0) == nil } + +func waitGone(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for alive(pid) { + if time.Now().After(deadline) { + t.Fatalf("login child %d still runs", pid) + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestCanceledLoginKillsItsChildAndCleansUp(t *testing.T) { + runs := fakeLauncher(t, "exec sleep 60") + id := InstanceID("cancel@example.invalid") + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(300*time.Millisecond, cancel) + start := time.Now() + res := startLogin(ctx, LoginRequest{Username: "cancel@example.invalid", Password: "x"}) + if res.Code != -1 || res.Msg != errLoginCanceled.Error() { + t.Fatalf("got %+v, want the canceled login", res) + } + if d := time.Since(start); d > 5*time.Second { + t.Errorf("the canceled login answered after %s", d) + } + got := pids(t, runs) + if len(got) != 1 { + t.Fatalf("%d login children, want 1", len(got)) + } + // The child was reaped before the answer, so it is gone, not a zombie. + if alive(got[0]) { + t.Error("the canceled login's child still runs") + } + if getLogin(id) != nil { + t.Error("the canceled login is still registered") + } + if _, err := os.Stat(instanceDir(id)); !os.IsNotExist(err) { + t.Error("the canceled login left its instance dir") + } + + // A retry right away runs its own child to its own end. + if err := os.WriteFile(launcherPath(), []byte("#!/bin/sh\necho $$ >> "+runs+"\nexit 3\n"), 0o755); err != nil { + t.Fatal(err) + } + res = startLogin(context.Background(), LoginRequest{Username: "cancel@example.invalid", Password: "x"}) + if res.Code != -1 || res.Msg != "login failed (exit code 3)" { + t.Fatalf("retry: got %+v, want the retry's own failure", res) + } + if n := len(pids(t, runs)); n != 2 { + t.Errorf("%d login children after the retry, want 2", n) + } +} + +func TestANewLoginReplacesOneWaitingForItsCode(t *testing.T) { + runs := fakeLauncher(t, "echo 'Enter your 2FA code'\nexec sleep 60") + id := InstanceID("2fa@example.invalid") + res := startLogin(context.Background(), LoginRequest{Username: "2fa@example.invalid", Password: "x"}) + if res.Code != 2 { + t.Fatalf("got %+v, want a code request", res) + } + first := pids(t, runs)[0] + if !alive(first) { + t.Fatal("the child waiting for its code is gone") + } + // The user starts over instead of entering the code. + res = startLogin(context.Background(), LoginRequest{Username: "2fa@example.invalid", Password: "y"}) + waitGone(t, first) + if res.Code != 2 { + t.Fatalf("got %+v, want the new login's own code request", res) + } + got := pids(t, runs) + if len(got) != 2 || !alive(got[1]) { + t.Fatalf("login children %v: want the old one gone and its replacement waiting", got) + } + st := getLogin(id) + if st == nil { + t.Fatal("the new login is not registered") + } + abortLogin(st) + waitGone(t, got[1]) + if getLogin(id) != nil { + t.Error("an aborted login is still registered") + } + if _, err := os.Stat(instanceDir(id)); !os.IsNotExist(err) { + t.Error("an aborted login left its instance dir") + } +} + +func TestAReplacedLoginLeavesTheNewOnesDir(t *testing.T) { + inTempDir(t) + id := InstanceID("late@example.invalid") + old := newLoginState(id) + loginRegistry.Lock() + loginRegistry.m[id] = old + loginRegistry.Unlock() + fresh, ok := reserveLogin(id) + if !ok { + t.Fatal("reserve") + } + t.Cleanup(func() { removeLogin(id) }) + if err := os.MkdirAll(instanceDir(id), 0o777); err != nil { + t.Fatal(err) + } + loginFailed(id, old, errLoginCanceled) + if _, err := os.Stat(instanceDir(id)); err != nil { + t.Errorf("the replaced login's late failure removed the new login's dir: %v", err) + } + if getLogin(id) != fresh { + t.Error("the replaced login's late failure dropped the new login") + } +} + +func TestShutdownAnswersThenStops(t *testing.T) { + stop := make(chan os.Signal, 1) + rec := httptest.NewRecorder() + handleShutdown(stop)(rec, httptest.NewRequest(http.MethodPost, "/shutdown", nil)) + if !strings.Contains(rec.Body.String(), `"code":0`) { + t.Errorf("answer %s", rec.Body) + } + select { + case sig := <-stop: + if sig != syscall.SIGTERM { + t.Errorf("signal %v", sig) + } + default: + t.Error("the manager was not told to stop") + } + rec = httptest.NewRecorder() + handleShutdown(stop)(rec, httptest.NewRequest(http.MethodGet, "/shutdown", nil)) + if len(stop) != 0 { + t.Error("a GET stopped the manager") + } +} + +func TestInstancesJSONWriteLeavesNoTemp(t *testing.T) { + inTempDir(t) + withInstances(t, &WrapperInstance{Id: "a", Region: "jp", Port: 1}) + SaveInstances() + if _, err := os.Stat("data/instances.json.tmp"); !os.IsNotExist(err) { + t.Error("the temp file stayed") + } + if got := LoadInstance(); len(got) != 1 || got[0].Id != "a" { + t.Errorf("instances.json = %+v", got) + } +} diff --git a/manager/main.go b/manager/main.go index fa6ee573..61fd4505 100644 --- a/manager/main.go +++ b/manager/main.go @@ -102,6 +102,10 @@ func main() { Instances = make([]*WrapperInstance, 0) restoreInstances(func(id string) { go WrapperStart(id) }) + // A stop request, from a signal or from POST /shutdown. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + mux := http.NewServeMux() // Resource endpoints (wrapper-lite compatible, aggregated over accounts). @@ -116,6 +120,13 @@ func main() { mux.HandleFunc("/login", handleLogin) mux.HandleFunc("/logout", handleLogout) + // Only the QEMU guest's init sets WRAPPER_MANAGER_GUEST: there the host + // stops the guest through this, and no one else reaches the port. A + // manager in a container is stopped by its runtime's signal instead. + if os.Getenv(guestEnv) == "1" { + mux.HandleFunc("/shutdown", handleShutdown(sigCh)) + } + // OpenTelemetry: build providers (OTLP env vars), bridge logrus logs and // wrap the HTTP handler for automatic per-request traces. tel := initTelemetry() @@ -162,8 +173,6 @@ func main() { } }() - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) <-sigCh log.Info("shutting down...") @@ -178,6 +187,31 @@ func main() { log.Warnf("otel shutdown: %v", err) } } + syscall.Sync() +} + +// guestEnv is set by the QEMU guest's init, which powers the guest off once +// the manager exits. +const guestEnv = "WRAPPER_MANAGER_GUEST" + +// handleShutdown implements POST /shutdown for the QEMU guest: it syncs the +// data disk, answers, then stops the manager as SIGTERM would. The guest's +// init then syncs again and powers off, so the host need not kill QEMU and +// lose what the disk had not written yet. +func handleShutdown(stop chan<- os.Signal) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + WriteLiteError(w, "method not allowed") + return + } + log.Info("shutdown requested") + syscall.Sync() + WriteLiteSuccess(w, map[string]any{}) + select { + case stop <- syscall.SIGTERM: + default: // already stopping + } + } } // handleLogin implements POST /login (two-phase 2FA). @@ -196,8 +230,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { return } + // A login outlasts the server's WriteTimeout; its own wait (loginWait) + // ends first, so the client gets this handler's answer. + _ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(loginWriteTimeout)) log.Infof("login request for %s", req.Username) - result := startLogin(req) + result := startLogin(r.Context(), req) switch result.Code { case 0: WriteLiteSuccess(w, map[string]any{"loginId": result.LoginId}) diff --git a/manager/multiaccount_test.go b/manager/multiaccount_test.go index 3e1b8cad..30efbaba 100644 --- a/manager/multiaccount_test.go +++ b/manager/multiaccount_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "net/http" @@ -131,7 +132,7 @@ func TestLoginRefusesAnEleventhAccount(t *testing.T) { } t.Cleanup(func() { removeLogin(pending.Id) }) - res := startLogin(LoginRequest{Username: "eleventh@example.com", Password: "x"}) + res := startLogin(context.Background(), LoginRequest{Username: "eleventh@example.com", Password: "x"}) if res.Code != -1 || !strings.Contains(res.Msg, "account limit reached") || !strings.Contains(res.Msg, "10") { t.Fatalf("got %+v, want the account-limit error", res) } @@ -143,7 +144,7 @@ func TestLoginRefusesAnEleventhAccount(t *testing.T) { } // An account already held still gets its own answer, not the cap. - if res := startLogin(LoginRequest{Username: "held0@example.com", Password: "x"}); res.Msg != "already login" { + if res := startLogin(context.Background(), LoginRequest{Username: "held0@example.com", Password: "x"}); res.Msg != "already login" { t.Errorf("held account: got %+v, want already login", res) } } diff --git a/manager/payload_source_test.go b/manager/payload_source_test.go index 759a141f..be566d4a 100644 --- a/manager/payload_source_test.go +++ b/manager/payload_source_test.go @@ -221,3 +221,31 @@ func TestPrepareWrapperRetriesUntilThePayloadArrives(t *testing.T) { t.Fatal("payload not ready after PrepareWrapper returned") } } + +func TestPinnedPayloadIsReadyFromAnyURL(t *testing.T) { + t.Chdir(t.TempDir()) + url, sum := servePayload(t) + setPin(t, url, sum) + zipPath := filepath.Join(t.TempDir(), "p.zip") + writePayloadZip(t, zipPath) + if _, err := installWrapperLite(zipPath); err != nil { + t.Fatal(err) + } + // The host serves the same zip on another port at the next boot. + setPin(t, "http://10.0.2.2:41234/wrapper-lite.zip", sum) + if !wrapperPayloadReady() { + t.Error("the same digest from another URL must not reinstall") + } + setPin(t, url, strings.Repeat("0", 64)) + if wrapperPayloadReady() { + t.Error("another digest must reinstall") + } + // A disk installed before: nightly.link URL with the same digest. + if err := os.WriteFile(payloadSourcePath(), []byte("https://nightly.link/AMDL-Web/wrapper/actions/runs/1/wrapper-lite-linux-x86_64.zip#sha256="+sum+"\n"), 0o644); err != nil { + t.Fatal(err) + } + setPin(t, "http://10.0.2.2:5/x.zip", sum) + if !wrapperPayloadReady() { + t.Error("a payload from the old pinned URL with the same digest must be ready") + } +} diff --git a/manager/wrapper.go b/manager/wrapper.go index bb81dcb6..fd1f4e2d 100644 --- a/manager/wrapper.go +++ b/manager/wrapper.go @@ -153,6 +153,9 @@ func wrapperPayloadInstalled() bool { // wrapperPayloadReady reports whether the installed payload came from // releaseAssetURL. A payload without that record (installed by upstream // wrapper-manager, so it leaks a context per /key) is replaced on start. +// With -payload-sha256 the digest alone names the payload: the same zip from +// another URL, such as the one a host serves the guest on a new port each +// boot, is the payload already installed. func wrapperPayloadReady() bool { if !wrapperPayloadInstalled() { return false @@ -161,8 +164,15 @@ func wrapperPayloadReady() bool { if err != nil { return true } - got, err := os.ReadFile(payloadSourcePath()) - return err == nil && strings.TrimSpace(string(got)) == want + raw, err := os.ReadFile(payloadSourcePath()) + if err != nil { + return false + } + got := strings.TrimSpace(string(raw)) + if PayloadSHA256 != "" { + return strings.HasSuffix(got, "#sha256="+PayloadSHA256) + } + return got == want } // installResult describes a completed wrapper-lite payload install. @@ -233,8 +243,11 @@ 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) + // The payload's files reach the disk before the record that says they + // are there. + syscall.Sync() if src, err := payloadSource(); err == nil { - if err := os.WriteFile(payloadSourcePath(), []byte(src+"\n"), 0o644); err != nil { + if err := writeFileDurable(payloadSourcePath(), []byte(src+"\n"), 0o644); err != nil { return installResult{}, err } }