Skip to content

Revive the build: CI, Apple sign-in, iOS 26, and wireless refresh - #138

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

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

Conversation

@Ben-Diehlci

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

Copy link
Copy Markdown

This is a fork that got AltServer-Linux working again end to end, offered back in
case it's useful. It's large — 95 commits — so it's probably not a
merge-as-is. Everything below is independent, and I'm happy to split any piece
into its own small PR; say which and I'll open them.

Verified against live Apple infrastructure and a real iPhone on iOS 26, not just
compiled.

The one that matters most: wireless refresh (#77)

libraries/libimobiledevice is pinned at c6f89dea, which reads a usbmux network
address as a BSD sockaddr — byte 0 as sa_len, byte 1 as the family. netmuxd
emits the Linux layout on Linux (devices.rs gates the BSD form on
target_os = "macos"), so byte 1 is 0x00:

NetworkAddress: Data(02 00 00 00 C0 A8 08 2D ...)

Byte 1 matches neither AF_INET (0x02) nor AF_INET6 (0x1E), so
idevice_connect returns IDEVICE_E_UNKNOWN_ERROR and AltStore reports "There
was an error connecting to the device." Byte 0 is also read as a length, giving
malloc(2), a truncated address, and then a 14–26 byte read out of a 2-byte heap
buffer.

Three sites are affected, and the one on AltServer's actual path is
idevice_from_mux_device — reached via idevice_new_with_options — not the
device-list site. A fix covering only the obvious two leaves malloc(2) where it
matters.

Both layouts put the port at offset 2 and the address at offset 4, so only the
family test and the copy length need to change, and the two forms are
distinguishable without ambiguity. Implemented as a build-time rewriter
(makefiles/libimobiledevice-build/rewrite_idevice_source.py) rather than a
submodule edit, matching the existing AltSign/ldid convention.

Other fixes

CI Was failing at "Set up job" on every run; no artifacts since 2025-03
corecrypto (#111) Apple's 2024 distribution needs three fixes; buildenv builds from source again
iOS 26 launch crash (#131) ldid hash-agility truncation — AltStore installs and launches
Apple's 2026 GSA block com.apple.dt.Xcode in X-MMe-Client-Info now 503s; rewritten at the single point anisette data enters
GrandSlam 429 Connection reuse; each request gets its own connection. Proven with a zero-credential probe (docs/gsa-connection-probe.sh)
mDNS silent failure DNSServiceRegister returning 0 is not proof of publication; failures are now detected and reported
Credentials in ps -p is world-readable; ALTSERVER_* env vars added as the preferred path
USBMUXD_SOCKET_ADRESS (#49) The widely-copied one-D spelling is silently ignored — documented

Rewriter guards

rewrite_altserver_source.py had no raise, assert or sys.exit, so a
substitution whose pattern stopped matching after an upstream_repo bump would
produce a quietly wrong binary — it's the rewriter that strips the Win32 GUI and
splices in the console Authenticate / ShowAlert / Start. All four rewriters
now fail the build loudly. Output verified byte-identical across all 35 files,
with nine deliberate regressions each caught.

Also fixed: a concurrency bug where libimobiledevice.mak ran twice under -j
because Makefile:24 was a multi-target .PHONY rule, compiling every object
twice to the same path.

Please drop these if you merge

They're specific to running this as a deployment and aren't upstream's business:

  • deploy/ — compose stacks and an AppArmor profile
  • docs/REVIVAL.md — a working log, deliberately long
  • web/ — a setup UI (2FA entry in the browser, since the code is read from std::cin)
  • Anything naming ghcr.io/ben-diehlci — that's this fork's namespace

buildenv/build_docker.sh is worth taking though: it hardcoded
ghcr.io/nyamisty, so every docker push was denied in any fork. It now derives
the namespace from GITHUB_REPOSITORY_OWNER, which works unchanged for you.

Thanks

This only exists because the Linux port did. Fork:
https://github.com/Ben-Diehlci/altserver-linux

🤖 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 30 commits September 14, 2026 21:32
…e inputs

A submodule bump changes only its gitlink, so the push reports the bare path.
Verified against this repo's history -- `git show --name-only fa3abe2` prints
exactly `upstream_repo`, never `upstream_repo/<anything>`.

GitHub's `upstream_repo/**` requires that `upstream_repo/` prefix, so it would
not match, and the image would keep the old binary with CI green. The bare entry
currently in build_image.yml is correct, but check_workflow_paths.py could not
tell the difference: covered() stripped `/**` and compared base names, so
rewriting that entry as `upstream_repo/**` -- which reads as a tidy-up, since
every other entry is a glob -- still printed ok and exited 0.

The guard now reads gitlinks from `git ls-files -s` (mode 160000) and requires an
exact match when a compile input is itself a submodule. Globs still legitimately
cover submodules nested inside a covered directory: bumping
libraries/libimobiledevice reports that full path, which 'libraries/**' matches,
so the previous commit's entry is right as written.

Mutation-tested both directions: 'upstream_repo' -> 'upstream_repo/**' now fails
naming the submodule and the offending glob, and dropping 'libraries/**' still
fails as before. All four guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A refresh triggered from AltStore completed over Wi-Fi with no cable attached:
the 44900-byte InstallProvisioningProfilesRequest removed both old profiles,
installed two with fresh UUIDs, and logged "Finished handling request!" with no
"Failed to handle request:" anywhere in the run. Before the fix a request of
almost exactly that shape (44887 bytes) died at "There was an error connecting to
the device."

Those profile lines are conclusive rather than merely encouraging. They come from
DeviceManager.cpp:985/:1029, inside the function at :757 that first calls
idevice_new_with_options with IDEVICE_LOOKUP_NETWORK -- reaching
idevice_from_mux_device, patch site 2 -- then
lockdownd_client_new_with_handshake, which needs a real TCP connection through
idevice_connect, patch site 3, then misagent over that connection. None of it can
print unless the network device connection was established.

netmuxd meanwhile still reports the device with byte-identical connection data,
02 00 00 00 C0 A8 04 2D, the same blob that used to fail. It was not upgraded or
reconfigured, so the same input now parses purely because the parser was fixed --
the controlled comparison the diagnosis predicted.

The lockdown handshake succeeding over the network also rules out the pairing
record being unusable for a network device, which had been flagged as the most
likely next failure.

Not proven by this run, and recorded as such: unattended refresh, since this was
triggered by hand and iOS waking AltStore is a separate ceiling; and the
124932582-byte full-install path, which uses the same connect path but was not
re-exercised.

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

The status page read FAIL "No device on either transport" while wireless refresh
was demonstrably working. Not a timing artefact: status_checks.py probed the
netmuxd socket with `idevice_id -l`, and -l is not a verbosity switch, it selects
the transport. tools/idevice_id.c sets include_usb for -l, include_network for
-n, and both only when neither is given. netmuxd presents the phone solely as
ConnectionType: Network, so -l against its socket returns an empty list
unconditionally -- the check was structurally incapable of reporting OK for a
wireless-only deployment, which is the only deployment this project targets.

idevicepair validate on the success path had the same defect: idevicepair.c:372
picks IDEVICE_LOOKUP_USBMUX unless -n is passed, so it validated a USB pairing
that cannot exist on a cable-free server. Both had to be right for the check to
go green and neither was.

The transport flag is now a required argument of _devices_via rather than a
literal inside it, so the two probes cannot drift onto the same transport again.

This also explains an earlier loose end: `idevice_id -l` inside the altserver
container printing nothing, previously recorded as unexplained, was the same
USB-only flag against the same network-only mux. It was never evidence.

Worth noting the direction of the error. During the sockaddr bug this check said
FAIL and was read as confirmation; it was right by accident and went on saying
FAIL after the fault was fixed. A check that cannot pass is worse than no check,
because it is trusted.

Separately, the three pages now carry a shared tab bar (Status / Pairing /
Install AltStore) instead of scattered inline links. Built by one _nav() helper
and injected after all three page strings exist, because PAIRING_PAGE and
INSTALL_PAGE are derived from PAGE's <head> and would not inherit a nav placed in
PAGE's <body>; three hand-written copies would drift. The active tab is marked
with aria-current and the styling hangs off that attribute, so the stylesheet and
a screen reader cannot disagree. Verified rendered at desktop and 375px, all
three tabs on one row with the right one active and no horizontal scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cron watched the upstream_repo submodule, which is rileytestut/AltServer-Windows
-- not this repo's upstream, NyaMisty/AltServer-Linux. That submodule is pinned at
071b1dd from 2022-04-25 and has not moved since, so the job ran six times a day
for years and found nothing.

The noise was the lesser issue. On a hit the build job ran
`git submodule update --remote -- upstream_repo` and built all four architectures
from that newer source without committing it, so a published artifact could come
from code no commit in this repo describes. The build rewrites those sources
textually and rewrite_altserver_source.py has no match guards at all, so an
upstream move could change what the binary does with nothing failing. Unattended
is the worst place for that.

The check still runs on demand via the existing sync_upstream workflow_dispatch
input. The `github.event_name == 'schedule'` conditions in the check and
matrix_setup jobs are left in place, so restoring the cron line is the only edit
needed to get the old behaviour back.

This removes the unattended trigger, not the underlying hazard; guarding
rewrite_altserver_source.py stays on the TODO list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revive the fork: unattended wireless refresh on a Linux home server
…list

46 agents across four lenses -- stale files, fresh-clone usability, leaked
personal data, repo hygiene -- run after bd/revival merged into new. Every one of
the 17 findings recorded survived an adversarial attempt to refute it; roughly
twice as many were refuted and dropped.

Recorded as an unticked checklist rather than prose so the work cannot quietly
go missing, and so each item is ticked off in the same commit that fixes it --
the same discipline the rest of this log uses.

Also records what was verified GOOD, so it is not re-litigated later: the GHCR
image is anonymously pullable, all six submodule URLs resolve, netmuxd's release
assets and the digest-pinned anisette image both return 200, nothing secret or
generated is tracked, and every shim and both CI workflows are genuinely reached.

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

A11, plus a repo-wide sweep for the user's own data.

web/installer.py's docstring has always claimed "Every line is filtered before it
leaves this module." It was not true. The filter was a blocklist built around
Apple's token names -- GsIdmsToken, adsid, DsPrsId -- and missed the anisette
machine identifiers a successful sign-in prints one per line: MachineID,
One-Time Password, Local User ID and Device UDID. All four rendered in full into
a page served over plain HTTP on the LAN, which is exactly the screenshot risk
the redaction exists to prevent. Verified against output captured from a real
install, not from reading the regex.

The filter now masks the VALUE and keeps the LABEL, so the page still shows how
far an install got -- a filter that redacts everything is safe and useless, and
would push the operator back to `docker logs`, which is not filtered at all. A
catch-all also masks any unlabelled 40+ character base64/hex run, so an
identifier nobody enumerated fails closed.

tests/check_redaction.py enforces this in CI, in both directions: no secret value
may survive, and listed progress lines must come through byte-identical. Asserting
the filter "looks right" is what failed here, so it asserts on output instead.

Separately, scrubbed the repo of personal data. No credentials, keys, tokens,
emails, device serial or Apple team ID were present -- verified by scan. What was
present: a LAN IP pair, an SSH username and a hostname, across BOOTSTRAP.md,
REVIVAL.md, deploy/anisette-stack.yml and a test fixture. The addresses were
shifted rather than flattened so REVIVAL.md's same-/22 subnet discussion still
holds and the captured netmuxd bytes still decode to the address beside them;
check_conn_data_layout.py caught one place where that consistency slipped.

NOTE: this cleans the working tree only. The values remain in git history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
History was rewritten with git-filter-repo to remove a LAN IP pair, an SSH
username and a hostname from all 128 commits. Nothing else of the operator's was
ever committed: no Apple ID, password, token, session key, p12, private key,
certificate, team ID or device serial appears anywhere in history, verified by
pattern scan across every commit. The GsIdmsToken / adsid / DsPrsId /
X-Apple-I-MD-M hits are field NAMES in presence-checking scripts and in
installer.py's own redaction regex, never values.

Rewriting changed every commit SHA, and this log cites them heavily. 39
references across REVIVAL.md and BOOTSTRAP.md were remapped through
filter-repo's commit-map. Five were deliberately left alone because they are not
commits of this repo: c6f89dea and the 806ab8d37cf / a172604e5af fixes in
libimobiledevice, 071b1dd in AltServer-Windows, and 56854e66. Every remaining
cited SHA now resolves here.

The rewrite was a no-op on file contents: each replacement targeted a string the
working tree no longer contained, so the tip tree hash is unchanged at
7b6eaaf before and after. Only ancestry changed.

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

The repo sweep is done and history is rewritten. This records the other half: the
Apple ID password, signing p12, anisette machine identity and pairing record all
live on the deployment, and four of those locations were verified directly
against the source rather than assumed.

Distinguishes working state (deleting it breaks unattended refresh) from stray
copies and logs (safe to clear), because conflating the two is how someone
follows a security checklist and ends up re-pairing over USB for no reason.

Notes one real exposure: altserver-web binds 0.0.0.0 and the status page shows
the device UDID and anisette Device-Id to anyone on the LAN.

The sweep was cut short by a session limit, so items beyond the four marked
(verified) are recorded as found-but-unverified rather than presented as fact.

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

A git-credential-manager diagnose run dropped gcm-diagnose.log into the repo root
during this session. It was never committed, but it captured the whole process
environment including an unrelated session token, and nothing would have stopped
it being staged.

The additions are the classes that actually matter for this project rather than a
generic template: signing material (*.p12 holds the PRIVATE KEY for the Apple
developer certificate, and AltServer caches one named after the team id under
AltServerData/Certificates/), .env files carrying an Apple ID password, the
anisette and lockdown backup tarballs BOOTSTRAP describes taking, device pairing
records named after the phone's UDID, and *.log.

/build_*/ is written with the underscore deliberately: /build*/ would also match
the tracked buildenv/ directory and silently stop new files there from being
committed -- the same class of bug as the paths: filter omissions already in this
log. Verified both directions: no tracked file becomes ignored, and each new
pattern actually matches what it claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit items A1, A2, A3, A4, A6, A9 and A10.

README advertised BOOTSTRAP.md as "first-time setup, start to finish", but it was
an operator log for one machine. It opened with `git push origin bd/revival` --
a stranger has no push access, and that branch no longer exists -- then scp'd a
binary from a named account at a hardcoded LAN address, and skipped the host
prerequisites entirely with "Already done on this host", omitting the single
package README calls the most common way to end up invisible to your phone.

It also contradicted the rest of the repo on whether the project works. Its
confidence table still read "Low -- unproven. No one has reported a completed
AltServer-Linux sign-in in 2026"; it framed NyaMisty#131 as an open unknown; and it made
wireless refresh a speculative Phase 7. All three are confirmed working and
README says so.

Worst, its Phase 7 told the reader to stop usbmuxd and let netmuxd take
/var/run/usbmuxd -- the opposite of what this stack deliberately does. netmuxd
gets its own --socket-path so the host usbmuxd keeps the cable and nothing
contends. web/pairing.py repeated the same wrong advice inside the UI; corrected
there too.

Rewritten around what the repo actually ships: pair over USB, deploy the stack,
install through the web UI (which exists because the 2FA code is read from stdin
and a detached container has no terminal), rather than a one-shot binary with the
password on the command line. Every trap that is still true was kept -- the
anisette lib/ mount, usbmuxd not being enableable on Ubuntu, "Finished!" not
meaning success, the misleading "AltServer could not be found", -d making
diagnosis worse -- and two were added: the checks that cannot fail, and that
changing the Apple ID password permanently kills unattended refresh until someone
re-enters it on the phone.

README's "No host preparation is needed" was also wrong, and is now accurate: the
stack bind-mounts the host's avahi, D-Bus and usbmuxd sockets plus
/var/lib/lockdown, so Phase 1 is a real prerequisite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit A5, A7, A8, A12, A13, A14, A15, A16, A17 -- all 17 items now closed.

Real defects:

  A12 shims/old-linux-polyfill.c leaked a file descriptor on every getrandom()
      call: /dev/urandom was never closed on the success path, the short-read
      path, or the error path. AltServer is a long-running daemon whose signing
      work calls this repeatedly, so it accumulated for the life of the process
      and would surface far from here as an unrelated open() failing with EMFILE.
      It also returned short reads verbatim, so a caller assuming buflen bytes of
      entropy could use an uninitialised tail. Now loops, handles EINTR, opens
      O_CLOEXEC, and preserves errno across close().

  A16 ad-m/github-push-action@master was an unpinned floating branch from another
      account, running in a job holding contents: write with GITHUB_TOKEN.
      Replaced with plain `git push` -- actions/checkout already persists
      credentials, so the third party bought nothing. softprops/action-gh-release
      was also moved from the mutable v3 tag to its commit.

  A8  the commented-out build fallback in the stack used `context: .`, which
      resolves relative to the compose file and so pointed at deploy/, which has
      no Dockerfile. Now ../ and docker/Dockerfile.

  A5  libraries/ideviceinstaller was a submodule nothing compiled -- a standalone
      CLI with its own main(), unlinkable into AltServer even deliberately.
      Removed, along with the two places that asserted it was compiled in.

Stale claims corrected against the code: A14 web/server.py said it "deliberately
does NOT sign in or handle 2FA" while serving /install, which does exactly that;
A15 the Dockerfile said the web UI is not started automatically, but the stack
runs it as a service; A17 --help called ALTSERVER_NO_SUBSCRIBE "(*unused*)" while
rewrite_altserver_source.py splices in a getenv for it; A13 a closed TODO carried
guidance ("do not keep usbmuxd running") that this stack has since inverted; A7
the published image is linux/amd64 only and nothing said so, which a Raspberry Pi
user would discover at pull time.

Layout: BOOTSTRAP.md folded into README.md. The two documents overlapped in five
places -- prerequisites, quick start, the web UI, Wi-Fi refresh and running the
binary directly -- and that duplication is what produced the contradictions the
audit found, with each copy drifting separately. One document cannot disagree
with itself. REVIVAL.md moved to docs/, Dockerfile and docker-entrypoint.sh to
docker/, leaving the root to README.md, LICENSE, Makefile and dotfiles.

Verified rather than assumed: the image builds end to end from docker/Dockerfile
with COPY docker/docker-entrypoint.sh landing correctly, the static binary builds
clean and contains the new help text, all five guards pass, and no anchor or
cross-reference is left dangling.

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

The item asked to deploy and prove the sockaddr fix against a real phone. That
happened and is recorded in the Done section above -- profiles refreshed over
Wi-Fi with no cable and netmuxd's connection data was byte-identical before and
after -- but the TODO entry was never ticked, so the list still read as though
the central piece of work were outstanding.

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

TODO 0b. Every source in upstream_repo/AltServer is passed through this script on
the way to the compiler, and the substitutions are matched against upstream text.
If upstream renames a symbol, reflows a signature, or changes an include, a
pattern silently stops matching and the build still succeeds -- emitting a binary
quietly missing a transformation. This is the rewriter that strips the Win32 GUI
and splices in the console implementations of Authenticate, ShowAlert and Start,
so "quietly missing" here means the parts that talk to Apple. It had no raise,
assert or sys.exit anywhere.

Two kinds of check, because the two kinds of substitution fail differently.

The AltServerApp.cpp block runs for exactly one file and every substitution in it
is mandatory, so each asserts a match count. Those counts were MEASURED against
the current submodule rather than assumed -- strsafe.h is included twice, and an
assertion of ==1 would have broken the build on correct input.

The global substitutions run over all 35 files in that directory, including
binaries like MenuBarIcon.ico and Resource.aps, and legitimately match zero times
in most of them, so a per-file count would be meaningless. They are checked as
post-conditions on the output instead: no L"..." literal, no boost::filesystem,
no bare std::wstring may survive (std::wstring_convert is fine, and is what line
15 deliberately reconstructs). Post-conditions are stronger than counting here
because they also catch an occurrence arriving in a NEW form the pattern was
never written to handle -- and weaker in one way, so both are used: they only fire
on a file that actually contains the construct.

Behaviour is unchanged: output is byte-identical to the unguarded version across
all 35 files. Nine mutations were introduced one at a time and all nine failed the
rewrite -- five upstream moves (an include renamed, strsafe.h included once
instead of twice, a removePart anchor renamed, an insertion marker reflowed, the
Authenticate signature reflowed) and four broken substitutions caught by their
post-conditions. Full static binary still builds clean.

README's rewriter table said this one does not fail loudly. That is no longer
true, and the table now explains why this one needs both mechanisms.

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

TODO 0c, TODO 1, and the server-side half of the credential sweep.

0c. Makefile:24 was a multi-target rule naming both archives, which GNU make
expands into two independent targets each carrying the same recursive recipe.
Both are .PHONY so both always ran, and libplist.a is reachable through
lib_AltSign while libimobiledevice.a is reachable through lib_libimobiledevice --
so under -j, which every shipping build uses, the sub-make ran twice
concurrently. Every object compiled twice to the same path and `ar rcs` ran twice
on the same archive: wasted time, and two cc processes writing one .o is a
corruption risk that would surface as an unrelated link error. libplist.a now
depends on libimobiledevice.a and carries no recipe of its own, because the
sub-make's `all` target already builds both. Measured before and after:
invocations 2 -> 1, idevice.c.o compiled once, ar rcs libplist.a run once, both
archives and the binary still produced.

1. InvalidAnisetteData's recovery suggestion told the operator to install iTunes
and iCloud from Apple rather than the Microsoft Store. There is no iTunes to
install on Linux, and the real causes are an unreachable or unhealthy anisette
server and clock skew on the anisette host, whose timestamp is forwarded to Apple
verbatim. It had to be rewritten in the rewriter rather than supplied at the
throw site, because ServerError::localizedRecoverySuggestion() returns from that
case directly and never reaches the default branch a userInfo key would populate.
The substitution is guarded, so an upstream reword fails the build rather than
silently leaving the Windows text in place. Verified in the linked binary.

deploy/credential-hygiene.sh audits the places this deployment stores Apple
credentials and device identity, and can clear the safe ones. Report-only by
default. It counts credential markers in container logs rather than printing
them, and never emits a secret value.

Its most important property is what it refuses to do. Four things are working
state, not leaks -- the Apple ID password in the container environment, the
signing p12, the anisette machine identity, and the pairing record in
/var/lib/lockdown -- and no flag removes them, because a security checklist that
quietly forces a USB re-pair is worse than no checklist. It flags the 0.0.0.0
web UI binding, which publishes the device UDID to the LAN, and gives the exact
one-line change rather than making it silently.

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

Smoke-testing on a host with none of the containers present surfaced a real bug:
`grep -c` prints 0 AND exits 1 when nothing matches, so `|| echo 0` appended a
second zero and the arithmetic test failed with

    [: 0
    0: integer expression expected

for both the container-log scan and the shell-history scan. set -e is not on, so
the non-zero exit needed no handling in the first place.

The interrupted-install section also printed nothing at all when neither
container existed, which reads like a crash rather than a skip. It now says so.

Verified in both directions against a scratch HOME: report-only flags four items
and deletes nothing, --clean-strays removes the three stray copies, and
.bash_history is flagged but never auto-deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_docker.sh hardcoded ghcr.io/nyamisty as the push target, so in this fork
every docker push was denied -- a fork's GITHUB_TOKEN cannot write to another
account's packages. The job built four architectures under QEMU first, so it
spent several minutes doing that before failing at the first push, every time,
for anyone who ran it.

The namespace now comes from GITHUB_REPOSITORY_OWNER, which Actions supplies, so
it works in any fork without editing. GHCR_NAMESPACE overrides it, and the value
is lowercased because GHCR requires that (Ben-Diehlci -> ben-diehlci). It refuses
to run rather than guessing when neither is set.

Also added set -euo pipefail. Without it a failed build fell through to its push,
and a failed push fell through to the next architecture, so the job's exit status
reflected only the last command.

This changes where images are PUBLISHED and nothing else. All six consumers --
both workflows, docker/Dockerfile, the stack's commented build block and README --
still pull ghcr.io/nyamisty/altserver_builder_alpine_*, which are public, alive
and what every build currently uses. Repointing them before this script has
successfully published a replacement set would break the build outright, so that
stays a separate deliberate step; the script prints the list on success.

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

The script built armv7 first, and the set -e added in the previous commit means
the first failure stops the run. armv7, aarch64 and i386 all build under QEMU
emulation on a GitHub runner: slow, and by far the likeliest to fail. amd64 is
native, and is the only one anything consumes -- build_image.yml passes it as the
BUILDER arg.

So the previous commit made one case worse than the unguarded script it replaced:
there, a failed armv7 fell through and amd64 still got built and pushed. Now it
would abort before reaching it. Ordering native-first keeps the loud failure and
removes that regression: whatever breaks under emulation, the image the build
actually depends on is already published.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every build path pulled ghcr.io/nyamisty/altserver_builder_alpine_* -- the four
workflow matrix entries, build_image.yml's BUILDER arg, docker/Dockerfile's ARG
default, the stack's commented build block, and README's docker run example. For
a project whose premise is that upstream went quiet, depending on that account's
packages surviving was the wrong foundation: if they were deleted, every build
here would stop, and until today nobody could rebuild them (issue NyaMisty#111).

Both halves of that are now resolved. NyaMisty#111 is closed, and the buildenv workflow
published a full set under ghcr.io/ben-diehlci -- all four public and anonymously
pullable. They are built from THIS repo's buildenv/Dockerfile, so they carry the
corecrypto fixes NyaMisty's 2022-era images predate.

Order mattered and was deliberate: the images were published and verified BEFORE
anything was repointed. Repointing first would have left every consumer pulling
images that did not exist.

Verified after the switch: the static binary builds clean against the new aarch64
image and contains the sockaddr patch, and the full container image builds all 19
steps, ships a working AltServer --help, and carries python3, netmuxd and the
entrypoint. Binary is ~68KB smaller than the one NyaMisty's toolchain produces,
which is expected from a toolchain rebuilt with the corecrypto fixes.

REVIVAL.md keeps its historical mentions of the old images but two claims there
were corrected, having just been disproved: that nobody could rebuild them, and
this morning's note that every consumer still pointed at them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked whether the hygiene script should run on a timer. It should not: most of
what it checks is static, so a daily run would re-report the same thing until
nobody reads it, and --clean-strays does rm -rf inside the containers, which on a
schedule could delete an in-progress install's working directory. It is an audit
tool. The thing that actually needed automating was the log, and the right fix is
to stop the credentials arriving rather than to sweep them up afterwards.

docker/redact-log.py filters AltServer's stdout and stderr. It imports _redact
from installer.py rather than reimplementing the rules: two copies of a security
filter drift, and the unwatched one rots. tests/check_redaction.py now exercises
the shipped script as a subprocess and fails if a secret survives it, if a
progress line is dropped, or if it stops importing from installer.py -- all three
mutation-tested.

It fails open at every level. Missing installer.py, a line that makes _redact
raise, or an unexpected error at the top level each degrade to passing output
through unchanged, because losing the logs would cost more than the credential
already sitting in them.

The entrypoint uses process substitution rather than a pipeline. `AltServer |
filter` would make AltServer a child of a subshell, so it would no longer be
tini's direct child, would not receive SIGTERM on docker stop, and its exit
status would not be the container's. Redirecting first and then exec'ing keeps
all three. Verified with the same construction: exit status 42 propagates intact,
the exec'd process keeps its own pid, stderr is merged and filtered.

Log rotation added to all four services as a floor -- Docker's json-file default
is unbounded, and these grow with every refresh. 10MB x 3 caps it without a cron
job or anything racing a running install. Rotation alone would still leave 30MB
of credentials on disk, which is why it is the floor and not the fix.

Also fixed a real false negative in credential-hygiene.sh, found by running it:
/var/lib/docker/containers is mode 0710 root:root, so the unprivileged `[ -e ]`
returned false and it reported "no json-file log on disk" for every container on
a host whose logs were full of credentials. The check now runs under sudo.

Verified in the built image: the installed filter masks MachineID and the device
UDID while leaving progress lines byte-identical, and the container starts and
logs normally through the new entrypoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docker/redact-log.py masks the VALUE and keeps the LABEL, so a successfully
filtered line still reads 'MachineID : [withheld]'. The marker grep matched the
word MachineID and counted it, reporting '1 line carrying credentials' for a log
that was doing exactly what it should.

Hits now exclude lines containing [withheld], and a log with redacted lines and
no unredacted markers is reported as clean, naming how many the filter caught --
which is the more useful signal anyway: it confirms the filter is running, rather
than leaving 'no credential markers found' ambiguous between working and idle.

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

Switching to the narrower altserver-mdns profile meant hand-editing two
security_opt lines, which a git-backed Portainer stack overwrites on the next
pull. It is now ${ALTSERVER_APPARMOR:-unconfined}, set once in Portainer's stack
environment and preserved across redeploys.

The default stays unconfined deliberately. A container that asks for a profile
the host kernel has not loaded FAILS TO START, so hardcoding altserver-mdns would
break deploying this repo unchanged on any host that has not run
deploy/apparmor/install.sh first -- a hostile default for a project whose quick
start promises no host preparation.

Verified both branches resolve as intended and the file still parses with the
placeholder in place.

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

install.sh checked its own work with `aa-status --profiled | grep -qx altserver-mdns`.
That flag prints the NUMBER of loaded profiles, not their names, so the grep was
matching a profile name against an integer and could never succeed. It reported

    WARNING: altserver-mdns did not appear in aa-status

for a profile that had loaded correctly -- and because the script exits non-zero
there, it told the operator to stop at exactly the point where continuing was
safe. A verification step that cannot pass is the same class of bug as the
reachability check that used a USB-only flag.

It now reads /sys/kernel/security/apparmor/profiles, which is the kernel's own
list, and falls back to parsing plain aa-status output. The failure message also
now says what not to do next: setting ALTSERVER_APPARMOR before the profile is
loaded gives a container that will not start.

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

check_redaction.py's constants were copied verbatim from a real install log: the
live X-Apple-I-MD-M machine identity, X-Apple-I-MD-LU local user id, and
X-Mme-Device-Id. The file's own docstring said 'Shapes are real; digits are
scrambled'. They were never scrambled. A test written to prove credentials do not
leak was itself publishing them.

Every fixture now contains the literal string EXAMPLE, so no future reader can
mistake one for a captured value, and the comment says plainly not to paste a real
value here again -- not even a partial one, since the docstring also quoted a
truncated MachineID.

The one-time password rotates per request and the committed one is long dead. The
machine identity and local user id do not rotate: they are the stable identity
this server presents to Apple, and REVIVAL.md records shared anisette identity as
the mechanism behind the issue NyaMisty#88 Apple ID lockouts. Removing them here does not
remove them from history -- 17 commits still carry them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The install page reported 'no UDID found' for a phone that was paired, online and
refreshing over Wi-Fi. /install prefills the UDID from /api/pairing, and
pairing.py looked the device up with `idevice_id -l` -- USB only, since
tools/idevice_id.c's case 'l' sets include_usb and nothing else. On a cable-free
server that returns an empty list, so the page demanded a cable that is not
needed and cannot help.

This is the third instance of the same flag trap, after the reachability check
and idevicepair validate. The pattern: -l and -n are not verbosity switches, they
select which transports are enumerated, and a wireless-only deployment is
invisible to -l by construction.

The lookup now queries USB and network separately and merges them. A device found
only over the network is reported as 'iPhone detected over Wi-Fi' with a note that
no cable is needed, rather than falling through to the plug-it-in step -- the
pairing wizard should not demand a cable from someone whose phone is already
paired. Validation follows the same transport, since idevicepair.c:372 selects
IDEVICE_LOOKUP_USBMUX unless -n is passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirming a refresh worked has meant SSHing in and running `docker logs -f
altserver`, because that is the only thing that can actually tell you: both
`idevice_id` and the wireless status row use Debian's libimobiledevice rather
than the vendored copy AltServer links, and were green throughout a bug that
broke every wireless refresh.

The panel reads a file on the altserver-data volume, which both containers
already mount at /data. docker/redact-log.py gained --tee and writes there as
well as to stdout, so the UI needs neither the Docker socket -- root-on-host, for
a service that takes an Apple ID password over plain HTTP -- nor a bind mount of
/var/lib/docker. Everything in that file has been through _redact, so the view is
safe by construction rather than by a second filter that could drift.

The tee is capped at 1MB, keeping the most recent 500KB. Nothing rotates a file
written for the life of a deployment, and unbounded growth is what the docker
logs were doing before.

Polling runs only while watching is on, and stops itself after five minutes: a
refresh takes seconds, so a longer session means the tab was left open, and an
abandoned tab polling every two seconds is a self-inflicted load on a box whose
job is to sit quietly.

check_page_js caught a real bug while writing this. PAGE is a Python string, so a
'\n' inside the embedded JS is interpreted by Python into a REAL newline, which
ends the string literal and kills the entire script block -- the same failure that
guard was written for. Fixed the way the rest of this file already does it, with
String.fromCharCode(10).

Verified in a browser: the toggle starts and stops, the log renders, the state
line reports the line count, and a redacted MachineID shows as [withheld].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Switching to the narrow profile made both mDNS status rows report 'avahi-browse
timed out after 15s' while refresh itself kept working and the server stayed
discoverable from another machine.

dmesg named it exactly:

  apparmor="DENIED" operation="dbus_signal" interface="org.freedesktop.Avahi.ServiceBrowser"
  member="ItemNew" name=":1.3" mask="receive" label="altserver-mdns"

A D-Bus signal carries the sender's UNIQUE connection name, not its well-known
one. peer=(name=org.freedesktop.Avahi) matches method calls and their replies --
which is why publishing was unaffected -- but never a signal from :1.3. So
avahi-browse subscribed, got neither ItemNew nor CacheExhausted, and waited for
its timeout. Matched on the interface instead, which does not change with
avahi's unique name.

The ptrace denials in the same output are left alone on purpose, and the profile
now says so. altserver-web runs with pid: host, so pgrep -af AltServer walks every
process on the box; AltServer is under this profile and readable, and the denials
are for unrelated host and docker-default processes. Widening to peer=unconfined
would let this container read other services' /proc/PID/environ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard was as slow as the SUM of its checks. Two of them shell out to
avahi-browse with a 15s timeout, so once the AppArmor profile started denying
avahi's D-Bus signals the page took over half a minute to show anything: the
checks were correctly reporting a problem, and the page was unusable while they
did it. A status page that becomes unreadable exactly when something is wrong is
the wrong shape for the one tool you reach for when something is wrong.

They are subprocess and HTTP calls, so a thread pool is the right tool. The page
is now as slow as its SLOWEST check. check_clock is the only genuine dependency,
needing the timestamp check_anisette collects, so anisette runs first and the
remaining five run together. Output order is preserved by index rather than by
completion, so the page does not reshuffle between polls.

Each result is also collected defensively: a check that raises now becomes an
UNKNOWN row naming the exception, rather than blanking the dashboard.

Measured with two slow checks stubbed in: 3.0s against the 6.0s the serial
version would have taken, six checks returned, order unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by scanning for SHAPES rather than for values already known to be a
problem. Every previous sweep grepped for specific strings -- the IPs, the
username, the anisette identity -- so it could only find what had already been
identified. That is why the test fixtures survived several 'clean' reports: a
scan keyed to known values cannot find an unknown one.

Scanning for classes instead (UUIDs, long hex and base64 runs, emails, private
IPs, home paths, Apple serial and team-id shapes) surfaced two more:

  tests/check_redaction.py had a real provisioning profile UUID, lifted from the
  same log paste as the anisette fixtures. Stale -- profiles are replaced on every
  refresh, and that one was superseded the same day -- but real. Now synthetic,
  and deliberately distinct from DEVICE_ID, which is in SECRETS: the first
  replacement collided with it and made a must-be-unchanged line contain a
  must-never-appear value, which the guard caught.

  docs/REVIVAL.md recorded the phone's mDNS name. Generic for a device named
  'iPhone', but genericised anyway.

Everything else the scan surfaced is upstream: a GUID constant in
AltServerApp.cpp, an example IP in PhoneHelper.c, two Arch PKGBUILD maintainer
addresses, four PKGBUILD source checksums, and the digest-pinned anisette image.

The scan method itself needed fixing first. git grep's ERE handles \b
inconsistently, so the first pass reported 'clean' for UUIDs while git grep -lF
found the same value in 22 commits. A validation step against a string known to
be present is what caught it -- the same lesson as every other check in this log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewed for two audiences. The technical half was fine -- build, rewriters, env
vars and reference are all there. The other half was not.

A newcomer's first two screens were 'AltServer for AltStore, but on-device'
followed immediately by a table whose second row is 'Apple's 2026 GSA client-info
block'. That is maintainer-facing: it says what this fork FIXED, which matters if
you arrived from an issue thread and is noise if you just want it running. It now
sits behind a 'Fork status' heading that says so, and says it is safe to skip.

In its place: what the thing actually does, for someone who does not already know
what AltStore or a 7-day certificate is; a What you need table (Linux host with
Docker, an iPhone, a cable once, an Apple ID, ~30 minutes); an explicit What it
will not do; and a jump table so each audience can leave the intro early.

Also restored the prerequisites summary lost when BOOTSTRAP.md was folded in --
there was nothing telling a reader what hardware or accounts to have ready --
fixed four 'Phase N' references left from BOOTSTRAP's numbering, and promoted The
web UI out of the numbered steps, where as an h3 after step 7 it read as step 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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