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
7 changes: 7 additions & 0 deletions manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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.

Expand Down
12 changes: 8 additions & 4 deletions manager/guest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
36 changes: 31 additions & 5 deletions manager/guest/init
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
40 changes: 33 additions & 7 deletions manager/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
log "github.com/sirupsen/logrus"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading