Skip to content

Make AltServer-Linux run unattended: wireless refresh, a setup web UI, and website for setup and failure reporting - #138

Open
Ben-Diehlci wants to merge 123 commits into
NyaMisty:newfrom
Ben-Diehlci:new
Open

Ben-Diehlci wants to merge 123 commits into
NyaMisty:newfrom
Ben-Diehlci:new

Conversation

@Ben-Diehlci

@Ben-Diehlci Ben-Diehlci commented Sep 16, 2026 •

Copy link
Copy Markdown

I forked this to solve one problem: keeping sideloaded apps alive without leaving a Mac or PC switched on. Free Apple certificates expire every 7 days, so something has to re-sign your apps on a schedule. This makes that something a small Linux server you can forget about.

It has been running on mine since September.

What was broken

Wireless refresh never worked. The server reads the phone's network address in a format that is correct on macOS and wrong on Linux, so every over-the-air refresh failed. Worse, it failed by reporting a device fault, sending you to debug a phone that was sitting there working perfectly. Fixed and confirmed on real hardware: AltStore and Apollo, refreshed over Wi-Fi with no cable attached.

The server could go silent and never tell you. It announces itself on your network once, at startup, and then assumes that worked forever. Mine stopped announcing after a background service restarted, and stayed invisible to my phone for three days. Everything looked healthy the whole time. It now re-checks its own announcement and re-announces when it disappears.

Signing in often failed with a misleading error. Requests to Apple shared a connection, which Apple rate-limited; the real reason was thrown away and you got a generic parse failure instead. Also fixed: several cases where Apple's actual response was discarded in favour of a vague message.

The build was broken in ways that hid themselves. The build environment could not be rebuilt from source. Editing a patch file did nothing at all, silently, while the build reported success using the old version.

What is new

A web interface, at http://your-server:8099. It is the whole setup process:

  • Status - is anisette reachable, do the clocks agree, can it see your phone, is it announcing itself, is the daemon alive. Each failure says what to try.
  • Pairing - walks you through the one-time USB pairing and tells the "nothing shows up" cases apart.
  • Install - sign in with your Apple ID and enter the 2FA code in the browser. Previously this needed a terminal, which a background service does not have.

A container and ready-made stacks, so deployment is one file. No host setup, no manual downloads - the AltStore app is fetched automatically and kept current.

Failures that reach you. The status page used to answer "is it working?" with a cheerful yes no matter what, so any monitoring you pointed at it saw green through a total outage. It now returns a real failure code, and every container reports health, so a problem shows up as a red badge in Docker or Portainer without you setting anything up.

A history. The page shows a timeline per check, so you can see when something broke rather than only that it is broken now. When mine went dark, the honest answer to "how long?" was "somewhere between three and nine days", because nothing recorded anything. Hover any bar to see that check's exact time.

Credentials stay out of logs. Your Apple account details and access tokens are filtered out of everything the server writes.

Honest notes

  • Use a secondary Apple ID. This is a good idea generally with sideloading, not specific to this fork.
  • The iOS 26 compatibility fix builds correctly but I have no iOS 26 device to test on, so that one is unverified in the real world.
  • Deployment is designed for your home network only. The install page accepts a password over plain HTTP; it must not be exposed to the internet.

About the size

This is large - 123 commits - because it covers both repairs and new capability. Roughly 1,800 lines are fixes to existing code, and roughly 8,900 are new: the web interface, the container, deployment files, documentation, and eleven automated checks that run on every change.

Those checks exist because of a pattern that showed up repeatedly here: things that reported success while failing. Each one guards a specific bug that shipped silently, and each is verified by deliberately reintroducing that bug and confirming the check catches it.

Happy to split this into smaller pieces if that is easier to review.


🤖 Generated with Claude Code

bwdiehl and others added 30 commits September 14, 2026 06:07
The scheduled build has failed on every run for a long time, dying at the
"Set up job" step -- the runner phase that resolves `uses:`, before any of
our own scripts execute. The cause is `gautamkrishnar/keepalive-workflow@master`:
its action manifest no longer resolves at any ref. Because `build`, `release`
and `update_submodule` all gate on `check` via `needs:`, the whole workflow
has been skipping -- no binaries since 2025-03-16, and no upstream_repo sync,
which is what NyaMisty#121 is actually reporting.

- Remove the dead keepalive step.
- Bump five actions off GitHub's retired node12/node16 runtimes: checkout
  v2->v4, setup-qemu-action v1->v3, login-action (pinned node12 SHA)->v3,
  download-artifact v2->v4, action-gh-release v1->v2. download-artifact must
  be v4 specifically: the uploader (NyaMisty/upload-artifact-as-is) depends on
  @actions/artifact ^2.2.1, the v4 backend, so a v2 downloader cannot see the
  artifacts it produces.
- Replace `::set-output` (removed by GitHub in 2023) with $GITHUB_OUTPUT.
- Add per-job `permissions`. `release` and `update_submodule` need
  contents:write and would 403 under today's read-only default token.
- workflow_dispatch: `github.event.inputs.sync_upstream` is always a string,
  so "false" evaluated truthy and manual sync fired unconditionally. Declare
  `type: boolean` and read it from the typed `inputs` context instead.
- build_docker.yml: drop `ref: master`; this fork has no master branch.

Verified without pushing: both files parse; all 8 action refs resolve on
supported runtimes; the four ghcr.io builder images are still publicly
pullable; and the rewritten check-step shell was executed against stubbed git
across all three paths (gate closed -> updated=0, unchanged -> updated=0,
upstream moved -> updated=1), matching the old ::set-output behaviour.

Not fixed here: `chmod +x` in the build step is a no-op, since artifact upload
does not preserve file permissions. Left in place. It is the likely root of
NyaMisty#126, which needs a packaging or documentation fix rather than a CI one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub now forces node20 actions onto the node24 runtime and annotates every
run with "Node.js 20 is deprecated". These are pure version-string bumps; no
inputs, defaults or behaviour change.

  actions/checkout             v4 -> v7   (4 sites)
  docker/setup-qemu-action     v3 -> v4   (2 sites)
  docker/login-action          v3 -> v4   (2 sites)
  softprops/action-gh-release  v2 -> v3   (1 site)

Each target was verified by fetching its action.yml and confirming
`using: node24`, rather than assuming the newest tag is node24 -- notably
actions/download-artifact v5 and v6 are still node20, so "newest major" and
"node24" are not the same question.

Three v5-v7 checkout changes were checked and are inert here: v6 moved the
auth token out of .git/config into a $RUNNER_TEMP credential file, which is
invisible to us because ad-m/github-push-action builds its own authenticated
remote from its github_token input and all six submodules are public https
URLs; the v6 container-action runner requirement applies to `uses:`-style
container actions, while our build shells out to `docker run` from an ordinary
`run:` step; and the v7 fork-PR guard only fires on pull_request_target /
workflow_run with an explicit repository/ref input, none of which we use.

The floating `v7` tag is used deliberately rather than v7.0.0, which shipped a
fork-PR guard that could fire on default checkouts and was fixed in v7.0.1.

Also bumps the commented-out docker/setup-buildx-action reference in
build_docker.yml so it is not stale if anyone uncomments it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the node24 migration. NyaMisty/upload-artifact-as-is@master declares
`using: node20` and is third-party, so it cannot be fixed in place -- it runs in
every build leg and would keep the deprecation annotation alive no matter what
else was bumped. Replacing it is the only way to clear the warning.

The upload and download halves MUST land together. The uploader stamps its
blobs with Content-Type "zip", which download-artifact@v8 does not recognise as
a zip: v8 decides whether to decompress from the Content-Type or a .zip URL
suffix, and otherwise writes the body to disk verbatim. Bumping only the
downloader would therefore have dropped raw .zip files into build_release/ and
shipped them as release assets. Bumping only the uploader would leave a 6.x
uploader paired with a v4 downloader. Neither half is independently correct.

  NyaMisty/upload-artifact-as-is@master -> actions/upload-artifact@v7
  actions/download-artifact             v4 -> v8

Both sides now run @actions/artifact 6.2.x. v8 rather than v7 because
download-artifact@v7 still ships @actions/artifact ^5.0.0.

The matrix is restructured to an `include:` list because upload-artifact
validates artifact names and rejects / \ : < > | * ? " -- so ${{ matrix.builder }}
("ghcr.io/nyamisty/...") cannot be used as a name. Still exactly 4 legs, and
${{ matrix.builder }} in the Build step is untouched.

Deliberately NOT set: `overwrite` (delete-then-upload races across 4 concurrent
legs), `archive: false` (new raw-upload path; hard-fails on multi-file globs),
`if-no-files-found` (left at the default `warn`, matching previous behaviour --
tighten separately once all four legs are observed emitting a binary).

Artifact names on the run page change from the gcc triple to the matrix label:
AltServer-x86_64 -> AltServer-amd64, AltServer-i586 -> AltServer-i386. RELEASE
ASSET FILENAMES ARE UNCHANGED, as they come from the file inside the artifact
rather than the artifact name. Job display names gain the arch label, which will
stale out any branch-protection required-check names.

The release job's `mv build_output/*/* build_release` still works: with no
`name:` input and merge-multiple off, v8 writes one subdirectory per artifact.
v5 added an `artifacts.length === 1` flattening case, unreachable here because
`needs: [build]` means all four legs must succeed and each uploads one artifact.

UNVERIFIED: the release job is tag-gated, so these two action bumps cannot be
exercised by a branch push. Testing them requires pushing a tag, which publishes
a real GitHub Release -- and action-gh-release@v3's make_latest has no default,
so it may displace the current "latest".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…yaMisty#111)

The buildenv image has not been rebuildable for some time. Three independent
problems stack up; this fixes the first two. The image still does not build, so
NyaMisty#111 stays open -- but each fix was needed to reveal the next, and both are
confirmed against a real build rather than reasoned about.

1. Apple versioned the archive's top-level directory. corecrypto.zip now
   extracts to corecrypto-2024/, not corecrypto/. The download and unzip were
   always fine; the failure was purely a path mismatch, made invisible by
   Docker's WORKDIR silently CREATING the missing /buildenv/corecrypto, so the
   error surfaced one line later as the confusing "source directory does not
   appear to contain CMakeLists.txt". Renamed version-agnostically rather than
   hardcoding -2024, and a `test -f corecrypto/CMakeLists.txt` now fails loudly
   at the real cause if Apple renames it again.

2. Apple's CMakeLists.txt include()s scripts/code-coverage.cmake, which the
   distribution does not ship -- scripts/ contains only the testvector
   converters. This is exactly what upstream PR NyaMisty#85 diagnosed in December 2022
   ("isn't really relevant for our use") and it was never merged. CODE_COVERAGE
   is off by default, so the include at line 63 is the only reference that
   actually breaks configure; the uses at lines 106 and 359 are already guarded.

STILL BROKEN, not addressed here: with both fixes applied, cmake now configures
far enough to fail at CMakeLists.txt:266 with "No SOURCES given to target:
corecrypto_static". CORECRYPTO_SRCS is populated at CoreCryptoSources.cmake:189,
and the Linux branch subtracts CORECRYPTO_EXCLUDE_SRCS at line 262; something in
that interaction empties the list on the 2024 distribution. That is Apple's
CMake, not ours, and needs its own investigation.

None of this blocks the main build, which pulls the prebuilt
ghcr.io/nyamisty/altserver_builder_alpine_* images -- all four verified still
publicly pullable, and all four legs currently build green against them. What is
broken is only the ability to rebuild those images from source, which is a
bus-factor risk rather than an outage.

Verified by building the Dockerfile through the cmake step for arm64v8/alpine:3.15
under Colima; error 1 and error 2 each confirmed present before the fix and
absent after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FetchAnisetteData() called response.extract_json() with no status or content-type
check, so any failure surfaced as one line naming neither the server nor the
status: "Incorrect Content-Type: must be textual to extract_string, JSON to
extract_json." That string is the most-reported failure in this project. The
hardcoded default anisette server it was talking to (armconverter.com) has been
returning HTTP 502 text/plain since ~2026-09, so every user hit it.

  - GetAnisetteURL(): treat an empty value as unset, and remove the dead default
    entirely. There is now no default: the old one is down, and pointing every
    user at one shared anisette identity can get Apple IDs locked (issue NyaMisty#88).
    Unset now yields a message naming the variable to set.
  - FetchAnisetteData(): rewritten straight-line. It was ALREADY synchronous --
    the old code chained pplx continuations then immediately called task.wait()
    -- so this costs no concurrency and makes it possible to attach the URL and
    status code to every failure. Transport errors, non-200 responses, non-JSON
    bodies and missing/mistyped fields are each reported specifically, as a
    ServerError(InvalidAnisetteData) rather than a raw cpprest exception, which
    matters because ClientConnection::ErrorResponse dynamic_casts to ServerError
    and forwards userInfo -- so the message now reaches AltStore on the device,
    not just the server log.
  - The http_client CONSTRUCTOR validates the URI and throws before any request:
    "localhost:6969" (no scheme) raises std::invalid_argument, which is not an
    Error subclass and would have reached the device as errorCode 0 (Unknown).
    Since this commit makes the variable mandatory, a hand-typed value is the
    likeliest failure it creates, so that path is handled explicitly.
  - strptime()'s return value was ignored entirely; an unparseable timestamp
    silently became whatever the zero-initialised struct produced. Now checked.
    The format deliberately stops at the seconds rather than matching a literal
    "Z": the value comes from a third-party anisette server, not from AltStore,
    and tails like ".123456Z" or a bare timestamp denote the same instant.
    An explicit numeric offset IS rejected, since timegm() ignores the tail and
    would otherwise produce an instant silently wrong by that offset.
  - mktime() -> timegm(). The timestamp is UTC; mktime interprets its input as
    LOCAL time, so the instant sent to Apple was skewed by the host's UTC offset
    on every machine not running in UTC.
  - ResetProvisioning(): was iterating "C:\ProgramData\Apple Computer\iTunes\adi"
    with fs::directory_iterator, throwing filesystem_error on every Linux run.
    Both callers (AltServerApp.cpp:471, :516) invoke it from inside
    catch (APIError&) on the InvalidAnisetteData path, so it escaped the handler
    and replaced Apple's real error with a Windows path -- and at :471 it also
    aborted the Sleep(12000) retry that follows. Now a no-op. Note the retry it
    was suppressing will now actually run.
  - README and --help both advertised the removed default, so both are updated.
    The alt_anisette_server image this project used to recommend was last
    published in April 2022 and is no longer presented as a recommendation.

Verified by linking the real object into a standalone harness and running it
against a local fake anisette server. Unset, no-scheme, malformed URL, refused
connection, HTTP 502, HTTP 404 and non-JSON each produce a specific message; a
well-formed response succeeds. Timestamp tails "Z", ".789012Z" and bare all
parse, "+02:00" and garbage are rejected. The timegm fix was confirmed by
running the success path under TZ=UTC, EST5EDT, JST-9 and IST-5:30 and getting
tv_sec=1789389296 in all four, matching 2026-09-14T12:34:56Z exactly.

Issue accounting, deliberately conservative:
  NyaMisty#104  CLOSED. Symptom and trigger both removed.
  NyaMisty#130  The valid-JSON-with-wrong-Content-Type shape is genuinely FIXED, since
        extract_utf8string() ignores Content-Type where extract_json() refused.
        Other shapes are now diagnosable, not fixed.
  NyaMisty#99, NyaMisty#100, NyaMisty#128  Resolved by making the required configuration explicit and
        self-describing, NOT by the error handling -- those servers are down and
        no client-side change reaches them.
  NyaMisty#88   Rationale only, not fixed. A user who picks a public shared anisette
        server still shares an identity; this only stops shipping one by default.

Not addressed here: ServerError's recovery suggestion for InvalidAnisetteData is
Windows-only advice ("download the latest versions of iTunes and iCloud"), shown
on the CLI path. Fixing it means adding a substitution to
makefiles/rewrite_altserver_source.py, which belongs in its own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d REVIVAL.md

ALTSERVER_ANISETTE_SERVER is read per-request, deep inside FetchAnisetteData().
A daemon started without it therefore comes up cleanly, advertises itself over
Bonjour and is discovered by the phone -- then fails only when someone first
tries to refresh. That is a long way from the actual mistake, and it matters
most in exactly the deployment this project exists for: an unattended headless
Linux box where nobody is watching a console. A systemd unit missing
Environment=, or `sudo` without -E dropping the variable, both produce a service
that looks healthy and cannot sign in.

The check WARNS rather than exiting, deliberately. Of the six request types the
daemon serves, only AnisetteDataRequest needs an anisette server -- PrepareApp,
InstallProvisioningProfiles, RemoveProvisioningProfiles, RemoveApp and
EnableUnsignedCodeExecution (AltJIT) all work without one, and refusing to start
would break those. It also catches a value with no http:// or https:// scheme,
which is the likeliest way to get this wrong now that the variable is mandatory,
and otherwise echoes the configured URL so the operator can confirm it was seen.

Verified in all three states against a real build: unset and no-scheme each
print their warning and the daemon still proceeds to advertise; a valid URL
prints "Using anisette server: ...".

Also adds REVIVAL.md, a working log for this branch. It records the project
goal, the build procedure (macOS cannot build this; the tree lives inside a
Colima VM), every commit so far, the verified facts behind them, and a ranked
TODO. Several of those facts cost real effort to establish and are not
recoverable from the code -- that the CI failure was an unresolvable action
rather than the node12 versions; that the old artifact uploader served blobs
with Content-Type "zip" which download-artifact@v8 does not treat as a zip; that
Apple now ships corecrypto as corecrypto-2024/ and Docker's WORKDIR silently
creates the missing directory; that only AnisetteDataRequest needs anisette.

REVIVAL.md also records ~/Local Work/AltStore (rileytestut/AltStore, v2.3.3,
actively maintained) as the reference to check FIRST for how something is
currently done, and ranks the remaining work against the real goal: running
unattended on a Linux home server, which makes Wi-Fi discovery and a working
anisette server the critical path rather than incidental issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two answers that change the plan, both from the operator rather than the code:

- The target device runs iOS 26.x, so NyaMisty#131 is confirmed in scope rather than
  hypothetical. Apps will install successfully and then crash at launch. That
  makes the upstream_repo bump required, not optional, and it is the largest
  remaining code task. Worth noting the failure is deceptive: installation
  reports success, so nothing looks wrong until the app is opened.
- The host is a Dell OptiPlex 5060 (x86_64) running Proxmox -> Ubuntu VM ->
  Docker via Portainer, already publishing to ghcr.io/ben-diehlci/. So the
  x86_64 leg is the one that matters, sideloading is entirely LAN-local and must
  not go behind the existing Cloudflare Tunnel / NPM setup, and re-namespacing
  build_docker.yml stops being hypothetical because the registry namespace
  already exists.

Adds item 8: a purpose-built container for the Portainer stack, which is the
natural end state given that platform.

Also records the question that gates everything and is not answered by the host
notes: whether the Ubuntu VM is bridged onto the same L2 segment as the phone's
Wi-Fi, or NAT'd behind Proxmox. mDNS is link-local and does not cross subnets or
VLANs, so if the VM is NAT'd or the phone sits on a guest/IoT VLAN, the device
can never discover _altserver._tcp regardless of how correct everything else is.
Cheap to check, and it would invalidate a lot of downstream work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as the phone

The operator confirmed the Ubuntu VM is bridged onto the same network as the
phone, reaching server services by IP from the phone while at home. So there is
no NAT or VLAN boundary and the mDNS prerequisite is satisfied. This was the
question gating everything else, since mDNS is link-local.

Keeps one narrower check rather than closing the item outright: reaching a host
by IP proves L3 routability, while mDNS needs multicast on the same broadcast
domain. A Wi-Fi AP doing client isolation or aggressive IGMP snooping can pass
ordinary TCP while dropping multicast between wireless and wired hosts, which
would look exactly like "the network is fine" right up until the phone cannot
discover the server. Records the avahi-browse command that settles it by looking
for the phone's own Bonjour advertisements from the server side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Topology settled: avahi-browse on the VM sees _companion-link._tcp from Apple
devices over IPv4 and IPv6, so multicast crosses from Wi-Fi to the wired VM and
_altserver._tcp will reach the phone.

More importantly, records a landmine found while checking it. dnssd_loader.cpp
does not link Bonjour -- DNSServiceRegister builds a Python one-liner, forks and
execlps python3 with CDLL('libdns_sd.so'). The parent branch of that fork is
`else { ; }`: `status` is declared and never used, there is no waitpid, and the
function returns 0 unconditionally. Advertisement failure is indistinguishable
from success inside AltServer, so a server that cannot advertise runs normally,
logs nothing wrong, and is permanently undiscoverable by the phone. Already
reproduced in the alpine build container.

Two practical consequences for this deployment:

- The usual advice to install libavahi-compat-libdnssd1 is NOT sufficient. That
  package ships libdns_sd.so.1, while the code dlopens the UNVERSIONED soname,
  whose symlink comes from libavahi-compat-libdnssd-dev. Records the exact
  python3 ctypes call to verify it, since it is the same call the program makes.
- The planned container (item 7) needs python3 and the compat dev package, or it
  will silently fail to advertise.

Adds TODO 8: make that failure loud. Small patch, and it removes the worst
silent failure mode for an unattended box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly succeeding

DNSServiceRegister does not link Bonjour -- it builds a python3 one-liner, forks,
and execlps it. The parent branch of that fork was `else { ; }`: `status` was
declared and never used, there was no waitpid, and the function returned 0
unconditionally. Advertisement failure was therefore indistinguishable from
success inside AltServer, so a server that could not advertise ran normally,
logged nothing wrong, and stayed permanently undiscoverable by the device.

For the deployment this project exists for -- an unattended headless Linux box
-- that is the worst available failure mode. Nobody finds out until a sideloaded
app expires a week later, and the only evidence is an unlabelled Python
traceback interleaved into the parent's stderr.

Two changes, because either alone leaves a hole:

  - The parent now polls waitpid(WNOHANG) for ~1s. The helper is meant to run
    forever (its Python ends in Event().wait()), so a prompt exit means failure.
    Polling rather than blocking because a healthy child never exits.
  - The helper now exits non-zero when DNSServiceRegister itself returns an
    error. Previously it blocked on Event().wait() regardless of the result, so a
    loadable libdns_sd.so with no avahi-daemon behind it looked identical to
    success no matter what the parent checked.

On failure it returns kDNSServiceErr_Unknown, which ConnectionManager.cpp:123
already handles by logging and returning without aborting, and prints remediation
naming the non-obvious part: libavahi-compat-libdnssd1 is NOT sufficient. That
package ships libdns_sd.so.1, while the code dlopens the UNVERSIONED soname whose
symlink comes from libavahi-compat-libdnssd-dev. Confirmed empirically on the
target host: CDLL('libdns_sd.so') raised OSError with libdnssd1's usual advice
applied, and succeeded only after installing the -dev package.

VERIFICATION IS INCOMPLETE, deliberately recorded rather than glossed. Both
failure paths were confirmed against a real build: missing libdns_sd.so, and
library-present-but-no-daemon. The SUCCESS path was NOT verified -- avahi will
not run in the alpine build container -- so the no-false-positive case still has
to be confirmed on the real host. A spurious error on a working server would be
worse than the bug this fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Finder writes .DS_Store into any directory it browses, so working on this repo
from a Mac leaves untracked noise that shows up as pending changes in clients
like GitHub Desktop and is easy to commit by accident. Three had already
appeared: ./, libraries/ and makefiles/.

A bare `.DS_Store` pattern matches at any depth, so one line covers all of them;
verified with git check-ignore. Also ignores ._* (AppleDouble resource forks),
which macOS creates when writing to non-HFS volumes.

None of these were tracked, so ignoring them is sufficient -- no git rm --cached
needed. Also restores the trailing newline the file was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eb UI

Both are the operator's stated end-goals, explicitly NOT scheduled. Written down
with enough grounding to be picked up cold rather than re-derived.

F1, self-contained deploy: point Portainer at the repo and have everything come
up with no manual steps beyond credentials. Notes the shape this implies
(compose file in-repo, image under ghcr.io/ben-diehlci/, network_mode: host,
persistent AltServerData given it is a relative path, anisette as a second
service) and that it depends on the existing registry-namespace and container
items. Records the trap that the image must carry python3 and the libdnssd -dev
package or advertisement fails silently.

F2, web interface: flags that this is probably NOT optional. While recording it
I checked how the two-factor code is obtained, and
rewrite_altserver_source.py:96 reads it with `std::cin >> _verificationCode` --
from stdin. Under systemd stdin is /dev/null, and in Docker without -i likewise,
so 2FA sign-in cannot currently be completed in the intended deployment at all.
ShowAlert has the mirror-image problem: it calls getchar() and blocks on an
interactive TTY.

That reframes the research question from "build a web UI" to the narrower and
cheaper one that has to be answered first: is the Apple session persisted under
AltServerData and reused, making 2FA a one-time step that a single `docker run
-it` could satisfy, or is a code needed on every refresh? The answer decides
whether a web UI is a convenience or a hard dependency of the whole unattended
premise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…strap-only, and 2 lines

Deployment research landed and corrects an earlier entry in this file that I had
wrong, plus adds two findings that change what "unattended" costs.

CORRECTION. A 7-day refresh does NOT re-sign anything and does not transfer an
IPA. AltStore/Operations/RefreshAppOperation.swift:68 sends exactly one request,
InstallProvisioningProfilesRequest, landing at ClientConnection.cpp:238 ->
DeviceManager::InstallProvisioningProfiles -> misagent. Signer has exactly ONE
call site in the whole server, AltServerApp.cpp:1453-1454, inside InstallApp,
reachable only from the CLI install path. Verified both by grep here rather than
taken on trust. So the 2022-stale signer and NyaMisty#131 block the FIRST install on iOS
26.x, not the recurring refresh that is the actual goal. This file previously
called it "the largest remaining code task" on the critical path; it is not on
the critical path at all.

Also: NyaMisty#131 is two lines, not a submodule bump. ldid.cpp:2215 runs
hash.resize(20) before :2217-2220 captures alternateCDSHA256 = hash, so the
SHA-256 hash-agility attribute carries a 20-byte-truncated hash. Confirmed in
source. The competing diagnosis in the issue thread does not hold against this
tree -- DER entitlements are emitted, CodeDirectory version is 0x00020400, and a
SHA-256 alternate CD is present.

NEW: pairing needs one physical USB connection. config.h:99 is
#undef HAVE_WIRELESS_PAIRING and idevicepair.c:180-182 says wireless pairing is
Apple-TV-only. Steady-state needs no Mac or PC, but the premise does not budget
for that one cable trip.

NEW, and the reason to build a watchdog: the phone fails silently too.
BackgroundRefreshAppsOperation.swift:60 sets ignoresServerNotFoundError = true,
consumed at :221-223 to suppress the alert, so a 3am refresh that finds no
server notifies nobody. RefreshAllAppsIntent.swift:187 sets it to false, so a
manual refresh DOES surface the error -- which makes manual refresh the
diagnostic tool and background refresh the thing that goes quiet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… failures

Scope narrowed by the operator: ONE iPhone, iOS 26.x, no iPad and no other
devices. Multi-device concerns (activeProfiles juggling, device-slot exhaustion,
NyaMisty#113/NyaMisty#86) are therefore out of scope.

Records two things worth far more than the code written so far.

HOST PREREQUISITES, as a concrete checklist with file:line justification rather
than folklore. Highlights that are not obvious and are not in any README:
netmuxd must OWN /var/run/usbmuxd with usbmuxd stopped, because stock usbmuxd
never emits ConnectionType "Network" and the two collide; the widely-copied
USBMUXD_SOCKET_ADRESS instruction in NyaMisty#49 is misspelled (one D) and silently
ignored; NTP matters on the ANISETTE host, not the AltServer host, because Linux
forwards the server's timestamp verbatim where macOS stamps Date() locally;
/var/lib/lockdown needs BOTH <UDID>.plist and SystemConfiguration.plist backed up
as a unit; the firewall needs the whole ephemeral range because sin_port = 0
means the port changes every start; and a container needs init: true because the
binary installs no SIGTERM handler, so as PID 1 every restart costs the full
grace period then SIGKILL.

SILENT FAILURE MODES, ranked -- the actual enemy for an unattended box. Fourteen
of them, each traced to source. Two deserve calling out here:

- Our own dnssd fix (04e928a) does NOT close the advertisement hole completely.
  It catches a child that exits, and the sys.exit addition catches a non-zero
  DNSServiceRegister result, but a user in closed_issue_0051 got result 0 with
  avahi-daemon stopped, consistent with avahi-compat deferring via
  AVAHI_CLIENT_NO_FAIL. avahi can lie about success and nothing in-process can
  detect it. Recorded as an explicit limitation rather than left implied.
- Real device faults are DISPLAYED as "AltServer could not be found", because
  AltStore remaps deviceNotFound/lostConnection to serverNotFound for any
  wireless server that is not isPreferred, and this port hardcodes serverID
  "1234567" where Mac/Windows use a UUID. So isPreferred is permanently false
  here and every netmuxd or pairing fault wears the wrong error message. That
  will send a debugger at mDNS when mDNS is fine.

Conclusion recorded: build an external watchdog before trusting any of this.
Nothing inside AltServer can be trusted to report its own health -- it has no
liveness signal, journalctl -p err is empty no matter what breaks, and adding -d
makes the two most diagnostic libusbmuxd lines disappear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AltStore is not yet installed on the target device -- installing it is the goal
-- so the CLI bootstrap path is required, and that is the one path that calls
Signer::SignApp. NyaMisty#131 is therefore the FIRST thing that will bite, not a
later concern.

ldid computed the CodeDirectory hash and truncated it to 20 bytes BEFORE
capturing the SHA-256 value that becomes the hash-agility attribute
(OID 1.2.840.113635.100.9.2). The attribute carried a SHA-256 hash chopped to 20
bytes, CoreTrust rejected the signature, and the app died at launch with no
crash report -- while the INSTALL reported success, which is what makes it so
deceptive. Two statements swapped: capture the full hash, then truncate for the
cdhashes array, which genuinely does want 20 bytes.

Applied in makefiles/AltSign-build/rewrite_ldid_source.py rather than in
upstream_repo/ldid/ldid.cpp, because that is a submodule and this rewriter is
the project's existing mechanism for patching vendored sources at build time.
The patch is guarded: if the pattern does not match exactly once it writes a
diagnostic to stderr and exits non-zero, failing the build rather than silently
shipping broken signatures again. Guard verified by feeding it already-patched
source (exit 1, clear message), and the file check means lookup2.c passes
through untouched.

Verified: full rebuild exits 0, build/ldid_patched/ldid.cpp has the capture
before the resize, binary links.

NOT VERIFIED: that this actually makes an app launch on a real iOS 26 device.
The diagnosis is confirmed in source and matches jaakkopalvaila's report in
issue NyaMisty#131, and the competing diagnosis in that thread was checked and does not
hold against this tree -- DER entitlements are emitted, CodeDirectory version is
0x00020400, and a SHA-256 alternate CD is present. But the only real test is
installing AltStore on the phone and opening it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous entry recorded a pre-amend hash, which never existed on this
branch. A commit cannot contain its own SHA, so the reference has to land in a
follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are on the path of the very first thing that will be run -- a hand-typed
CLI install of AltStore.ipa -- so they are worth clearing before that attempt
rather than debugging mid-bootstrap.

1. PR NyaMisty#135, X-MMe-Client-Info sanitization. Since ~2026-09 Apple's GSA edge
   rejects with an immediate 503 any request whose X-MMe-Client-Info contains
   "com.apple.dt.Xcode", and anisette servers commonly return exactly that --
   upstream AltStore still builds one containing com.apple.dt.Xcode/3594.4.19 as
   of v2.3.3, confirmed in the local clone. Rewritten to com.apple.akd at the
   single point where anisette data enters the program.

   This is an unverified third-party claim that cannot be tested without a real
   Apple ID, and it alters a header Apple sees, so it is defeatable at runtime:
   ALTSERVER_NO_CLIENTINFO_SANITIZE=1 disables it. If sign-in fails with the
   rewrite in place, toggle that before blaming the anisette server. It also
   logs when it actually rewrites something, so the logs say which mode was in
   effect.

2. argv parsing in AltServerMain.cpp. `case 'a'` had no break and fell through
   into `case 'p'`, so `-a ID` set the password to ID as well. With the
   conventional `-u -a -p` ordering it works by accident because -p overwrites
   afterwards; with `-p PASS -a ID` the password silently becomes the Apple ID
   and sign-in fails with an unrelated-looking error. All five argv pointers were
   also uninitialised while udid/appleID/password are read unconditionally at the
   install call, making a missing flag undefined behaviour. And 'h' was handled
   in the switch but absent from the optstring, so -h printed
   "?? getopt returned character code 077 ??" before the usage text.

   Added a guard that names exactly which required flags are missing instead.

Verified against a real build: -h prints usage cleanly; `-u X -a Y` with no -p
now reports "Missing: --password", which is the direct proof that -a no longer
sets it; all three flags together pass the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Layer 3 is solved, and my earlier local diagnosis of it was wrong. The CI logs
for the buildenv run show the error I had missed locally, because I only looked
at the trailing message:

  CMake Error at CMakeLists.txt:266 (add_library):
    Cannot find source file:
      corecrypto_static/ccrng_static.c

"No SOURCES given to target: corecrypto_static" is a FOLLOW-ON, and it is the
one people notice -- which is why this reads as "the source list is empty" when
the list is fine and exactly one entry is stale. Apple's 2024 distribution moved
ccrng_static.c to the tree root but left CoreCryptoSources.cmake:251 pointing at
the corecrypto_static/ subdirectory, which no longer exists.

Fixed with a guarded sed: it asserts ccrng_static.c is at the root, that
corecrypto_static/ is genuinely absent, and that no stale reference survives, so
it fails the build loudly if Apple moves things again rather than silently
not applying.

cmake now reports Configuring done / Generating done / Build files have been
written. The full corecrypto make is still building at time of commit, so this
is not yet claimed to close NyaMisty#111 -- configure passing is necessary, not
sufficient.

Also settled from the same logs: this is NOT architecture-specific. The CI run
is amd64 and fails identically to local aarch64, which closes the open question
recorded earlier about probing the amd64 leg.

Adds BOOTSTRAP.md: the first-time install runbook, phased so the cabled install
is proven before wireless refresh is attempted. Includes the exact anisette JSON
contract our client requires, the restart-persistence check for anisette
identity, an error-to-meaning table for the install output, and the explicit
warning that "Finished!" prints even on failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full corecrypto make and make install now exit 0 in the alpine builder, so
this is no longer "configure passes, rest unknown". All three layers are fixed:
the corecrypto-2024 directory rename, the missing scripts/code-coverage.cmake
include (upstream PR NyaMisty#85's diagnosis, unmerged since December 2022), and the
stale corecrypto_static/ccrng_static.c path in Apple's own source list.

The buildenv image can be rebuilt from source again, which it could not for
roughly four years. That matters beyond the issue: the whole build depends on
four prebuilt ghcr.io images that nobody could reproduce, so this removes a real
bus-factor risk rather than just closing a ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
systemctl enable usbmuxd prints 'The unit files have no installation config'
because the Ubuntu unit has no [Install] section by design: usbmuxd is started
by udev when an iOS device is plugged in. The instruction produced a confusing
message for no benefit. The real check is whether idevice_id -l sees the phone
in Phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Portainer

Two gaps the operator hit. First, 'copy it to the VM' was hand-waved: the
artifact downloads to the Mac's browser and GitHub artifact URLs require an
authenticated API call even for a public repo, so curl on the VM does not work.
Records the scp line and the unzip dependency.

Second, and more important given this host is managed entirely through
Portainer: states explicitly which pieces are stacks and which are not. The
anisette server is a long-lived service with persistent state and belongs in a
stack. AltServer's BOOTSTRAP run does not -- the 2FA code is read from stdin, so
that one run needs a real interactive terminal. It becomes a stack afterwards,
once sign-in no longer needs stdin, which is future item F1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… binary

Two corrections from a real run-through.

The instructions assumed a .zip, but Safari's 'Open safe files after
downloading' expands it, so the operator ends up with a bare AltServer-x86_64.
Covers both cases, and notes chmod +x is needed regardless -- scp does not
reliably preserve the bit either, on top of artifact upload stripping it.

More useful: adds a string check to distinguish the new binary from one built
before the fixes, which are otherwise indistinguishable. Presence of 'No
anisette server is configured' and ABSENCE of the dead armconverter default are
sufficient. This matters because installing from a pre-fix artifact on iOS 26
produces an app that installs cleanly and crashes at launch, which reads as a
failed deployment rather than a stale binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The usage text documents "-h  --help" and the switch has handled 'h' all along,
but "help" was never listed in long_options. So --help fell through to the
default branch, which printed

    unrecognized option: help
    ?? getopt returned character code 077 ??

above the usage text and exited 1. It looked like it worked, because the error
branch prints the usage too -- which is why it went unnoticed, including by me
when I added 'h' to the optstring in 04dd7ee. That change fixed -h and left
--help still broken.

Both forms now print identical output and exit 0.

Found by running the shipped binary rather than by reading the source, which is
the second time in this session that a documented-but-unreachable flag only
surfaced under actual execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
USB pairing succeeded, which was the step with the most hardware unknowns:
Proxmox passthrough worked, idevicepair validate returns SUCCESS, and
/var/lib/lockdown is backed up containing both the per-device plist and
SystemConfiguration.plist -- the pair that must be restored together.

Records one detail worth not rediscovering: idevicepair validate fails with
'Could not validate ... because a passcode is set' unless the device is unlocked
at that moment. It succeeds on retry once unlocked.

Device UDID deliberately not recorded here; it identifies the hardware.

Anisette (Phase 2) is now the only gate before the install itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…no data

The check as written compared jq output from two files without confirming either
contained anything. jq -r on an empty file prints an empty string and exits 0,
so when the server returned nothing the loop compared "" to "" four times and
reported every field "stable" -- a confident pass on a server that was not
answering at all. Hit for real during Phase 2.

Now guards first: both files must be non-empty and valid JSON, and each field
lookup uses jq -e so a missing key is reported as missing rather than silently
becoming an empty string that matches the other empty string.

Also adds an explicit "does it answer at all" step, because `curl -s … | jq` on
an empty response prints nothing, which reads as a pass at a glance rather than
as a failure.

Fitting, given this repo: a verification that cannot fail is worse than no
verification, and that is the same class of bug as the mDNS advertisement
returning success unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… get wrong

The operator manages this host entirely through Portainer, so Phase 2 is now a
compose stack (deploy/anisette-stack.yml) rather than the docker run commands it
described before.

THE FINDING THAT MATTERS. dadoum/anisette-v3-server's own README -- and every
forum post copying it -- tells you to mount
  /home/Alcoholic/.config/anisette-v3/lib/
That subdirectory holds ONLY the two Apple .so files fetched at first run. The
machine identity is one level up. Verified in the source commit the published
image was actually built from:

  59:  configurationPath = expandTilde("~/.config/anisette-v3");
  95:  libraryPath = configurationPath.buildPath("lib");
  134: v1Device = new Device(configurationPath.buildPath("device.json"));
  136: v1Adi.provisioningPath = configurationPath;

Mount lib/ and the identity sits on the container's writable layer, so every
Portainer "Update the stack" destroys it: a new machine is minted, Apple demands
2FA, and unattended refresh dies silently with nothing logged. That is issue NyaMisty#86,
and it comes straight from the official documentation.

Two other load-bearing settings, both non-obvious:

- TZ must be UTC. The server stamps X-Apple-I-Client-Time from LOCAL wall-clock
  time and then appends a literal "Z", so under any other TZ it sends Apple a
  timestamp wrong by the UTC offset while claiming to be UTC -- well-formed,
  contract-passing and silently wrong. The existing stacks on this host use a
  local TZ, so this is an easy and invisible mistake to make.
- The healthcheck probes /v3/client_info, never /. The v1 route at / performs
  real provisioning against Apple when unprovisioned, so polling it would hammer
  Apple's endpoint at exactly the moment the identity volume went missing.

Image is digest-pinned deliberately: :latest is the only published tag and is
~17 months behind the git source because upstream's publish workflow keeps
failing. Digest verified to resolve, and :latest is a multi-arch index including
linux/amd64.

Confidence is recorded honestly rather than flattened: the JSON contract and the
volume path are verified in source; that this yields a successful Apple sign-in
is NOT -- no completed AltServer-Linux sign-in has been reported in 2026, and
every success report predates both the GSA block and iOS 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bwdiehl and others added 27 commits September 22, 2026 07:40
The Download section said 'tags build all four architectures' and stopped there.
It never named them, never mentioned that the all_arches workflow_dispatch input
gets the same result without tagging, and never said what a tag additionally does
(publishes a GitHub Release). Someone wanting an arm64 binary for a Pi had a fact
with no instruction attached.

Now names all four with the hosts they suit, and gives three routes: the
all_arches input for a one-off, a tag when others will download them, and a local
docker run against the matching toolchain image.

Also documents the naming mismatch that has caught people out: the artifact is
named for the matrix label (AltServer-amd64) while the binary inside is named for
the gcc triple (AltServer-x86_64). Only amd64 differs, which is exactly why it
surprises.

Explains why the default is amd64-only rather than leaving it looking arbitrary:
the other three run under QEMU on the runner and take several times longer, so
paying it per-commit is latency for no benefit -- but dropping them would make
this useless on a Pi, which is much of the point of AltServer-Linux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tag route said 'push a tag' and gave one command. It did not say what tag
names are accepted, how to see what you already have, how to move one you got
wrong, or that a tag also publishes a GitHub Release.

The important part it missed: this repo carries EIGHT tags inherited from
upstream -- v0.0.1 through v0.0.5 and some -rc variants -- none of which are
published to this origin, because git push does not push tags. A single
`git push --tags` would publish all eight as this fork's own, and each one would
start its own four-architecture QEMU build and publish its own release. Now
called out with the commands to see what you are carrying.

Also documents that deleting a tag does not delete the Release it created, and
that anyone who already fetched a moved tag keeps the old commit -- so moving a
published tag is worth avoiding rather than fixing.

Three bash blocks used angle-bracket placeholders. bash reads < and > as
redirections, so pasting `-v <stack>_anisette-config:/data` does not fail, it
silently redirects and can create files. This is the same trap the old BOOTSTRAP
documented and I reintroduced while writing the tag section. All blocks now use
bare uppercase placeholders, and all eleven parse under bash -n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked whether someone could change build_image.yml to suit their own host. Mostly
they should not have to: the published image name already derives from
github.repository_owner, so a fork's images go to its own namespace unedited.

BUILDER did not follow that pattern -- it was hardcoded to ghcr.io/ben-diehlci
when the toolchain was switched over, which is the same hardcoding that made
buildenv/build_docker.sh fail in every fork, one file along. It now reads
vars.BUILDER_NAMESPACE with a default, so a fork that has published its own
toolchain switches by adding one repository variable.

Deliberately NOT derived from github.repository_owner like the image name. Doing
that would point every fresh fork at toolchain images it has never built, and the
build would fail on the pull rather than fall back to the public set that works.

README now names both knobs in one place -- BUILDER_NAMESPACE for Actions,
ALTSERVER_APPARMOR for Portainer's stack environment -- and explains why
platforms: linux/amd64 is hardcoded rather than looking like an oversight: the
build pulls an architecture-specific toolchain, so a plain multi-arch list would
run the amd64 toolchain under emulation and still emit an amd64 binary, giving an
image that is mislabelled rather than merely slow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…llback arch-aware

Asked what someone on a Pi would do without forking. The answer was 'build the
image locally', which the README implied but never spelled out -- and two things
in the way would have bitten them.

The stack's commented build: fallback hardcoded the amd64 toolchain, so a Pi user
uncommenting it got an x86_64 toolchain. It now reads ALTSERVER_BUILD_ARCH and
ALTSERVER_TARGETARCH, defaulting to amd64.

Those are deliberately two variables, because the two names genuinely differ: the
toolchain image is named for the gcc triple (aarch64) while TARGETARCH uses
Docker's platform name (arm64). Setting only the first produces a correctly
compiled arm64 AltServer that then downloads an x86_64 netmuxd and dies at
startup -- which is exactly the failure I hit building locally earlier, where
TARGETARCH defaulted to amd64 under classic docker build.

The new section also explains the two-stage build first, since the fix only makes
sense once you know stage one runs inside a prebuilt toolchain image: building on
a Pi means pointing stage one at the arm64 toolchain, not compiling corecrypto
and boost yourself.

And a caveat on advice I nearly shipped wrong: tagging the local build with the
name the stack expects works for plain docker compose, but Portainer's 're-pull
image' fetches the amd64 image and discards the local arm64 one, leaving
containers failing with exec-format errors. Portainer users are pointed at the
build: block instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three sections covered this between them -- "Building for other architectures",
"Running on a Raspberry Pi", "If you fork this" -- organised around what the
project does rather than what the reader has. Someone on a Pi had to read the
CI-artifact section first to learn the fork variables were irrelevant to them.

Now one section keyed to the two questions that actually decide everything: what
architecture is the host, and are you forking. Three labelled paths, each
self-contained.

The honest part it was missing: armv7 and i386 CANNOT run the container stack.
netmuxd publishes releases for x86_64 and aarch64 only -- verified against the
v0.4.3 assets, the other four return 404 -- and netmuxd is what makes cable-free
refresh work. The Dockerfile already refuses rather than shipping an image that
looks fine and cannot reach the phone:

    netmuxd publishes no build for TARGETARCH=arm

The support table now says so, with the static binary offered as the fallback and
USB install called out as what still works. Claiming four-architecture support
without that caveat would have sent someone with an older Pi down a path that
ends at a build failure.

Also folds the tag guidance and the artifact/binary naming mismatch into a
"Downloads and releases" section, so the release machinery is in one place rather
than interleaved with the deployment paths, and updates the top jump table, which
still pointed at the old anchors.

Verified: every anchor and file link resolves, all 12 bash blocks parse, no
angle-bracket placeholders remain, five guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The support table said "wireless refresh: no" for those two, which reads as
broken. They are not. Refreshing over USB works exactly as it always has -- the
host's usbmuxd hands AltServer the device, which is the job netmuxd does for the
cable-free case. A small always-on machine with the phone permanently attached is
a perfectly reasonable deployment, and a good use for hardware too slow for much
else. The table now says "refresh: over a cable" rather than "no".

Added the note that matters more than any of it: most armv7 users do not need to
be on armv7. Raspberry Pi OS shipped 32-bit by default until 2022, so a Pi 3, 4 or
Zero 2 W installed a few years ago reports as armv7 while the CPU is 64-bit
capable. `uname -m` plus `lscpu | grep -i 64` tells them, and reinstalling 64-bit
moves them to Path B with the container stack and wireless refresh -- far less
work than building netmuxd from source, which was the only route previously
offered.

Considered dropping both architectures and did not. They cost nothing ongoing:
they build only on tags or the all_arches input, never on a normal push, and
amount to four lines of build matrix. Removing them would take away a working
configuration and diverge further from upstream, which supports them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The platform guide asked a question people cannot answer. "Is your host amd64,
arm64, armv7 or i386" is the wrong opening for someone who knows only that they
have a Raspberry Pi 4.

Now a table keyed to actual devices, mapping each to what `uname -m` reports and
which path to follow, with the two commands to check when unsure.

It also captures the thing that matters most for Pi owners: a Pi 3, 4 or Zero 2 W
appears twice, once per OS bitness, because Raspberry Pi OS shipped 32-bit by
default until 2022. Most people reading `armv7l` on those boards are one
reinstall away from the arm64 path and full wireless refresh, rather than stuck
with the cabled one.

And it names a trap the architecture table could not: the Pi Zero and Zero W are
ARMv6. They look like the obvious tiny always-on machine for this job, and the
armv7 binary fails on them with an illegal instruction. The Zero 2 W is a
different chip (Cortex-A53) and is fine. Someone would have found that out the
slow way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reads it

Cross-checked every claim in the README against the code that implements it --
env var names against the files that read them, CLI flags against
AltServerMain.cpp, paths and ports against the compose file, toolchain image
names against the CI matrix. 25 assertions, three failures, two of them real.

ALTSERVER_APPARMOR was named without its value. The table said to set it and
never said to what, so a reader had the variable and no idea what to type.
BUILDER_NAMESPACE had the same shape. Both now show the value, and all_arches is
named alongside its UI label so it can be searched for.

The structural one: the AppArmor instructions lived in "If you fork it", which a
non-forking reader skips by design -- and AppArmor has nothing to do with forking.
The how-to now sits in Setup beside the setting it explains, with the install
order spelled out (profile first, variable second: a container requesting an
unloaded profile fails to start), the verification from ANOTHER machine, and a
note that the ptrace denials in dmesg afterwards are the profile working rather
than something to fix. The fork table now cross-references it.

The third failure was my assertion, not the doc: port 62078 is a manual check
against the phone, so there is nothing in the code for it to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A readability pass. The mechanical checks came back clean -- no repeated words, no
sentences over 45 words, consistent terminology once identifiers like
altserver-mdns and github.com are excluded -- so the findings are all about what
a reader can actually follow.

"anisette" appears twenty times and was explained once, in a table cell inside
Reference, four hundred lines after the reader first meets it in the Apple ID
warning. It is not optional vocabulary here: it is why sign-in fails with a clock
error, why the identity is worth backing up, and why the lockouts in issue NyaMisty#88
happen. Now explained where the stack's parts are introduced, and the earlier
mention no longer assumes the word.

Issue numbers were bare text. They are upstream's issues and a reader has every
reason to want to read them, so NyaMisty#77, NyaMisty#88, NyaMisty#111 and NyaMisty#131 are now links.

Added a signpost a linear reader needs: Setup step 3 says to deploy the stack and
the published image is linux/amd64, so anyone on a Pi follows the instructions,
watches the pull fail, and only then finds the platform section. Step 3 now sends
them there first and tells them it is one extra command rather than a different
procedure.

Also one stray "--" in a paragraph where every other dash is an em dash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs had 264 characters that cannot be typed on a US keyboard: 218 em
dashes, 28 arrows, 14 ellipses, one en dash, one "greater than or equal".
All now ASCII. tests/check_ascii_punctuation.py keeps them out and runs in
CI; it is verified against 10 mutants, and caught its own author reaching
for a literal em dash while writing it.

Two of the substitutions were load-bearing rather than cosmetic, and both
fail silently:

  * A line that STARTS with an em dash becomes a Markdown list item once
    the character is swapped for "- ", splitting the paragraph mid-
    sentence. Three continuation lines were like this. The result is valid
    Markdown, so nothing errors and it is only visible when rendered.
    Fixed by moving the dash to the end of the previous line first, then
    asserting no line begins with one.

  * GitHub strips an em dash from a heading's anchor slug but keeps a
    hyphen, so "Path A <em dash> amd64" moves from #path-a--amd64 to
    #path-a---amd64. Nine headings changed. Checked rather than assumed
    that nothing links to them: all 11 in-repo anchors still resolve.

" -- " is left alone deliberately. It is the repo's existing ASCII prose
dash, it is on a keyboard, and some of the 297 instances are real shell
syntax ("colima ssh -- bash", "git submodule update --remote -- ...") plus
one in the vendored Apple dns_sd.h header.

Proved content-neutral by applying the same character map to the HEAD text
and diffing against the working tree: byte-identical for all seven files.
Bullet, heading and line counts unchanged; all six guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On 2026-09-22 avahi-daemon restarted on the deployment host. Every
registration on the box was dropped and AltServer's never came back. The
server was undiscoverable for three days: AltStore could not find it,
nothing refreshed, and every other health signal stayed green. It was found
only because a human opened the status page.

This was written down in REVIVAL.md as silent failure mode 3 before it
happened, which is the argument for writing them down.

Nothing in the process could have caught it. avahi-compat's worker thread
runs exactly one poll() and then blocks for a command only
DNSServiceProcessResult() sends, which this project never calls -- so the
client never reaches AVAHI_CLIENT_FAILURE and the registration callback can
never fire. DNSServiceRefSockFD, which would drive that loop, is a stub
returning 0xDEADBEEF. Liveness is not a signal either: the helper sat alive
for three days holding a handle to a service that no longer existed.

So the advertisement is now checked the only honest way, from outside: the
helper browses for its own record every ALTSERVER_MDNS_RECHECK_SECONDS
(default 60, 0 disables) and re-registers when it is gone. Four rules, each
easy to get wrong and invisible when wrong:

  * Match on port AND the serverID TXT value, never the service name. flags
    is 0, so avahi RENAMES on collision rather than withdrawing, and a
    name-based check would re-register forever against its own healthy
    record. Ports are ephemeral, so another AltServer on the LAN can collide
    on port alone.
  * A failed browse is UNKNOWN, not absent. Never act on ignorance.
  * Two consecutive misses before acting, deallocating first, so a false
    negative costs a sub-second gap rather than a duplicate record.
  * The watchdog must not fail silently itself. If avahi-browse cannot run
    every check is UNKNOWN and it is blind, so it probes once at startup and
    says so, and warns again on long unreadable streaks.

Two latent bugs fixed in passing, both exposed by needing the handle:
sdRef was a c_int while DNSServiceRef is a pointer, so on 64-bit the daemon
wrote 8 bytes into 4 and the stored handle was truncated; and the TXT record
was hexed through a signed char, sign-extending any byte >= 0x80.

The helper moved from an interpolated one-liner to a raw string literal
taking argv, which lets the guard execute the shipped text verbatim. Before
this a syntax error in it was only discoverable on a live host, reported as
the generic "the python3 helper exited immediately".

tests/check_mdns_watchdog.py: 8 cases against a fake avahi, verified to fail
under 8 mutants. The foreign_server case came from mutation testing, not
design -- matching port alone survived every other case.

Also corrects REVIVAL.md's AVAHI_CLIENT_NO_FAIL claim: compat passes flags
0, so a failed client never reconnects.

Verified: links clean for aarch64, helper text present in the binary, all
seven guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-09-22 outage had a worse half than the dropped mDNS advertisement:
status_checks.py detected the failure correctly for its entire duration and
both machine-readable channels threw the answer away.

  * /api/status answered HTTP 200 whatever `overall` said, so an uptime
    monitor saw a healthy endpoint through a total outage.
  * `python3 status_checks.py` exited 0 whatever it found, so a cron job
    could never alert.

Following the README's own advice to "have something poll it" would have
shown green throughout. That is worse than no monitoring at all, because it
answers "is it up?" with a confident yes.

Now /api/status returns 503 when overall is "fail", and status_checks.py
exits with the Nagios plugin codes (0 OK, 1 WARNING, 2 CRITICAL) that a
monitoring agent already understands. Both mappings live in status_checks.py,
which owns the state vocabulary.

Deliberately unchanged: WARN stays HTTP 200. Degraded is not down, and
UNKNOWN collapses into WARN whenever a tool is merely absent. The cost is
stated in the code and the README rather than left to be discovered: a
monitor watching only the status code CANNOT see a degraded result,
including the case where the mDNS check cannot run at all.

tests/check_status_signals.py drives the REAL request handler and runs the
REAL script against a genuinely dead anisette server. Testing the mapping
functions alone would not do -- a correct mapping that no caller uses is the
same bug wearing a better hat, and that is precisely what the original was:
`overall` computed perfectly, then discarded one line later. 19 checks,
verified to fail under 6 mutants.

One coupling the guard pins: the page's own fetch() must not gate on
response.ok, or a 503 would blank the dashboard during exactly the outage it
exists to describe. The rendered page was checked in a browser against a
real 503, not reasoned about.

README: the "watch it from outside" bullet now says what to poll and what
the codes mean, and no longer claims there is no re-registration when avahi
restarts. ALTSERVER_MDNS_RECHECK_SECONDS and ALTSERVER_APPARMOR added to the
environment table -- both were referenced in prose with no entry to look up.

Still open: nothing polls it. No healthchecks in the stack, and
`restart: unless-stopped` cannot fire because AltServer never exits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or work it skipped

Two findings from a 38-agent sweep for the pattern behind the mDNS outage:
code that reports success while failing. 34 candidates, 28 refuted under
adversarial review. Every finding from the C/C++ lens was refuted; the
problems are in the Python, the build glue and the compose file.

EDITING A SOURCE REWRITER REBUILT NOTHING

This project patches vendored code by rewriting it at compile time, so
makefiles/rewrite_*.py IS the patch and editing one is the documented way to
change vendored behaviour. Three of the four rules that run a rewriter listed
only the vendored SOURCE as a prerequisite, never the rewriter. Measured:

    touch makefiles/rewrite_altserver_source.py
    make          ->  regenerates 0 files
    make clean    ->  patched tree still present, 35 files
    make          ->  regenerates 0 files

The build reported complete success with the previous patch compiled in.
clean did not help because it never removed AltServer_patched,
AltSign_patched or ldid_patched, so `make clean && make` rebuilt every object
from stale rewritten sources and was not a clean build at all. Only deleting
build/ outright worked -- which is exactly why CI never saw this, since CI
always starts from an empty tree. Locally it can silently waste hours. After:

    build, then make  ->  regenerates 0   (quiet when nothing changed)
    touch a rewriter  ->  regenerates 35
    make clean        ->  all 3 patched trees removed, rebuild byte-identical

Also .DELETE_ON_ERROR in every makefile that runs a rewriter. Same class one
step along: the rewriters exit non-zero on a pattern mismatch BY DESIGN, and
`>` has already created the output by then, so make left a truncated source
and treated it as finished. rewrite_idevice_source.py already avoided this
with tmp-and-mv, which is stronger still because it survives an interruption
rather than only a non-zero exit; the guard accepts either.

THE CLOCK CHECK REPORTED OK FOR A COMPARISON IT NEVER PERFORMED

check_clock wrapped its real anisette-drift comparison in a bare
`except Exception: pass` and fell through to timedatectl -- which measures
LOCAL NTP, the thing its own docstring says proves nothing, because Linux
forwards the ANISETTE server's X-Apple-I-Client-Time to Apple verbatim.

The trigger is not hypothetical. AltServer's own C parses that field with
strptime(), a PREFIX match, while the check used a full match on a fixed
19-char slice:

    "2026-9-25T12:34:56Z"    C ACCEPTS (tail "Z")   Python RAISES
    "2026-09-25 12:34:56Z"   C rejects              Python RAISES
    "1758800000"             C rejects              Python RAISES

A legal timestamp from an independent anisette implementation would leave
refresh working normally while the check became a permanent silent no-op --
and skew is the failure that surfaces as an opaque Apple -36607 with nothing
naming time as the cause. A parse failure now returns WARN naming the value,
and the timedatectl fallback reports "Local NTP synchronised, anisette drift
NOT checked" rather than OK. Fixing the swallow also repaired a message that
lied: the fallback used to say "No anisette timestamp to compare against"
when one was present, and "resolves itself once the anisette check above
succeeds" when that check was already succeeding.

tests/check_rewriter_deps.py (10 invariants) and 13 new cases in
check_status_signals.py. 9 mutants, all caught. The rewriter guard found a
fourth makefile on its first run.

Verified: from-scratch build exit 0, clean-and-rebuild byte-identical, all
nine guards pass.

Still open from the sweep: no healthcheck on any service; fetch_altstore
fails open making the entrypoint warning unreachable; the redaction filter is
a child nobody waits on and its tee disables on first write error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remaining items from the silent-success sweep. All four are the same
shape: a failure that presents as a healthy, idle server.

HEALTHCHECKS ON ALL FOUR SERVICES

Without one, Docker computes no health state at all, so Portainer had no
health column and was structurally incapable of showing the 09-22 outage.
Plain Compose never RESTARTS an unhealthy container (that is Swarm), so
these exist to make failure visible, not to act on it.

The altserver probe browses for its own advertisement, which is the only
honest test. Its window (2m x 3 = 6 min) deliberately outlasts the
watchdog's recovery (2 x 60s), so it cannot flag an outage already being
repaired. anisette's healthcheck was restored from anisette-stack.yml, where
it had existed all along for the same image -- and it must keep hitting
/v3/client_info, never /, because the v1 route performs REAL provisioning
against Apple.

The altserver-web check is ALSO what finally runs the checks on a schedule.
Until now status_checks.py executed only inside a browser's HTTP GET, so the
503 and Nagios exit codes added earlier today had no listener in any
deployment -- a working smoke alarm with no battery. -q prints one line
naming what is wrong; Docker truncates healthcheck output to 4KB.

THREE MORE SILENT PATHS

fetch_altstore returns EX_TEMPFAIL (75), not 0. The entrypoint wraps it in
`if ! fetch-altstore` purely to warn, and returning 0 made that unreachable
on the common path. It also records .lastfail so repeated fail-open is
datable instead of looking new every time.

The entrypoint now EXERCISES the log filter rather than stat-ing it. `[ -x ]`
says nothing about whether a thing can execute, and AltServer ignores
SIGPIPE, so a filter that failed to start took every log line with it while
the container stayed Up.

redact-log reports and retries a failed tee instead of setting tee = None
forever, and /api/logs reports mtime/age/stale so a frozen log is not
presented as the live view it advertises.

TWO THINGS THE GUARDS CAUGHT IN MY OWN WORK

The first retry was DEAD CODE: tee_retry_at was compared against n, which
counts only successful tee writes and stops advancing the moment the tee is
disabled, so the condition could never become true again. A guard that
grepped for "tee_failures" passed it. Driving main() against a tee that
raises ENOSPC caught it at once. Grep-shaped assertions do not catch
behaviour.

And check_ascii_punctuation.py had a hole in exactly the same spirit as the
bugs it guards: it scanned file BYTES, but — in a Python source is six
ASCII characters on disk and a real em dash at runtime. Eight such escapes
sat in server.py's page markup -- in link text, a placeholder and a status
line -- so the files were ASCII while the PAGE served to every visitor was
not, and the guard reported clean throughout. It now makes a second ast pass
over what string literals EVALUATE to. The served page is 0 non-ASCII
characters, down from 8.

tests/check_observability.py: 19 assertions, 14 mutants, all caught.
Verified live: /api/logs reports staleness, the page renders, all ten guards
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to a000f87, found by thinking about what the badge would actually
do in a week of normal use rather than in a test.

check_device returns FAIL when the device is on neither transport. That is
correct on the dashboard -- refresh genuinely cannot happen -- but it is the
wrong verdict for a CONTAINER healthcheck: the altserver-web check would
have gone unhealthy every time the phone left the house. A badge that is red
half the time gets ignored, and an ignored badge is no better than the
no-badge state that let the 09-22 outage run for days undetected.

status_checks.py gains --server-only, which drops the two device-dependent
checks and leaves the four things the server is actually responsible for:
anisette, clock agreement, its own mDNS advertisement, and the daemon
process. The compose healthcheck uses it. The page is unchanged and always
shows everything.

The guard asserts the flag is present AND that it actually drops the checks
rather than merely being accepted -- verified by patching check_device to
record whether it ran at all. 2 mutants, both caught.

Verified: all ten guards pass; CI green on a000f87 including both new guard
steps and the image build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every check result was computed fresh and discarded, so the page could say
"broken now" and never "broken since Tuesday". During the 09-22 outage the
honest answer to "how long has this been down" was "somewhere between three
and nine days" -- reconstructed from journalctl and a container start time,
because nothing here recorded anything.

status_checks.py --record appends one line per run to
/data/status-history.jsonl (ALTSERVER_HISTORY, bounded by
ALTSERVER_HISTORY_MAX, default 5000 runs ~ 17 days). The altserver-web
healthcheck is the writer: it already runs on a fixed five-minute cadence,
which is a better timeline than page loads would give. /api/history serves
it and the status page draws a per-check timeline with "last not-ok 2h ago"
beside each row.

REVERSES A DECISION FROM EARLIER TODAY

--server-only originally SKIPPED the device checks. Right for the verdict --
a phone that has left the house must not mark the container unhealthy -- and
wrong once history exists, because those checks would never be recorded and
"when did the phone drop off the network" would be unanswerable for exactly
the reason the mDNS outage was. It now narrows the VERDICT only; every check
runs and is recorded. The guard was rewritten to assert the intent (a failing
device check does not make the server unhealthy, a failing server check still
does) rather than the implementation detail it had pinned.

TWO BUGS THE GUARD CAUGHT IN THIS FEATURE

The trim was gated on size > HISTORY_MAX * 400 as a cheap way to avoid
reading the file each run. That guess does not bind: with the short lines a
four-check run produces it held 93 entries against a bound of 25, and how
long the window actually was depended on how long the check names happened
to be. It counts lines now.

And `_result` as a variable in __main__ shadowed the module-level _result()
function, so the "could not record history" path raised TypeError: 'dict'
object is not callable. The error handler itself was broken, and only running
the failure path showed it.

A THIRD FORM OF THE NON-ASCII HOLE

check_ascii_punctuation.py scanned file bytes, then string literal VALUES
after \u escapes decode. It still missed HTML entities: an em-dash entity is
seven ASCII characters in the file AND in the response body, and an em dash
only once a browser renders it. Three were live on the status page. A third
pass now covers entities, exempting the escaping ones. The served page is
ASCII at every layer: raw bytes, decoded values, and after full entity
expansion -- verified against the running server.

7 mutants, all caught. Verified in a browser against a seeded 120-run
history: the timeline renders, a simulated outage shows as a red band with
"last not-ok 2h ago".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by the owner turning their phone's Wi-Fi off to test the new
healthcheck.

netmuxd keeps listing a known device for a while after it disappears, so
idevice_id -n still returned it, idevicepair then could not reach it, and
both the status page and the pairing page announced a pairing problem:

  "The pairing record in /var/lib/lockdown is stale or half-written.
   Re-pair over USB and tap Trust; back up BOTH files together."
  "Trust this computer on the iPhone ... unplug and replug the cable."

That is confidently wrong, and expensive: it sends someone to fetch a cable,
unplug, re-pair and re-Trust a pairing that is perfectly fine, to fix a
phone that was merely switched off.

lockdownd distinguishes these and the codes mean opposite things. -8 is
LOCKDOWN_E_MUX_ERROR, a TRANSPORT failure that says nothing whatever about
pairing (lockdown.h:47). The codes that DO mean a pairing problem are -2
INVALID_CONF, -4 PAIRING_FAILED and -5 SSL_ERROR.

Both pages now branch on the code:

  -8 / -7   transport      "the phone did not answer" -- do NOT re-pair
  -19       Trust pending  unlock and tap Trust, no cable
  -18       user denied    re-pair and tap Trust this time
  -2/-4/-5  pairing        the existing stale-record advice
  anything  unrecognised   report it and invent no cause

pairing.py imports the codes from status_checks rather than keeping a second
copy, because two tables drift and this one decides whether someone is told
to go and re-pair. No cycle: status_checks does not import pairing.

Guarded in check_status_signals.py: every branch is exercised through the
real functions, including the pairing page, where _run is the only seam
because _udids is nested inside diagnose(). 5 mutants; 4 caught and the
fifth confirmed EQUIVALENT (adding MUX_ERROR to the later tuple is dead code
because the earlier branch already returned).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Guards job failed on the previous commit. Nothing was wrong with the
code it guards -- the test was.

check_status_signals.py drives pairing.diagnose() to prove a transport
failure is not reported as a pairing problem. diagnose() returns early if
/var/run/usbmuxd is absent, and I patched shutil.which() and _run() but not
that. The path EXISTS on macOS and does not on the Linux CI runner, so the
test passed locally, bailed at the usbmuxd step in CI, and graded that
early-return's text as a feature failure:

  FAIL it names the transport failure instead (next: 'usbmuxd is not running')

A test that depends on undeclared host state is not a test; it is a coin
flip that happened to land. Every host dependency in diagnose() is now
pinned, and it is verified under BOTH conditions -- socket present and
socket forced absent -- rather than under whichever one this machine has.

The deeper flaw was that the assertions graded output TEXT, so an early
return looked like a wrong answer rather than a broken setup. There is now
a reached_pairing() precondition that fails with the diagnostic "it bailed
at 'usbmuxd is not running', so the assertions below would be grading the
wrong branch". Verified by removing the pin and confirming that message is
what appears.

The build job itself was green; only Guards failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduced by me earlier in this session, found by the README audit.

check_anisette fetches the BARE ROOT of the anisette server. That is the v1
data route, and on a server whose machine identity is missing it performs
REAL PROVISIONING against Apple. deploy/anisette-stack.yml:62-73 refuses to
point even a healthcheck at it, in as many words:

  "a polling healthcheck on / would hammer Apple's endpoint at exactly the
   moment your identity volume has gone missing -- turning a
   restore-the-backup incident into rate-limit / account-lock territory."

That was harmless while the checks ran only when a human opened the status
page. It stopped being harmless when I made the altserver-web healthcheck
run them every five minutes: the deployment would retry provisioning
against Apple 288 times a day, indefinitely, precisely during the incident
where the identity had been lost. The consequence is an Apple ID lockout on
an account whose whole purpose is unattended refresh.

check_anisette now takes polling=True, used automatically by --record and
--server-only (so the compose healthcheck cannot avoid it), and probes
/v3/client_info instead -- static, no Apple contact. It says plainly that
the field contract was not verified. The page, which is a human asking once,
still does the real fetch and the full ten-field check.

The trade-off is deliberate: the timer loses the field-contract signal and
keeps reachability. The anisette container's own healthcheck already covers
"is it up", and no signal is worth a lockout.

Guarded in check_observability.py by intercepting urlopen and asserting
which URL each mode actually fetches, plus that the CLI flags imply polling
so the guard is not decorative.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oning route

Two defects in yesterday's work, found by auditing the README against the
code rather than by using it.

/api/status RETURNED 503 WHENEVER THE PHONE WAS AWAY

The endpoint the README tells you to monitor judged its status code on every
check, including iPhone reachability -- which fails whenever the phone is
asleep, out of the house, or off Wi-Fi. An alert that fires every time its
owner goes out is muted within a week, which lands back at no monitoring at
all: the exact state that let the 09-22 outage run for days undetected.

The healthcheck already used --server-only for this reason. The HTTP
endpoint did not. The status code now follows server_overall, the body
carries both verdicts (overall for the page, server_overall for the code),
and ?full=1 restores judging on everything for anyone who wants that.

ANYTHING POLLING THE STATUS HIT THE PROVISIONING ROUTE

check_anisette fetches the anisette server's bare root, which is the v1 DATA
route. deploy/anisette-stack.yml:62-73 refuses to point even a healthcheck
at it, because on a server whose machine identity is missing it performs
REAL PROVISIONING against Apple.

Two loops were doing exactly that. The healthcheck I added yesterday, 288
times a day -- and, larger and pre-existing, the status page's own
30-second auto-refresh: 2880 times a day for any tab left open. The compose
file's warning had been sitting next to the page it described.

Polled requests now probe /v3/client_info, which is static and contacts
Apple for nothing, and say plainly that the field contract was not verified.
A human opening the page gets the real fetch once; the 30s refresh does not.
--record and --server-only imply polling, so the healthcheck cannot opt out
of safety by omission.

The guard intercepts urlopen and asserts WHICH URL each mode fetches. That a
flag is present proves nothing about where the request goes.

README: the monitoring table now gives the runnable docker exec form
(status_checks.py is not on anyone's PATH), documents server_overall and
?full=1, and says which endpoint is safe to poll and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First batch from the diagnosis audit (27 confirmed findings). These four are
the ones that produce a false alarm or invite an expensive wrong action.

AN avahi-browse THAT REFUSED WAS REPORTED AS "NOT PUBLISHED"

_run collapses three outcomes into rc None (missing, timed out, could not
run), and a NON-ZERO exit was not handled at all -- it fell through to the
"found nothing" branch. So a D-Bus denial or a stopped avahi-daemon was
announced as "_altserver._tcp is NOT published", a confident outage report
from a probe that never answered. That drives the HTTP 503 and the container
healthcheck. The same hole reported "the phone is not advertising" and sent
the owner to wake a phone that was already awake.

All three now report UNKNOWN, and only a browse that actually ANSWERED and
found nothing reports FAIL. The "install avahi-utils" advice is reserved for
the case where it really is missing.

ANY _altserver._tcp ON THE LAN READ AS OK

check_advertisement accepted any resolved row, so a second AltServer on the
network -- or a stale record from a machine that has gone -- made the check
green while this host advertised nothing at all. It now matches the
advertising hostname against this one, and says whose it is when they differ.
The mDNS watchdog has matched on port and serverID since it was written; the
status check was the weaker of the two.

AN HTTP ERROR WAS REPORTED AS "CANNOT REACH ... CHECK docker ps"

An HTTP response proves a listener. Reporting 404/502 as unreachable sends
the owner to a command that shows the container UP, and invites a redeploy --
which anisette-stack.yml:47-58 documents as destroying device.json and
adi.pb, minting a new machine and demanding 2FA. I introduced this in the
polling branch a few hours ago, with a bare `except Exception`, while the
non-polling path right below it caught HTTPError separately.

MISSING FIELDS WERE BLAMED ON THE SERVER'S CONTRACT

All that is established is that ten keys are absent from a 200. The message
asserted "this server does not speak the legacy v1 contract", sending the
owner to replace a working anisette server -- when the likelier cause is the
opposite: this route is where provisioning happens, and a server whose
identity is gone answers 200 with an error object. The body, which usually
names the difference, was discarded. It now shows the body and gives both
readings without picking one.

Guarded in check_status_signals.py by driving each failure mode through the
real functions. One guard needle had to be tightened: "install" matched the
correct fix text saying "avahi-browse IS installed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…checks

MY LOCKDOWND FIX WAS LARGELY UNREACHABLE

Verified against libraries/libimobiledevice/tools/idevicepair.c rather than
assumed: print_error_message (:115-144) prints pairing-stage failures as
plain English with NO number, and only connection-stage failures carry
"error code %d" (:409). So the -19/-18/-2/-4/-5 branches I added yesterday
could never fire for `validate`, and every genuine pairing failure fell into
"unrecognised, do not assume a pairing problem" -- the exact opposite of the
truth, with no action offered. Only the -8 transport case worked, which is
why the fix looked right when tested against the bug that prompted it.

classify_pair_failure() now matches the TEXT first and the code second, and
both the status check and the pairing wizard import the one implementation
so they cannot give different instructions for the same output.

THE PAIRING WIZARD BLOCKED ON THE NORMAL STATE OF THIS SERVER

It stat'd /var/run/usbmuxd and refused to continue without it. Ubuntu's
usbmuxd is udev-activated and exits when the last cable is removed, so a
cable-free wireless server never has that socket -- the wizard answered the
designed configuration by telling the owner to start a service that was
behaving correctly, and a phone already paired over netmuxd never got
checked. Now informational. (This is also what made my own CI guard need a
fake os.path.exists.)

A DEAD netmuxd LOOKED LIKE AN ABSENT PHONE

_udids discarded the return code, so "ERROR: Unable to retrieve device
list!" and a mux that answered with no devices were the same empty list. A
down netmuxd got hypervisor USB passthrough and a different cable
prescribed, for a container that needed restarting. Same conflation in
check_device, where _devices_via returns None for "could not ask" and [] for
"asked, nothing there" -- both falsy, both landing on the confident "No
device on either transport".

THE PROCESS CHECK COULD NOT FAIL IN THE SHIPPED STACK

Its blindness guard tested for /proc/1/root/usr/local/bin/AltServer, which
is MISSING precisely when pid: host is set, because PID 1 is then the host
init. So it fired in the one deployment where the check can see everything,
and a dead daemon read as "this check is blind" -> UNKNOWN -> HTTP 200. It
now reads /proc/1/cmdline to answer the question directly. The match also
accepted any command line containing "AltServer", including this web
server's own installer subprocess.

ALSO: a 404 on the static anisette probe is a WARN, not a FAIL (a v1-only
server legitimately has no /v3/client_info); HTTP statuses and transport
errors get advice specific to what they mean instead of one sentence; a
non-JSON 200 no longer blames the endpoint path, which is usually the one
thing that is right; and a crashed check keeps its NAME instead of becoming
"Check #N", which broke its history row and lost its device classification.

Guards driven through the real functions, and verified under both host
conditions -- with and without /var/run/usbmuxd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 confirmed defects from auditing README.md against the tree, 13 refuted.
Documentation was the one part of this repo nothing executed, so it drifted
freely while every other class of silent failure got a guard.

THE WORST ONE WAS A SECURITY CLAIM

"`docker logs` is not filtered and does contain the full account record and
bearer tokens." False for months -- the entrypoint routes stdout and stderr
through the same _redact function, so no unredacted copy exists anywhere in
the deployment. The README contradicted itself 90 lines later. It misled in
both directions: withhold logs believing they carry live tokens, or go there
for unredacted detail during an incident and quietly get a filtered stream.
The fail-open WARNING line that IS the exception was undocumented.

OTHERS THAT WOULD BLOCK OR MISLEAD

"Two settings that are load-bearing" listed network_mode: host on two
services when three have it, omitting `altserver` -- where it matters most,
since Bonjour cannot cross a bridge and AltServer binds an ephemeral port a
ports: mapping cannot replace.

Setup runs files from the repo (plain compose, the AppArmor installer) and
never said to clone it. `docker build -t altserver .` cannot work; there is
no Dockerfile at the root. avahi-utils was missing from Runtime requirements
-- without it the mDNS watchdog silently turns ITSELF off. Step 7 backs up
the pairing record and the anisette identity and never mentioned the
altserver-data volume holding the signing certificate. The history was
described as 17 days when the timeline draws 120 runs, about 10 hours.
Four environment variables, three API endpoints, the watchdog's log lines,
fetch-altstore's flags and deploy/credential-hygiene.sh were undocumented.
BUILDER_NAMESPACE was presented as fork-wide when only build_image.yml reads
it. And "Verify before going further" curls the anisette route that
provisions against Apple, with no warning not to loop it.

Added a guard-suite section too: what each of the eleven guards exists
because of, how to run them, and the two conventions -- verify by mutation,
exercise rather than grep -- that this session learned the hard way.

tests/check_readme_claims.py NOW EXECUTES THE README

Every documented variable is read by the code and every runtime variable is
documented; every endpoint exists both ways; every guard is named and every
named guard exists; the stated healthcheck windows match the compose file;
anchors and file links resolve; bash blocks parse and contain no
angle-bracket placeholders, which bash reads as redirections.

It checks only what is mechanically decidable -- prose accuracy is not
testable and is not attempted. It caught itself on the first run for not
being in the table it checks. 6 mutants, all caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ixing them

THE REPRODUCED --help SILENTLY DROPPED ITS ENVIRONMENT SECTION

AltServerMain.cpp:78-99 prints five environment variables after the flags.
The README reproduced only the flags, with no elision marker, so it implied
the binary had nothing to say about them -- which is how ALTSERVER_NO_SUBSCRIBE
stayed undocumented here while the program itself described it. The elision is
now marked, and points at `docker exec altserver AltServer --help` for the
authoritative text.

STEP 4 COULD DEAD-END ON THE IPA STEP 3 PROMISES IS AUTOMATIC

The install page says "No IPA at /data/AltStore.ipa" and stops. The message
is fine; the README never said what to do about it. It now explains that a
failed catalogue fetch is not fatal (only a first-time install needs the
file), how to confirm it from the logs, and how to retry with
`docker exec altserver fetch-altstore` without recreating anything. Verified
end to end: the command fetched AltStore 2.3.

AND A TRAP I CREATED WHILE WRITING THAT

The recovery commands went inside a blockquote, so every line carried a "> "
prefix -- and bash reads a leading > as a redirection, meaning anyone copying
that block would write files named `docker` instead of running anything. The
same class as the angle-bracket placeholders this repo already guards, and
check_readme_claims.py caught it on the very block that introduced it.

Commands moved out of the blockquote, and the guard now rejects a bash fence
nested in one. Verified by re-nesting it and confirming the guard fires.

Also widened the documented log grep: it named two failure strings and the
fetcher emits four.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the last seven commits did not touch the running log, against the
standing rule that it stays current in the same commit as the change. Two
were covered by 0b4e931's entry; the two diagnosis batches and the README
audit were not.

Records what the 65 findings had in common, since that matters more than any
single fix: every one was a check that could not distinguish "I could not
tell" from "I determined no". Including mine -- the lockdownd branching was
largely unreachable because idevicepair prints pairing failures as words and
only connection failures as codes, so the one case I had tested worked and
the rest silently did not.

Also keeps the method note: almost none of this was found by reading. The
pairing bug came from a human turning Wi-Fi off, the dead tee-retry from
driving it against ENOSPC, the em dashes from reading the served page rather
than the source, the unreachable branches from opening the vendored tool's
source instead of trusting its documented codes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found in production: altserver-web stuck on "health: starting" with
"warn: Clock agreement" on every probe, once every ~6 seconds.

My own regression from this morning. Switching the polled anisette probe to
/v3/client_info -- so it would stop provisioning against Apple on a timer --
removed the X-Apple-I-Client-Time the full route carries. check_clock then
had nothing to compare against, returned UNKNOWN permanently, and UNKNOWN
rolls into WARN, which I had deliberately made mark the container unhealthy.
So the container could never become healthy again.

Both halves of that were right on their own. The bug was not noticing that
one fed the other.

Fixed by reading the anisette host's clock from the HTTP Date header, which
RFC 9110 requires on every response from an origin server with a clock. That
is the same machine's time -- the thing that actually breaks sign-in, since
Linux forwards that server's timestamp to Apple verbatim -- measured for
free, with no Apple contact, which was the entire point of probing the
static route.

Verified against a stub server that behaves like anisette: the polled run
now reports "Anisette clock within 0s of ours" instead of "No anisette
timestamp to compare against".

Guarded, and the guard verified by dropping the header forward.

Note on the symptom for next time: the ~6s probe cadence was not a clue.
Docker probes faster during start_period, and the status stays "starting"
until a probe SUCCEEDS -- so a check that always fails looks like a slow
start rather than a failure until the window closes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each bar is about two pixels wide, so a run of them was obviously SOMETHING
without being readable. The grey stretch in the owner's Clock agreement row
was the bug fixed in ca2a3ac, and there was no way to tell when it started
without counting bars.

Hovering now shows the state and the local time of that run. Dock-style
magnification: the bar under the cursor grows most, its two neighbours on
each side less, so it is unambiguous which one the tooltip belongs to even
at two pixels. Falls back to no animation under prefers-reduced-motion.

Timestamps are data attributes rather than title=. The native tooltip takes
a second to appear, cannot be styled, and on a bar this narrow gives no
indication which one it came from.

Same-day runs show just the time; older ones carry the date.

Verified: the tooltip content and positioning were driven through a real
mouseover event and screenshotted (OK 02:15:36 AM, correctly placed), and
all five CSS rules including the :has() neighbour selectors were confirmed
parsed and supported. The magnification itself could NOT be verified through
the automation, which does not trigger the :hover pseudo-class - that one is
worth a look on the real page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Ben-Diehlci Ben-Diehlci reopened this Sep 26, 2026
@Ben-Diehlci Ben-Diehlci changed the title Revive the build: CI, Apple sign-in, iOS 26, and wireless refresh Make AltServer-Linux run unattended: wireless refresh, a setup web UI, and website for setup and failure reporting Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants