Repository navigation
feat: machines update themselves, through the daemon's installer - #92
Merged
Merged
Conversation
ADR 0034 and the contract in docs/desktop.md: the daemon installs signed releases with the app closed, a paired machine on a newer version only triggers an early check of the official feed, the app relaunches only when no service runs in its tabs, and Linux keeps its manual install.
The daemon runs as the user, installs only while the app is closed and only into a bundle the user owns; the app updates on the person's click and never relaunches by itself; existing installs opt in; presence versions are hints behind one coalesced, backed-off check; extraction and signing requirements are spelled out; no automatic rollback in this version.
Part A of ADR 0034. A Mac with the app closed now installs new releases by itself, so a runner nobody sits at stops falling behind its paired machines. There is one installer, in the core, used by the daemon unattended and by `localify update install` when a person asks. - The installer verifies the archive's minisign signature with the built-in updater key before extracting, extracts under the spec's rules (one Localify.app, no absolute or `..` paths, no hard links or special files, no setuid or setgid, internal relative symlinks never followed, 1 GiB and 20,000 entries), checks the bundle's version, the strictly-newer and highest-installed rules, failed digests, and `codesign` pinned to Localify's team and bundle id. It swaps with renamex_np(RENAME_SWAP) under update.lock, only with app.lock free and no process running from the bundle, and keeps the previous bundle. - The daemon checks 2 minutes after start, then every 6 h +/- 30 min, and early when a paired machine announces a newer version. Every check goes through one machine-wide coalesced operation with a 10-minute cooldown, doubled after failures up to 6 h, persisted in updates.json. - `update_status` and `update_settings` on the management socket, and `localify update [--status] [--auto on|off]` and `localify update install [--relaunch]` in the CLI. - `[updates] automatic`: `localify install` writes it on for a new install; a config without it keeps updating by hand. - Linux only reports "Update available: run the install command again"; its release build now gets the manifest's address. New dependencies: flate2 and tar (no default features, so no xattr) to read the release .tar.gz; minisign-verify, the crate the app's updater already uses for this signature format; semver, already in the tree, to compare versions; ed25519-dalek as a dev-dependency to sign test archives.
Extraction no longer follows symlinks at all: every path is walked from directory descriptors with O_NOFOLLOW, files are created with O_EXCL, and symlinks are made after every other entry. On a case-insensitive volume, `up` and `UP` are one name, so the old path check let a chain of links write outside staging. The archive is read in tar's raw mode, so long-name and PAX headers count toward the entry cap and are read here under a 1 MiB cap, instead of whole by tar. PAX `path`/`linkpath` are honoured; `size`, sparse records and global headers are refused. The decompressed stream is capped, and entries that aren't files may carry no data. Staging is private even when the parent has an inheritable ACL: the updates folder, staging/ and each staging dir are checked owned by this account, lose group/other write and their ACLs. The staged bundle's version and codesign are checked again under the lock, right before the swap. An unreadable or invalid updates.json now stops every check and install with a failed status naming the file, instead of reading as new and forgetting the version floor and the failed releases. Deleting it starts again. A release whose archive failed is remembered by version and manifest signature and skipped before any download, and the daemon downloads at most once every 6 hours until an install succeeds, so peer hints can't make it fetch a rejected archive again and again. The replaced bundle is moved into previous/ before the older copy is removed. When the move fails, its staging dir is kept and its path is in the install's result and the status. Freshness uses semver precedence, so build metadata never makes a release newer or raises the floor; the manifest/bundle match stays exact.
The desktop-shell job builds src-tauri with --locked, and its lock file still listed the core crate without tar, flate2, minisign-verify and semver, so the job failed before any test ran.
…cting releases Another account that could change a folder above staging (search, create and delete_child on ~/Library/Application Support, for example) could rename Localify/ and put its own tree at the staged bundle's path between the last codesign check and the swap, since both resolved the absolute path again. Now every folder from / down to the updates folder must be owned by this account or root, with no group or other write and no ACL entry letting another account write in it, or the installer stops and the status names the folder. The staging directory is opened from / one component at a time, held open until the swap, and swapped with renameatx_np from that descriptor and the destination's folder. codesign and plutil still need a path, so the installer checks right before and right after each that the path leads to the bundle it holds. A full disk or another write error while extracting was reported as an archive error, and a codesign or plutil that couldn't run as a failed check, so a valid release was skipped for good. Writes now fail as this machine's errors, and a tool that didn't start or was killed is no verdict; only archive defects and real verification failures are remembered.
…the checked path codesign exits 1 for local reasons too (too many open files), so a refusal of an archive whose minisign signature passed is retried at the next check instead of held against the release. The staging path given to codesign and plutil is now the canonical one whose every folder was checked, so a symlink in the configured spelling can no longer let an unchecked folder redirect them.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part A of automatic updates (ADR 0034). The PR carries the contract: the three doc commits from
docs/automatic-updates(ADRdocs/adr/0034-machines-update-themselves.mdand "Automatic updates" indocs/desktop.md), then the code.What it does
src/infrastructure/updates/), run unattended by the daemon and bylocalify update installwhen a person asks:latest.json(same endpoint source as the app:LOCALIFY_UPDATER_ENDPOINT/LOCALIFY_UPDATER_BASE_URL, none in debug builds), downloads the archive capped at 256 MiB, HTTPS only, no redirects;minisign-verify, prehashed only) with the key fromsrc-tauri/updater.pubbefore extracting;/down to the updates folder must be owned by this account or root, with no group/other write and no ACL entry letting another account add, delete or rename entries or change the ACL or owner (denials, this account's own entries and inherit-only entries don't count); otherwise the install stops and the status names the folder. The folders are opened from/one component at a time withO_NOFOLLOW, and the staging dir stays open until the swap. The updates folder,staging/and the new dir must be owned by this account, lose group/other write and any ACL (inherited ones included), and the new dir is checked empty. OneLocalify.app, no absolute or..paths, no hard links, devices or FIFOs, no setuid/setgid, no data in non-file entries, relative symlinks that stay inside (checked lexically, then through chains after extraction). Every path is walked from directory descriptors (openat/mkdiratwithO_NOFOLLOW, files withO_EXCL) and symlinks are made last, so nothing ever follows a symlink, case-insensitive volumes included. tar is read in raw mode: long-name and PAX headers count toward 20,000 entries and are read here under 1 MiB together; PAXpath/linkpathare used,size, sparse and global headers refused; files capped at 1 GiB, the decompressed stream capped too. No xattrs, so no quarantine;codesign/plutilthat didn't start or was killed;codesign --verify --deep --strictwith a requirement anchored to Apple, Developer ID, teamP5G8WAJ2G2andcom.localify.desktop, plus the same onContents/MacOS/localify(localify). The team ID comes from the shipped app's signature (codesign -dvv /Applications/Localify.app), not a guess. Behind aSignatureChecktrait;/Applicationsor~/Applications, identifiercom.localify.desktop, owned by this account, writable along with its parent, not a dev build. Each refusal is a sentence for Settings;update.lock:app.lockmust be free, and no process may run from the bundle (proc_listallpids+proc_pidpath; an unreadable path stops the install). Then it re-checks the destination's inode and version, the staged bundle's version andcodesignagain (right before and after each tool, the path it passes must lead to the bundle in the staging dir it holds open), re-readsautomaticwhen unattended, swaps withrenameatx_np(RENAME_SWAP)from the open staging dir and the destination's open folder (fails without touching anything when unsupported), and moves the old bundle intoprevious/before removing the older copy. If that move fails, the staging dir is kept and its path is in the result (Previous::Stranded) and the status'sreason;updates.json, log inupdater.log, all in~/Library/Application Support/Localify/. A missingupdates.jsonis a first use; an unreadable or invalid one (bad JSON, a floor that isn't a version, malformed digests) stops every check and install with afailedstatus naming the file and the way out (delete it).check.lock, with a 10-minute cooldown doubled after each failure up to 6 h, persisted. Unattended installs only for a launchd-managed daemon, withautomaticon and the app checks passing; it checks the app before downloading, then again under the lock. Unattended downloads are at most one per 6 h until an install succeeds, recorded before the download starts, so peer hints and restarts can't repeat one.update_status→{installed, available, state, reason, automatic};update_settings {automatic}.localify update [--status] [--json](JSON when stdout isn't a TTY; read locally when the daemon is down),localify update --auto on|off,localify update install [--relaunch](waits up to 2 min forapp.lock, installs, thenopens the app, the new one or the old one if the install failed). No GUI needed, so it works over SSH.[updates] automatic.localify installwritestruefor a new install; an existing config without the key readsfalse.localify statusand the status say "Update available: run the install command again".release.ymlnow builds the CLIs withLOCALIFY_UPDATER_BASE_URLso a Linux daemon knows the manifest.update_status,update_settings,MgmtSocketClient::update_status/update_settings, andlocalify::infrastructure::updates::app_lock_path().Choices to review
Contents/MacOS/localifyis not "in use": the daemon and everylocalify devrun from it, and they outlive a swap. A process whose executable was deleted (proc_pidpath→ ENOENT) can't be running the bundle's files on disk. Without that exception, any app that updated itself while running would block installs forever; this Mac has four such processes. Documented indocs/desktop.md.update installgoes through the same coalesced check: within the cooldown it uses the last check's release instead of the network; within a failure's cooldown it reports that failure. The 6 h download cooldown applies only to the daemon.tarand carry PAX headers (mtimeandcom.apple.provenancexattrs, 11 of 22 headers in 1.6.0), so PAX can't be refused outright. Parsing in raw mode, with long names handled here, means the size checks see exactly the structure that gets extracted.updater.loglocation. The installer logs to~/Library/Application Support/Localify/updater.log, next to its locks. The app's own updater log stays inLocalify Desktop/until B moves it.src-tauriis untouched: the core reads the same pubkey file and the same build env.Tested
cargo fmt,cargo clippy --locked -- -D warnings,cargo test --locked(all pass, macOS). Clippy on--testsadds no new warnings.here -> .,out -> here/..,up -> out/.., thenUP/payload, in both orders) leave nothing outside staging; long-name/PAX headers count toward the entry cap; oversized PAX and GNU long names are refused before they are read; PAXsize/sparse/global headers and payloads on directories and symlinks are refused; GNU and PAX long names extract where they say.updates.jsonstops checks, installs and downloads and is left as it is, and the status says how to start again; a rejected release isn't downloaded again, and unattended downloads wait out their cooldown, cleared by an install; aprevious/that can't be written leaves the replaced bundle in staging and reports it, and the older copy survives; build metadata never makes a version newer or raises the floor.ed25519-dalek, dev-only): valid, tampered data, wrong key, garbage.app.lockheld stops it, a--relaunch-style wait proceeds once the lock is released, a process from the bundle or an unreadable one stops it,automaticturned off at the last moment stops an unattended install, a destination replaced during the install is left alone.codesignis never swapped in; the swap uses the staging dir held open, not its path; anENOSPCwhile extracting (injected through a test hook) and acodesignthat can't run don't mark the release as failed, and the same release installs afterwards. The ACL reader is checked entry by entry againstchmod +a.renameatx_np(RENAME_SWAP)swap on a temp APFS dir (#[cfg(target_os = "macos")]), and the full stage-and-swap pipeline on fake bundles withprevious/kept.automatic = true; a pre-existing config reads and savesfalse.update_settingsthenupdate_status.codesign): the ignored testthe_live_release_stages_with_every_real_checkdownloaded the real 1.6.0 archive fromdownloads.localify.dev, verified it with the built-in key, extracted it under the strict rules, matched the version, and passed the real pinnedcodesigncheck, all in a temp dir with no swap. Run it withLOCALIFY_UPDATE_MANIFEST=https://downloads.localify.dev/latest.json cargo test the_live_release -- --ignored.HOMEand--config: status JSON,--auto onwriting[updates], a debug build refusinginstall, clap refusing--autowithinstall.Not tested
/Applications/Localify.appand the daemon restarting on it. On purpose: nothing ran against the real bundle on this machine. The first real end-to-end run needs a published release newer than an installed signed build.--relaunchopening the app, and installs over SSH on a real runner.cfg.darwin-aarch64, so an Intel Mac reports that the manifest has no archive for it.