Skip to content

Latest commit

 

History

History
210 lines (139 loc) · 42.3 KB

File metadata and controls

210 lines (139 loc) · 42.3 KB

malmo Environment Profiles

Working spec for the two environments malmo runs in — the bare-metal home appliance and the malmo-operated cloud VM — and everything that differs between them. Companion to SPEC.md, CONTROL_PLANE.md, BUILD.md, FIRST_RUN.md, STORAGE.md, MALMO_NETWORK.md, DISCOVERY.md, BOOT.md, THREAT_MODEL.md, AUTH.md.

Why this doc exists

Every other spec in docs/specs/ was written for one environment: a bring-your-own x86 box sitting on the user's LAN — the "old laptop in the pantry" (SPEC.md). malmo is also offered as a hosted product: the same OS running inside a cloud VM that malmo operates, where the customer pays for the resources their OS and apps consume. The cloud VM is a fundamentally different environment — no LAN, no physical disks, no TPM, no USB installer, a public endpoint, and malmo's own infrastructure inside the trust boundary.

Rather than fork malmo into two products, we treat the environment as a profile the OS is built and configured for. This doc owns the profile concept and every hosted-profile delta. It is the single home for hosted-specific design — the other specs describe the appliance profile and carry a short pointer here. When this doc and another spec appear to disagree, this doc wins for the hosted profile only; the other spec remains authoritative for the appliance profile.

Target customer for hosted: small and medium businesses running open-source SaaS apps as Docker containers, who want those apps reachable over the public internet. Not the household/family audience the appliance optimizes for. This sharpens several of the cuts below — the family-photos, shared-folder, and cross-device-file-sharing motivations behind a number of appliance features simply do not apply to a hosted SMB box.

The two profiles

appliance hosted
What it is BYO x86 box on the user's LAN malmo-operated cloud VM, one per tenant
Install USB → kiosk installer → wipe disk (FIRST_RUN.md Phase 1) provisioned from a cloud image; cloud-init-style first boot
Reachability .local on the LAN; .malmo.network HTTPS opt-in <slug>.<box-id>.malmo.network public HTTPS, always on
Storage physical OS + data drives, LUKS+TPM, mergerfs virtual block volume(s), provider/KMS encryption
Network stack NetworkManager (ethernet + WiFi), Avahi/mDNS single virtual NIC from cloud metadata; no mDNS
File sharing Samba/SMB over the LAN none (in-dashboard file manager only)
Trust boundary the user owns the hardware malmo-operated infra is inside the boundary
Security posture closed by default, nothing publicly exposed public-by-default, authentication is the gate

appliance is the default and is what every existing spec already describes. It is not re-litigated here. hosted is the new profile this doc defines.

(Names are working labels. Alternatives considered: self-hosted/managed, box/cloud. appliance/hosted is used throughout this spec set.)

Two layers, treated differently

The single most important framing: malmo is two layers, and the profile split touches them very differently.

Layer 1 — the control plane. malmo-brain, web-ui, Caddy, the docker-socket-proxy, the brain↔host-agent protocol, the catalog, manifests, the app lifecycle, and the auth model. This is ~95% of malmo's logic (CONTROL_PLANE.md # Layer 2) and it is identical across both profiles. Forking it would mean maintaining two products and would break the migration-portability guarantee below. The brain is profile-aware only at a handful of narrow seams (it consults the profile marker, e.g. to skip mDNS publish), never branched wholesale.

Layer 2 — the base image and host integration. Which Debian packages ship, the installer, the boot chain, and host-agent. This is where the two profiles diverge, and the divergence is cheap and healthy because the architecture already treats Layer 2 as the small, boring, swappable layer: host-agent is "a few hundred lines of Go… deliberately boring" (CONTROL_PLANE.md # Layer 1) and the Tier-2 services (Avahi, Samba, NetworkManager) are systemd drop-ins, not core machinery. Almost everything the hosted profile "rips out" lives entirely in Layer 2. That the large cuts concentrate in the cheap-to-diverge layer is evidence the layering is right, not a warning sign.

How the profile is realized

  • Same substrate. Debian + systemd + Docker for both profiles. The brain hard-depends on it (docker compose CLI, systemd units, PAM as identity). A different base OS for hosted is explicitly rejected — see # Rejected: a different base OS.
  • A lean cloud image profile. The hosted image is not the appliance rootfs with services disabled. It is its own mkosi image profile that installs only what a cloud VM needs. Avahi, Samba, NetworkManager, cryptsetup/TPM tooling, mergerfs, and openssh-server are not installed — not installed-and-disabled. (SSH is off in hosted v1 — there is no LAN to scope it to.) nftables is the one exception — it is installed, but not for malmo: docker-ce hard-Depends on it as its firewall backend since Docker 28, so any image that runs docker carries it (DECISIONS.md 2026-06-23). malmo manages no general firewall ruleset of its own in hosted — its one in-guest rule is a standing block of app-container egress to the cloud metadata endpoint (# Provisioning & first-boot, #251); nftables's appliance job is LAN-scoping SSH/SMB, which is moot here, and public-surface filtering is the cloud provider's security-group concern (# Public-by-default). mkosi is already the single image builder for every target (BUILD.md # 2, DECISIONS.md 2026-06-16), so this is a second image definition, not a second builder.
  • A build-tagged slim cloud host-agent. It keeps PAM verify_password + user create/delete/set-role/set-password, OS update and reboot, and the DNS/cert posture; it drops LUKS/TPM unlock, NetworkManager/WiFi, Avahi/mDNS publish, the Samba allowlist, and nftables LAN-scoping (the ruleset function — the package is present as docker-ce's backend). It uses the same Go build-tag mechanism already in the repo that splits the cross-platform surface from the Linux-only host integration (CLAUDE.md # Developing), and slots beside the existing cmd/host-agent (fake) and cmd/host-agent-real.
  • A runtime marker. The image carries its profile at a well-known path (e.g. /etc/malmo/profile, contents appliance | hosted). The brain reads it at startup; host-agent's behavior is determined by its build tag. appliance is the no-op default, so a box with no marker behaves exactly as today.

Rejected: a different base OS

We considered building hosted on a leaner, container-optimized base (Flatcar/Talos-style) since a hosted box is "just run Docker plus the brain." Rejected: the brain assumes a Debian-ish host with the docker CLI, systemd units, and PAM; a different base forces a full host-agent rewrite and re-validation of the boot/identity assumptions, for no win on a single-VM-per-tenant model. Staying on Debian keeps the divergence contained to "which packages + which host-agent build," which is the cheap Layer-2 seam by design.

Rejected: one rootfs, services disabled at runtime

We also considered shipping the appliance image unchanged into the cloud and disabling Avahi/Samba/NetworkManager at runtime. Rejected: it carries dead appliance baggage (and its attack surface and update burden) into every tenant VM, and "disabled" is a weaker guarantee than "absent." A distinct lean image profile is barely more work and is structurally cleaner.

Assumption inventory — what the hosted environment breaks

Every load-bearing appliance assumption that does not hold in a cloud VM, the owning spec, and the hosted delta. This is the at-a-glance map; the sections that follow expand each.

Appliance assumption Owning spec Hosted delta
TPM 2.0 present; LUKS auto-unlock seals to PCR 7 STORAGE.md, BOOT.md No TPM dependency; volume encryption is provider/KMS-keyed (# Storage)
Physical OS drive + data drive(s), mergerfs union, add/eject, canary STORAGE.md Virtual block volume(s); resize, not add/eject; no mergerfs/canary (# Storage)
USB-boot kiosk installer, disk-select, confirm-wipe FIRST_RUN.md, BUILD.md Image is provisioned; cloud-init-style first boot (# Provisioning)
TPM/UEFI/RAM hardware floor checked by installer FIRST_RUN.md No hardware-check gate; the VM shape is fixed at provision (# Provisioning)
NetworkManager owns interfaces; WiFi is first-class BOOT.md, FIRST_RUN.md Single virtual NIC from cloud metadata; no NM, no WiFi (# Networking)
Avahi/mDNS publishes <slug>.local; .local is the foundation URL DISCOVERY.md, MALMO_NETWORK.md No mDNS; public DNS is the resolver (# Networking)
.malmo.network HTTPS is an opt-in toggle MALMO_NETWORK.md Always on; the only scheme; enrolled at provision (# Networking)
Closed by default — nothing publicly exposed THREAT_MODEL.md, MALMO_NETWORK.md Public-by-default, auth-gated (# Public-by-default)
Samba/SMB cross-device file access over the LAN STORAGE.md Not shipped; in-dashboard file manager only (# Access & files)
SSH enabled, scoped to LAN + mesh via nftables BUILD.md, AUTH.md Off in v1 (no LAN, no mesh) — the nftables package is still present, but only as docker-ce's hard-dep firewall backend, with no malmo SSH/SMB ruleset (the one malmo-owned in-guest rule is the metadata-endpoint SSRF block, # Provisioning & first-boot, #251)
The user owns the hardware ("data you own, hardware you own") SPEC.md, THREAT_MODEL.md malmo-operated infra is in the trust boundary; honest convenience tier (# Threat model)
Migration is deferred; restore is off-box backup STORAGE.md, NEXT.md Logical export/restore bundle promoted to load-bearing (# Export bundle)

PAM-as-identity, the Docker/app lifecycle, the catalog/manifest contract, Caddy subdomain routing, the brain↔UI protocol, and the auth/session model are unchanged — they are Layer 1.

Provisioning & first-boot (hosted)

Replaces FIRST_RUN.md Phase 1 (the kiosk installer) entirely. There is no installer in hosted — the cloud image is the installed system.

  • Provisioning. A tenant VM is created from the hosted cloud image (BUILD.md emits it from the same mkosi config). The VM shape (vCPU/RAM/disk) is fixed at provision time by the control plane, so there is no hardware-check gate and no disk-selection or confirm-wipe step. The TPM/UEFI/RAM floor of FIRST_RUN.md # Hardware floor does not apply.
  • First-boot configuration. Configuration is injected at provision time (cloud-init-style seed data): the assigned <box-id> and its *.<box-id>.malmo.network enrollment credentials, and a one-time admin-bootstrap secret. The box does not generate a LUKS recovery passphrase or enroll a TPM (# Storage).
  • Setup wizard, trimmed. The FIRST_RUN.md Phase 2 wizard shrinks: the network step is gone (the NIC is configured from cloud metadata, there is no WiFi), the storage/disk step is gone, and the enrollment step is gone (enrollment happened at provision; secure URLs are not a toggle). What survives is the first admin account (FIRST_RUN.md # Step 2, including the recovery code) and time zone. Telemetry consent stays as specced.
  • Admin bootstrap. The first admin is created against PAM exactly as on the appliance (identity stays PAM-sourced — # Two layers). The bootstrap secret gates who gets to create that first account, replacing the appliance's "whoever is physically at the box during first boot" trust.

Admin bootstrap — as built

The brain-side seed ingestion and the /setup gate are realized (C3a, #206; the cloud-lane delivery of the seed onto a booted VM is deferred, see below). The mechanism:

  • Seed file. The seed is JSON at /var/lib/malmo/seed.json (overridable with MALMO_SEED_PATH): {box_id, admin_bootstrap_secret, enrollment}. enrollment carries the per-box acme-dns account {subdomain, username, password} and is consumed by C3b (#207) for the wildcard cert — see # Networking & discovery — as built. Its JSON shape mirrors the cloud producer's wire contract byte-for-byte (the two repos meet at the format, not a shared Go type). The brain reads the seed only when profile == hosted; on appliance it is never touched.
  • Ingestion is first-boot-once. At startup the brain ingests the seed and persists the box_id plus the SHA-256 hash of the bootstrap secret — never the plaintext — into its SQLite. The persisted box_id is the install's frozen identity (mirroring MALMO_NETWORK.md's "name frozen at enrollment"): on every later boot the brain loads the stored values and ignores the seed, so re-delivering or changing the seed file cannot re-key a provisioned box. The hash is persisted before the box-id (the box-id is the commit marker) so a crash mid-write re-ingests cleanly next boot rather than stranding a box-id with no secret.
  • The /setup gate. On hosted, POST /setup requires the secret in a bootstrap_secret body field and constant-time-compares its hash against the stored one before the normal empty-box check. A box that hasn't ingested a seed yet returns 503 ("not provisioned") — it never falls through to the appliance's open-on-empty-box behavior. A missing or wrong secret returns 401 and is audited (setup.failure, like a failed login). A correct secret proceeds to the ordinary first-admin creation, so the gate is naturally one-time: once the first admin exists the existing empty-box guard returns 409 regardless. The resolved box_id is surfaced on hosted (GET /me); appliance /setup is byte-for-byte unchanged.
  • Operator hand-off. The brain never serves the secret over any endpoint. The operator receives it out-of-band (the cloud console, the way a VPS hands over an initial root password) and types it into the setup wizard.
  • Seed delivery (realized, cloud-lane, #220). Putting seed.json onto a real booted VM is done: a first-boot malmo-seed.service materializes a systemd credential delivered over SMBIOS type 11 (io.systemd.credential.binary:malmo.seed=<base64 JSON>) to /var/lib/malmo/seed.json before host-agent launches the brain, and the QEMU cloud lane (dev/cloud/run-cloud-tests.sh) asserts all four gate properties end-to-end over a 3-boot sequence (un-seeded 503 → seeded 401/200+box_id → frozen-identity reboot). The SMBIOS channel is the test-lane analogue of the production cloud-init write_files path; both converge on the same on-box seed.json. The seed's enrollment field stays carried-but-unconsumed (C3b).
  • Production image first-boot wiring (realized, #242). Through C2/C3a the seed service + control-plane wiring lived only in the test lane (dev/cloud/test/); the make build-cloud-image image was a lean docker host with no malmo wiring, so a real tenant box booted network-less. #242 promotes the wiring into the production image (dev/cloud/): a single-NIC systemd-networkd DHCP config (no NetworkManager), host-agent.service + the baked control-plane image bundle + the seed materializer, the malmo identities, and systemd-firstboot masked. The image stays lean by the package manifest (the wiring adds no apt packages — the lean check still passes), and the boot-proof test lane now boots that exact production image plus a serial self-check. The SMBIOS seed channel above is baked in; reading the seed from the real-cloud metadata/user-data channel is the next bullet.
  • Real-cloud seed channel (realized, #246). On Hetzner the seed arrives via the metadata service (user-data), not SMBIOS, so the production image needs both channels. The materializer (dev/cloud/malmo-seed-materialize.sh) tries the SMBIOS / systemd-credential channel first and, when present, writes it and exits without ever touching the network — so the air-gapped QEMU lane and fw_cfg clouds are untouched and fast. Only when the credential is absent does it fall back to fetching the seed verbatim over HTTP from the link-local endpoint (default http://169.254.169.254/hetzner/v1/userdata; MALMO_SEED_METADATA_URL overrides, keeping the GCP/other-cloud swap a config change). The two channels carry the identical bytes (the brain's ReadSeed input) — nothing to unwrap or decode either side. The fetch uses bash /dev/tcp (no curl in the lean image — same idiom as cloud-assertions.sh) and rides out the first-boot DHCP race with a bounded in-script retry (MALMO_SEED_FETCH_DEADLINE, default 60s) rather than a unit-level network dependency: malmo-seed.service keeps Before=host-agent.service with only a passive After=network.target, so systemd-networkd-wait-online stays disabled. It never blocks forever — a clean 404 (the un-seeded case on a real cloud) or the elapsed deadline (an air-gapped box that will never have metadata) logs "no seed" and exits 0, identical to the un-seeded behavior (/setup stays 503). The fetch/parse/retry/404 logic is unit-tested against a mock metadata server in ci-go (dev/cloud/seed_materialize_test.go); the live Hetzner endpoint + DHCP timing is the cloud#6 / CL6 acceptance. Residual risk — now closed (#251, next bullet): the metadata endpoint stays retrievable for the server's life and Docker NATs container egress out to it, so the production image carries a standing in-guest rule that blocks app-container egress to 169.254.169.254, independent of this first-boot script.
  • Metadata-endpoint egress block (realized, #251). Docker NATs container egress out the host NIC, so on a hosted box any local app container could read the seed secrets above straight off http://169.254.169.254/... — the classic cloud-metadata SSRF (cf. Capital One 2019); the acme-dns password is the worst case (the long-lived credential to rewrite _acme-challenge.<box-id> and so mint/MITM the box's *.<box-id>.malmo.network wildcard cert). A dedicated first-boot oneshot (malmo-metadata-firewall.service) loads a standing nftables forward-hook DROP of egress to 169.254.169.254 (/etc/malmo/metadata-firewall.nft) before docker/host-agent start any container. The split is by netfilter hook: the seed materializer fetches the endpoint as host root in the host netns (OUTPUT path), so it is untouched; every container — apps and the brain, which reads the seed from disk, never from metadata — takes the FORWARD path and is dropped. It is a standing policy (applied every boot; no unblock-until-seeded timing), shipped as committed static files under dev/cloud/mkosi.extra/ and enabled by mkosi.postinst.chroot for both the production image and the boot-proof lane — which asserts it (cloud-assertions.sh: a container probe must hit the drop counter while no OUTPUT hook exists, so the host seed fetch still works). This is the first malmo-owned in-guest rule; the general default-deny backstop stays deferred (# Public-by-default). Live end-to-end block is the cloud#6 / CL6 acceptance.

Networking & discovery (hosted v1)

  • No mDNS, no .local. Avahi is not installed. There is no LAN to multicast on. Everything in DISCOVERY.md is appliance-only.
  • Public DNS is the resolver. Every app is reachable at <slug>.<box-id>.malmo.network over public HTTPS, resolved through malmo.network's authoritative DNS to the VM's public address. This is the default and only URL scheme — there is no .local fallback because there is no LAN. The <slug>.<box-id> shape is exactly the appliance secure-URL scheme (MALMO_NETWORK.md), so the brain's per-app route + URL-surfacing logic is unchanged; what changes is that it is the sole scheme rather than a toggle-gated overlay.
  • Enrollment is automatic and always-on. The box enrolls with malmo.network at provision time. There is no "Use secure URLs" toggle (MALMO_NETWORK.md # The toggle) — secure URLs are the only URLs. The toggle, its off-state, and the Android-compatibility framing are all appliance-only concepts.
  • Certs. Caddy obtains a real Let's Encrypt wildcard cert for *.<box-id>.malmo.network via ACME DNS-01 — the same mechanism the appliance uses when enrolled (MALMO_NETWORK.md # Enrollment flow). Per-box Caddy ACME therefore survives essentially unchanged; it is simply always-on instead of toggle-gated. The DNS-01 credential is part of the provisioned enrollment data.
  • One virtual NIC. Addressing comes from the cloud's DHCP/metadata. NetworkManager is not installed; there are no WiFi flows, no primary-connection pinning, no network-online-scoped-to-primary logic (BOOT.md # NetworkManager). The single interface is brought up by the minimal cloud-native path (a systemd-networkd Type=ether DHCP match).
  • Name resolution is a static /etc/resolv.conf. The lean image ships no systemd-resolved (not in the package set) and systemd-networkd does not itself write /etc/resolv.conf, so the first-boot wiring drops a static resolver (public nameservers). Without it the box gets an IP but resolves no name: it still reaches the metadata service and its own :80 by IP, so the gap is invisible until the brain's Caddy must resolve acme-v02.api.letsencrypt.org / auth.malmo.network for the wildcard cert (below), which then never issues. Docker also derives a bridge container's resolver from the host file, so malmo-caddy needs it present. A static file (no extra package — the lean manifest is unchanged) suffices because the box only ever resolves public names; control-plane images are baked, with no registry pulls. (Realized in the cloud#6 live on-ramp.)

Networking & discovery — as built

The box-side machinery for the always-on wildcard scheme is realized (C3b, #207). The cloud's public DNS and acme-dns face are now deployed and confirmed — as of 2026-06-23 (malmoos/cloud #14) the control-plane VM fronts acme-dns with Caddy over real Let's Encrypt TLS for auth.malmo.network (only /update + /health exposed; /register stays loopback-only) with the authoritative :53 face answering publicly, so https://auth.malmo.network is a confirmed live endpoint rather than a chosen default. What remains is real end-to-end issuance — a live box obtaining its *.<box-id>.malmo.network wildcard against that face — exercised jointly in the cloud on-ramp (cloud #6 / CL6), not the inner loop. The mechanism:

  • Enrollment consumed from the seed. The brain ingests the seed's enrollment ({subdomain, username, password}) at first boot, persists it in box_meta alongside the box-id (hash → enrollment → box-id commit order), and reloads it on every later boot — so a frozen-identity boot reconfigures Caddy without re-reading the ignored seed. An absent/incomplete enrollment means the box still gates /setup but serves no HTTPS (the cert pass logs and skips), never a boot failure.
  • Wildcard cert via acme-dns. At startup the brain configures Caddy's tls app (one automation policy, ACME issuer, DNS-01 challenge via the acmedns provider) for the cert subjects <box-id>.malmo.network + *.<box-id>.malmo.network — the apex is listed separately because a *.<box-id> wildcard covers <slug>.<box-id> but not the bare <box-id> dashboard host. Both share one _acme-challenge.<box-id> record, so one challenge issues the combined cert. Renewal (~60 days) runs box→acme-dns directly with no control-plane call. The acme-dns API endpoint is a box-side constant (MALMO_ACMEDNS_ENDPOINT, default https://auth.malmo.network), not seeded — the same for every box.
  • Custom Caddy build. DNS-01 needs a DNS-provider module in the Caddy binary; stock caddy:2-alpine ships none. The hosted profile sets MALMO_CADDY_IMAGE (which docker compose inherits) to a build with caddy-dns/acmedns compiled in (dev/control-plane/caddy-acmedns/Dockerfile). Appliance keeps stock Caddy — it does no ACME yet (DECISIONS.md).
  • Sole URL scheme. Per-app routes are keyed on (and surfaced URLs, MALMO_APP_URL, the dashboard host all use) <slug>.<box-id>.malmo.network on hosted; no mDNS is published (no LAN). The branch is a single profile-aware seam (internal/profile host helpers consumed by internal/lifecycle + internal/api); appliance's .local/Avahi path is byte-for-byte unchanged, ready for the future appliance toggle to reuse the seam.

Public-by-default, auth-gated — an honest inversion of "closed by default"

The appliance is closed by default: no app or service is publicly exposed, access is identity-mesh only, and public-internet exposure is not even a default option (THREAT_MODEL.md, MALMO_NETWORK.md # Security posture). Hosted v1 intentionally inverts this, and the spec is honest about it rather than pretending otherwise:

  • The apps are publicly reachable. Reaching them over the internet is the entire reason an SMB chooses hosted.
  • The gate is authentication, not network-unreachability. Apps that ship their own login carry it; the malmo dashboard sits behind the malmo login. The reverse proxy serves the public endpoint; the app or the dashboard decides who gets in.
  • Network filtering is the cloud provider's, not the box's. Hosted enforces no malmo-managed host firewall. nftables is present — docker-ce hard-Depends on it as its firewall backend (# How the profile is realized) — but malmo installs no ruleset of its own; nftables's appliance job is LAN-scoping SSH/SMB, and hosted drops both. L3/L4 filtering is the provider's security group / VPC firewall, and provisioning every tenant behind one that admits only the intended public surface (443, plus 80 for the ACME/redirect path) is an explicit operator requirement: without it, Docker's default publish-to-0.0.0.0 would expose an app's ports directly. The first malmo-owned in-guest rule does now ship — a standing block of app-container egress to the cloud metadata endpoint (# Provisioning & first-boot, #251) — but that is a targeted SSRF control for a malmo-specific secret exposure (a link-local source that never even reaches the provider's L3/L4 edge), not public-surface filtering. A general in-guest nftables default-deny backstop for provider postures that lack security groups stays deferred — the package is already present as docker-ce's backend; only the malmo-owned ruleset and host-agent seam remain to be built (DECISIONS.md 2026-06-19, NEXT.md).
  • This is a deliberate product position for the hosted profile, not a regression of the appliance's posture. The appliance stays closed-by-default; the hosted box is open-but-authenticated.

Per-app "expose to anonymous users vs require a malmo session in front" controls are now built (# Per-app owner-only access): each app carries an exposure state, hosted apps default to owner-only (the box login in front), and the owner opts an individual app out to public.

Per-app owner-only access — forward-auth via the box identity

The "require a malmo session in front" half of that refinement is being built as a forward_auth step in the box's Caddy in front of each restricted app, backed by the box's own PAM identity (never a central or federated account). Two cookies keep it safe, and the split is load-bearing:

  • The dashboard session (malmo_session, AUTH.md # Sessions) stays host-only on the dashboard host <box-id>.malmo.network, so a third-party app subdomain can never receive it and replay it as an admin session.

  • A separate, domain-wide, lower-privilege forward-auth cookie (malmo_forward_auth, Domain=<box-id>.malmo.network) is what the browser sends to app subdomains <slug>.<box-id>.malmo.network. Its value is a distinct random token stored in a distinct column, so it proves "a valid box session exists" but never resolves as a dashboard session — even if it leaked it grants app access, not dashboard control. Caddy validates it against the brain and strips malmo_forward_auth (and only it) from the Cookie header of every hosted app route (#306, narrowed in #335) so no app upstream — restricted or public — ever receives it. This is the standard oauth2-proxy / Authelia forward-auth shape: strip your own cookie, pass every other one through.

    Only that one cookie is removed, and this is load-bearing. #306 originally deleted the whole Cookie header. That also deleted the app's own session cookies, so a third-party app with a cookie login could never authenticate on hosted: it re-issued a new session on every request, and correct credentials were rejected at the login form (#335). An app must receive its own cookies to function; what it must never receive is malmo's.

    The consequence to accept, once apps receive cookies again: an app can set a Domain=<box-id>.malmo.network cookie that a sibling app on another subdomain will then receive (cookie tossing / session fixation between apps). That is inherent to hosting apps as subdomains of one registrable domain, and is the same exposure every subdomain-per-app host carries. Apps that use __Host- prefixed cookies are immune; that cannot be forced on third-party images. The blanket delete was never a deliberate defence against this, and was not a usable one, since it left no app working at all.

The brain side of this — the verify endpoint the box Caddy calls (GET /_malmo/forward-auth/verify), and minting/clearing the forward-auth cookie on every hosted session-establishment path (login, the portal-to-box SSO exchange, logout) — is built (#305). Identity scope is owner-only in v1: only the box owner's session validates; box users the owner may later create are an additive step. All of it is behind the hosted profile gate — a Domain=<box-id>.malmo.network cookie is meaningless on the appliance's .local / mesh names, so the appliance login path and its same-origin Tier-2 auth model are byte-for-byte unchanged.

The box-side enforcement is now built too (#306): a per-instance exposure state (restricted owner-only / public) in the brain's SQLite, reconciled through one central Caddy route builder (the safety boundary) that, on hosted, strips malmo_forward_auth from the Cookie header of every app route (#335) and wraps a restricted app in the forward_auth gate at the verify endpoint (a 2xx injects the identity headers and proxies on; a 401 redirects to the box login). The PUT /api/v1/apps/{id}/exposure endpoint (owner-or-admin, hosted-only, audited) re-applies the route on a toggle. The hosted default for new installs is flipped to restricted (owner-only); the appliance is untouched — always public, never wrapped, byte-for-byte the same plain reverse_proxy. What remains of epic #304 is the dashboard Only-me / Public toggle (#307) and the end-to-end lane proving both modes plus the cookie-leak probe of the strip invariant (#308).

Storage (hosted)

  • Virtual block volumes, not physical disks. The "OS drive / data drive" user-facing model (STORAGE.md) collapses: there is one (or a small number of) cloud block volume(s), not removable physical media. The user-visible storage vocabulary stays minimal, but the physical-disk framing is gone.
  • The root filesystem grows to fill the whole provider disk on boot. The image bakes a fixed 8 GiB root (small, sparse raw); a provider VM's disk is far larger (e.g. 80 GB). On every boot malmo-grow-root.service first runs systemd-repart against a runtime definition (dev/cloud/mkosi.extra/usr/lib/repart.d/) that extends the root partition to the whole disk, then grows the ext4 filesystem inside it directly (systemd-growfs) — a deliberate second step, not a side effect of the partition grow: providers boot with an explicit root=PARTUUID=... rather than root=gpt-auto, so the generator-wired path that would otherwise grow the filesystem automatically never engages. Both run before docker or the brain write anything. Without it the box runs on 8 GiB while docker image storage and the brain's SQLite store share that one volume, so a single catalog-app install can fill it, and once full the brain's first store write (the SSO replay-nonce) fails and login returns a bare 500. For v1 this is deliberately one volume for everything (system, docker images, app data, store); splitting app/data storage onto its own volume is the external-drives follow-up (# Open questions), not this profile.
  • No mergerfs, no add/eject, no canary. mergerfs unions physical drives for zero-downtime expansion (STORAGE.md # Data drives); a cloud volume is resized instead, so the add-drive/eject-drive flows, the data-drive enrollment marker, and the storage canary (which exist to detect a removed or wrong physical drive) do not apply. Capacity change is a volume resize, orchestrated by the control plane.
  • Encryption-at-rest under a custodian model. The appliance's LUKS+TPM auto-unlock defends against drive theft (STORAGE.md # Threat model) — a threat that does not exist for a cloud volume the customer never physically holds. Hosted relies on provider volume encryption, or LUKS keyed from a hosted KMS (key custody is an open question, # Open questions). What this defends: a co-tenant, a stolen/leaked disk image, an idle volume. What it explicitly does not defend against: the malmo-operated infrastructure itself — see # Threat model. There is no TPM seal, no PCR policy, and no user-held recovery passphrase, because none of those map to a VM the operator runs.

Access & files (hosted)

  • SSH: off in v1. The appliance enables sshd-but-allows-no-account and scopes it to RFC1918 + mesh via nftables (BUILD.md # SSH, AUTH.md). In hosted v1 there is no LAN to scope to and no mesh, so SSH is off. Operator/console rescue is a hosting concern, not a user-facing feature. (Re-opening user SSH is a candidate for the later mesh pass.)
  • No Samba/SMB. Cross-device file sharing over SMB assumes a LAN with first-class native clients (STORAGE.md # Cross-device access). Over the public internet it is a non-starter and is not shipped. File access is the in-dashboard file manager (FILES.md), with a WebDAV-over-HTTPS path as a possible later addition.
  • Identity stays PAM-sourced. Even though no SSH or SMB surface consumes it in v1, PAM remains the source of truth for accounts and passwords (the decision in # Two layers). This keeps Layer 1 — including the auth model and the migration bundle — identical to the appliance.

Boot (hosted)

The boot chain (BOOT.md) simplifies in Layer 2:

  • Dual-firmware boot (UEFI + legacy BIOS). The provider's server type — not the image — decides the firmware a tenant VM boots under, so the image must satisfy both: Hetzner CX (Intel) boots the VM under legacy BIOS (SeaBIOS) while CPX (AMD) presents UEFI, and a UEFI-only image bricks on the former with a bare Booting from Hard Disk hang (#277). The cloud image therefore installs both bootloaders side by side — systemd-boot for UEFI plus GRUB for BIOS (Bootloader=systemd-boot + BiosBootloader=grub, a 1 MiB BIOS Boot Partition alongside the ESP) — the same firmware-agnostic posture mainstream cloud images ship. The boot-proof lane exercises both firmwares so a regression can't reach a provider (docs/dev/hosted-boot-proof.md).
  • No TPM unseal step. The root volume is not LUKS-sealed-to-TPM (# Storage), so the initramfs TPM-unlock stage and its malmo-recovery.target trigger (BOOT.md # Failure → recovery target) do not exist in the same form. Volume decryption, where present, is provider/KMS-mediated before the OS sees the disk.
  • No NetworkManager / storage-assembly-of-physical-disks. The single NIC comes up via the cloud-native path; malmo-storage-ready.target has no mergerfs/data-drive/canary work to do (# Storage), though the bind-mount layout for /home and /var/lib/malmo onto the data volume is retained.
  • Recovery target, reconsidered. The appliance recovery target serves a static page on port 80 for a user standing at a console (BOOT.md # What recovery target serves). A headless cloud VM has no such user; the host-agent-crashloop case routes to an operator-visible signal instead. The precise hosted recovery surface is an open question (# Open questions).

Threat model (hosted)

The appliance threat model rests on a sentence hosted cannot honor: the user owns the hardware (SPEC.md, THREAT_MODEL.md). Hosted keeps the data-ownership half of the pitch (the user's data is theirs, exportable and portable — # Logical export / restore bundle) but gives up the hardware-ownership half: malmo runs the machine. The spec is explicit about this rather than papering over it. This is the honest convenience tier — hosted trades "hardware you own" for "you don't have to run it."

  • malmo-operated infrastructure is inside the trust boundary. Once a volume is unlocked, the operator can read it; the operator runs the hypervisor and the host. At-rest encryption (# Storage) defends against a co-tenant, a leaked disk image, and an idle volume — not against the operator. We say so plainly; we do not claim operator-blind or confidential-compute properties in this pass.
  • Tenant isolation is VM-level. One VM per tenant (the whole-machine assumption that makes malmo malmo holds — CONTROL_PLANE.md), so isolation between customers is the hypervisor's VM boundary, not in-process multi-tenancy. There is no shared brain across tenants.
  • The exposure posture is public-by-default, auth-gated (# Public-by-default, auth-gated). The defended perimeter is authentication at the edge and per-app login, not network-unreachability.
  • What does not change. App isolation between apps on one box (APP_ISOLATION.md), the brain's own attack surface and the socket-proxy mitigation (CONTROL_PLANE.md), and the auth/session model (AUTH.md) are all Layer 1 and carry over unchanged.

A future operator-blind posture (customer-held keys, confidential VMs, remote attestation) is not promised here and is noted as a direction, not a commitment — it constrains backups, password reset, and support in ways this pass does not take on.

Logical export / restore bundle

The migration-portability promise — "it's your OS, move it home whenever you want" — is what makes hosted strategically coherent with the appliance. Making it real promotes the currently-deferred cross-box migration / restore-from-backup paths (NEXT.md) to load-bearing.

The portable unit is not the disk image. The two profiles' images differ (LUKS/LAN/mDNS present on one, absent on the other), so a byte-for-byte clone of a hosted VM will not boot on a laptop and vice versa. The portable unit is a logical bundle:

  • the user content under /home/<user>/ and the shared tree,
  • the installed-app set as their manifests (plus per-instance config/data),
  • the brain's SQLite state.

Restoring the bundle onto a fresh malmo install of either profile reproduces the user's apps and data. Designing the export path as this bundle (rather than an image clone) keeps the promise honest in both directions and is what a hosted customer is handed when they graduate to their own box.

This section is the one candidate to split into its own spec if it outgrows this doc — the bundle format and the restore transaction are a real surface in their own right. Call that at write time; for now it is specified here as an OS capability, distinct from the deferred commercial layer.

Per-instance resource limits

A hosted tenant pays for the resources their apps consume, which requires the lifecycle owner to be able to bound an app's consumption. The mechanism is in scope; the pricing/metering pipeline is not.

  • Mechanism (in scope). The app lifecycle (APP_LIFECYCLE.md) applies per-app cgroup limits as a policy on the generated compose project's main service (deploy.resources.limits), persisted per-instance and re-applied by the install/reconcile transaction. The limit is not sourced from the manifest — the manifest declares only recommended specs and can never impose a ceiling (APP_ISOLATION.md # Resource limits) — it comes from the user (the appliance's optional memory-only cap) or a control-plane-supplied policy (hosted). Memory is capped in both profiles; CPU is capped only in hosted — on the appliance CPU is never capped (it is time-shared, so throttling only makes an app feel sluggish; same spec). A disk quota is the third dimension the hosted control plane needs but is deferred: the locked ext4 + overlay2 storage stack cannot enforce a per-container quota portably (it needs XFS project quotas driven through the host-agent), tracked in NEXT.md.
  • Deferred. Pricing tiers, the metering pipeline (sampling /proc + disk into a billing system), and the billing surface itself are the commercial layer (# Deferred). The brain already samples resource usage; wiring that to billing is out of scope for this pass.

Deferred

Named so they are not relitigated, and pushed to NEXT.md:

  • The identity-based WireGuard mesh and per-device pairing for hosted (the appliance mesh design in MALMO_NETWORK.md # Deferred remains the reference). Hosted v1 is a plain public endpoint.
  • A central shared ingress with "no per-VM public IP" routing into the tenant fleet. v1 gives each VM its own public reachability.
  • Multi-user per-app access. The per-app owner-only / public control is built (# Per-app owner-only access), but its identity scope is owner-only in v1 — only the box owner's session validates. Restricting an app to a subset of box users the owner creates is the additive follow-up.
  • The commercial control plane outside the tenant: the provisioning/control API, resource metering → billing, pricing tiers, fleet management, suspend/restore, and abuse handling. This is net-new infrastructure with no analogue in the appliance product and is where the operational weight of being a data custodian lives.

Open questions

Tracked centrally in NEXT.md. The notable ones this pass surfaces:

  • Encryption key custody. Provider volume encryption vs LUKS keyed from a hosted KMS. A vTPM is available on some hypervisors but is pointless under a custodian model. Picks the exact at-rest mechanism for # Storage.
  • Hosted recovery surface. What replaces the appliance's console-served malmo-recovery.target page for a headless VM (# Boot).
  • Export/restore bundle home. Whether # Logical export / restore bundle stays in this doc or graduates to its own spec, and the bundle's concrete format.
  • Profile names. appliance/hosted vs alternatives.