From df570bd6a21c70d634ce738a6964c062c048c931 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:07:09 -0400 Subject: [PATCH 01/95] ci: unbreak GitHub Actions build 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 #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 #126, which needs a packaging or documentation fix rather than a CI one. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 38 +++++++++++++++++------------- .github/workflows/build_docker.yml | 13 +++++----- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58ec01d..6abf0f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,9 @@ on: workflow_dispatch: inputs: sync_upstream: + description: 'Also check upstream_repo for new commits and sync if it changed' required: false + type: boolean default: false debug_enabled: description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' @@ -24,16 +26,18 @@ jobs: check: runs-on: ubuntu-latest name: "Check Upstream Updates" + permissions: + contents: read steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive - name: Check If Need Checking id: needCheck - if: ${{ github.event_name == 'schedule' || ( github.event_name == 'workflow_dispatch' && github.event.inputs.sync_upstream ) }} + if: ${{ github.event_name == 'schedule' || ( github.event_name == 'workflow_dispatch' && inputs.sync_upstream ) }} run: | echo "needCheck=1" >> $GITHUB_ENV @@ -54,19 +58,15 @@ jobs: git submodule update --init if [[ "$current_upstream" != "$new_upstream" ]]; then echo "Upstream got new commits, go updating!" - echo "::set-output name=updated::1" + echo "updated=1" >> $GITHUB_OUTPUT else echo "Upstream no change~" - echo "::set-output name=updated::0" + echo "updated=0" >> $GITHUB_OUTPUT fi else echo "Needn't to check changes, directly return~" - echo "::set-output name=updated::0" + echo "updated=0" >> $GITHUB_OUTPUT fi - - - uses: gautamkrishnar/keepalive-workflow@master - with: - commit_message: "[proj] keepalive-workflow auto commit" outputs: updated: ${{ steps.check.outputs.updated }} @@ -76,9 +76,12 @@ jobs: matrix: builder: [ghcr.io/nyamisty/altserver_builder_alpine_armv7, ghcr.io/nyamisty/altserver_builder_alpine_aarch64, ghcr.io/nyamisty/altserver_builder_alpine_amd64, ghcr.io/nyamisty/altserver_builder_alpine_i386] runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive @@ -88,9 +91,9 @@ jobs: git submodule update --remote -- upstream_repo - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -115,12 +118,14 @@ jobs: if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: [build] name: "release" + permissions: + contents: write steps: - name: "Create artifact directory" run: | mkdir -p build_output - name: "Download all artifacts" - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v4 with: path: build_output - name: "Rearrange artifacts" @@ -131,7 +136,7 @@ jobs: ls build_release if [ "$(ls -A build_release)" ]; then exit 0; else exit 1; fi - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: build_release/* env: @@ -141,9 +146,11 @@ jobs: runs-on: ubuntu-latest needs: [check, build] if: ${{ needs.check.outputs.updated == '1' }} + permissions: + contents: write steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 submodules: recursive @@ -158,4 +165,3 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: ${{ github.ref }} - \ No newline at end of file diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index befdc6a..eaee20e 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -9,17 +9,19 @@ env: jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: - ref: master # set the branch to merge to fetch-depth: 0 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 #- - # uses: docker/setup-buildx-action@v1 + # uses: docker/setup-buildx-action@v3 # id: buildx # with: # install: true @@ -27,11 +29,10 @@ jobs: name: Available platforms run: echo ${{ steps.buildx.outputs.platforms }} - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build & Push Dockers run: cd ./buildenv && bash ./build_docker.sh - From 6cd382ab26a04254c800c8249cfd52dcb2c47b28 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:38:11 -0400 Subject: [PATCH 02/95] ci: move first-party and docker actions to node24 majors 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 --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/build_docker.yml | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6abf0f5..8edfb48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: recursive @@ -81,7 +81,7 @@ jobs: packages: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: recursive @@ -91,9 +91,9 @@ jobs: git submodule update --remote -- upstream_repo - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -136,7 +136,7 @@ jobs: ls build_release if [ "$(ls -A build_release)" ]; then exit 0; else exit 1; fi - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: build_release/* env: @@ -150,7 +150,7 @@ jobs: contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml index eaee20e..3504c6c 100644 --- a/.github/workflows/build_docker.yml +++ b/.github/workflows/build_docker.yml @@ -14,14 +14,14 @@ jobs: packages: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 #- - # uses: docker/setup-buildx-action@v3 + # uses: docker/setup-buildx-action@v4 # id: buildx # with: # install: true @@ -29,7 +29,7 @@ jobs: name: Available platforms run: echo ${{ steps.buildx.outputs.platforms }} - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 8494e36584deabccf0d60dfb42caccedf8226f95 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:38:38 -0400 Subject: [PATCH 03/95] ci: replace third-party uploader with actions/upload-artifact@v7 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 --- .github/workflows/build.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8edfb48..94ea290 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,18 @@ jobs: needs: [check] strategy: matrix: - builder: [ghcr.io/nyamisty/altserver_builder_alpine_armv7, ghcr.io/nyamisty/altserver_builder_alpine_aarch64, ghcr.io/nyamisty/altserver_builder_alpine_amd64, ghcr.io/nyamisty/altserver_builder_alpine_i386] + # `include:`-only matrix: still exactly 4 legs, but each now carries a short `arch` + # label. actions/upload-artifact rejects '/' and ':' in artifact names, so the image + # reference cannot be used as the name -- hence this label. + include: + - arch: armv7 + builder: ghcr.io/nyamisty/altserver_builder_alpine_armv7 + - arch: aarch64 + builder: ghcr.io/nyamisty/altserver_builder_alpine_aarch64 + - arch: amd64 + builder: ghcr.io/nyamisty/altserver_builder_alpine_amd64 + - arch: i386 + builder: ghcr.io/nyamisty/altserver_builder_alpine_i386 runs-on: ubuntu-latest permissions: contents: read @@ -109,8 +120,9 @@ jobs: sudo rm -rf build git clean -fdX - name: Upload to github artifact - uses: NyaMisty/upload-artifact-as-is@master + uses: actions/upload-artifact@v7 with: + name: AltServer-${{ matrix.arch }} path: /tmp/build_output release: @@ -125,7 +137,7 @@ jobs: run: | mkdir -p build_output - name: "Download all artifacts" - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: build_output - name: "Rearrange artifacts" From a26686177cc1594a048ef06e431718f3a4a625ef Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:38:57 -0400 Subject: [PATCH 04/95] buildenv: fix two of three corecrypto build failures (does NOT close #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 #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 #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 --- buildenv/Dockerfile | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/buildenv/Dockerfile b/buildenv/Dockerfile index 20c0458..7c41e21 100644 --- a/buildenv/Dockerfile +++ b/buildenv/Dockerfile @@ -8,8 +8,18 @@ RUN mkdir /buildenv WORKDIR /buildenv -RUN curl -JO 'https://developer.apple.com/file/?file=security&agree=Yes' -H 'Referer: https://developer.apple.com/security/' && unzip corecrypto.zip +RUN curl -JO 'https://developer.apple.com/file/?file=security&agree=Yes' -H 'Referer: https://developer.apple.com/security/' \ + && unzip -q corecrypto.zip \ + && rm -rf __MACOSX \ + && if [ ! -d corecrypto ]; then mv corecrypto-* corecrypto; fi \ + && test -f corecrypto/CMakeLists.txt WORKDIR /buildenv/corecrypto + +# Apple's corecrypto distribution include()s a code-coverage helper it does not ship: +# scripts/ contains only the testvector converters. CODE_COVERAGE is off by default, so +# this include is the only reference that actually breaks configure. Cf. upstream PR #85. +RUN sed -i -E 's|^include\(scripts/code-coverage\.cmake\)|#&|' CMakeLists.txt + RUN mkdir build; cd build; CC=clang CXX=clang++ cmake ..; WORKDIR /buildenv/corecrypto/build RUN sed -i -E 's|^(all: CMakeFiles\/corecrypto_perf)|#\1|' CMakeFiles/Makefile2; sed -i -E 's|^(all: CMakeFiles\/corecrypto_test)|#\1|' CMakeFiles/Makefile2 From b8855019bb12e42d2234d323194b34656cf481f2 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:21:40 -0400 Subject: [PATCH 05/95] anisette: make Linux anisette failures legible, and fix two real bugs 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 #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: #104 CLOSED. Symptom and trigger both removed. #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. #99, #100, #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. #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 --- README.md | 10 +- src/AltServerMain.cpp | 7 +- src/AnisetteDataManager.cpp | 300 ++++++++++++++++++++++++++---------- 3 files changed, 232 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 345d77c..a3f494d 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ Usage: AltServer-Linux options [ ipa-file ] -d --debug Print debug output, can be used several times to increase debug level. The following environment var can be set for some special situation: - - ALTSERVER_ANISETTE_SERVER: Set to custom anisette server URL - if not set, the default one: https://armconverter.com/anisette/irGb3Quww8zrhgqnzmrx, is used + - ALTSERVER_ANISETTE_SERVER: (REQUIRED) URL of an anisette server, including + the scheme, e.g. http://127.0.0.1:6969 + There is no default. The server that used to be hardcoded here has been + returning HTTP 502 since 2026-09, and pointing every user at one shared + anisette identity can get Apple IDs locked. - ALTSERVER_NO_SUBSCRIBE: (*unused*) Please enable this for usbmuxd server that do not correctly usbmuxd_listen interfaces ``` @@ -28,7 +31,8 @@ The following environment var can be set for some special situation: ## TODO / Special Features - [x] Track upstream (AltServer-Windows) develop branch (i.e. Beta version) - [x] Support Offline Anisette Data Generation (i.e. without Sideloadly) - - Finsihed, please run [alt_anisette_server](https://hub.docker.com/r/nyamisty/alt_anisette_server) & use `ALTSERVER_ANISETTE_SERVER` to specify custom server URL + - You must supply your own anisette server and point `ALTSERVER_ANISETTE_SERVER` at it. There is no default. + - This project historically suggested [alt_anisette_server](https://hub.docker.com/r/nyamisty/alt_anisette_server), but that image was last published in **April 2022** and has not been verified against Apple's current authentication flow. Treat it as a starting point, not a recommendation. - [x] Support Wi-Fi Refresh - [netmuxd](https://github.com/jkcoxson/netmuxd) now supports network devices (needs version > v0.1.1, be sure to check pre-release) - Download `netmuxd`, stop the original `usbmuxd`, and run `netmuxd` before running `AltServer-Linux` diff --git a/src/AltServerMain.cpp b/src/AltServerMain.cpp index e169582..a5b9d13 100644 --- a/src/AltServerMain.cpp +++ b/src/AltServerMain.cpp @@ -83,8 +83,11 @@ void print_help() { " -d --debug Print debug output, can be used several times to increase debug level.\n" "\n" "The following environment var can be set for some special situation:\n" - " - ALTSERVER_ANISETTE_SERVER: Set to custom anisette server URL\n" - " if not set, the default one: https://armconverter.com/anisette/irGb3Quww8zrhgqnzmrx, is used\n" + " - ALTSERVER_ANISETTE_SERVER: (REQUIRED) URL of an anisette server, including\n" + " the scheme, e.g. http://127.0.0.1:6969\n" + " There is no default. The server that used to be hardcoded here has been\n" + " returning HTTP 502 since 2026-09, and pointing every user at one shared\n" + " anisette identity can get Apple IDs locked. See the README.\n" " - ALTSERVER_NO_SUBSCRIBE: (*unused*) Please enable this for usbmuxd server that do not correctly usbmuxd_listen interfaces\n" ); } diff --git a/src/AnisetteDataManager.cpp b/src/AnisetteDataManager.cpp index 84ba608..1e663a9 100644 --- a/src/AnisetteDataManager.cpp +++ b/src/AnisetteDataManager.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "AnisetteData.h" #include "AltServerApp.h" @@ -49,28 +51,53 @@ using namespace web; // Common features like URIs. using namespace web::http; // Common HTTP functionality using namespace web::http::client; // HTTP client features +// The anisette server that used to be hardcoded here (armconverter.com) has been returning +// HTTP 502 with a text/plain body since at least 2026-09, and response.extract_json() on that +// reply throws a bare "Incorrect Content-Type: must be textual to extract_string, JSON to +// extract_json." naming neither the server nor the status code. That single unexplained line +// is the most-reported failure in this project (issues #99, #100, #128, #130). +// +// There is deliberately no default any more: pointing every user at one shared anisette +// identity also gets Apple IDs locked (issue #88). The server must be chosen explicitly. std::string GetAnisetteURL() { const char *server = getenv("ALTSERVER_ANISETTE_SERVER"); - if (server) { - return server; + if (server == NULL || *server == '\0') { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "No anisette server is configured. Set the ALTSERVER_ANISETTE_SERVER environment " + "variable to the URL of an anisette server before running AltServer." } + }); } - return U("https://armconverter.com/anisette/irGb3Quww8zrhgqnzmrx"); + return server; } std::shared_ptr AnisetteDataManager::FetchAnisetteData() { - // auto client = web::http::client::http_client(U("https://armconverter.com")); - // std::string wideURI = ("/anisette/irGb3Quww8zrhgqnzmrx"); - - // auto encodedURI = web::uri::encode_uri(wideURI); - // uri_builder builder(encodedURI); - - // http_request request(methods::GET); - // request.set_request_uri(builder.to_string()); + std::string anisetteURL = GetAnisetteURL(); + odslog("Fetching anisette data from: " << anisetteURL); + + // http_client's constructor validates the URI and throws before any request is made: a + // missing scheme or hostname ("localhost:6969", which is exactly what someone running a + // containerised anisette server is likely to type) raises std::invalid_argument, and a + // malformed URI raises uri_exception. Neither derives from Error, so uncaught they reach the + // device as errorCode 0 (Unknown) rather than InvalidAnisetteData, and the CLI prints raw + // cpprest text under a generic title -- the exact failure mode this function exists to end. + std::unique_ptr client; + try + { + client.reset(new web::http::client::http_client(anisetteURL)); + } + catch (const std::exception& exception) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "ALTSERVER_ANISETTE_SERVER is not a usable URL (\"" + anisetteURL + "\"): " + + exception.what() + ". It must include a scheme, for example http://127.0.0.1:6969." } + }); + } - auto client = web::http::client::http_client(GetAnisetteURL()); http_request request(methods::GET); - + std::map headers = { {"User-Agent", "Xcode"}, }; @@ -85,63 +112,172 @@ std::shared_ptr AnisetteDataManager::FetchAnisetteData() request.headers().add(pair.first, pair.second); } - std::shared_ptr anisetteData = NULL; + // This function was already synchronous -- the original chained pplx continuations and then + // immediately called task.wait(). Doing it in a straight line makes it possible to attach the + // URL and status code to every failure, which is the whole point of the exercise. + http_response response; + try + { + response = client->request(request).get(); + response.content_ready().wait(); + } + catch (const std::exception& exception) + { + // DNS failure, connection refused, TLS error, malformed URL, ... + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "Could not reach the anisette server at " + anisetteURL + ": " + exception.what() } + }); + } - auto task = client.request(request) - .then([=](http_response response) - { - return response.content_ready(); - }) - .then([=](http_response response) - { - odslog("Received response status code: " << response.status_code()); - return response.extract_json(); - }) - .then([&anisetteData](pplx::task previousTask) - { - odslog("parse anisette data ret"); - json::value jsonVal = previousTask.get(); - odslog("Got anisetteData json: " << jsonVal); - std::vector keys = { - "X-Apple-I-MD-M", - "X-Apple-I-MD", - "X-Apple-I-MD-LU", - "X-Apple-I-MD-RINFO", - "X-Mme-Device-Id", - "X-Apple-I-SRL-NO", - "X-MMe-Client-Info", - "X-Apple-I-Client-Time", - "X-Apple-Locale", - "X-Apple-I-TimeZone" - }; - for (auto &key : keys) { - odslog(key << ": " << jsonVal.at(key).as_string().c_str()); - } - - struct tm tm = { 0 }; - strptime(jsonVal.at("X-Apple-I-Client-Time").as_string().c_str(), "%Y-%m-%dT%H:%M:%SZ", &tm); - unsigned long ts = mktime(&tm); - struct timeval tv = { 0 }; - tv.tv_sec = ts; - tv.tv_usec = 0; - - odslog("Building anisetteData obj..."); - anisetteData = std::make_shared( - jsonVal.at("X-Apple-I-MD-M").as_string(), - jsonVal.at("X-Apple-I-MD").as_string(), - jsonVal.at("X-Apple-I-MD-LU").as_string(), - std::atoi(jsonVal.at("X-Apple-I-MD-RINFO").as_string().c_str()), - jsonVal.at("X-Mme-Device-Id").as_string(), - jsonVal.at("X-Apple-I-SRL-NO").as_string(), - jsonVal.at("X-MMe-Client-Info").as_string(), - tv, - jsonVal.at("X-Apple-Locale").as_string(), - jsonVal.at("X-Apple-I-TimeZone").as_string()); - - //IterateJSONValue(); + auto statusCode = response.status_code(); + odslog("Received response status code: " << statusCode); + + std::string body; + try + { + body = response.extract_utf8string(true).get(); + } + catch (const std::exception&) + { + // A body we cannot even read as text is reported below via the status code alone. + body = ""; + } + + // Clamp the preview to printable ASCII on one line. Two reasons beyond readability: + // truncating at a byte boundary can split a multi-byte UTF-8 sequence, and this string is + // copied verbatim into the JSON ErrorResponse sent to the device -- invalid UTF-8 there makes + // the phone reject the whole response, losing the message this function worked to build. It + // also neutralises terminal escapes, since the CLI path prints this straight to stdout. + std::string bodyPreview = body.substr(0, 256); + for (auto& character : bodyPreview) + { + unsigned char byte = static_cast(character); + if (byte < 0x20 || byte > 0x7E) + { + character = (byte == '\r' || byte == '\n' || byte == '\t') ? ' ' : '.'; + } + } + + if (body.empty()) + { + bodyPreview = "(empty response body)"; + } + else if (body.size() > 256) + { + bodyPreview += "..."; + } + + if (statusCode != status_codes::OK) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " returned HTTP " + + std::to_string(statusCode) + ". Response body: " + bodyPreview } + }); + } + + std::error_code parseError; + json::value jsonVal = json::value::parse(body, parseError); + if (parseError || !jsonVal.is_object()) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " did not return a JSON object. " + "Response body: " + bodyPreview } + }); + } + + odslog("Got anisetteData json: " << jsonVal); + + auto requireString = [&](const std::string& key) -> std::string + { + if (!jsonVal.has_field(key)) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " returned a response with no \"" + + key + "\" field. Response body: " + bodyPreview } }); - - task.wait(); + } + + const json::value& field = jsonVal.at(key); + if (!field.is_string()) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " returned a non-string value for \"" + + key + "\": " + field.serialize() } + }); + } + + return field.as_string(); + }; + + std::string clientTime = requireString("X-Apple-I-Client-Time"); + + // The canonical form is YYYY-MM-DDTHH:MM:SSZ -- that is what upstream AltStore emits, via + // NSISO8601DateFormatter with default options (AltSign/Apple API/ALTAppleAPI.m). But the value + // parsed HERE comes from a third-party anisette server, not from AltStore, and those are + // independent implementations. Matching the trailing "Z" as a literal would reject tails like + // ".123456Z" or a bare "2026-09-14T12:34:56" that resolve to exactly the same instant, turning + // a working server into a hard failure on every refresh. So parse only through the seconds. + struct tm tm = { 0 }; + const char* tail = strptime(clientTime.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (tail == NULL) + { + // The original ignored strptime()'s return value entirely, so an unparseable timestamp + // silently became whatever the zero-initialised struct produced. + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " returned an X-Apple-I-Client-Time that " + "does not begin with YYYY-MM-DDTHH:MM:SS: " + clientTime } + }); + } + + // An explicit numeric UTC offset IS rejected, because timegm() below ignores the tail entirely + // and would otherwise silently produce an instant wrong by that offset. + if (strchr(tail, '+') != NULL || strchr(tail, '-') != NULL) + { + throw ServerError(ServerErrorCode::InvalidAnisetteData, { + { LocalizedFailureErrorKey, + "The anisette server at " + anisetteURL + " returned an X-Apple-I-Client-Time with a " + "non-UTC offset, which cannot be interpreted reliably: " + clientTime } + }); + } + + // The timestamp carries a trailing "Z", so it is UTC. mktime() interprets the fields as + // LOCAL time, which skewed the instant by the host's UTC offset on any machine not running + // in UTC. timegm() is the UTC counterpart. + struct timeval tv = { 0 }; + tv.tv_sec = timegm(&tm); + tv.tv_usec = 0; + + odslog("Building anisetteData obj..."); + // Read the fields into locals first. As arguments to make_shared these would be evaluated in + // an unspecified order, so which missing field got reported would depend on the compiler -- + // unhelpful in a function whose entire purpose is a deterministic diagnosis. + std::string machineID = requireString("X-Apple-I-MD-M"); + std::string oneTimePassword = requireString("X-Apple-I-MD"); + std::string localUserID = requireString("X-Apple-I-MD-LU"); + std::string routingInfo = requireString("X-Apple-I-MD-RINFO"); + std::string deviceUniqueIdentifier = requireString("X-Mme-Device-Id"); + std::string deviceSerialNumber = requireString("X-Apple-I-SRL-NO"); + std::string deviceDescription = requireString("X-MMe-Client-Info"); + std::string locale = requireString("X-Apple-Locale"); + std::string timeZone = requireString("X-Apple-I-TimeZone"); + + auto anisetteData = std::make_shared( + machineID, + oneTimePassword, + localUserID, + std::atoi(routingInfo.c_str()), + deviceUniqueIdentifier, + deviceSerialNumber, + deviceDescription, + tv, + locale, + timeZone); odslog(*anisetteData); @@ -259,16 +395,20 @@ bool AnisetteDataManager::ReprovisionDevice(std::function provisionC bool AnisetteDataManager::ResetProvisioning() { - std::string adiDirectoryPath = "C:\\ProgramData\\Apple Computer\\iTunes\\adi"; - - // Remove existing AltServer .pb files so we can create new ones next time we provision this device. - for (const auto& entry : fs::directory_iterator(adiDirectoryPath)) - { - if (entry.path().extension() == ".altserver") - { - fs::remove(entry.path()); - } - } - + // On Windows this clears AltServer's cached ADI provisioning files so the next attempt + // re-provisions the machine. There is no such directory on Linux -- anisette data comes + // from an external anisette server, and nothing here is cached locally, so there is + // nothing to reset. + // + // This used to iterate the literal Windows path below, which threw a + // std::filesystem::filesystem_error about "C:\\ProgramData\\..." on every Linux run: + // + // std::string adiDirectoryPath = "C:\\ProgramData\\Apple Computer\\iTunes\\adi"; + // + // Both callers (AltServerApp.cpp, in `catch (APIError&)` when Apple returns + // InvalidAnisetteData) invoke this while already handling an error, so that exception + // escaped the handler and replaced Apple's real, actionable error with a confusing + // Windows path -- and at the first call site it also aborted the 12-second retry that + // was about to run. See issue #104. return true; -} \ No newline at end of file +} From 301eb8bdad8a0ae526401571d938cd15d1d27a49 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:27:44 -0400 Subject: [PATCH 06/95] daemon: warn at startup when no anisette server is configured, and add 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 --- REVIVAL.md | 219 ++++++++++++++++++++++++++++++++++++++++++ src/AltServerMain.cpp | 35 +++++++ 2 files changed, 254 insertions(+) create mode 100644 REVIVAL.md diff --git a/REVIVAL.md b/REVIVAL.md new file mode 100644 index 0000000..aaff8b2 --- /dev/null +++ b/REVIVAL.md @@ -0,0 +1,219 @@ +# AltServer-Linux revival log + +Working log for the `bd/revival` branch of `Ben-Diehlci/altserver-linux`, a fork of +`NyaMisty/AltServer-Linux`. Upstream's last real code commit predates 2025 — everything since +is `[proj] keepalive-workflow auto commit` bot noise. + +**Keep this file current in the same commit as the change it describes.** The point is that +nothing here has to be re-derived later. + +--- + +## The goal + +**Run AltServer unattended on a Linux home server, so sideloaded apps keep refreshing without a +Mac or PC having to be powered on.** + +Everything below is ranked against that, and it implies requirements a desktop AltServer does +not have: + +- **Headless.** No GUI, no console operator. Failures must be visible in logs and on the phone, + because nobody is watching a screen. This is why the anisette work routes errors through + `ServerError` (they reach AltStore on the device) and why startup warns about missing config. +- **Unattended and long-lived.** Runs under systemd or Docker across reboots. Config comes from + the unit file or `-e` flags, so "it starts but silently cannot sign in" is the worst failure + mode — hence the startup check. +- **Wi-Fi, not USB.** The phone is not plugged into the server. Wireless refresh is *essential + here*, not optional, which promotes the netmuxd/usbmuxd discovery issues from "someone's + edge case" to a core requirement. +- **7-day refresh cycle.** Free Apple developer certificates expire weekly, so the whole point + is that the box signs in and refreshes on its own. Anything that breaks sign-in breaks the + entire premise. + +--- + +## Reference sources + +| Source | What it is | Why it matters | +|---|---|---| +| `~/Local Work/AltStore` | **`rileytestut/AltStore`, actively maintained.** Clone is at `56854e66`, v2.3.3, 2026-07-14. | The live upstream. Contains `AltSign`, the macOS `AltServer`, `AltJIT`, `AltDaemon`. **Check here first** for how something is *currently* done before inventing an answer. | +| `upstream_repo/` (submodule) | `rileytestut/AltServer-Windows`, pinned at `071b1dd`, **2022-04-25** | What this fork actually compiles. Four years stale — see TODO. | +| `libraries/*` (submodules) | libimobiledevice family | All pinned 2020–2022. | +| `NyaMisty/AltServer-Linux` issues | 53 open, most long stale | Triaged 2026-09-13; ~28 of 53 are Apple-side, obsolete, or another project's bug. | + +Cross-referencing AltStore has already paid off twice: it confirmed `NSISO8601DateFormatter` +with default options is the canonical `X-Apple-I-Client-Time` form +(`Dependencies/AltSign/AltSign/Apple API/ALTAppleAPI.m:47`), and that +`AltServer/Anisette Data/AnisetteDataManager.swift:165` still emits a client-info string +containing `com.apple.dt.Xcode/3594.4.19` as of v2.3.3 — consistent with the Apple GSA block +being a recent (~2026-09) change rather than a long-standing one. + +--- + +## How to build + +macOS cannot build this. A Colima VM is set up; the build tree lives **inside** the VM at +`~/altserver-linux` (ext4, case-sensitive) — *not* the macOS copy, which is case-insensitive +APFS and can mask CI-only include-casing errors. + +```bash +colima start --vm-type=vz --vz-rosetta --cpu 8 --memory 12 --disk 60 + +colima ssh -- bash -lc 'cd ~/altserver-linux && docker run --rm \ + -v "$HOME/altserver-linux":/workdir -w /workdir \ + ghcr.io/nyamisty/altserver_builder_alpine_aarch64 \ + bash -c "mkdir -p build; cd build; make -f ../Makefile -j8"' +``` + +`aarch64` is native on Apple Silicon. `amd64` and `386` work via Rosetta/QEMU. **`arm/v7` is +not registered by default** and needs an extra binfmt handler. Changes do not sync between the +macOS and VM copies automatically — copy deliberately in both directions. + +To exercise runtime behaviour without an iPhone, link a harness against the built objects, +excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, +`temporary_directory()` and `readFile()`. + +--- + +## Done + +| Commit | What | +|---|---| +| `df570bd` | **CI unbroken.** Dead `gautamkrishnar/keepalive-workflow@master` removed; five actions off retired node12/16; `::set-output` → `$GITHUB_OUTPUT`; per-job `permissions`; `sync_upstream` boolean bug. | +| `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | +| `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | +| `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | +| `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | + +### Verified facts worth not re-deriving + +- **The CI failure was an unresolvable action, not the node12 versions.** `uses:` resolution + happens in "Set up job", before any script runs. `gautamkrishnar/keepalive-workflow` has no + `action.yml` at any ref. Because `build`/`release`/`update_submodule` gate on `check` via + `needs:`, the whole workflow skipped — which is what #121 is really reporting. +- **`@actions/artifact` has exactly one backend boundary**: toolkit 2.0.0, the v3→v4 service + migration. The download path has no format-version gating. But the old uploader served blobs + with Content-Type `zip`, which `download-artifact@v8` does *not* recognise as a zip — so the + uploader and downloader had to move together or raw `.zip` files would land in releases. +- **`upload-artifact` rejects `/ \ : < > | * ? "` in artifact names**, which is why the matrix + grew an `arch` label instead of reusing `matrix.builder`. +- **Artifact upload does not preserve the executable bit** — so `chmod +x` in the build step is + a no-op. Likely the root of #126, unfixable in CI alone: raw GitHub Release assets never carry + the bit. Needs docs or tarball packaging. +- **The four `ghcr.io/nyamisty/altserver_builder_alpine_*` images are alive and public.** The + build depends entirely on them; nobody can currently rebuild them (see #111). +- **Apple versioned the corecrypto archive**: it now extracts to `corecrypto-2024/`. Docker's + `WORKDIR` silently *creates* a missing directory, which is why the error surfaced one line + later as a confusing "no CMakeLists.txt". +- **The build rewrites Windows source at compile time.** `makefiles/rewrite_altsign_source.py` + contains `content.replace(b'winsock2.h', b'WinSock2.h')` — that is how lowercase Windows + includes resolve against capitalised shim filenames on case-sensitive Linux. +- **`-mno-default` is already guarded to i386/i686.** The README's "remove it for ARM" note is + stale; ARM builds work unmodified. +- **`SPOOF_MAC` is never defined**, so the `#else` provisioning block in `AnisetteDataManager` + is dead code. +- **Only `AnisetteDataRequest` needs an anisette server.** The daemon's other five request types + — PrepareApp, Install/RemoveProvisioningProfiles, RemoveApp, EnableUnsignedCodeExecution + (AltJIT) — work without one. This is why the startup check warns instead of exiting. +- **Errors reach the phone, not just the log.** `ClientConnection::ErrorResponse` dynamic_casts + to `ServerError` and forwards `userInfo`, so a `ServerError` with `NSLocalizedFailure` set is + displayed in AltStore. + +--- + +## TODO + +### Blockers for the actual goal — a working headless refresh server + +These are what stand between us and "the Linux box refreshes apps by itself". Ranked by what +breaks the premise soonest, not by how interesting the code is. + +- **A. An anisette server that actually works in 2026.** Without one, sign-in fails and nothing + refreshes — the entire goal is dead. The fork no longer ships a default (it was dead anyway), + so one has to be chosen and run, most likely alongside AltServer on the same box. The old + `nyamisty/alt_anisette_server` image is from April 2022 and unverified against Apple's current + flow. **This is the number one practical blocker and is a deployment decision, not a code + change.** +- **B. PR #135 — the Apple GSA block.** Even with a healthy anisette server, Apple 503s any + request whose `X-MMe-Client-Info` contains `com.apple.dt.Xcode`, which anisette servers + commonly return. Trivial code fix, listed under "Next up" below. Blocks sign-in, so it blocks + the 7-day refresh. +- **C. Wi-Fi device discovery.** The phone will not be plugged into the server, so wireless + refresh is mandatory here. Needs `netmuxd` (> v0.1.1) in place of or alongside `usbmuxd`. + Covers issues #87, #81, #77, #76, #75, #122, #49, #13 — previously triaged as mostly + user-error, but for *this* deployment they describe the critical path. Needs a verified, + written-down working configuration. +- **D. iOS-version signing compatibility.** Only matters if the target device runs iOS 26.4+, + where apps install but crash at launch (#131). That needs the `upstream_repo` bump, item 4 + below. Check the device's iOS version before spending effort here. + +Items A and C are deployment/config work rather than patches, and both need a real device to +confirm. B is a small patch. None of them are blocked by anything already done. + +### Next up + +1. **`ServerError` recovery suggestion is Windows-only advice.** `ServerError.hpp:170` returns + "download the latest versions of iTunes and iCloud… not from the Microsoft Store" for + `InvalidAnisetteData`, appended to the CLI alert by `AltServerApp.cpp:1614`. Now newly + visible, since the anisette work routes failures through `ServerError`. Fix by adding a + substitution to `makefiles/rewrite_altserver_source.py` (it already rewrites this file). + Note: stuffing `NSLocalizedRecoverySuggestionErrorKey` into `userInfo` does **not** work — + `ServerError::localizedRecoverySuggestion()` returns from its `case` before reaching + `default`. +2. **PR #135 — sanitize `X-MMe-Client-Info`.** Rewrite `com.apple.dt.Xcode` → `com.apple.akd` at + `src/AnisetteDataManager.cpp`, the single point where anisette data enters. Apple's GSA edge + 503s any request carrying that substring as of ~2026-09. Trivial. Closes no open issue + (nobody has reported it — the anisette failure fired first) and needs a real Apple ID to + confirm 503→401. Only two of four call sites hit `gsa.apple.com`; the others hit + `developerservices2.apple.com` and were never in the author's A/B test. +3. **corecrypto layer 3.** `CORECRYPTO_SRCS` is populated at `CoreCryptoSources.cmake:189` and + Linux subtracts `CORECRYPTO_EXCLUDE_SRCS` at `CMakeLists.txt:262`, but the list ends up empty + at `add_library` (`:266`). Cheapest next probe: build the amd64 leg to see whether it is + arch-specific. Closes #111. + +### Bigger + +4. **Bump `upstream_repo` to 1.7.4.** The only fix for #131 (iOS 26.4 launch crash — `ldid.cpp` + truncates a hash to 20 bytes before it becomes the SHA-256 attribute; CoreTrust rejects it). + `.gitmodules` pins `branch = develop`, whose tip is from 2022, so `--remote` can never reach + it. Needs a hand-edit: the new `Signer.cpp:277` passes `app.path() + "\\"` and + `rewrite_altsign_source.py` does no backslash translation. Harden `removePart()` to assert + each regex matched before attempting this. **Cross-check against `~/Local Work/AltStore`, + which has the current AltSign.** +5. **README pass.** Merge PR #124 (`cd build`), apply the same fix to the cpprestsdk step, use + the exact seds from `buildenv/Dockerfile`, lead with the `docker run` command CI uses, + document `chmod +x` and the python3/`libdns_sd.so`/avahi requirements. Closes #124, #120, + partially #111. Do **not** rewrite the Wi-Fi section to "keep usbmuxd running" — netmuxd + binds the unix socket by default and the only success report in #77 says the opposite. +6. **`build_docker.yml` namespace.** Pushes to `ghcr.io/nyamisty/*`, which this fork's token + cannot write to, so it fails on the fork regardless. Only worth fixing if we decide to own + our own builder images. Separately, `build_docker.sh` passes no `--platform`, so all four + builds run as host-arch regardless of the arch-specific base image. +7. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to + `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at + `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` + are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted + command lines in the issue corpus, nobody wrote `-p` before `-a`. + +### Explicitly not doing + +- **PR #98 (CMake rewrite).** Author wrote "doesn't 100% work" in 2023 and never returned; keyed + to a 2023 upstream while we are pinned at 2022. Would remove the one build path known to work. +- **PR #57 (macOS).** Replaces the idempotent out-of-tree rewriters with in-place patching, + architecturally incompatible with submodule syncing. +- **AltJIT on iOS 17+ (#103).** Needs personalized DDI, TSS signing and a RemoteXPC tunnel, none + of which exist in our 2021 libimobiledevice pin. Document the limit; point at pymobiledevice3. +- **Opening PRs for the ~28 non-issues.** -36607 is Apple refusing an abused shared anisette + identity. #117 is netmuxd's log output. #125 is an AltStore error code this binary cannot emit + (`ServerErrorCode` tops out at 101). + +--- + +## Open questions + +- Is the `release` job correct? It is tag-gated, so `download-artifact@v8` and + `action-gh-release@v3` are still unexercised. Testing means 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". +- Which anisette server should we actually recommend? `nyamisty/alt_anisette_server` was last + published April 2022 and is not verified against Apple's current flow. diff --git a/src/AltServerMain.cpp b/src/AltServerMain.cpp index a5b9d13..8ff399c 100644 --- a/src/AltServerMain.cpp +++ b/src/AltServerMain.cpp @@ -181,6 +181,41 @@ int main(int argc, char *argv[]) { signal(SIGPIPE, SIG_IGN); + // ALTSERVER_ANISETTE_SERVER is read per-request, deep inside FetchAnisetteData(). Without a + // check here, a daemon started without it comes up cleanly, advertises itself over Bonjour and + // is discovered by the phone -- then fails only when someone first tries to refresh, which is + // a long way from the actual mistake (a systemd unit missing Environment=, or `sudo` without + // -E dropping it from the environment). + // + // This deliberately WARNS rather than exiting. 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. + { + const char *anisetteServer = getenv("ALTSERVER_ANISETTE_SERVER"); + + if (anisetteServer == NULL || *anisetteServer == '\0') + { + fprintf(stderr, + "WARNING: ALTSERVER_ANISETTE_SERVER is not set.\n" + " Signing in with an Apple ID will fail, so installing and refreshing apps\n" + " will not work. In server mode, AltJIT and provisioning profile requests\n" + " still work. Set it to the URL of an anisette server, including the scheme,\n" + " e.g. http://127.0.0.1:6969 -- see --help.\n"); + } + else if (strncmp(anisetteServer, "http://", 7) != 0 && strncmp(anisetteServer, "https://", 8) != 0) + { + fprintf(stderr, + "WARNING: ALTSERVER_ANISETTE_SERVER (\"%s\") has no http:// or https:// scheme.\n" + " It will be rejected when anisette data is first requested. Use a full URL,\n" + " e.g. http://127.0.0.1:6969\n", anisetteServer); + } + else + { + printf("Using anisette server: %s\n", anisetteServer); + } + } + if (installApp) { odslog("Installing app..."); std::shared_ptr _selectedDevice = std::make_shared("unknown", udid, Device::Type::All);; From 6be911bbc9d4d5bb1ff6239c8ad56bec21d91b89 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:35:18 -0400 Subject: [PATCH 07/95] REVIVAL.md: record the actual deployment target Two answers that change the plan, both from the operator rather than the code: - The target device runs iOS 26.x, so #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 --- REVIVAL.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index aaff8b2..a346498 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -143,12 +143,48 @@ breaks the premise soonest, not by how interesting the code is. Covers issues #87, #81, #77, #76, #75, #122, #49, #13 — previously triaged as mostly user-error, but for *this* deployment they describe the critical path. Needs a verified, written-down working configuration. -- **D. iOS-version signing compatibility.** Only matters if the target device runs iOS 26.4+, - where apps install but crash at launch (#131). That needs the `upstream_repo` bump, item 4 - below. Check the device's iOS version before spending effort here. +- **D. iOS-version signing compatibility — CONFIRMED IN SCOPE.** The target device runs + **iOS 26.x**, so #131 applies: apps install successfully and then crash at launch. This is not + hypothetical and cannot be worked around by configuration; it needs the `upstream_repo` bump + (item 4 below), which is the largest remaining code task. Note the failure mode is + *deceptive* — installation reports success, so everything looks fine until the app is opened. Items A and C are deployment/config work rather than patches, and both need a real device to -confirm. B is a small patch. None of them are blocked by anything already done. +confirm. B is a small patch. D is a substantial one. None are blocked by anything already done. + +### Confirmed deployment facts + +Target device runs **iOS 26.x** → #131 / the `upstream_repo` bump is required. +Anisette: **none yet**, needs standing up, most likely as another container on the same box. + +Host (from the operator's `HOMELAB_CONTEXT.md`): + +| | | +|---|---| +| Hardware | Dell OptiPlex 5060, Intel **x86_64** | +| Stack | Proxmox → Ubuntu VM → Docker, managed via Portainer | +| Binary | **`AltServer-x86_64`** — already produced by our CI, confirmed by artifact name | +| mDNS | `apt install libavahi-compat-libdnssd1` on the VM; `network_mode: host` if containerised | +| Registry | Already publishes to `ghcr.io/ben-diehlci/` | +| Network | Zero open router ports; Cloudflare Tunnels + NPM for external access | +| Philosophy | Prefer simplicity; avoid unjustified complexity | + +Consequences for this project: + +- The x86_64 leg is the one that matters. It builds under Rosetta locally and is already green + in CI. `aarch64` remains the fast local loop for compile-checking. +- Sideloading is **entirely LAN-local**. Cloudflare Tunnels, NPM and the zero-open-ports posture + are irrelevant to it — no reverse proxy should be put in front of AltServer, and nothing about + this needs to be externally reachable. +- Because they already own `ghcr.io/ben-diehlci/`, re-namespacing `build_docker.yml` (item 6) + stops being hypothetical, and a purpose-built AltServer container for the Portainer stack + becomes the natural deliverable — see item 8. + +**OPEN QUESTION, and it gates everything:** is the Ubuntu VM's NIC **bridged** onto the same L2 +segment as the phone's Wi-Fi, or NAT'd behind Proxmox? mDNS/Bonjour is link-local and does not +cross subnets or VLANs. If the VM is NAT'd, or the phone is on a guest/IoT VLAN, the device will +never discover `_altserver._tcp` no matter how correct everything else is. This is cheap to +check and would invalidate a lot of downstream work, so check it first. ### Next up @@ -189,7 +225,13 @@ confirm. B is a small patch. None of them are blocked by anything already done. cannot write to, so it fails on the fork regardless. Only worth fixing if we decide to own our own builder images. Separately, `build_docker.sh` passes no `--platform`, so all four builds run as host-arch regardless of the arch-specific base image. -7. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to +8. **Purpose-built container for the Portainer stack.** Given the host is Docker-on-Ubuntu + managed by Portainer, and `ghcr.io/ben-diehlci/` already exists, the natural end state is an + image containing `AltServer-x86_64`, `python3` and `libavahi-compat-libdnssd1`, deployed with + `network_mode: host` and an absolute bind mount for `AltServerData`. Pairs with item 6. + Remember `./AltServerData` is a **relative** path, so `WorkingDirectory` / the container + workdir matters. +9. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted From d674cd1e44396f36a8de0fccc432d1484b8eff8b Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:35:27 -0400 Subject: [PATCH 08/95] REVIVAL.md: fix TODO numbering Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index a346498..fc92034 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -225,13 +225,13 @@ check and would invalidate a lot of downstream work, so check it first. cannot write to, so it fails on the fork regardless. Only worth fixing if we decide to own our own builder images. Separately, `build_docker.sh` passes no `--platform`, so all four builds run as host-arch regardless of the arch-specific base image. -8. **Purpose-built container for the Portainer stack.** Given the host is Docker-on-Ubuntu +7. **Purpose-built container for the Portainer stack.** Given the host is Docker-on-Ubuntu managed by Portainer, and `ghcr.io/ben-diehlci/` already exists, the natural end state is an image containing `AltServer-x86_64`, `python3` and `libavahi-compat-libdnssd1`, deployed with `network_mode: host` and an absolute bind mount for `AltServerData`. Pairs with item 6. Remember `./AltServerData` is a **relative** path, so `WorkingDirectory` / the container workdir matters. -9. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to +8. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted From 8674780c7ea7803a6a430bde62fe2d05f3a40c76 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:39:21 -0400 Subject: [PATCH 09/95] REVIVAL.md: network topology resolved -- VM is bridged, same segment 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 --- REVIVAL.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index fc92034..4260ad6 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -180,11 +180,23 @@ Consequences for this project: stops being hypothetical, and a purpose-built AltServer container for the Portainer stack becomes the natural deliverable — see item 8. -**OPEN QUESTION, and it gates everything:** is the Ubuntu VM's NIC **bridged** onto the same L2 -segment as the phone's Wi-Fi, or NAT'd behind Proxmox? mDNS/Bonjour is link-local and does not -cross subnets or VLANs. If the VM is NAT'd, or the phone is on a guest/IoT VLAN, the device will -never discover `_altserver._tcp` no matter how correct everything else is. This is cheap to -check and would invalidate a lot of downstream work, so check it first. +**Network topology — RESOLVED.** The Ubuntu VM is **bridged onto the same network as the phone**; +the operator reaches server services by IP from the phone while at home. So there is no NAT or +VLAN boundary between them and the mDNS prerequisite is satisfied. + +Residual risk, small but worth one command to rule out: reaching a host by IP proves L3 +routability, whereas mDNS needs **multicast on the same broadcast domain**. A Wi-Fi AP with +client/AP isolation or aggressive IGMP snooping can pass ordinary TCP while dropping multicast +between wireless and wired hosts. Confirm the multicast path specifically by checking that the +server can see the phone's *own* Bonjour advertisements: + +```bash +sudo apt install -y avahi-utils +avahi-browse -art | grep -iE "iphone|ipad|_companion-link|_rdlink|_airplay|_raop" +``` + +If the phone appears there, `_altserver._tcp` will reach it too. If it does not, fix multicast +before touching anything else — nothing downstream can work without it. ### Next up From 60c2c56e1d9d6d361dc1ec47fed6231ff4ec77f9 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:44:06 -0400 Subject: [PATCH 10/95] REVIVAL.md: multicast confirmed, and mDNS advertisement fails silently 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 --- REVIVAL.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/REVIVAL.md b/REVIVAL.md index 4260ad6..02e966d 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -198,6 +198,40 @@ avahi-browse -art | grep -iE "iphone|ipad|_companion-link|_rdlink|_airplay|_raop If the phone appears there, `_altserver._tcp` will reach it too. If it does not, fix multicast before touching anything else — nothing downstream can work without it. +**CONFIRMED 2026-09-14.** `avahi-browse -art` on the VM (192.168.9.16, ens18, Ubuntu 24.04.4) +sees `_companion-link._tcp` from Apple devices over both IPv4 and IPv6. Multicast crosses from +Wi-Fi to the wired VM. Topology is settled. Installing `avahi-utils` also pulled in +`avahi-daemon`, which was NOT previously present and which the compat layer requires — so that +was a necessary prerequisite obtained by accident. + +### mDNS advertisement is a SILENT failure — the top risk for unattended operation + +`libraries/dnssd_loader/dnssd_loader.cpp` does not link Bonjour. `DNSServiceRegister` builds a +Python one-liner, forks, and `execlp`s `python3 -c "from ctypes import *; dll = CDLL('libdns_sd.so'); ..."` +(`:25`, `:66`). The parent branch of the fork is literally `else { ; }` — `status` is declared at +`:53` and never used, there is no `waitpid`, and the function `return 0`s unconditionally at +`:69`. **Advertisement failure is therefore indistinguishable from success inside AltServer.** + +Consequences, and they are exactly the wrong shape for a headless box: + +- If `python3` is missing, or `libdns_sd.so` cannot be dlopened, AltServer runs normally, reports + nothing wrong, and is permanently undiscoverable by the phone. The only evidence is the child's + Python traceback on stderr — unlabelled, and under systemd it lands in the journal interleaved + with the parent's output. Reproduced in the alpine build container. +- **`libavahi-compat-libdnssd1` alone is NOT sufficient**, despite being the usual advice: it + ships `libdns_sd.so.1`, while `CDLL('libdns_sd.so')` dlopens the *unversioned* soname, whose + symlink comes from **`libavahi-compat-libdnssd-dev`**. Verify with the same call the program + makes: + + ```bash + python3 -c "from ctypes import CDLL; CDLL('libdns_sd.so'); print('libdns_sd.so OK')" + ``` + +- `avahi-daemon` must be installed AND running for the compat layer to work. +- For the container plan (item 7): the image needs `python3` **and** the compat dev package. A + minimal image will silently fail to advertise. + + ### Next up 1. **`ServerError` recovery suggestion is Windows-only advice.** `ServerError.hpp:170` returns @@ -243,7 +277,12 @@ before touching anything else — nothing downstream can work without it. `network_mode: host` and an absolute bind mount for `AltServerData`. Pairs with item 6. Remember `./AltServerData` is a **relative** path, so `WorkingDirectory` / the container workdir matters. -8. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to +8. **Make mDNS advertisement failure loud.** `dnssd_loader.cpp` never waits on the forked + python3 child and always returns success, so an unadvertised server is silently invisible — + the single worst failure mode for unattended operation. Fix: `waitpid` with a short timeout, + or at minimum check the child did not exit immediately, and log unmistakably on failure. + Small patch, high value for this deployment. +9. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted From 04e928aab6cf3651a8332c33358b1138bf8ea551 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:48:35 -0400 Subject: [PATCH 11/95] dnssd: detect and report mDNS advertisement failure instead of silently 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 --- REVIVAL.md | 8 +--- libraries/dnssd_loader/dnssd_loader.cpp | 55 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 02e966d..7f71223 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,6 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | +| `` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | ### Verified facts worth not re-deriving @@ -277,12 +278,7 @@ Consequences, and they are exactly the wrong shape for a headless box: `network_mode: host` and an absolute bind mount for `AltServerData`. Pairs with item 6. Remember `./AltServerData` is a **relative** path, so `WorkingDirectory` / the container workdir matters. -8. **Make mDNS advertisement failure loud.** `dnssd_loader.cpp` never waits on the forked - python3 child and always returns success, so an unadvertised server is silently invisible — - the single worst failure mode for unattended operation. Fix: `waitpid` with a short timeout, - or at minimum check the child did not exit immediately, and log unmistakably on failure. - Small patch, high value for this deployment. -9. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to +8. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted diff --git a/libraries/dnssd_loader/dnssd_loader.cpp b/libraries/dnssd_loader/dnssd_loader.cpp index ed90b1b..7079926 100644 --- a/libraries/dnssd_loader/dnssd_loader.cpp +++ b/libraries/dnssd_loader/dnssd_loader.cpp @@ -4,6 +4,8 @@ #include #include // prctl(), PR_SET_PDEATHSIG #include // signals +#include // waitpid() +#include // ntohs() DNSServiceErrorType DNSSD_API DNSServiceRegister @@ -45,6 +47,11 @@ DNSServiceErrorType DNSSD_API DNSServiceRegister pyCommand += "txtRecord = b'" + txtRecordHex + "'; "; pyCommand += "ret = dll.DNSServiceRegister(byref(sdRef), flags, interfaceIndex, name, regtype, domain, host, port, txtLen, txtRecord, None, None); "; pyCommand += "print('DNSServiceRegister result: %d' % ret); "; + // Exit non-zero if registration itself failed, rather than waiting forever on a + // registration that never happened. Without this the helper blocks on Event().wait() + // regardless of the result, so a loadable libdns_sd.so with no avahi-daemon behind it + // would look identical to success to the parent's liveness check below. + pyCommand += "import sys; (sys.exit(1) if ret != 0 else None); "; pyCommand += "from threading import Event; Event().wait(); "; @@ -66,7 +73,53 @@ DNSServiceErrorType DNSSD_API DNSServiceRegister execlp("python3", "python3", "-c", pyCommand.c_str(), NULL); exit(1); } else { - ; + // The child is meant to run forever -- the Python command ends with Event().wait(). + // So if it exits promptly, advertisement failed. Poll rather than block: a healthy + // child never exits, and a plain waitpid() would hang here for the life of the server. + // + // This check matters more than its size suggests. Without it this function returned + // success unconditionally, so a missing python3 or an unloadable libdns_sd.so left + // AltServer running, logging nothing wrong, and completely undiscoverable by the + // device. On an unattended headless server that is the worst possible failure mode: + // nobody finds out until a sideloaded app expires a week later. + bool advertised = true; + + for (int attempt = 0; attempt < 20; attempt++) // ~1 second total + { + usleep(50 * 1000); + + int status = 0; + pid_t result = waitpid(child, &status, WNOHANG); + + if (result == child) + { + advertised = false; + break; + } + + if (result < 0) + { + // Cannot tell either way; assume it is running rather than cry wolf. + break; + } + } + + if (!advertised) + { + fprintf(stderr, + "ERROR: could not advertise this server over mDNS -- the python3 helper exited\n" + " immediately. AltStore on your device will NOT be able to discover this\n" + " server, and refreshing will never happen.\n" + " Verify with the same call this program makes:\n" + " python3 -c \"from ctypes import CDLL; CDLL('libdns_sd.so')\"\n" + " On Debian/Ubuntu install libavahi-compat-libdnssd-dev -- the -dev package is\n" + " the one providing the unversioned libdns_sd.so symlink, not libdnssd1 -- and\n" + " make sure avahi-daemon is running.\n"); + + return kDNSServiceErr_Unknown; + } + + printf("Advertising this server over mDNS as _altserver._tcp on port %d\n", ntohs(port)); } return 0; } From cc011dd505ee3b9fbfa1447ac7ca8d66ea568d73 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:48:43 -0400 Subject: [PATCH 12/95] REVIVAL.md: fill in the commit SHA for the dnssd fix Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/REVIVAL.md b/REVIVAL.md index 7f71223..c4e6cc1 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,7 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | -| `` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | +| `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | ### Verified facts worth not re-deriving From ebfbc5b044a02b6e042f17f61bc4cdff32cd603f Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:50:39 -0400 Subject: [PATCH 13/95] gitignore: ignore macOS .DS_Store and AppleDouble files 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 --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9d1f8dd..b7a7910 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /build *.o -.vscode \ No newline at end of file +.vscode + +# macOS +.DS_Store +._* From fa1a0a071bb02fe5e01d94703927e67623e24d36 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:57:24 -0400 Subject: [PATCH 14/95] REVIVAL.md: record two future goals -- self-contained deploy, and a web 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 --- REVIVAL.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/REVIVAL.md b/REVIVAL.md index c4e6cc1..35e5bde 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -284,6 +284,47 @@ Consequences, and they are exactly the wrong shape for a headless box: are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted command lines in the issue corpus, nobody wrote `-p` before `-a`. +### Future / research — NOT scheduled, recorded so they are not lost + +These are the operator's stated end-goals for the project. Do not start them until the blockers +above are cleared; they are written down here with enough grounding to be picked up cold. + +**F1. Self-contained deploy.** Point Portainer (or any compose-based platform) at the GitHub repo +and have everything come up with no manual steps beyond entering account credentials. Portainer +supports deploying a stack straight from a Git repository, so the shape is: a `docker-compose.yml` +in the repo, an image published to `ghcr.io/ben-diehlci/`, `network_mode: host` for mDNS, a named +volume or absolute bind mount for `AltServerData` (remember it is a **relative** path), and the +anisette server as a second service in the same stack. Depends on TODO 6 (registry namespace) and +7 (the container image). The image must contain `python3` and `libavahi-compat-libdnssd-dev` or +advertisement fails silently — see the mDNS section above. + +Open questions to research: can the anisette server be bundled in the same stack, or does it need +its own identity/state? What is the minimum set of secrets, and can they be Docker secrets rather +than plain env vars? Does anything need to run privileged or with host devices for usbmuxd/netmuxd? + +**F2. Web interface, replacing the desktop GUI.** On macOS and Windows AltServer has a tray/GUI +for signing in, entering the 2FA code, choosing a device and triggering a refresh. This port +replaced all of that with a console implementation injected by +`makefiles/rewrite_altserver_source.py`. A web UI is the natural equivalent for a headless box. + +**This is probably not optional.** `rewrite_altserver_source.py:96` reads the two-factor code with +`std::cin >> _verificationCode`, i.e. from **stdin**. Under systemd stdin is `/dev/null`, and in +Docker without `-i` likewise, so 2FA sign-in cannot currently be completed in the target +deployment at all. `ShowAlert` (`:132`) has the same problem in reverse — it calls `getchar()` and +would block on an interactive TTY. + +So the first research question is narrower than "build a web UI": **how often is 2FA actually +required?** If Apple's session or token is persisted under `AltServerData` and reused, this is a +one-time interactive step that could be handled by running the container once with `-it`, and a +web UI is then a convenience. If a code is needed on every refresh, an out-of-band way to submit +it is mandatory and the whole unattended premise depends on it. Establish that before designing +anything. + +If built: it should cover sign-in, 2FA entry, device selection, manual refresh, and — given how +much of this session was spent on silent failures — visible health, i.e. is the server advertising, +is the anisette server reachable, when did the last successful refresh happen, and when do the +current certificates expire. + ### Explicitly not doing - **PR #98 (CMake rewrite).** Author wrote "doesn't 100% work" in 2023 and never returned; keyed From 66635972aed131c9d8212863156d14a675491539 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:58:45 -0400 Subject: [PATCH 15/95] REVIVAL.md: correct the refresh-path analysis -- #131 is bootstrap-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 #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: #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 --- REVIVAL.md | 50 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 35e5bde..d65d46a 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -144,11 +144,24 @@ breaks the premise soonest, not by how interesting the code is. Covers issues #87, #81, #77, #76, #75, #122, #49, #13 — previously triaged as mostly user-error, but for *this* deployment they describe the critical path. Needs a verified, written-down working configuration. -- **D. iOS-version signing compatibility — CONFIRMED IN SCOPE.** The target device runs - **iOS 26.x**, so #131 applies: apps install successfully and then crash at launch. This is not - hypothetical and cannot be worked around by configuration; it needs the `upstream_repo` bump - (item 4 below), which is the largest remaining code task. Note the failure mode is - *deceptive* — installation reports success, so everything looks fine until the app is opened. +- **D. iOS-version signing — BOOTSTRAP ONLY, NOT THE REFRESH PATH.** Corrected 2026-09-14; an + earlier entry here wrongly called this the largest task on the critical path. + **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`, which lands at `ClientConnection.cpp:238` → + `DeviceManager::InstallProvisioningProfiles` → misagent. `Signer` has exactly **one** call site + in the entire server, `AltServerApp.cpp:1453-1454`, inside `InstallApp`, reachable only from + the CLI install path. So the 2022-stale signer and #131 block the *first* install on iOS 26.x, + not the recurring refresh that is the actual goal. + **And #131 is two lines, not a submodule bump.** `upstream_repo/ldid/ldid.cpp:2215` runs + `hash.resize(20)` *before* `:2217-2220` captures `alternateCDSHA256 = hash`, so the SHA-256 + hash-agility attribute carries a hash truncated to 20 bytes and CoreTrust rejects it. Moving the + capture above the resize is the whole fix — verified in source here, matching jaakkopalvaila's + diagnosis in `open_issue_0131.md`. The competing diagnosis in that thread does not hold against + this tree: DER entitlements *are* emitted, CodeDirectory version *is* 0x00020400, and a SHA-256 + alternate CD *is* present. + **Free question that may remove this entirely:** if AltStore is already installed and launching + on the phone, the bootstrap is already done and only pairing, anisette and discovery matter. Items A and C are deployment/config work rather than patches, and both need a real device to confirm. B is a small patch. D is a substantial one. None are blocked by anything already done. @@ -205,6 +218,28 @@ Wi-Fi to the wired VM. Topology is settled. Installing `avahi-utils` also pulled `avahi-daemon`, which was NOT previously present and which the compat layer requires — so that was a necessary prerequisite obtained by accident. +### Pairing needs a one-time USB connection + +A pair record can only be created over USB in this tree. `libraries/libimobiledevice` falls back +to `lockdownd_pair`, `tools/idevicepair.c:180-182` states wireless pairing is Apple-TV-only, and +`makefiles/libimobiledevice-build/config.h:99` is `#undef HAVE_WIRELESS_PAIRING`. So the phone +must physically touch the Linux box once, with a cable, and Trust it. The "no Mac, no PC" premise +holds for steady-state operation but does not budget for that one cable trip. + +### The phone ALSO fails silently — both ends at once + +`AltStore/Operations/BackgroundRefreshAppsOperation.swift:60` sets +`ignoresServerNotFoundError = true`, consumed at `:221-223` to suppress the alert. So a 3am +background refresh that cannot find the server posts **no notification on the phone** and logs +nothing on the server — the first symptom is an app that will not open, seven days later. + +Useful asymmetry: `AltStore/Intents/App Intents/RefreshAllAppsIntent.swift:187` sets it to +**false**, so a *manually* triggered refresh does surface the error. Manual refresh is therefore +the diagnostic tool; background refresh is the thing that goes quiet. + +This is the strongest argument for an external watchdog — something that independently checks the +server is advertising and that a refresh actually succeeded, rather than trusting either end. + ### mDNS advertisement is a SILENT failure — the top risk for unattended operation `libraries/dnssd_loader/dnssd_loader.cpp` does not link Bonjour. `DNSServiceRegister` builds a @@ -256,7 +291,10 @@ Consequences, and they are exactly the wrong shape for a headless box: ### Bigger -4. **Bump `upstream_repo` to 1.7.4.** The only fix for #131 (iOS 26.4 launch crash — `ldid.cpp` +4. **Fix #131 in place (~2 lines), or bump `upstream_repo` to 1.7.4.** Prefer the two-line fix: + move the `alternateCDSHA256 = hash` capture above `hash.resize(20)` in + `upstream_repo/ldid/ldid.cpp` (~:2215). Bootstrap-only — see blocker D. The full bump is the + heavier alternative (`ldid.cpp` truncates a hash to 20 bytes before it becomes the SHA-256 attribute; CoreTrust rejects it). `.gitmodules` pins `branch = develop`, whose tip is from 2022, so `--remote` can never reach it. Needs a hand-edit: the new `Signer.cpp:277` passes `app.path() + "\\"` and From d094368e2ef866ffb18aea724a6abeda9859d12e Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:04:27 -0400 Subject: [PATCH 16/95] REVIVAL.md: record the full deployment research -- prereqs and silent 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, #113/#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 #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 .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 --- REVIVAL.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/REVIVAL.md b/REVIVAL.md index d65d46a..94ef584 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -122,6 +122,121 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, --- +## Deployment research findings (2026-09-14) + +**Scope: one iPhone, iOS 26.x. No iPad, no second device, no other systems.** So multi-device +concerns, `activeProfiles` juggling and device-slot exhaustion (#113, #86) are all out of scope. + +### Host prerequisites — concrete checklist + +- **`python3` on the service's PATH.** A *runtime* dependency, not a build one: + `dnssd_loader.cpp:68` `execlp`s it, because the AltServer binary is `-static` and cannot dlopen + Bonjour itself. Needs only stdlib `ctypes`. +- **`libavahi-compat-libdnssd-dev`**, not `...-libdnssd1`. Confirmed on the target host. +- **`avahi-daemon` running, with dbus under it.** avahi-compat is a thin client proxying to the + daemon; it owns UDP/5353, not us. +- **`/etc/avahi/avahi-daemon.conf`**: `[publish] disable-publishing=no`, + `disable-user-service-publishing=no`, and `allow-interfaces=ens18` so avahi does not also + publish `docker0`/`virbr0` — `DNSServiceRegister` is called with `interfaceIndex 0` + (`ConnectionManager.cpp:122`). Consider `use-ipv6=no`: `ConnectionManager.cpp:140,152` binds + AF_INET only, while upstream macOS uses a dual-stack listener. +- **`avahi-utils`** for `avahi-browse` — the only way to distinguish *published* from + *DNSServiceRegister returned 0*. +- **`usbmuxd` + `libimobiledevice-utils`** for the one-time cabled pairing and for triaging + netmuxd without involving AltServer. +- **`netmuxd` >= 0.3 owning `/var/run/usbmuxd`, with `usbmuxd` STOPPED.** Stock usbmuxd never + emits ConnectionType "Network", and netmuxd binds that socket by default, so the two collide. + The only success report in #77 is: netmuxd, no flags, usbmuxd not running. If pointing at TCP + instead, the variable is `USBMUXD_SOCKET_ADDRESS` — the widely-copied instruction in #49 + misspells it `USBMUXD_SOCKET_ADRESS` (one D) and is silently ignored. +- **`/var/lib/lockdown` on real persistent storage, never tmpfs.** Back up `.plist` **and** + `SystemConfiguration.plist` as a unit — they are not independent, and half a pairing is + indistinguishable from none. In Docker this lives in whichever container runs the muxer. +- **Anisette on the same box, loopback, plain HTTP**, ADI state on a named volume. Plain + `http://127.0.0.1:6969` also sidesteps TLS trust entirely: `FetchAnisetteData` uses the default + http_client config, so certificate verification is ON (unlike AltSign's gsa client). +- **Accurate NTP on whichever host runs the ANISETTE server**, not the AltServer host. Linux + forwards the anisette server's `X-Apple-I-Client-Time` verbatim; macOS stamps `Date()` locally. +- **Absolute `WorkingDirectory`** (systemd) or workdir (docker) — `./AltServerData` is relative + and systemd defaults CWD to `/`. +- **Disable journald rate limiting** for the unit (`LogRateLimitIntervalSec=0`, + `LogRateLimitBurst=0`). `WirelessConnection.cpp:95,122` print two unbuffered lines per <=4096-byte + chunk, so a large transfer trips the 10000-per-30s default — and the suppressed messages are the + ones at the END of an install, exactly the errors you want. +- **Firewall: the whole ephemeral TCP range on the LAN interface, plus UDP/5353 both ways.** + `ConnectionManager.cpp:151` sets `sin_port = 0`, so the port differs every start and no static + rule is writable. +- **Docker only:** `network_mode: host`, a bind mount of the dbus system bus socket (or + avahi-daemon inside the image), and **`init: true`** — the binary installs no SIGTERM handler, + so as PID 1 the kernel drops `docker stop`'s SIGTERM and every restart costs the full grace + period then SIGKILL. +- **`chmod +x` the downloaded release binary** — artifact upload does not preserve the bit. + systemd at least fails legibly here: `status=203/EXEC`. + +### Silent failure modes — the real enemy for unattended operation + +Ranked. These are the ways it stops refreshing with nobody finding out. + +1. **Both ends go quiet at once.** Covered above: the phone suppresses server-not-found on + background refresh, and the server logs nothing because no connection was attempted. Zero + evidence anywhere. This is why the Shortcuts intent path + (`RefreshAllAppsIntent.swift:187`, `ignoresServerNotFoundError = false`) is a requirement. +2. **`DNSServiceRegister result: 0` is not proof of publication.** A user in `closed_issue_0051` + got result 0 with avahi-daemon *stopped* — consistent with avahi-compat deferring via + `AVAHI_CLIENT_NO_FAIL`. **Our committed fix (`654907a`) does not close this**: it catches a + child that exits, and the `sys.exit` addition catches a non-zero result, but it cannot catch + avahi *lying* about success. Only an out-of-band `avahi-browse` from another host can. +3. **avahi restarts and nothing re-registers.** `StartAdvertising` is called exactly once + (`ConnectionManager.cpp:174`). No retry, no health check, and nothing calls + `DNSServiceProcessResult`, so the registration callback never fires either way. An + unattended-upgrades run touching avahi overnight is a multi-day silent outage. +4. **A stale python3 child can advertise a dead port.** `PR_SET_PDEATHSIG` fires when the + *forking thread* exits, not the process, and the fork happens on the listening thread. The + child can survive holding a registration for an ephemeral port that no longer exists; since + `flags = 0` (no `NoAutoRename`), avahi renames rather than replaces, and the phone takes + `discoveredServers.first` — a coin flip between live and dead. +5. **A dropped client pins a worker at 100% CPU and floods the disk.** `ReceiveData` ignores + `recv()`'s return (`WirelessConnection.cpp:116`); on peer close it returns 0 forever while + select still reports readable, so the loop spins printing two lines per pass. Enough of these + exhaust cpprest's ~40-thread pool and the daemon stops answering while still reporting + `active (running)`. Never filed — it presents as "the server stopped refreshing". +6. **A response that failed to send is logged as success.** `SendData` never checks `send()`'s + return while SIGPIPE is ignored, and its break condition is true on the first iteration + regardless. Journal says "Finished handling request!"; the device saw a timeout. +7. **Anisette identity silently regenerates.** Every identity field comes from the HTTP response; + Linux holds no local ADI state. A container recreated onto the wrong volume path means Apple + sees a new machine and demands 2FA — which background refresh can never surface. #86 reports + exactly this. +8. **Anisette clock drift is invisible here.** We parse and re-emit the *server's* timestamp, so + NTP on the AltServer box proves nothing; skew surfaces as an opaque -36607. +9. **`X-Apple-I-MD-RINFO` uses `std::atoi`**, which returns 0 for non-numeric input with no error + — the one field of the ten not guarded by `requireString`. Pre-existing; unchanged by our work. +10. **Real device faults are displayed as "AltServer could not be found".** AltStore remaps + deviceNotFound/lostConnection to serverNotFound for any wireless server that is not + `isPreferred`, and AltServer-Linux hardcodes serverID `"1234567"` while Mac/Windows use a + UUID — so unless the Linux box itself installed AltStore, `isPreferred` is permanently false + and every netmuxd/pairing fault wears the wrong error message. **This will send you to debug + mDNS when mDNS is fine.** +11. **Adding `-d` makes the decisive lines disappear.** `AltServerMain.cpp:179` calls + `libusbmuxd_set_debug_level(debugLogLevel - 2)`, so one `-d` sets level -1 and + `LIBUSBMUXD_ERROR` stops printing — losing exactly the two messages that diagnose a netmuxd + mismatch. +12. **`journalctl -p err` is empty no matter what breaks.** `OutputDebugStringA` is `std::cout`, + so nearly everything is stdout at info. Severity filtering is useless on this unit, and + unbuffered cout from ~40 threads interleaves mid-token, so even grep can miss it. +13. **The CLI bootstrap exits 0 even when it failed.** `AltServerMain.cpp:224-238` catches, logs, + prints "Finished!" and falls off the end of main. A oneshot unit cannot tell success from + failure. +14. **No liveness signal at all, so `Restart=` can never fire.** `main()` ends in + `while (1) { sleep(100); }` and never joins the listening thread. `Listen()` can return early + on socket or bind failure and the process stays `active (running)` forever with no listener + and no advertisement. + +**Conclusion: build an external watchdog before trusting any of this.** Something that +independently runs `avahi-browse` to confirm the service is published, checks the anisette +endpoint, and tracks when a refresh last actually succeeded. Nothing inside AltServer can be +trusted to report its own health. + ## TODO ### Blockers for the actual goal — a working headless refresh server From 0e8090bb769761b5b72b7451d2c60ce16fbc0758 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:08:57 -0400 Subject: [PATCH 17/95] ldid: fix the iOS 26 hash-agility truncation (issue #131) 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. #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 #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 --- REVIVAL.md | 19 +++++--- .../AltSign-build/rewrite_ldid_source.py | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 94ef584..4e65c23 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,6 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | +| `5364cd3` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | @@ -275,8 +276,15 @@ breaks the premise soonest, not by how interesting the code is. diagnosis in `open_issue_0131.md`. The competing diagnosis in that thread does not hold against this tree: DER entitlements *are* emitted, CodeDirectory version *is* 0x00020400, and a SHA-256 alternate CD *is* present. - **Free question that may remove this entirely:** if AltStore is already installed and launching - on the phone, the bootstrap is already done and only pairing, anisette and discovery matter. + **ANSWERED 2026-09-14: AltStore is NOT installed. Installing it is the whole point.** So the + bootstrap path IS required, and #131 is therefore back on the critical path — it is the *first* + thing that will bite. The mechanism analysis above still stands (it is not a *refresh* blocker), + but you cannot reach refresh without passing through the install it breaks. + **FIXED in `makefiles/AltSign-build/rewrite_ldid_source.py`** rather than in the submodule, + using the project's existing build-time rewriting mechanism, with a guard that fails the build + loudly if upstream ldid.cpp ever stops matching. **UNVERIFIED ON HARDWARE** — the diagnosis is + confirmed in source and matches the issue reporter, but nobody has yet confirmed it makes an app + launch on a real iOS 26 device. Items A and C are deployment/config work rather than patches, and both need a real device to confirm. B is a small patch. D is a substantial one. None are blocked by anything already done. @@ -406,10 +414,9 @@ Consequences, and they are exactly the wrong shape for a headless box: ### Bigger -4. **Fix #131 in place (~2 lines), or bump `upstream_repo` to 1.7.4.** Prefer the two-line fix: - move the `alternateCDSHA256 = hash` capture above `hash.resize(20)` in - `upstream_repo/ldid/ldid.cpp` (~:2215). Bootstrap-only — see blocker D. The full bump is the - heavier alternative (`ldid.cpp` +4. ~~**Fix #131**~~ — **DONE**, see the Done table. Left here only as a pointer: the heavier + alternative, if the rewriter patch ever proves insufficient, is bumping `upstream_repo` to + 1.7.4 (`ldid.cpp` truncates a hash to 20 bytes before it becomes the SHA-256 attribute; CoreTrust rejects it). `.gitmodules` pins `branch = develop`, whose tip is from 2022, so `--remote` can never reach it. Needs a hand-edit: the new `Signer.cpp:277` passes `app.path() + "\\"` and diff --git a/makefiles/AltSign-build/rewrite_ldid_source.py b/makefiles/AltSign-build/rewrite_ldid_source.py index 20f208d..c9ad54d 100644 --- a/makefiles/AltSign-build/rewrite_ldid_source.py +++ b/makefiles/AltSign-build/rewrite_ldid_source.py @@ -12,6 +12,49 @@ content = content.replace(br'\\', br'/') content = re.sub(br'/(\.[A-Za-z])', br'\\\\\1', content) +# --- AltStore issue #131: apps install, then crash at launch on iOS 26+ --------------- +# +# ldid truncates the CodeDirectory hash 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 therefore +# carries a SHA-256 hash chopped to 20 bytes, CoreTrust rejects the signature, and the app +# dies at launch with no crash report -- while the INSTALL reports success, which is what +# makes this so deceptive to diagnose. +# +# Fix: capture the full hash first, then truncate for the cdhashes array (which genuinely +# wants 20 bytes). Two statements swapped; no behaviour change for the truncated array. +# +# Applied here rather than in upstream_repo/ldid/ldid.cpp because that is a submodule; this +# rewriter is the project's existing mechanism for patching vendored sources at build time. +_ldid_131_old = ( + b'algorithm(hash, blob.data(), blob.size());\r\n' + b'\t\t\t\t\t\thash.resize(20);\r\n' + b'\r\n' + b'\t\t\t\t\t\tif (algorithm.type_ == CS_HASHTYPE_SHA256_256)\r\n' + b'\t\t\t\t\t\t{\r\n' + b'\t\t\t\t\t\t\talternateCDSHA256 = hash;\r\n' + b'\t\t\t\t\t\t}\r\n' +) +_ldid_131_new = ( + b'algorithm(hash, blob.data(), blob.size());\r\n' + b'\r\n' + b'\t\t\t\t\t\tif (algorithm.type_ == CS_HASHTYPE_SHA256_256)\r\n' + b'\t\t\t\t\t\t{\r\n' + b'\t\t\t\t\t\t\talternateCDSHA256 = hash;\r\n' + b'\t\t\t\t\t\t}\r\n' + b'\r\n' + b'\t\t\t\t\t\thash.resize(20);\r\n' +) + +if F.endswith('ldid.cpp'): + # Fail the build loudly rather than silently shipping broken signatures again. + if content.count(_ldid_131_old) != 1: + sys.stderr.write( + "rewrite_ldid_source.py: the issue #131 hash-agility patch matched %d times, expected 1.\n" + " upstream ldid.cpp has changed. Re-check whether alternateCDSHA256 is still captured\n" + " after hash.resize(20) before removing this guard.\n" % content.count(_ldid_131_old)) + sys.exit(1) + content = content.replace(_ldid_131_old, _ldid_131_new) + content = content.replace(br'int main(', br'int __main(') sys.stdout.buffer.write(content) \ No newline at end of file From ca2022c7f8794ee9622a33fa2536d1cd14d36fc4 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:09:16 -0400 Subject: [PATCH 18/95] REVIVAL.md: correct the #131 commit SHA 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 --- REVIVAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/REVIVAL.md b/REVIVAL.md index 4e65c23..12a1f17 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,7 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | -| `5364cd3` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | +| `65a5727` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | From 04dd7eeb3f815f1434b6512cd3e4bb6a085382cc Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:55:15 -0400 Subject: [PATCH 19/95] Clear the last two bootstrap blockers: GSA client-info, and argv parsing 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 #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 --- REVIVAL.md | 5 +++-- src/AltServerMain.cpp | 29 +++++++++++++++++++++++------ src/AnisetteDataManager.cpp | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 12a1f17..427d6c3 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,6 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | +| `` | **Bootstrap blockers cleared:** PR #135 client-info sanitization (with an `ALTSERVER_NO_CLIENTINFO_SANITIZE` escape hatch), and the getopt `-a` fallthrough / uninitialised argv pointers / unreachable `-h`. | | `65a5727` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | @@ -401,7 +402,7 @@ Consequences, and they are exactly the wrong shape for a headless box: Note: stuffing `NSLocalizedRecoverySuggestionErrorKey` into `userInfo` does **not** work — `ServerError::localizedRecoverySuggestion()` returns from its `case` before reaching `default`. -2. **PR #135 — sanitize `X-MMe-Client-Info`.** Rewrite `com.apple.dt.Xcode` → `com.apple.akd` at +2. ~~**PR #135 — sanitize `X-MMe-Client-Info`.**~~ **DONE** — see the Done table. Original note kept for the caveats: Rewrite `com.apple.dt.Xcode` → `com.apple.akd` at `src/AnisetteDataManager.cpp`, the single point where anisette data enters. Apple's GSA edge 503s any request carrying that substring as of ~2026-09. Trivial. Closes no open issue (nobody has reported it — the anisette failure fired first) and needs a real Apple ID to @@ -438,7 +439,7 @@ Consequences, and they are exactly the wrong shape for a headless box: `network_mode: host` and an absolute bind mount for `AltServerData`. Pairs with item 6. Remember `./AltServerData` is a **relative** path, so `WorkingDirectory` / the container workdir matters. -8. **getopt hygiene** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to +8. ~~**getopt hygiene**~~ **DONE** (`src/AltServerMain.cpp`). `case 'a'` has no `break` and falls through to `case 'p'`, so `-a` sets *both* appleID and password; `-h` is documented and handled at `case 'h'` but absent from the optstring `"u:i:a:p:P:d"`, so it is unreachable; five `char*` are uninitialised. All real UB — but fix as hygiene and claim no issue: across 16 pasted diff --git a/src/AltServerMain.cpp b/src/AltServerMain.cpp index 8ff399c..2ff7cc7 100644 --- a/src/AltServerMain.cpp +++ b/src/AltServerMain.cpp @@ -104,11 +104,13 @@ int main(int argc, char *argv[]) { {0, 0, 0, 0} }; - char *udid; - char *ipaddr; - char *appleID; - char *password; - char *pairDataFile; + // Initialised: these are read unconditionally at the install call below, so leaving them + // indeterminate made a missing flag undefined behaviour rather than an error. + char *udid = NULL; + char *ipaddr = NULL; + char *appleID = NULL; + char *password = NULL; + char *pairDataFile = NULL; char *ipaPath = NULL; int debugLogLevel = 0; @@ -117,7 +119,9 @@ int main(int argc, char *argv[]) { int this_option_optind = optind ? optind : 1; int option_index = 0; - int c = getopt_long (argc, argv, "u:i:a:p:P:d", + // 'h' was handled below but missing from this string, so -h fell through to the error + // branch and printed "?? getopt returned character code 077 ??" before the usage text. + int c = getopt_long (argc, argv, "hu:i:a:p:P:d", long_options, &option_index); if (c == -1) break; @@ -130,6 +134,7 @@ int main(int argc, char *argv[]) { break; case 'a': appleID = optarg; + break; // was missing: -a fell through into -p, so `-a ID` set the password to ID too case 'p': password = optarg; break; @@ -168,6 +173,18 @@ int main(int argc, char *argv[]) { return 1; } + if (installApp && (udid == NULL || appleID == NULL || password == NULL)) + { + fprintf(stderr, + "ERROR: installing an IPA requires -u/--udid, -a/--appleID and -p/--password.\n" + " Missing:%s%s%s\n" + " Run with no IPA argument to start in server (daemon) mode instead.\n", + udid == NULL ? " --udid" : "", + appleID == NULL ? " --appleID" : "", + password == NULL ? " --password" : ""); + return 1; + } + setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0); diff --git a/src/AnisetteDataManager.cpp b/src/AnisetteDataManager.cpp index 1e663a9..970bb44 100644 --- a/src/AnisetteDataManager.cpp +++ b/src/AnisetteDataManager.cpp @@ -264,6 +264,39 @@ std::shared_ptr AnisetteDataManager::FetchAnisetteData() std::string deviceUniqueIdentifier = requireString("X-Mme-Device-Id"); std::string deviceSerialNumber = requireString("X-Apple-I-SRL-NO"); std::string deviceDescription = requireString("X-MMe-Client-Info"); + + // Since ~2026-09 Apple's GSA edge (gsa.apple.com/grandslam/GsService2) rejects with an + // immediate HTTP 503 any request whose X-MMe-Client-Info contains "com.apple.dt.Xcode", + // independent of version or User-Agent. Anisette servers commonly return exactly that + // substring -- upstream AltStore still builds one containing com.apple.dt.Xcode/3594.4.19 + // as of v2.3.3. Sanitize it here, at the single point where anisette data enters this + // program, before it is ever used to build a request header. See upstream PR #135. + // + // This is an UNVERIFIED third-party claim that we cannot test without a real Apple ID, and + // it alters a header Apple sees -- so it is defeatable without a rebuild. If sign-in fails + // with the rewrite in place, try ALTSERVER_NO_CLIENTINFO_SANITIZE=1 before assuming the + // anisette server is at fault. + const char *noSanitize = getenv("ALTSERVER_NO_CLIENTINFO_SANITIZE"); + if (noSanitize == NULL || *noSanitize == '\0') + { + const std::string needle = "com.apple.dt.Xcode"; + const std::string replacement = "com.apple.akd"; + + size_t position = 0; + bool rewrote = false; + while ((position = deviceDescription.find(needle, position)) != std::string::npos) + { + deviceDescription.replace(position, needle.length(), replacement); + position += replacement.length(); + rewrote = true; + } + + if (rewrote) + { + odslog("Rewrote " << needle << " -> " << replacement << " in X-MMe-Client-Info " + "(Apple 503s requests carrying it). Set ALTSERVER_NO_CLIENTINFO_SANITIZE=1 to disable."); + } + } std::string locale = requireString("X-Apple-Locale"); std::string timeZone = requireString("X-Apple-I-TimeZone"); From 907e7fec141e1f103b44bc0b3bb1f01e4d3b1cf1 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:55:15 -0400 Subject: [PATCH 20/95] REVIVAL.md: record the bootstrap-blocker commit SHA Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/REVIVAL.md b/REVIVAL.md index 427d6c3..d8f02b6 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -83,7 +83,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | | `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | -| `` | **Bootstrap blockers cleared:** PR #135 client-info sanitization (with an `ALTSERVER_NO_CLIENTINFO_SANITIZE` escape hatch), and the getopt `-a` fallthrough / uninitialised argv pointers / unreachable `-h`. | +| `f2540f3` | **Bootstrap blockers cleared:** PR #135 client-info sanitization (with an `ALTSERVER_NO_CLIENTINFO_SANITIZE` escape hatch), and the getopt `-a` fallthrough / uninitialised argv pointers / unreachable `-h`. | | `65a5727` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | From 790973d2d6cdff2e771e7ec5d866f72c32b87962 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:59:53 -0400 Subject: [PATCH 21/95] buildenv: fix corecrypto layer 3, and add BOOTSTRAP.md 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 #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 --- BOOTSTRAP.md | 224 ++++++++++++++++++++++++++++++++++++++++++++ REVIVAL.md | 9 +- buildenv/Dockerfile | 9 ++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 BOOTSTRAP.md diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md new file mode 100644 index 0000000..09c7c24 --- /dev/null +++ b/BOOTSTRAP.md @@ -0,0 +1,224 @@ +# Bootstrap: installing AltStore on the iPhone from the Linux server + +First-time install, from nothing to AltStore running on the phone. Ongoing wireless refresh is +**Phase 7**, deliberately last — get the install working over a cable first, then make it wireless. +Background and rationale live in [REVIVAL.md](REVIVAL.md). + +Target: Dell OptiPlex 5060 → Proxmox → Ubuntu 24.04 VM (`192.168.9.16`, `ens18`) → one iPhone on +iOS 26.x, same LAN. + +> **Use a secondary Apple ID if you have one.** Issue #88 documents Apple IDs being *locked* after +> anisette trouble. An app-specific password will **not** work — sideloading needs the real +> password plus a 2FA code. Free accounts cap at 3 sideloaded apps, 10 app IDs/week, 7-day certs. + +--- + +## Phase 0 — Get a binary that contains all our fixes + +The artifact from the last green CI run predates every code fix. Push first, then take the new one. + +```bash +git push origin bd/revival +``` + +Then on GitHub: **Actions → Build AltServer →** newest run → **Artifacts → `AltServer-amd64`**. +Copy it to the VM, then: + +```bash +unzip AltServer-amd64.zip +chmod +x AltServer-x86_64 # artifact upload strips the executable bit; without this: status=203/EXEC +./AltServer-x86_64 --help # sanity check +``` + +The binary is named for the gcc triple (`x86_64`), the artifact for the matrix label (`amd64`). + +--- + +## Phase 1 — Host prerequisites + +Already done on this host: `avahi-daemon`, `avahi-utils`, `libavahi-compat-libdnssd-dev`. +Multicast is confirmed working. Remaining: + +```bash +sudo apt install -y usbmuxd libimobiledevice-utils +sudo systemctl enable --now usbmuxd +``` + +Re-confirm the mDNS chain, since a silent failure here makes the server invisible later: + +```bash +python3 -c "from ctypes import CDLL; CDLL('libdns_sd.so'); print('libdns_sd.so OK')" +systemctl is-active avahi-daemon +``` + +--- + +## Phase 2 — Anisette server + +**This is the one step most likely to cost you an evening.** Run it on the same box, bound to +loopback, over plain HTTP — that avoids TLS trust entirely, which matters because our client uses +the default http_client config with certificate verification ON. + +Run whichever anisette server you choose in Docker with a **named volume for its provisioning +state**, then verify it against our client's actual contract before going further. + +### The contract our client requires (from `src/AnisetteDataManager.cpp`) + +A plain `GET` returning HTTP **200** and a JSON **object** with these ten keys, **every value a +JSON string** (not a number): + +``` +X-Apple-I-MD-M X-Apple-I-MD X-Apple-I-MD-LU X-Apple-I-MD-RINFO X-Mme-Device-Id +X-Apple-I-SRL-NO X-MMe-Client-Info X-Apple-I-Client-Time X-Apple-Locale X-Apple-I-TimeZone +``` + +Note the inconsistent capitalisation — `X-MMe-Client-Info` (capital MM) versus `X-Mme-Device-Id` +(lowercase m). Matching is case-sensitive. + +```bash +curl -s -H 'User-Agent: Xcode' http://127.0.0.1:6969 | jq 'map_values(type)' +``` + +Every one of the ten must read `"string"`. A server emitting `X-Apple-I-MD-RINFO` as a *number* is +a known real-world variant — and it is the one field parsed with `std::atoi`, which returns 0 +silently rather than erroring, producing an opaque `-36607` later. + +### Verify its identity survives a restart + +Skip this and you may hit a 2FA prompt on every refresh, which unattended operation can never +answer: + +```bash +curl -s http://127.0.0.1:6969 > /tmp/a1.json +docker restart +sleep 5 +curl -s http://127.0.0.1:6969 > /tmp/a2.json + +for k in X-Apple-I-MD-M X-Apple-I-MD-LU X-Mme-Device-Id X-Apple-I-SRL-NO; do + a=$(jq -r ".\"$k\"" /tmp/a1.json); b=$(jq -r ".\"$k\"" /tmp/a2.json) + [ "$a" = "$b" ] && echo "$k stable" || echo "$k CHANGED -- state is not persisting" +done +``` + +Those four must be **identical**. `X-Apple-I-MD` is a one-time password and *should* differ. +If anything changed, find where the ADI blob actually lives and mount that path properly. + +**Also: NTP must be right on whichever host runs the anisette server**, not just the AltServer +host — Linux forwards the anisette server's timestamp verbatim to Apple. Clock skew surfaces as a +generic `-36607` with nothing pointing at a clock. + +--- + +## Phase 3 — Pair the iPhone (requires a USB cable, once) + +Unavoidable: `HAVE_WIRELESS_PAIRING` is undefined in this build, and wireless pairing is +Apple-TV-only. Plug the iPhone into the Ubuntu VM (pass the USB device through in Proxmox if the +VM does not see it), unlock it, and tap **Trust**. + +```bash +idevice_id -l # prints the UDID -- save it +idevicepair validate # expect: SUCCESS +``` + +Back up **both** files together — they are not independent, and half a pairing is +indistinguishable from none: + +```bash +sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/ +``` + +--- + +## Phase 4 — The install + +Get `AltStore.ipa` from onto the VM. Then, **in an interactive terminal** — +2FA is read from stdin, which does not exist under systemd: + +```bash +export ALTSERVER_ANISETTE_SERVER=http://127.0.0.1:6969 + +./AltServer-x86_64 \ + -u \ + -a \ + -p '' \ + AltStore.ipa +``` + +Flag order no longer matters (the `-a` fallthrough is fixed), but keep `-u -a -p` anyway. You will +be prompted for a **2FA code** — type it at the prompt. + +### Reading the output + +| What you see | What it means | +|---|---| +| `No anisette server is configured` | `ALTSERVER_ANISETTE_SERVER` not exported — note `sudo` drops it without `-E` | +| `ALTSERVER_ANISETTE_SERVER is not a usable URL` | Missing `http://` scheme | +| `Could not reach the anisette server at …` | Container not running, or wrong port | +| `… returned HTTP 502/404. Response body: …` | Anisette server up but unhealthy — body is quoted for you | +| `… did not return a JSON object` | Wrong endpoint; you are getting HTML | +| `… no "X-Apple-I-MD-M" field` | Protocol mismatch — re-check Phase 2 | +| `-36607` / "Unable to sign you in" | Anisette identity or clock. Check NTP on the anisette host. Then try `ALTSERVER_NO_CLIENTINFO_SANITIZE=1` | +| `AltServer could not find the device` | Pairing or usbmuxd, **not** mDNS at this stage | +| `Finished!` | **Not proof of success** — it prints even on failure. Read the lines above it | + +--- + +## Phase 5 — Verify it actually worked + +1. AltStore appears on the home screen. +2. **Settings → General → VPN & Device Management** → trust the developer certificate. +3. **Open AltStore.** This is the real test. + +**If it installs but crashes instantly at launch, that is issue #131** — the fix in `65a5727` did +not work, and that is exactly the unverified assumption. Report the symptom; do not conclude the +deployment failed. + +--- + +## Phase 6 — Don't lose it + +```bash +sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/ +docker run --rm -v :/data -v ~:/backup alpine tar czf /backup/anisette-state.tgz /data +``` + +Certificates expire in **7 days**. Phase 7 is what stops that mattering. + +--- + +## Phase 7 — Wireless refresh (only after Phase 5 passes) + +Do not start this until the cabled install works — it changes the device transport, and debugging +both at once is miserable. + +1. **Stop `usbmuxd`, start `netmuxd`** (≥ 0.3). They collide: stock usbmuxd never emits + ConnectionType `Network`, and netmuxd binds `/var/run/usbmuxd` by default. The only success + report in issue #77 is netmuxd with no flags and usbmuxd not running. + *(If pointing at TCP instead, the variable is `USBMUXD_SOCKET_ADDRESS` — the widely-copied + instruction in #49 misspells it `USBMUXD_SOCKET_ADRESS`, one D, and is silently ignored.)* +2. Run AltServer in **daemon mode** — no IPA argument — with `ALTSERVER_ANISETTE_SERVER` set. It + prints `Using anisette server: …` and `Advertising this server over mDNS as _altserver._tcp`. +3. **Confirm publication from another machine**, not from the server: + ```bash + avahi-browse -rt _altserver._tcp + ``` + Do this from a different host. `DNSServiceRegister result: 0` is **not** proof of publication — + avahi can report success while publishing nothing. +4. In AltStore on the phone, trigger a **manual** refresh. Manual surfaces errors; background + refresh suppresses them. + +### Before trusting it unattended + +- **Watchdog first.** Nothing inside AltServer reports its own health: no liveness signal, no + re-registration if avahi restarts, and `journalctl -p err` is empty no matter what breaks. A + background refresh that finds no server notifies nobody on either end. Have something external + run `avahi-browse` and track the last successful refresh. +- **Beware a misleading error.** Real device faults are *displayed* as "AltServer could not be + found", because AltStore remaps them for any server that is not `isPreferred`, and this port + hardcodes serverID `"1234567"` where Mac/Windows use a UUID. It will send you to debug mDNS when + mDNS is fine. +- **`-d` makes things worse.** `libusbmuxd_set_debug_level(debugLogLevel - 2)` underflows, and one + `-d` silences the two messages that actually diagnose a netmuxd mismatch. +- Systemd needs an **absolute `WorkingDirectory`** (`./AltServerData` is relative, and systemd + defaults CWD to `/`), `Environment=ALTSERVER_ANISETTE_SERVER=…`, and journald rate limiting off. + A container needs `network_mode: host` and `init: true`. diff --git a/REVIVAL.md b/REVIVAL.md index d8f02b6..44b1819 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -408,7 +408,14 @@ Consequences, and they are exactly the wrong shape for a headless box: (nobody has reported it — the anisette failure fired first) and needs a real Apple ID to confirm 503→401. Only two of four call sites hit `gsa.apple.com`; the others hit `developerservices2.apple.com` and were never in the author's A/B test. -3. **corecrypto layer 3.** `CORECRYPTO_SRCS` is populated at `CoreCryptoSources.cmake:189` and +3. ~~**corecrypto layer 3.**~~ **SOLVED** — the diagnosis below was wrong, corrected from CI logs. + The real first error is `Cannot find source file: corecrypto_static/ccrng_static.c`; + `No SOURCES given to target` is a follow-on and the one people notice. Apple's 2024 + distribution moved `ccrng_static.c` to the tree root but left `CoreCryptoSources.cmake:251` + pointing at the now-nonexistent `corecrypto_static/` subdirectory. Exactly one entry. Fixed by + a guarded sed in `buildenv/Dockerfile`; **cmake now configures** (`Generating done`). Also + settled: it is NOT arch-specific — the amd64 CI run fails identically to local aarch64. + Superseded note: `CORECRYPTO_SRCS` is populated at `CoreCryptoSources.cmake:189` and Linux subtracts `CORECRYPTO_EXCLUDE_SRCS` at `CMakeLists.txt:262`, but the list ends up empty at `add_library` (`:266`). Cheapest next probe: build the amd64 leg to see whether it is arch-specific. Closes #111. diff --git a/buildenv/Dockerfile b/buildenv/Dockerfile index 7c41e21..b76de21 100644 --- a/buildenv/Dockerfile +++ b/buildenv/Dockerfile @@ -20,6 +20,15 @@ WORKDIR /buildenv/corecrypto # this include is the only reference that actually breaks configure. Cf. upstream PR #85. RUN sed -i -E 's|^include\(scripts/code-coverage\.cmake\)|#&|' CMakeLists.txt +# Apple's 2024 distribution moved ccrng_static.c to the tree root but left CoreCryptoSources.cmake +# still pointing at the old corecrypto_static/ subdirectory, which no longer exists. CMake reports +# this as "Cannot find source file: corecrypto_static/ccrng_static.c" and then, confusingly, as +# "No SOURCES given to target: corecrypto_static" -- it is the second message people notice and +# the first one that actually explains it. Exactly one entry is affected. +RUN test -f ccrng_static.c && ! test -d corecrypto_static \ + && sed -i 's|"corecrypto_static/ccrng_static.c"|"ccrng_static.c"|' CoreCryptoSources.cmake \ + && ! grep -q 'corecrypto_static/ccrng_static.c' CoreCryptoSources.cmake + RUN mkdir build; cd build; CC=clang CXX=clang++ cmake ..; WORKDIR /buildenv/corecrypto/build RUN sed -i -E 's|^(all: CMakeFiles\/corecrypto_perf)|#\1|' CMakeFiles/Makefile2; sed -i -E 's|^(all: CMakeFiles\/corecrypto_test)|#\1|' CMakeFiles/Makefile2 From dbb9977bf93f2009081504b7f84df271f3fcee03 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:00:12 -0400 Subject: [PATCH 22/95] REVIVAL.md: corecrypto fully builds -- #111 closed 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 #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 --- REVIVAL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 44b1819..714441b 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -82,7 +82,7 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `df570bd` | **CI unbroken.** Dead `gautamkrishnar/keepalive-workflow@master` removed; five actions off retired node12/16; `::set-output` → `$GITHUB_OUTPUT`; per-job `permissions`; `sync_upstream` boolean bug. | | `6cd382a` | **node24 bump**, part 1: checkout v4→v7, setup-qemu v3→v4, login-action v3→v4, gh-release v2→v3. | | `8494e36` | **node24 bump**, part 2: third-party uploader → `actions/upload-artifact@v7`, download-artifact v4→v8, matrix restructured to carry an `arch` label. | -| `a266861` | **corecrypto**, 2 of 3 layers. Does *not* close #111. | +| `a266861` + `15e5be6` | **corecrypto: all 3 layers fixed — CLOSES #111.** Full `make; make install` exits 0. The buildenv image is rebuildable from source again. | | `f2540f3` | **Bootstrap blockers cleared:** PR #135 client-info sanitization (with an `ALTSERVER_NO_CLIENTINFO_SANITIZE` escape hatch), and the getopt `-a` fallthrough / uninitialised argv pointers / unreachable `-h`. | | `65a5727` | **#131 fixed** via the ldid rewriter: capture the SHA-256 CodeDirectory hash before truncating to 20 bytes. Build-verified; **not** verified on an iOS 26 device. | | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | @@ -413,7 +413,7 @@ Consequences, and they are exactly the wrong shape for a headless box: `No SOURCES given to target` is a follow-on and the one people notice. Apple's 2024 distribution moved `ccrng_static.c` to the tree root but left `CoreCryptoSources.cmake:251` pointing at the now-nonexistent `corecrypto_static/` subdirectory. Exactly one entry. Fixed by - a guarded sed in `buildenv/Dockerfile`; **cmake now configures** (`Generating done`). Also + a guarded sed in `buildenv/Dockerfile`; **full `make; make install` exits 0 — #111 CLOSED.** Also settled: it is NOT arch-specific — the amd64 CI run fails identically to local aarch64. Superseded note: `CORECRYPTO_SRCS` is populated at `CoreCryptoSources.cmake:189` and Linux subtracts `CORECRYPTO_EXCLUDE_SRCS` at `CMakeLists.txt:262`, but the list ends up empty From cd137badb4ccd9ac664713849e7c09cce47bec4d Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:08:15 -0400 Subject: [PATCH 23/95] BOOTSTRAP.md: do not enable usbmuxd on Ubuntu -- it is udev-activated 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 --- BOOTSTRAP.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 09c7c24..37312b4 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -41,9 +41,13 @@ Multicast is confirmed working. Remaining: ```bash sudo apt install -y usbmuxd libimobiledevice-utils -sudo systemctl enable --now usbmuxd ``` +Do **not** `systemctl enable usbmuxd` on Ubuntu — it is udev-activated and has no `[Install]` +section, so `enable` prints a confusing "unit files have no installation config" message. It +starts on its own when an iOS device is plugged in. Confirm it is working in Phase 3, by whether +`idevice_id -l` sees the phone, not by whether the unit is enabled. + Re-confirm the mDNS chain, since a silent failure here makes the server invisible later: ```bash From bbb3c038d1a60daffb6b88af5521be4df7d898ab Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:08:30 -0400 Subject: [PATCH 24/95] BOOTSTRAP.md: install jq, which the Phase 2 anisette checks use Co-Authored-By: Claude Opus 5 --- BOOTSTRAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 37312b4..95c7444 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -40,7 +40,7 @@ Already done on this host: `avahi-daemon`, `avahi-utils`, `libavahi-compat-libdn Multicast is confirmed working. Remaining: ```bash -sudo apt install -y usbmuxd libimobiledevice-utils +sudo apt install -y usbmuxd libimobiledevice-utils jq # jq is used by the Phase 2 checks ``` Do **not** `systemctl enable usbmuxd` on Ubuntu — it is udev-activated and has no `[Install]` From 6bad8a9434d5a1e54e75ccc899f2ce113d9262a4 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:12:53 -0400 Subject: [PATCH 25/95] BOOTSTRAP.md: how to get the binary onto the VM, and what belongs in 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 --- BOOTSTRAP.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 95c7444..a286221 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -22,16 +22,37 @@ git push origin bd/revival ``` Then on GitHub: **Actions → Build AltServer →** newest run → **Artifacts → `AltServer-amd64`**. -Copy it to the VM, then: +That downloads to your Mac's browser. GitHub artifact URLs need an authenticated API call even on +a public repo, so fetching it directly on the VM with `curl` will not work — copy it across: ```bash -unzip AltServer-amd64.zip +# on the Mac +scp ~/Downloads/AltServer-amd64.zip youruser@192.168.9.16:~/ +``` + +```bash +# in the VM's SSH session +sudo apt install -y unzip +unzip ~/AltServer-amd64.zip chmod +x AltServer-x86_64 # artifact upload strips the executable bit; without this: status=203/EXEC ./AltServer-x86_64 --help # sanity check ``` The binary is named for the gcc triple (`x86_64`), the artifact for the matrix label (`amd64`). +### What runs in Portainer and what does not + +This host is managed through Portainer, but the split for bootstrap is deliberate: + +| Component | Where | Why | +|---|---|---| +| **Anisette server** | **Portainer stack** | A long-lived service with persistent state. Belongs in the stack. | +| **AltServer, for the bootstrap install** | **Plain binary over SSH** | The 2FA code is read from **stdin**. It needs a real interactive terminal for this one run. | +| **AltServer, as a daemon afterwards** | Portainer stack (Phase 7 / F1) | Once signed in, it no longer needs stdin. | + +So Phase 4 is a one-time command typed into an SSH session, not a container. Containerising it is +future item F1 in [REVIVAL.md](REVIVAL.md), and it depends on solving 2FA entry out-of-band. + --- ## Phase 1 — Host prerequisites From fe5cda3f672b8978198d1c148217888f32ad2451 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:16:04 -0400 Subject: [PATCH 26/95] BOOTSTRAP.md: Safari auto-expands the artifact, and how to verify the 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 --- BOOTSTRAP.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index a286221..61ab64f 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -25,19 +25,41 @@ Then on GitHub: **Actions → Build AltServer →** newest run → **Artifacts That downloads to your Mac's browser. GitHub artifact URLs need an authenticated API call even on a public repo, so fetching it directly on the VM with `curl` will not work — copy it across: +Safari auto-expands downloads ("Open safe files after downloading"), so you will most likely end +up with the bare binary `AltServer-x86_64` rather than `AltServer-amd64.zip`. Either is fine — +copy whichever you actually got: + ```bash -# on the Mac +# on the Mac -- if Safari already expanded it +scp ~/Downloads/AltServer-x86_64 youruser@192.168.9.16:~/ + +# or, if you got the zip scp ~/Downloads/AltServer-amd64.zip youruser@192.168.9.16:~/ ``` ```bash # in the VM's SSH session -sudo apt install -y unzip -unzip ~/AltServer-amd64.zip -chmod +x AltServer-x86_64 # artifact upload strips the executable bit; without this: status=203/EXEC -./AltServer-x86_64 --help # sanity check +sudo apt install -y unzip # only if you copied the zip +unzip ~/AltServer-amd64.zip # only if you copied the zip +chmod +x ~/AltServer-x86_64 # ALWAYS -- artifact upload strips the executable bit, + # and scp does not reliably preserve it either. + # Without it, systemd fails with status=203/EXEC +~/AltServer-x86_64 --help +``` + +### Confirm you have the right binary, not the pre-fix one + +The artifact from a run that predates the fixes looks identical. Check the strings: + +```bash +grep -c "No anisette server is configured" ~/AltServer-x86_64 # expect >= 1 +grep -c "armconverter.com/anisette" ~/AltServer-x86_64 # expect 0 -- the dead default ``` +Expect the first to be non-zero and the second to be **zero**. `file` will also report +`statically linked, with debug_info, not stripped` at roughly 52 MB — the size is the `-g` debug +info from the Makefile, not a problem. + The binary is named for the gcc triple (`x86_64`), the artifact for the matrix label (`amd64`). ### What runs in Portainer and what does not From 061af4c80376c6146a50db7bc09e3272077c2ed0 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:17:58 -0400 Subject: [PATCH 27/95] argv: register --help as a long option 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 --- src/AltServerMain.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/AltServerMain.cpp b/src/AltServerMain.cpp index 2ff7cc7..e6fcffe 100644 --- a/src/AltServerMain.cpp +++ b/src/AltServerMain.cpp @@ -95,6 +95,10 @@ void print_help() { int main(int argc, char *argv[]) { static struct option long_options[] = { + // "help" was documented in the usage text and handled in the switch, but was never + // listed here -- so --help fell through to the error branch, printing + // "?? getopt returned character code 077 ??" above the usage and exiting 1. + {"help", no_argument, 0, 'h'}, {"udid", required_argument, 0, 'u'}, {"appleID", required_argument, 0, 'a'}, {"password", required_argument, 0, 'p'}, From 957f903ce4320db2b199fc05a65738dc41cee294 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:25:37 -0400 Subject: [PATCH 28/95] REVIVAL.md: record bootstrap progress -- phases 0, 1 and 3 complete 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 --- REVIVAL.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/REVIVAL.md b/REVIVAL.md index 714441b..923f2e9 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -290,6 +290,18 @@ breaks the premise soonest, not by how interesting the code is. Items A and C are deployment/config work rather than patches, and both need a real device to confirm. B is a small patch. D is a substantial one. None are blocked by anything already done. +### Bootstrap progress + +- **Phase 0 — binary: DONE.** `AltServer-x86_64` from a green CI run on `8997649`, verified by + string check (contains the new anisette text; the dead armconverter default is absent). +- **Phase 1 — host prereqs: DONE.** avahi-daemon, avahi-utils, libavahi-compat-libdnssd-dev, + usbmuxd, libimobiledevice-utils. `CDLL('libdns_sd.so')` loads. +- **Phase 3 — USB pairing: DONE.** Proxmox passthrough worked; `idevicepair validate` returns + SUCCESS and `/var/lib/lockdown/` is backed up with BOTH the per-device plist and + `SystemConfiguration.plist`. Note: validation fails with "a passcode is set" unless the device + is unlocked at the time — expected, and it will matter again if re-pairing. +- **Phase 2 — anisette: OUTSTANDING.** The only remaining gate before the install. + ### Confirmed deployment facts Target device runs **iOS 26.x** → #131 / the `upstream_repo` bump is required. From b49d9227320beca8afbabc889a043988a7369b98 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:27:02 -0400 Subject: [PATCH 29/95] BOOTSTRAP.md: the anisette persistence check could report success on no data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BOOTSTRAP.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 61ab64f..817f1a9 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -141,12 +141,27 @@ docker restart sleep 5 curl -s http://127.0.0.1:6969 > /tmp/a2.json +# Guard FIRST: jq -r on an empty file prints an empty string and exits 0, so without this +# the comparison below happily reports every field "stable" when the server returned nothing. +for f in /tmp/a1.json /tmp/a2.json; do + [ -s "$f" ] || { echo "FAIL: $f is EMPTY -- the server returned nothing. Fix that first."; exit 1; } + jq -e . "$f" >/dev/null 2>&1 || { echo "FAIL: $f is not valid JSON:"; head -c 200 "$f"; exit 1; } +done + for k in X-Apple-I-MD-M X-Apple-I-MD-LU X-Mme-Device-Id X-Apple-I-SRL-NO; do - a=$(jq -r ".\"$k\"" /tmp/a1.json); b=$(jq -r ".\"$k\"" /tmp/a2.json) + a=$(jq -er ".\"$k\"" /tmp/a1.json 2>/dev/null) || { echo "$k MISSING from first response"; continue; } + b=$(jq -er ".\"$k\"" /tmp/a2.json 2>/dev/null) || { echo "$k MISSING from second response"; continue; } [ "$a" = "$b" ] && echo "$k stable" || echo "$k CHANGED -- state is not persisting" done ``` +> **Check the server answers at all before trusting any of this.** `curl … | jq` prints nothing on +> an empty response, which looks like a pass at a glance: +> ```bash +> curl -s --max-time 5 http://127.0.0.1:6969 | tee /tmp/raw.json | head -c 400 +> echo "bytes: $(wc -c < /tmp/raw.json)" # 0 bytes means nothing is serving there +> ``` + Those four must be **identical**. `X-Apple-I-MD` is a one-time password and *should* differ. If anything changed, find where the ADI blob actually lives and mount that path properly. From 3cd30acaad150bc32be467cbcbb350bf6e44642d Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:33:06 -0400 Subject: [PATCH 30/95] Phase 2: a Portainer stack for anisette, and the volume path the docs 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 #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 --- BOOTSTRAP.md | 148 +++++++++++++++++++++++++------------- deploy/anisette-stack.yml | 79 ++++++++++++++++++++ 2 files changed, 179 insertions(+), 48 deletions(-) create mode 100644 deploy/anisette-stack.yml diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 817f1a9..74e58fb 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -100,74 +100,126 @@ systemctl is-active avahi-daemon --- -## Phase 2 — Anisette server +## Phase 2 — Anisette server (Portainer stack) -**This is the one step most likely to cost you an evening.** Run it on the same box, bound to -loopback, over plain HTTP — that avoids TLS trust entirely, which matters because our client uses -the default http_client config with certificate verification ON. +Use [`deploy/anisette-stack.yml`](deploy/anisette-stack.yml) — **dadoum/anisette-v3-server**, +digest-pinned, loopback-only. It is the only maintained server still serving the legacy v1 +flat-JSON endpoint our client needs, verified by reading the source commit the published image +was built from. -Run whichever anisette server you choose in Docker with a **named volume for its provisioning -state**, then verify it against our client's actual contract before going further. +### ⚠️ The trap: the official README tells you to mount the wrong directory -### The contract our client requires (from `src/AnisetteDataManager.cpp`) +Every copy of the docs, and every forum post repeating them, says to mount: -A plain `GET` returning HTTP **200** and a JSON **object** with these ten keys, **every value a -JSON string** (not a number): +``` +/home/Alcoholic/.config/anisette-v3/lib/ ← WRONG +``` + +`lib/` holds **only** the two Apple `.so` files downloaded at first run. The machine identity — +`device.json` and the ADI provisioning blob — lives one level **up**. Verified in source: ``` -X-Apple-I-MD-M X-Apple-I-MD X-Apple-I-MD-LU X-Apple-I-MD-RINFO X-Mme-Device-Id -X-Apple-I-SRL-NO X-MMe-Client-Info X-Apple-I-Client-Time X-Apple-Locale X-Apple-I-TimeZone +59: configurationPath = expandTilde("~/.config/anisette-v3"); +95: libraryPath = configurationPath.buildPath("lib"); // just the .so cache +134: v1Device = new Device(configurationPath.buildPath("device.json")); +136: v1Adi.provisioningPath = configurationPath; // identity lives HERE ``` -Note the inconsistent capitalisation — `X-MMe-Client-Info` (capital MM) versus `X-Mme-Device-Id` -(lowercase m). Matching is case-sensitive. +Mount `lib/` and the identity stays on the container's writable layer, so **every Portainer +"Update the stack" destroys it**: the server mints a new machine, Apple demands 2FA, and +unattended refresh dies silently. That is issue #86, straight out of the official docs. + +### Prep (before deploying) + +The container runs as **uid 1000**, and a bind mount inherits the *host* directory's ownership +(`root:root`), so without this it cannot write and dies with `FileException … Permission denied`: ```bash -curl -s -H 'User-Agent: Xcode' http://127.0.0.1:6969 | jq 'map_values(type)' +sudo mkdir -p /opt/stacks/anisette/config +sudo chown 1000:1000 /opt/stacks/anisette/config +sudo chmod 700 /opt/stacks/anisette/config +timedatectl # must say: System clock synchronized: yes ``` -Every one of the ten must read `"string"`. A server emitting `X-Apple-I-MD-RINFO` as a *number* is -a known real-world variant — and it is the one field parsed with `std::atoi`, which returns 0 -silently rather than erroring, producing an opaque `-36607` later. +### Deploy + +Portainer → **Stacks → Add stack → Web editor**, paste `deploy/anisette-stack.yml`, Deploy. + +First start downloads the Apple Music APK and provisions against Apple — expect a few minutes, +ending in `Machine creation done!` then `Provisioning done!`. + +### Two settings that are load-bearing, and why -### Verify its identity survives a restart +- **`TZ: UTC` — do not copy `TZ=America/New_York` from your Plex/Immich/AdGuard stacks.** The + server stamps `X-Apple-I-Client-Time` from *local* wall-clock time and then appends a literal + `Z`. Under any other TZ it sends Apple a timestamp wrong by your UTC offset while claiming to be + UTC — well-formed, contract-passing, and silently wrong. +- **The healthcheck hits `/v3/client_info`, never `/`.** The v1 route at `/` performs *real* + provisioning against Apple when the machine is not yet provisioned, so polling `/` would hammer + Apple's endpoint exactly when your identity volume has gone missing. If the container reports + unhealthy immediately, the image may lack `curl` — just delete the healthcheck block. -Skip this and you may hit a 2FA prompt on every refresh, which unattended operation can never -answer: +### Verify (in order — each catches something the next assumes) ```bash -curl -s http://127.0.0.1:6969 > /tmp/a1.json -docker restart -sleep 5 -curl -s http://127.0.0.1:6969 > /tmp/a2.json - -# Guard FIRST: jq -r on an empty file prints an empty string and exits 0, so without this -# the comparison below happily reports every field "stable" when the server returned nothing. -for f in /tmp/a1.json /tmp/a2.json; do - [ -s "$f" ] || { echo "FAIL: $f is EMPTY -- the server returned nothing. Fix that first."; exit 1; } - jq -e . "$f" >/dev/null 2>&1 || { echo "FAIL: $f is not valid JSON:"; head -c 200 "$f"; exit 1; } -done - -for k in X-Apple-I-MD-M X-Apple-I-MD-LU X-Mme-Device-Id X-Apple-I-SRL-NO; do - a=$(jq -er ".\"$k\"" /tmp/a1.json 2>/dev/null) || { echo "$k MISSING from first response"; continue; } - b=$(jq -er ".\"$k\"" /tmp/a2.json 2>/dev/null) || { echo "$k MISSING from second response"; continue; } - [ "$a" = "$b" ] && echo "$k stable" || echo "$k CHANGED -- state is not persisting" -done +docker exec anisette id # expect uid=1000(Alcoholic); the mount depends on it + +curl -sS -o /dev/null -w 'HTTP %{http_code}\n' -H 'User-Agent: Xcode' http://127.0.0.1:6969/ + +curl -sS -H 'User-Agent: Xcode' http://127.0.0.1:6969/ | python3 -c ' +import json,sys +d=json.load(sys.stdin) +req=["X-Apple-I-MD-M","X-Apple-I-MD","X-Apple-I-MD-LU","X-Apple-I-MD-RINFO","X-Mme-Device-Id", + "X-Apple-I-SRL-NO","X-MMe-Client-Info","X-Apple-I-Client-Time","X-Apple-Locale","X-Apple-I-TimeZone"] +bad=[k for k in req if k not in d] +notstr=[k for k in req if k in d and not isinstance(d[k],str)] +print("MISSING:",bad or "none") +print("NOT A STRING:",notstr or "none") +print("Client-Time:",d.get("X-Apple-I-Client-Time")) +print("Client-Info:",d.get("X-MMe-Client-Info")) +print("VERDICT:","PASS" if not bad and not notstr else "FAIL")' + +date -u +%Y-%m-%dT%H:%M:%SZ # must match Client-Time above to within seconds + +docker exec anisette ls -la /home/Alcoholic/.config/anisette-v3 # device.json + adi.pb + lib/ +sudo ls -la /opt/stacks/anisette/config # same files on the HOST side +docker diff anisette | grep -iE 'anisette-v3|adi|device' # expect NOTHING identity-related + +sudo ss -lntp | grep 6969 # must be 127.0.0.1:6969, never 0.0.0.0 ``` -> **Check the server answers at all before trusting any of this.** `curl … | jq` prints nothing on -> an empty response, which looks like a pass at a glance: -> ```bash -> curl -s --max-time 5 http://127.0.0.1:6969 | tee /tmp/raw.json | head -c 400 -> echo "bytes: $(wc -c < /tmp/raw.json)" # 0 bytes means nothing is serving there -> ``` +**Then the test that actually matters — survive a REDEPLOY, not a restart.** `docker restart` +keeps the same container and proves nothing; Portainer redeploys destroy and recreate it, which is +what breaks people. In Portainer: **Stacks → anisette → Editor → Update the stack**, then confirm +the container ID changed *and* `X-Mme-Device-Id` / `X-Apple-I-MD-LU` did **not**. +(`X-Apple-I-MD` is a one-time password and *should* differ.) -Those four must be **identical**. `X-Apple-I-MD` is a one-time password and *should* differ. -If anything changed, find where the ADI blob actually lives and mount that path properly. +### Back up the identity immediately -**Also: NTP must be right on whichever host runs the anisette server**, not just the AltServer -host — Linux forwards the anisette server's timestamp verbatim to Apple. Clock skew surfaces as a -generic `-36607` with nothing pointing at a clock. +`adi.pb` is rewritten on every request, so copy it while idle: + +```bash +docker stop anisette +sudo tar czf ~/anisette-identity-$(date +%F).tgz -C /opt/stacks/anisette config +docker start anisette +``` + +Restoring that tarball is the **only** disaster-recovery path. Without it, a lost identity means +re-provisioning and a fresh 2FA prompt. + +### Honest confidence + +| Claim | Confidence | +|---|---| +| Serves all ten keys as strings, correct casing | **Verified in source** | +| Identity lives in the parent dir, not `lib/` | **Verified in source** | +| Published image matches that source | Medium — `:latest` is ~17 months behind; upstream's publish workflow has been failing | +| This produces a successful Apple sign-in | **Low — unproven.** No one has reported a completed AltServer-Linux sign-in in 2026; every success report predates both the GSA block and iOS 26 | + +Rejected alternatives: `dadoum/anisette-server` (crashes at startup, missing libplist), +`omnisette-server` (its v1 handler removes three of the ten keys), +`nyamisty/alt_anisette_server` (dead since 2022, implicated in the #88 lockouts), and any public +shared server (shared identity is the lockout mechanism). --- diff --git a/deploy/anisette-stack.yml b/deploy/anisette-stack.yml new file mode 100644 index 0000000..b6e1592 --- /dev/null +++ b/deploy/anisette-stack.yml @@ -0,0 +1,79 @@ +# Anisette server for AltServer-Linux (the legacy "anisette-v1" flat-JSON endpoint). +# +# LAN-ONLY BY DESIGN. Bound to loopback. This hands out an Apple machine identity -- +# it must NOT go behind Nginx Proxy Manager and must NOT get a Cloudflare Tunnel. +# +# --------------------------------------------------------------------------------- +# ONE-TIME PREP ON THE UBUNTU VM, BEFORE YOU DEPLOY THIS STACK. +# The container runs as uid 1000, and a bind mount inherits the HOST directory's +# ownership (root:root by default), so without this the process cannot write its +# identity files and dies with: +# std.file.FileException@std/file.d(3001): .../lib: Permission denied +# +# sudo mkdir -p /opt/stacks/anisette/config +# sudo chown 1000:1000 /opt/stacks/anisette/config +# sudo chmod 700 /opt/stacks/anisette/config +# --------------------------------------------------------------------------------- + +services: + anisette: + # Digest-pinned on purpose. ":latest" is the only tag published, and it is ~17 + # months behind the git source because upstream's publish workflow keeps failing. + # Pinning means a surprise CI fix can never silently swap the binary that holds + # your Apple machine identity out from under a running deployment. + # CHANGE ONLY IF: you decide to build from source into ghcr.io/ben-diehlci/ (see fallbacks). + image: dadoum/anisette-v3-server@sha256:1e20384985d3c49965f444bef39d627768dacc39ea0dca91f2a535edb7591ba3 + container_name: anisette + restart: unless-stopped + + environment: + # MUST STAY UTC. The server stamps X-Apple-I-Client-Time from LOCAL wall-clock + # time and then staples a literal "Z" onto it. Under any other TZ it sends Apple + # a timestamp wrong by your UTC offset while claiming to be UTC -- a well-formed, + # contract-passing, silently wrong value that nothing in any log will name. + # DO NOT copy TZ=America/New_York here from your Plex / Immich / AdGuard stacks. + TZ: UTC + + ports: + # Loopback only. This assumes AltServer runs on THIS SAME VM sharing the host + # network namespace (network_mode: host, or as a bare binary), which it must + # anyway for mDNS and usbmuxd. + # CHANGE ONLY IF: AltServer runs as a bridge-networked container. In that case + # delete this whole ports: block, put both services on one internal compose + # network, and point AltServer at http://anisette:6969 instead. Do not publish + # this port on 0.0.0.0 or on 192.168.9.16. + - "127.0.0.1:6969:6969" + + volumes: + # ***** THE MACHINE IDENTITY LIVES HERE. THIS LINE IS THE WHOLE DEPLOYMENT. ***** + # Mount this DIRECTORY, not its lib/ subdirectory. + # The project's own README -- and every forum copy of it -- says + # .../anisette-v3/lib/ + # That subdirectory holds ONLY the two Apple .so files downloaded at first run. + # device.json and adi.pb live one level UP. Mount lib/ and they stay on the + # container's writable layer, so every Portainer "Update the stack" destroys + # them, the server mints a brand-new machine, Apple demands 2FA, and unattended + # refresh dies silently. That is issue #86, and it comes from the official docs. + # CHANGE ONLY IF: your stacks live somewhere other than /opt/stacks. Left side only. + # NEVER change the right side. + - /opt/stacks/anisette/config:/home/Alcoholic/.config/anisette-v3 + + healthcheck: + # Deliberately /v3/client_info and NOT "/". The v1 route at "/" performs REAL + # provisioning against Apple whenever the machine is not yet provisioned, so 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. /v3/client_info is static and makes + # no Apple contact. + # CHANGE ONLY IF: the container reports "unhealthy" immediately. The April-2025 + # image may not ship curl -- check with `docker exec anisette curl --version`. + # If curl is missing, just DELETE this entire healthcheck block. It is a + # Portainer dashboard signal only; plain Compose never restarts an unhealthy + # container (that is Swarm), and auto-restarting into Apple provisioning would + # be the harmful loop described above. + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:6969/v3/client_info"] + interval: 5m + timeout: 10s + retries: 3 + # First run downloads the Apple Music APK and provisions against Apple. Minutes. + start_period: 5m From 2fd4a92a0c365ec1f4a4031b51603dc0feb91e2b Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:14:09 -0400 Subject: [PATCH 31/95] REVIVAL.md: anisette deployed and verified; the GSA sanitizer is load-bearing Phase 2 is up. The contract passes -- ten keys, all strings, HTTP 200, clock in sync -- and more importantly the volume mapping is proven rather than assumed: docker diff shows nothing identity-related on the container's writable layer, while adi.pb and device.json are present host-side. The lib/-only mount that the upstream README recommends was avoided, so a Portainer stack update will not destroy the machine identity. Records one result that retroactively justifies an earlier change: this server returns com.apple.dt.Xcode/3594.4.19 in X-MMe-Client-Info, which is exactly the substring Apple's GSA edge rejects with a 503. The PR #135 sanitizer applied in 04dd7ee is therefore load-bearing for sign-in rather than speculative, and ALTSERVER_NO_CLIENTINFO_SANITIZE should stay unset. Also notes adi.pb is mode ---x-w-rwt, written that way by Apple's closed-source libCoreADI, so backups of it require sudo. Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/REVIVAL.md b/REVIVAL.md index 923f2e9..198379b 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -300,7 +300,18 @@ confirm. B is a small patch. D is a substantial one. None are blocked by anythin SUCCESS and `/var/lib/lockdown/` is backed up with BOTH the per-device plist and `SystemConfiguration.plist`. Note: validation fails with "a passcode is set" unless the device is unlocked at the time — expected, and it will matter again if re-pairing. -- **Phase 2 — anisette: OUTSTANDING.** The only remaining gate before the install. +- **Phase 2 — anisette: DEPLOYED AND VERIFIED** (redeploy-persistence test still pending). + `dadoum/anisette-v3-server`, digest-pinned, Portainer stack, `127.0.0.1:6969`. + Contract PASS — all ten keys present, all JSON strings, HTTP 200. Clock matches `date -u`. + **The volume mapping is proven correct**: `docker diff` shows nothing identity-related on the + writable layer, and `adi.pb` + `device.json` + `lib/` are visible host-side at + `/opt/stacks/anisette/config`. The `lib/`-only mount the upstream README recommends was avoided. + Container runs as uid 1000 = host `youruser`. + Quirk: `adi.pb` is mode `---x-w-rwt` (written by Apple's closed-source libCoreADI), so backups + need `sudo`. + **Confirmed: this server returns `com.apple.dt.Xcode/3594.4.19` in `X-MMe-Client-Info`**, so the + PR #135 sanitizer is load-bearing for sign-in. Leave `ALTSERVER_NO_CLIENTINFO_SANITIZE` unset. +- **Phase 4 — install: NEXT.** ### Confirmed deployment facts From 9fbf8b75f89b862fe8adb6623c40a5ef3a1800a3 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:17:16 -0400 Subject: [PATCH 32/95] BOOTSTRAP.md: make the Phase 4 command paste-safe, and keep the password out of history The install command used placeholders, which bash reads as input redirection -- pasting it verbatim fails with "No such file or directory" rather than with anything that hints at the real problem. Hit for real during Phase 4. Replaced with read prompts, which fixes the paste hazard and additionally keeps the Apple ID password out of ~/.bash_history. Also adds an explicit check that AltStore.ipa is present, since that step was previously buried in a comment. Records the residual issue rather than hiding it: the password is still visible in ps for the duration, because the binary accepts it only as an argv parameter. Added as TODO 9 -- accept it from an env var or stdin instead, which matters more once this runs unattended, where the password has to be stored somewhere regardless. Co-Authored-By: Claude Opus 5 --- BOOTSTRAP.md | 20 +++++++++++++++----- REVIVAL.md | 5 +++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 74e58fb..11825b6 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -248,16 +248,26 @@ sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/ Get `AltStore.ipa` from onto the VM. Then, **in an interactive terminal** — 2FA is read from stdin, which does not exist under systemd: +Do **not** paste angle-bracket placeholders into the shell — bash reads `<` as input redirection +and fails with `No such file or directory`. Prompt for the values instead, which also keeps the +Apple ID password out of `~/.bash_history`: + ```bash +ls -la ~/AltStore.ipa # confirm the IPA is actually there first + export ALTSERVER_ANISETTE_SERVER=http://127.0.0.1:6969 -./AltServer-x86_64 \ - -u \ - -a \ - -p '' \ - AltStore.ipa +read -rp "UDID: " UDID +read -rp "Apple ID: " APPLEID +read -rsp "Password: " APPLEPW; echo + +~/AltServer-x86_64 -u "$UDID" -a "$APPLEID" -p "$APPLEPW" ~/AltStore.ipa ``` +> The password is still visible in `ps` for the duration of the run, because the binary accepts it +> only as a command-line argument. Harmless on a single-user homelab VM, but it is a real interface +> flaw — see TODO 9 in [REVIVAL.md](REVIVAL.md). + Flag order no longer matters (the `-a` fallthrough is fixed), but keep `-u -a -p` anyway. You will be prompted for a **2FA code** — type it at the prompt. diff --git a/REVIVAL.md b/REVIVAL.md index 198379b..bb82eec 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -516,6 +516,11 @@ much of this session was spent on silent failures — visible health, i.e. is th is the anisette server reachable, when did the last successful refresh happen, and when do the current certificates expire. +9. **Accept the Apple ID password from somewhere other than argv.** `-p` puts the password in + `ps` output for the life of the process and in shell history. An `ALTSERVER_APPLE_PASSWORD` + env var, or reading from stdin when `-p` is absent, would fix it. Small, and it matters more + once this runs unattended, where the password has to live somewhere anyway. + ### Explicitly not doing - **PR #98 (CMake rewrite).** Author wrote "doesn't 100% work" in 2023 and never returned; keyed From 118e9fb8350ed41171d3c273c9e7239e57f7dedc Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:26:16 -0400 Subject: [PATCH 33/95] altsign: report a non-200 from Apple's auth endpoint instead of "invalid response" Hit for real during the first install attempt. Apple returned: Received auth response status code: 200 Received auth response status code: 429 Alert: Could not install AltStore.ipa to unknown. Server returned invalid response. Error: com.rileytestut.ALTAppleAPI (17). 429 is Too Many Requests -- Apple rate limiting. But AppleAPI+Authentication.cpp:993 logs the status and then discards it, passing the body straight to plist_from_xml. A 429 body is not plist XML, so parsing fails and the user is told "Server returned invalid response" (APIErrorCode::InvalidResponse), which sends them hunting a protocol bug when nothing is wrong with the protocol. This is the same defect class this project already fixed once in src/AnisetteDataManager.cpp: log the status, ignore it, mis-report the consequence. Here the file is vendored, so the fix goes through rewrite_altsign_source.py, which already processes every file in AltSign/. Now prints an explicit warning naming the status, and specifically that 429 means waiting rather than retrying -- because retrying in a loop is the mechanism behind the Apple ID lockouts in issue #88, so the misleading error was actively dangerous, not merely unhelpful. Guarded like the ldid patch: if the pattern stops matching exactly once the build fails loudly rather than silently not applying. Verified: rewrite runs clean, the string is present in the patched output, and a full rebuild exits 0. Co-Authored-By: Claude Opus 5 --- .../AltSign-build/rewrite_altsign_source.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/makefiles/AltSign-build/rewrite_altsign_source.py b/makefiles/AltSign-build/rewrite_altsign_source.py index ba7cfa2..a244aa3 100644 --- a/makefiles/AltSign-build/rewrite_altsign_source.py +++ b/makefiles/AltSign-build/rewrite_altsign_source.py @@ -16,6 +16,39 @@ content = content.replace(b'"%FT%T%z"', b'"%Y-%m-%dT%H:%M:%SZ"') content = content.replace(b'localtime(', b'gmtime(') + +# --- Make a non-200 from Apple's auth endpoint legible ------------------------------- +# AppleAPI+Authentication.cpp logs the HTTP status and then DISCARDS it, feeding the body +# straight to plist_from_xml. A 429 body is not plist XML, so it fails to parse and the user +# is told "Server returned invalid response" (APIErrorCode::InvalidResponse, 17) -- which +# sends them looking for a protocol bug when Apple is simply rate limiting them. +# Observed for real: auth request 1 -> 200, request 2 -> 429, reported as "invalid response". +# Same defect class as the unchecked extract_json() this project already fixed in +# src/AnisetteDataManager.cpp: log the status, ignore it, mis-report the consequence. +_auth_old = ( + b'\t\t\t\todslog("Received auth response status code: " << response.status_code());\r\n' +) +_auth_new = ( + b'\t\t\t\todslog("Received auth response status code: " << response.status_code());\r\n' + b'\t\t\t\tif (response.status_code() != 200)\r\n' + b'\t\t\t\t{\r\n' + b'\t\t\t\t\todslog("WARNING: Apple\'s auth endpoint returned HTTP " << response.status_code()\r\n' + b'\t\t\t\t\t\t<< ". If this is 429, Apple is RATE LIMITING this machine or Apple ID: wait "\r\n' + b'\t\t\t\t\t\t "30-60 minutes and try ONCE more. Do NOT retry in a loop -- repeated failed "\r\n' + b'\t\t\t\t\t\t "attempts are how Apple IDs get locked. Any \'invalid response\' error below is "\r\n' + b'\t\t\t\t\t\t "misleading: the body is simply not a plist.");\r\n' + b'\t\t\t\t}\r\n' +) + +if F.endswith('AppleAPI+Authentication.cpp'): + if content.count(_auth_old) != 1: + sys.stderr.write( + "rewrite_altsign_source.py: auth status-code patch matched %d times, expected 1.\n" + " upstream AppleAPI+Authentication.cpp changed; re-check before removing this guard.\n" + % content.count(_auth_old)) + sys.exit(1) + content = content.replace(_auth_old, _auth_new) + content = content.replace(b'winsock2.h', b'WinSock2.h') sys.stdout.buffer.write(content) \ No newline at end of file From 4f40fddbe4b1528ff913b35ac2b6d3797c84dc85 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:05 -0400 Subject: [PATCH 34/95] argv: accept credentials from the environment, not only from argv Prerequisite for running this in a container at all. A detached container has no argv to type into, and -p places the Apple ID password in `ps` output for every user on the host as well as in shell history -- neither is acceptable once this runs unattended, where the password has to be stored somewhere regardless. ALTSERVER_UDID, ALTSERVER_APPLE_ID and ALTSERVER_APPLE_PASSWORD now fill in for -u, -a and -p. Precedence is flag > environment, so every existing command line keeps working unchanged. An empty variable counts as unset, so an unpopulated compose environment: block does not masquerade as a supplied credential. The missing-argument error now names what is absent regardless of which channel was used, and the usage text documents both the new variables and ALTSERVER_NO_CLIENTINFO_SANITIZE, which was previously undocumented. Verified against a real build: full environment satisfies the guard, a partial environment still reports exactly which pieces are missing, and empty values are correctly treated as unset. Co-Authored-By: Claude Opus 5 --- src/AltServerMain.cpp | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/AltServerMain.cpp b/src/AltServerMain.cpp index e6fcffe..9e2fcc7 100644 --- a/src/AltServerMain.cpp +++ b/src/AltServerMain.cpp @@ -88,6 +88,13 @@ void print_help() { " There is no default. The server that used to be hardcoded here has been\n" " returning HTTP 502 since 2026-09, and pointing every user at one shared\n" " anisette identity can get Apple IDs locked. See the README.\n" + " - ALTSERVER_UDID / ALTSERVER_APPLE_ID / ALTSERVER_APPLE_PASSWORD:\n" + " Alternatives to -u / -a / -p. A command-line flag wins if both are given.\n" + " Prefer these when running unattended or in a container: a password passed\n" + " as -p is visible in `ps` to every user on the host, and lands in shell history.\n" + " - ALTSERVER_NO_CLIENTINFO_SANITIZE: set to 1 to stop rewriting com.apple.dt.Xcode\n" + " to com.apple.akd in X-MMe-Client-Info. Only useful for diagnosing sign-in\n" + " failures; leave unset normally.\n" " - ALTSERVER_NO_SUBSCRIBE: (*unused*) Please enable this for usbmuxd server that do not correctly usbmuxd_listen interfaces\n" ); } @@ -177,15 +184,32 @@ int main(int argc, char *argv[]) { return 1; } + // Fall back to the environment when a flag is absent. This is what makes unattended and + // containerised operation possible at all: a detached container has no argv to type into, + // and -p places the Apple ID password in `ps` output for every user on the host and in shell + // history. An env var (or a Docker secret sourced into one) is strictly better on both counts. + // Precedence is flag > environment, so existing command lines keep working unchanged. + { + const char *envUdid = getenv("ALTSERVER_UDID"); + const char *envAppleID = getenv("ALTSERVER_APPLE_ID"); + const char *envPassword = getenv("ALTSERVER_APPLE_PASSWORD"); + + if (udid == NULL && envUdid != NULL && *envUdid != '\0') { udid = (char *)envUdid; } + if (appleID == NULL && envAppleID != NULL && *envAppleID != '\0') { appleID = (char *)envAppleID; } + if (password == NULL && envPassword != NULL && *envPassword != '\0') { password = (char *)envPassword; } + } + if (installApp && (udid == NULL || appleID == NULL || password == NULL)) { fprintf(stderr, - "ERROR: installing an IPA requires -u/--udid, -a/--appleID and -p/--password.\n" + "ERROR: installing an IPA requires a UDID, an Apple ID and a password.\n" " Missing:%s%s%s\n" + " Supply them as -u/--udid, -a/--appleID, -p/--password, or as the environment\n" + " variables ALTSERVER_UDID, ALTSERVER_APPLE_ID and ALTSERVER_APPLE_PASSWORD.\n" " Run with no IPA argument to start in server (daemon) mode instead.\n", - udid == NULL ? " --udid" : "", - appleID == NULL ? " --appleID" : "", - password == NULL ? " --password" : ""); + udid == NULL ? " UDID" : "", + appleID == NULL ? " AppleID" : "", + password == NULL ? " password" : ""); return 1; } From 1fe89a32e7059a63fd841b691098c4cfc7d4b91d Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:47:29 -0400 Subject: [PATCH 35/95] Containerise AltServer: image, combined stack, and a publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of the self-contained-deploy goal (F1), so the whole thing can be deployed from a Portainer Git-repository stack rather than assembled by hand. Dockerfile — multi-stage. The build stage uses the project's own alpine toolchain (the only place the static corecrypto/cpprestsdk/boost live) and normalises the arch-suffixed binary name to a single path. The runtime stage installs every RUNTIME prerequisite so nothing has to be apt-installed on the host, and the two that matter are the two that fail silently: python3, because the -static binary cannot dlopen Bonjour itself and shells out to python3 to do it, and libavahi-compat-libdnssd-dev rather than -libdnssd1, because the code dlopens the UNVERSIONED soname whose symlink only the -dev package provides. The image runs CDLL('libdns_sd.so') at BUILD time and fails the build if it cannot load, so an image that could not advertise cannot be published. deploy/altserver-stack.yml — both services in one stack. network_mode: host is mandatory (mDNS does not cross a bridge, and a ports: block would be meaningless anyway since the listener binds an ephemeral port that changes every start). init: true is mandatory too: the binary installs no SIGTERM handler, so as PID 1 the kernel discards docker stop's signal. Mounts cover AltServerData (the app writes to the RELATIVE ./AltServerData, so the image's WORKDIR is load-bearing), the lockdown pairing record, the usbmuxd socket, and the dbus/avahi sockets that actually perform the publishing. .github/workflows/build_image.yml — publishes to ghcr.io//altserver-linux, deriving the namespace from github.repository_owner and lowercasing it, so a fork publishes to its own namespace. That is precisely what build_docker.yml gets wrong by hardcoding ghcr.io/nyamisty, which is why it can never succeed on a fork. Pinned to amd64 deliberately: the build stage pulls an arch-specific builder, so a naive platforms: list would emit an amd64 binary labelled as something else. Every action is on a node24 major, including build-push-action@v7 (v6 is still node20 and would have reintroduced the deprecation warnings just removed). Verified by building the image locally: builds clean, --help works, python3 and the unversioned libdns_sd.so symlink are present and loadable, and credentials are accepted from the environment inside the container. Also records that F2's scope has grown: the web portal should help CONNECT the phone, not just sign in. Pairing is where a novice gets stuck and its failures are opaque -- the device must be unlocked for idevicepair validate, a USB cable is mandatory once, and a genuine device fault is displayed as "AltServer could not be found". Co-Authored-By: Claude Opus 5 --- .dockerignore | 7 ++ .github/workflows/build_image.yml | 85 ++++++++++++++++++++++++ Dockerfile | 72 ++++++++++++++++++++ REVIVAL.md | 26 +++++++- deploy/altserver-stack.yml | 106 ++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build_image.yml create mode 100644 Dockerfile create mode 100644 deploy/altserver-stack.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce4362b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +build/ +*.o +.DS_Store +._* +REVIVAL.md +BOOTSTRAP.md diff --git a/.github/workflows/build_image.yml b/.github/workflows/build_image.yml new file mode 100644 index 0000000..04f38b6 --- /dev/null +++ b/.github/workflows/build_image.yml @@ -0,0 +1,85 @@ +name: Build AltServer Image + +# Publishes a self-contained AltServer image to ghcr.io//altserver-linux, so a Portainer +# stack can just pull it instead of building on the host. +# +# Uses ${{ github.repository_owner }} rather than a hardcoded namespace, so a fork publishes to +# its OWN namespace and does not fail against someone else's — which is exactly the mistake +# build_docker.yml makes by hardcoding ghcr.io/nyamisty. + +on: + push: + branches: + - new + - bd/revival + paths: + - 'Dockerfile' + - '.dockerignore' + - 'src/**' + - 'shims/**' + - 'makefiles/**' + - 'Makefile' + - '.github/workflows/build_image.yml' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + +jobs: + image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Lowercase the owner: GHCR rejects uppercase in image names, and GitHub usernames may + # contain capitals (Ben-Diehlci does). + - name: Compute image name + id: name + run: echo "image=${REGISTRY}/$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')/altserver-linux" >> "$GITHUB_OUTPUT" + + # amd64 only for now. The build stage pulls an ARCH-SPECIFIC alpine builder image, so a + # multi-arch build needs a per-arch BUILDER arg rather than a plain platforms: list — + # buildx would otherwise run the amd64 toolchain under emulation and still emit an amd64 + # binary, producing an image that is mislabelled rather than merely slow. + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + build-args: | + BUILDER=ghcr.io/nyamisty/altserver_builder_alpine_amd64 + platforms: linux/amd64 + push: true + tags: | + ${{ steps.name.outputs.image }}:latest + ${{ steps.name.outputs.image }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Summary + run: | + echo "Pushed ${{ steps.name.outputs.image }}:latest" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "The package is PRIVATE by default. To let Portainer pull it without credentials," >> "$GITHUB_STEP_SUMMARY" + echo "make it public once: GitHub -> Packages -> altserver-linux -> Package settings ->" >> "$GITHUB_STEP_SUMMARY" + echo "Change visibility -> Public." >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a518427 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# AltServer-Linux — self-contained image. +# +# Builds the binary from this repo and installs every RUNTIME prerequisite, so nothing has to be +# apt-installed on the host by hand. The two that are easy to miss, and both fail SILENTLY: +# +# python3 — AltServer is a -static binary and therefore cannot dlopen +# Bonjour itself. It shells out to python3, which dlopens +# libdns_sd.so on its behalf (libraries/dnssd_loader). +# libavahi-compat-libdnssd-dev — NOT ...-libdnssd1. The runtime package ships only +# libdns_sd.so.1, while the code dlopens the UNVERSIONED +# soname, whose symlink comes from the -dev package. +# +# Without either, the server starts, reports nothing wrong, and is permanently undiscoverable +# by the phone. The image verifies both at build time so that cannot ship broken. + +# --------------------------------------------------------------------------------------------- +# Build stage — uses the project's own alpine toolchain, which carries the static corecrypto, +# cpprestsdk, boost and libzip this build needs. Override BUILDER for a different architecture: +# ..._amd64 (default) | ..._aarch64 | ..._armv7 | ..._i386 +# --------------------------------------------------------------------------------------------- +ARG BUILDER=ghcr.io/nyamisty/altserver_builder_alpine_amd64 + +FROM ${BUILDER} AS build + +WORKDIR /src +COPY . /src + +# The binary is named for the gcc triple (AltServer-x86_64, AltServer-aarch64, …), so normalise +# it to a single known path for the runtime stage to copy. +RUN set -eux; \ + rm -rf build; mkdir -p build /out; \ + cd build; \ + make -f ../Makefile -j"$(nproc)"; \ + cp AltServer-* /out/AltServer; \ + chmod +x /out/AltServer; \ + ls -la /out/AltServer + +# --------------------------------------------------------------------------------------------- +# Runtime stage +# --------------------------------------------------------------------------------------------- +FROM debian:bookworm-slim + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + python3 \ + libavahi-compat-libdnssd-dev \ + ca-certificates \ + tzdata; \ + rm -rf /var/lib/apt/lists/*; \ + # Fail the BUILD rather than ship an image that cannot advertise. This is the exact call + # dnssd_loader.cpp makes, so if it works here it works at runtime. + python3 -c "from ctypes import CDLL; CDLL('libdns_sd.so')"; \ + echo "libdns_sd.so loads OK" + +COPY --from=build /out/AltServer /usr/local/bin/AltServer + +# AltServerApp writes to the RELATIVE path ./AltServerData, resolved against the working +# directory — so the workdir is load-bearing, not cosmetic. Mount a volume here to persist it. +WORKDIR /data + +# Sane defaults; override in compose. +# ALTSERVER_ANISETTE_SERVER is REQUIRED and deliberately has no default: the server that used +# to be hardcoded is dead, and a shared anisette identity can get Apple IDs locked. +ENV ALTSERVER_ANISETTE_SERVER="" + +# Documents intent only. mDNS needs the host network, so compose must set network_mode: host — +# a published port cannot help, and Bonjour does not cross a bridge. +EXPOSE 51820 + +# No IPA argument = daemon mode. Add one (or docker exec) for a one-time install. +ENTRYPOINT ["/usr/local/bin/AltServer"] diff --git a/REVIVAL.md b/REVIVAL.md index bb82eec..f5bad33 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -480,7 +480,17 @@ Consequences, and they are exactly the wrong shape for a headless box: These are the operator's stated end-goals for the project. Do not start them until the blockers above are cleared; they are written down here with enough grounding to be picked up cold. -**F1. Self-contained deploy.** Point Portainer (or any compose-based platform) at the GitHub repo +**F1. Self-contained deploy — LARGELY BUILT.** `Dockerfile` (multi-stage, installs every runtime +prerequisite including the python3 + `libavahi-compat-libdnssd-dev` pair that otherwise fails +silently, and *verifies* `CDLL('libdns_sd.so')` at build time so a broken image cannot ship), +`deploy/altserver-stack.yml` (both services, `network_mode: host`, `init: true`, all the mounts), +and `.github/workflows/build_image.yml` (publishes to `ghcr.io//altserver-linux`, using +`repository_owner` so a fork publishes to its own namespace instead of failing against someone +else's — the mistake `build_docker.yml` makes). Portainer supports Git-repository stacks natively, +so "point Portainer at the repo" now works. Remaining: make the package public once, and confirm +the stack end-to-end on the host. Original notes: + +**F1 (original).** Point Portainer (or any compose-based platform) at the GitHub repo and have everything come up with no manual steps beyond entering account credentials. Portainer supports deploying a stack straight from a Git repository, so the shape is: a `docker-compose.yml` in the repo, an image published to `ghcr.io/ben-diehlci/`, `network_mode: host` for mDNS, a named @@ -493,7 +503,17 @@ Open questions to research: can the anisette server be bundled in the same stack its own identity/state? What is the minimum set of secrets, and can they be Docker secrets rather than plain env vars? Does anything need to run privileged or with host devices for usbmuxd/netmuxd? -**F2. Web interface, replacing the desktop GUI.** On macOS and Windows AltServer has a tray/GUI +**F2. Web interface — now the main remaining piece, and the scope has grown.** Beyond sign-in, +2FA entry, device selection and health, the operator wants it to **help connect the phone**, so +that someone without background knowledge can get through setup. That is the right instinct: +pairing is where a novice gets stuck, and the failure modes are opaque — the device must be +*unlocked* for `idevicepair validate`; wireless pairing is impossible so a USB cable is mandatory +once; and a real device fault is *displayed* as "AltServer could not be found" because AltStore +remaps it for any server that is not `isPreferred`. A setup wizard that ran `idevice_id -l`, +reported "plug your phone in and tap Trust", and distinguished those cases would remove most of +the difficulty. Original notes: + +**F2 (original).** On macOS and Windows AltServer has a tray/GUI for signing in, entering the 2FA code, choosing a device and triggering a refresh. This port replaced all of that with a console implementation injected by `makefiles/rewrite_altserver_source.py`. A web UI is the natural equivalent for a headless box. @@ -516,7 +536,7 @@ much of this session was spent on silent failures — visible health, i.e. is th is the anisette server reachable, when did the last successful refresh happen, and when do the current certificates expire. -9. **Accept the Apple ID password from somewhere other than argv.** `-p` puts the password in +9. ~~**Accept the Apple ID password from somewhere other than argv.**~~ **DONE** — `dcac3de`. `-p` puts the password in `ps` output for the life of the process and in shell history. An `ALTSERVER_APPLE_PASSWORD` env var, or reading from stdin when `-p` is absent, would fix it. Small, and it matters more once this runs unattended, where the password has to live somewhere anyway. diff --git a/deploy/altserver-stack.yml b/deploy/altserver-stack.yml new file mode 100644 index 0000000..0223a9b --- /dev/null +++ b/deploy/altserver-stack.yml @@ -0,0 +1,106 @@ +# AltServer-Linux + anisette — the whole thing as one Portainer stack. +# +# Portainer: Stacks -> Add stack -> Repository, point it at this repo and set the compose path to +# deploy/altserver-stack.yml. Or paste this into the Web editor. +# +# LAN-ONLY BY DESIGN. Sideloading never leaves the local network. Do NOT put either service behind +# Nginx Proxy Manager and do NOT give them a Cloudflare Tunnel route. +# +# ---------------------------------------------------------------------------------------------- +# ONE-TIME PREP ON THE HOST, before deploying: +# +# sudo mkdir -p /opt/stacks/anisette/config /opt/stacks/altserver/data +# sudo chown 1000:1000 /opt/stacks/anisette/config # anisette runs as uid 1000 +# sudo chmod 700 /opt/stacks/anisette/config +# +# The phone must also have been paired over USB once (see BOOTSTRAP.md Phase 3) so that +# /var/lib/lockdown holds a pairing record. Wireless pairing is not possible in this build. +# ---------------------------------------------------------------------------------------------- + +services: + + # -------------------------------------------------------------------------------------------- + # Anisette. Supplies the Apple machine identity. See deploy/anisette-stack.yml for the full + # annotated version and the reasoning behind every line here. + # -------------------------------------------------------------------------------------------- + anisette: + image: dadoum/anisette-v3-server@sha256:1e20384985d3c49965f444bef39d627768dacc39ea0dca91f2a535edb7591ba3 + container_name: anisette + restart: unless-stopped + environment: + # MUST stay UTC. The server stamps X-Apple-I-Client-Time from LOCAL time and appends a + # literal "Z", so any other TZ sends Apple a timestamp wrong by your offset while claiming + # to be UTC. Do not copy TZ from the other stacks on this host. + TZ: UTC + ports: + # Loopback only. AltServer reaches this via the host's loopback because it runs with + # network_mode: host below. + - "127.0.0.1:6969:6969" + volumes: + # The machine identity. Mount this DIRECTORY, never its lib/ subdirectory — the upstream + # README says lib/, which persists only the Apple .so cache and loses device.json + adi.pb + # on every stack update. That is issue #86. + - /opt/stacks/anisette/config:/home/Alcoholic/.config/anisette-v3 + + # -------------------------------------------------------------------------------------------- + # AltServer itself. + # -------------------------------------------------------------------------------------------- + altserver: + # Published by .github/workflows/build_image.yml. To build from source instead, comment the + # image line and uncomment build: — useful if you want a arch other than the published one. + image: ghcr.io/ben-diehlci/altserver-linux:latest + # build: + # context: . + # dockerfile: Dockerfile + # args: + # BUILDER: ghcr.io/nyamisty/altserver_builder_alpine_amd64 + container_name: altserver + restart: unless-stopped + depends_on: + - anisette + + # REQUIRED. The binary installs no SIGTERM handler (its only signal call is + # signal(SIGPIPE, SIG_IGN)), so as PID 1 the kernel discards docker stop's SIGTERM and every + # restart costs the full 10s grace period followed by SIGKILL. + init: true + + # REQUIRED, and not negotiable: mDNS/Bonjour is link-local and does not cross a Docker bridge. + # On a bridge the phone can never discover _altserver._tcp no matter what else is correct. + # Note this makes `ports:` meaningless here — AltServer binds an EPHEMERAL port that differs + # on every start (ConnectionManager sets sin_port = 0), so no static rule could work anyway. + network_mode: host + + environment: + # Reaches the anisette service above via the host's loopback. + ALTSERVER_ANISETTE_SERVER: http://127.0.0.1:6969 + + # Credentials. Prefer putting these in Portainer's stack environment variables rather than + # committing them. They are only needed for sign-in; see BOOTSTRAP.md. + # Passing them here beats -p on a command line, which exposes the password in `ps`. + ALTSERVER_UDID: "${ALTSERVER_UDID:-}" + ALTSERVER_APPLE_ID: "${ALTSERVER_APPLE_ID:-}" + ALTSERVER_APPLE_PASSWORD: "${ALTSERVER_APPLE_PASSWORD:-}" + + volumes: + # AltServerData. The app writes to the RELATIVE path ./AltServerData against its working + # directory, which the image sets to /data — so this mount is what makes it persist. + - /opt/stacks/altserver/data:/data + + # The pairing record from the one-time USB pairing. Read-write: libimobiledevice may + # refresh it. Losing this means fetching the cable again. + - /var/lib/lockdown:/var/lib/lockdown + + # Device access. usbmuxd (or netmuxd) owns this socket on the host. + - /var/run/usbmuxd:/var/run/usbmuxd + + # mDNS. The image ships python3 + libavahi-compat-libdnssd-dev, but the actual publishing is + # done by the HOST's avahi-daemon, reached over these sockets. Without them the server runs, + # reports success, and is silently undiscoverable. + - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket + - /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket + + # No IPA argument => daemon mode. The first-time install is a separate one-off run; see + # BOOTSTRAP.md Phase 4. You can do it from Portainer's container console: + # AltServer -u "$ALTSERVER_UDID" -a "$ALTSERVER_APPLE_ID" -p "$ALTSERVER_APPLE_PASSWORD" /data/AltStore.ipa + # It needs an interactive console because the 2FA code is read from stdin. + command: [] From 6e2d81d200d5b9e9003bd80608ee97e8c277b0c3 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:53:46 -0400 Subject: [PATCH 36/95] REVIVAL.md: PR #135's GSA client-info block is CONFIRMED against real Apple infra The com.apple.dt.Xcode -> com.apple.akd rewrite was applied on an unverified third-party claim from a PR opened two days earlier. It is now reproduced in both directions on this deployment, using the ALTSERVER_NO_CLIENTINFO_SANITIZE escape hatch added for exactly this purpose: sanitizer ON -> client-info says com.apple.akd -> first GSA request 200 sanitizer OFF -> client-info says com.apple.dt.Xcode -> first GSA request 503 So Apple really does reject anything carrying that substring, and the rewrite really does get past it. Keep it enabled. Worth reporting back on PR #135, whose author had only a curl repro. This also separates two problems that presented as one. The remaining failure is a 429 on the SECOND GSA request: unaffected by client-info, identical across two attempts 28 minutes apart, and present on the very first sign-in ever attempted from this machine -- so it is not cumulative volume throttling, which would be expected to move or escalate. Records the leading hypothesis while it is investigated: X-Apple-I-MD is a one-time password that regenerates per anisette fetch (observed directly -- two fetches 82 seconds apart returned different values), but FetchAnisetteData is called once and the result is used for BOTH GSA requests. The second request may therefore be replaying a consumed OTP. That fits every observation and would be fixable here rather than being an Apple-side wall. Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/REVIVAL.md b/REVIVAL.md index f5bad33..870649a 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -88,6 +88,32 @@ excluding `AltServerMain.cpp.o` (it owns `main`) and stubbing `make_uuid()`, | `654907a` | **mDNS advertisement failure made loud.** Both failure paths verified; success path NOT verified locally — no working avahi in the build container. Must be confirmed on the real host. | | `b885501` | **anisette error handling** rewritten; `mktime`→`timegm`; `ResetProvisioning` Windows-path bug. Closes #104. | +### CONFIRMED 2026-09-14: the Apple GSA client-info block is real + +Upstream PR #135 was an unverified third-party claim. It is now reproduced against real Apple +infrastructure, in both directions, on this deployment: + +| `ALTSERVER_NO_CLIENTINFO_SANITIZE` | `X-MMe-Client-Info` sent | First GSA response | +|---|---|---| +| unset (sanitizer ON) | `…(com.apple.akd/3594.4.19)` | **200** | +| `=1` (sanitizer OFF) | `…(com.apple.dt.Xcode/3594.4.19)` | **503** | + +So Apple's `gsa.apple.com/grandslam/GsService2` does reject any request carrying the substring +`com.apple.dt.Xcode`, and the `com.apple.akd` rewrite does get past it. **Keep the sanitizer on.** +Worth reporting back on PR #135 — the author's curl repro is independently confirmed. + +This also cleanly separates two issues that looked like one. The remaining failure is a **429 on +the SECOND GSA request**, which is unaffected by the client-info value, reproduced identically 28 +minutes apart, and present on the very first attempt ever made from this machine — so it is not +cumulative volume throttling. + +**Leading hypothesis: the one-time password is being replayed.** `X-Apple-I-MD` is an OTP and +regenerates on every anisette fetch (observed: two fetches 82 seconds apart returned different +values). `FetchAnisetteData` is called ONCE and the resulting object is used for BOTH GSA +requests, so the second request may be presenting an OTP the first already consumed. That would +explain the stable failure point, and it is fixable in our code by re-fetching between SRP steps. +Under investigation. + ### Verified facts worth not re-deriving - **The CI failure was an unresolvable action, not the node12 versions.** `uses:` resolution From 5b4fe84b24d373e78a21c3baaec65711b610f5a2 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:58:17 -0400 Subject: [PATCH 37/95] web: status dashboard -- slice 1 of the setup portal First piece of the web UI (F2), chosen deliberately: it is useful immediately and does not depend on sign-in, which is currently blocked by an Apple-side 429. Building a UI around a flow we cannot exercise would test nothing. It is also the watchdog the deployment research said to build before trusting any of this unattended. Every check corresponds to a failure this system produces SILENTLY: - avahi can report a successful registration while publishing nothing, so the only trustworthy advertisement test is an external avahi-browse -- not anything AltServer reports about itself. - AltStore suppresses the one error it would raise during a background refresh (BackgroundRefreshAppsOperation sets ignoresServerNotFoundError = true), so the phone stays quiet too. - Essentially everything AltServer logs is stdout at info level, so journalctl -p err is empty no matter what breaks. Without something like this the first symptom of a broken deployment is an app that will not open, seven days later, with no signal in between. Stdlib only, no new dependencies: python3 is already a hard runtime requirement, because the -static binary cannot dlopen Bonjour and shells out to python3 to do it. Serves one auto-refreshing page plus /api/status returning the same JSON, so it doubles as an endpoint something else can poll. The anisette check enforces the client's ACTUAL contract rather than merely pinging the server: HTTP 200, a JSON object, all ten keys with the correct inconsistent casing, and every value a JSON STRING. That last one matters because X-Apple-I-MD-RINFO sent as a number is the one field std::atoi swallows into a 0 without erroring, surfacing much later as an opaque Apple -36607. It also detects com.apple.dt.Xcode in the client-info and reports that the sanitizer is load-bearing. Verified against a local fake server across every branch: valid payload, RINFO as a number, a missing key, HTML instead of JSON, HTTP 502, and a scheme-less URL. Missing host tools degrade to "unknown" rather than crashing, and a check raising is caught so it cannot take the dashboard down. Binds 127.0.0.1 by default since it reports device identifiers. Co-Authored-By: Claude Opus 5 --- web/__pycache__/status_checks.cpython-314.pyc | Bin 0 -> 13773 bytes web/server.py | 180 ++++++++++++++ web/status_checks.py | 223 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 web/__pycache__/status_checks.cpython-314.pyc create mode 100644 web/server.py create mode 100644 web/status_checks.py diff --git a/web/__pycache__/status_checks.cpython-314.pyc b/web/__pycache__/status_checks.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f006d8e5c765ae259414df8fb1cf7885e5501bb5 GIT binary patch literal 13773 zcma)je{d7oooBaxTasmL*_h#%8*IQbmTZ2^3>X7r{A2K7WV=mSHt3v8hwao5pg}omeb~o8DnN1DZWNXC;7GH;|xvI<6RsJIb zsa%q)tNVOkt0mbmWE<=0>Gk{Zec$(e-{0&GRC_r*eY)V@gr20 zIFZx1vz*8e@*2;+ZJLdJ+ci7;c4!Xv?bMv?+oieLw?}hFIMF}MYgJR3wRNJik<)5L7s@)ZR&=x0Al`detsb>1R%aaWIA_INXXe#W^rClYZ0$^OiQGa(?TYlT5QJ~ z)MZ7|B1?%yK@#R9B_Sxew3eKg1w}reODZx=C1hp|Y;HQ0ROf&_jKFr&v+Mw9H$()2rsPy@0Z`AOE~{*- zz=M1d00IbMTFES^a)fQ6N0^r4=Vp~m4nQdK3~)LJyZ}ElNkT2oLS>Xk_)>G(EIO!w zdwP+fO3MJ_z>S=mVeqydE6paFll5e?>Ts4Q5*WZ-z+-YYjbW-u(b0@HK#TV)3TXW> z0WX76Kv1-wEXY?DK!S@Zn;*m~6LDos=-(ph!_Yoc}`nM%>o33Q2Y{vZeWoRGq$Rfsro zA-u)=FJA;%5q%R?nS(*7fGIOCoA6#ps>$gTPBR*JmKhJ{L*0(g<6|g@mBzqiV!UXJ z*+hHHeu&>y>^zHJn>nx{hvFVrB0m1(`SN&QW3~r+oPS3QGE8EfBq54DBNgwgHd;fpi}rC| zbi7>bnYE4EMhKs49<(yay1Mxp2kJym|LR`!>sdY9E4mT9LM6O`ikHQzi%T>X$%&7x z!A=+N=R{YGXW#C|3}bAF9$1i8omjTVb z0ksi`nalQH%tC-CjH>>NaXCv|Hsmnu@%e;8h&A}EVV5t)Rid7qJ0VesNDRjmrdPJ2 zO3I1)Hz=04pS!ES^6q=@F85yJ|KmWxz3EeT^GZ!H-_Ukr>}Ks>jo)wBm#^8E_w0LQ zb2*Pb^sm1h{c7~uz8l)DmOJiy{*g=kO3k{<@4o-;HR+pk-Z_GFJ-zTrr|=E!}|k>5SC{;X*FNL?S+7`a z&2WgH;&Zl~eX67{GfoFwt$ng~a4_(hwT~FEurl((+IKh&X!@noa6rGuzx^bu@HBC! z?Pu>CSgwc3QRa_crdd!-6*Jn%r%@iMb|A zOxJ?8bjFZ1Ur0AwVr!t;Pd6S9IBdkMaojrYlsjjOa5>vRHOHM_h`A2p7MLo)3D8;0 z9krdPq3f`f%X+fzn8#W>vF|x+Z=Kv@tq}8AyQ;*L*ffIiYoBsoQ7B{Xnb0S+Cgg1-*8NJFQ-on9KUaT`})LY%l@Fy_t*o#MXA4o%LjWt7z3SH90X9^OY$; z;C7d}4>;0hE@^Axwt(*GUq3&zWJ*rR_cAZ2&}^*LMmAfu@;9V5G%!eQ=PF3b9IY~hmn++cI%8qJ(FYYv#t*|P2=N~;#ytq%^!8f;@J z4IKp5hmepoiCKmhq*M|=$jlO?S&0-9W*Ws)N!TyDU|Gbq9JFVcnVN!n#CV}+C^ptF zj`xdGJ<-9j{#dMk%KUArC^bFSBBrLTw|wCMOAP`{`Y~80WbHAdFd@%KP{0o{P|Rse z^8}_2Jj!3;Vj4nfI^r*h;CFlU3FKQFfn za%46l%xPM-tD|Gj-j^et__wF)rGqaW)OXORIf+41>>4ndB@q zwuFGrJCul?E4fmDx#O|eh+yK8(5J%ojKiXZwn+XDV753>I7OB`tvM~jBLjmJjtK%+ zN}E<=M1Vc>Jb=Aq?}U41abkw^G$AF#8*Vrgpn$3$@Jv-pavaa;&YU(AK47?E`KP3~ z9Mzo+O5G*QC@?Vf?PDc#90qNCPJ;0#Ov66SrA^<&VnlE0WquMepiA^b% zfbW5}&FJI89w93&rZN(gY;t$NFz<-yTh`7g2{fyanWnSUH=j(ZDjnGla&s`C5^!9(7R*g`zn%hA|spnznX>DfGwMnXQlX}fYoPUsfXF*r8}o@8z|1jRDAL* z5XG@hAS*1a>hC-YEzGTjE1tvN*rfr@vHlU^bQr#qlpG!mpX>{ZgVBLuno<(dxp{c_ z2z7M8Wa7ZFazRp(Qo86&!UZF57RV_~c1B+{>6oco2>Z-QOpZ>nc2$ zz~TS41|8<*aH-AUkLq=pJOZx;+zSaU!pK(tvhZ&4%7mFaq)1mP0kWoV$%`|cOQtk* z7gUgzmeft?=>)^mG62W1w5DVU=UZk((c!7yp+Pw8Xya33U}UkuH~K;4U_~Lly$7sg zPp5f;Hj|gotY#G&X%jpYA(4AqQl}&eo;TY%{ z9D*&TNs6W}B(*uil~Yo1tJ0-BpMyiqaEn$2Z^(&5RvGyy3dgJ6CuB02T-sdbuO58V{|60YiTfYk>IdFe z^4?eewC?K0D;uwFy|Ohg9J(bIYF}CE{olU8!vN9q z4+Hg=)9p4$gIr!x-Szz6)x;1dW^VsdaJM}+$ZKd(RV>`dD>k-FSbv<%9oD=-Rn&4&qtNOK; zLQT^p#}hB-U%#CAv%UwuZJ+wKS(D^Dhx4PS^5bvZAAPe>Gnw~H{;!Rlg}|}98#|Xr zZ^i!luJhRfc>FVN@#p{iQ#?VURDZ=Xn z!|Me9s{q&ZVxjKT-L3nt)qd}_+uDy7?v0E+=#E|8v%Ck5ODFPmr#^Gnl_46lX4_O~ z3=_tq4CBKL<0!oxP}O>+HNPox(^jb4w{&8qdegN8c>wae4%})kY&~+n`pDA2ihI+w zcka2{9=W)x8XzV(!v6~B8R0Lvp13%FFduCB_H@4ObpDOE3O*_CkbW)kKr~J!U%mf# z)TQon_xs&I*j?YYC!8G~2u47E)pC36lhglr`k&N%Cty9a(s-CQeDqhKqoZtG!xPR% zR8gY0p4+|mJ74{2@XpvzHmo#8>8LDV;8mc$zk5=T1EO(sau%Hq+;QBA{j--)iXjvH z=YSuG#fS<1|9&3eYI^zKd|t-|$9eS+a1TB@(BmIG%zf;L42GN^@AaVmkIn0c9NdpO zJwqLiA02j~{KUO=Xuso=HW$hthkS!wRX^VEK+hj{*{M9-G{oEgX>-d7m;EOkkNQts zeky~kUcdcBxBVwCv3j=??LT$w7;3lwbT^MO4iHL@8#Oz5Hva(?cs616nGfP*o?+h7 zix+Tn6@HwXfwc~s2qVl#fpAAe~?4J3#mA-wOU zmpWe-NJ5d)Va9_bKnpb*n4r!~L7-?yB;+)F)Jm2F6fwi0&Se&KTR3af0Ms!HQXLy{ zhr>)E3AvbqkonjR_o?WK=`aKAh!Z^qJy; z;Z&Kxi+_)gDxrX@vAid?;`V-J z@eddO=$&Qxo_mwoLCl8^6>7Tkp6-Xfy5;U~*XMVER}ZcP&*0Mgr-;irr+9?~)++iZ zdSmq8$z)TR{>e)pu{}lqriZRfkM;`scfcF(EYsujZvZ?l@N+YdOB*~c+egULxaP5L zi{+6h`eUFZz|ZxZ{_xeb7d;ww_{!|1@lQivzD>AGqNfaf1tzLQ@6(t#%dr~_56UWj zU@!X#M4#v%M?AjLBU9=;GI7Z7P+wy-r0yT{Z*HUr~|3{mt3BT`|ICR2%sFib(1@ssVvz&*{9l~h#& z)6C7zMGRlX{tXX98u*i5JyOIbYth}O`Ud-8qZl3qmLjf;{dS*9GyYrN z35C@aIn3D5A@fAcAaq)m5!r^3lw(6=_$4-2D<+a=kfsF7d!ihs-3l2B5z!84UQNmh zF$%Ls1d4bIxvX$(ICdOZ1$qbz9Wcl`a9$k*RDv=Gv)^vesyk##qw zZYA&R$d8QvbL;&hVxeX%?-^Th*DjA;ef`SoUw`wSTPUy5b9>(>2R}OaG1SDO5=T1v z1pjkiT|U_S?O1+y|84b?#g7(0eg~k`kMNJ}HvcFSE2B{EHgVO*d6fb(f7{p>bbQoM zk8)KYiCnW_JqRH`L(q(K5&Kyr9$6s|rf$`Z^D}E1wxdGaAY5A+5SlE7i4XCkj@P&Z z#0rbwBVFnF;$mGXurdJoSP~Y17A|t^X)9F0lTGCM7pqQg+Qv}=)W3B`OMqIlCVoJ( z{=U(t>_yQCGDT3w{>)I9ckSFo@d#J+WXTbZvCj}JqR$#@eV=t5_|sCr<1hs5?-_U2 zRVmgWaG}Vs;D{q@uTWPV>t4c3g}y4BDj2v+x$$g@M_1x+RUseB?S0XC>X;bj?PlwH(S%qL>G zYLw1|WG|8lEDMQ=8dJlK=zf;l6gJb3bVpdpYDzO8)9dDuNri;4imCODm7UiJ#Q$Uo zM6-`61(7%dX@EbH0K?It*TMW#NybVUf9rZWx62fkl)uDsyH)|fJZRv@rj%z3QC1cv z{WoP~MOJYI2X;wb7?zE*9YF*NF%RIx&)1HC%dUOIQYy(sZpqoiod89!d&xI*FCx(a zDmQ;P8sZfS?2-N*=~SjrW%~EdB3G~KhPPS1m{pD#J52_^YJZV#NG2(z9*LCt{b!nj zB0RqUe0lHTyY1cA`z{}S|LC=S4}48|UsIvI`!`H>ytk!^j}0gZ^fmZHdd&`o_+nlf zgt2c-Mgmj@Ne(+yg`%Hk;GvjTLH1F(Z*R0{yWr?72+4*aQbHCOIZauX8IVt81u+?C zqL}F!7HKW_GLN^F??@sV!q+4ZI3}}j*y490t*VJ|U;nt!N>i1Z_H;3#ACX$Q(YUCL(GkWac_dW5R%Y7~EKtr6F6h*hS5Uy1 zB00_kx6(rmE){8eNanD^%gKZ~>^3)T5_)HaFz1mnGDo?RW=>UEln0-2(n=OH{{=qk zfs#azULUyeYCgFC*7UtV&uXc@`@@O+OJY7Y@yKS|u8 zaudd1azb2Vs-KfjX7`6;e$TPn6ZiKFeRllh@|Uh_f4=yw#s9kgd-hxXfA1~q=`HN) zyWiMn`f5xuzcTi;)MvU!-|dC_9nqEGX_ES9NCEMmB?ZKJ7E-^8mQ>-y;C@*5LjMNG z4?8!b{J56NRoC2&E=>4G7^2Iz6}&9oHC`4H`)j={tF261nVCb7(%fqv%Wi^=X$ev= z3S>{g79?A$C@3N9MIJ4Hsn`O>Gj4}4LzaXk%qqmxHWE`6;-fM<#W{|Q>q?O*t`d-C zh*ym-+h4FfD|1hZHWmpSpB@`DZ(T713Oe;s0lWc51q>3Fj0*PKKXSbPWWU%m1e-%K zIRx@N&0Jc>n2ATjd|~$hn!Vx7&VpqdRi)sIUR$bCYA_{RArw|}`LKa-@S6cI#I&vh zlTqCVNUY1P@5K-1Zb8V#;zy>O04&8MT9^SW&2tX%6FD8xKW1AgFI|jYrjkhfrRbuH ztXL`4B@9s>ak@3cgbhV;1Vl(E%0*TOS!pK7!?s_O2o;+C^+B zEz>@cu-VQ;shXRaNyaIDOx8$QQZqTsFd#_N;IgLC!#FK7MC^(JZ0Po|G^41fRl10n zWnwZoGDIxyRYj*V1#D?${-vbR0YMM4U)FN4#v%u^(m*LujY5ygMU44AKI%766kSMg zj_+B`z1lu}HUHwk9ebf>DDN3s*|6!Gfo}xvZD=j{#k(6?m-pN_acd9c+Sf)A6Brr2 z+cSE3^wKEakU1iL=JqpFq3y$C@aMmHyY~ABZufovNTKGnyyrDIobth)A3AQ#uLNHO zZ>&EFHsSC`!6qEeDAIQ=kK#gX60A8fY(p@!qNCKWSaD!57 z)^W1A?E5M58E_c%31H{!Sv3;x%m*V~Wi>hL08es|FB#cy#9G-iQg7AD-{t>5(Scmg z3a^ZVB^euT(gu*CX}B2WAOuH}G2~z*6Pllxpy9ahK)(i_Un#XxinF6r{BL!^<) zmDAGuH^6$3(rU|`GaPW~Bi2c5(QxO|=kOmU(ji+Es_-v}LPI=idJWKD%ps3VY4L36 z1>}oQA!A``O6fu`rHu-rIps@K5X~8WGsGKV0b2!@8fOHGO~s(Ea+EqV*2ZXuxm-kQ zBY%LY?F~^4CL*_%~vNqi@p6+@4hn19kL|MiQ z@CZ)*6BNjI<@sOQg1qB#8^`;9##Q}{t7d<-KjVTw;~GBq_%_<|-1 + + + + +AltServer status + + + +
+
+

AltServer status

+
+ checking… +   · +
+
+
+
+ Refreshes every 30s. Read-only — this page does not sign in or change anything. + Raw JSON at /api/status. +
+
+ + + +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "AltServerStatus/0.1" + + def _send(self, code, body, content_type): + payload = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(payload) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path in ("/", "/index.html"): + self._send(200, PAGE, "text/html; charset=utf-8") + elif path == "/api/status": + try: + data = status_checks.run_all() + except Exception as exc: # never let a check crash the dashboard + data = {"overall": "fail", "host": "", "checks": [{ + "name": "Status service", "state": "fail", + "summary": "A check raised an exception", "detail": str(exc), "fix": ""}]} + self._send(200, json.dumps(data), "application/json") + else: + self._send(404, "not found\n", "text/plain; charset=utf-8") + + def log_message(self, fmt, *args): + pass # the dashboard polls every 30s; logging that is pure noise + + +def main(): + ap = argparse.ArgumentParser(description="AltServer-Linux status dashboard") + ap.add_argument("--host", default="127.0.0.1", + help="bind address (default 127.0.0.1; this reports device identifiers, so " + "do not expose it)") + ap.add_argument("--port", type=int, default=8099) + args = ap.parse_args() + + print("AltServer status dashboard on http://%s:%d" % (args.host, args.port), flush=True) + HTTPServer((args.host, args.port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/web/status_checks.py b/web/status_checks.py new file mode 100644 index 0000000..7a5cbdf --- /dev/null +++ b/web/status_checks.py @@ -0,0 +1,223 @@ +"""Health checks for an AltServer-Linux deployment. + +Stdlib only, deliberately: python3 is already a hard runtime requirement of this project (the +-static binary cannot dlopen Bonjour, so it shells out to python3 to do it), which means adding +these checks costs no new dependency. + +Every check here corresponds to a failure this project can produce SILENTLY. That is the whole +point -- AltServer cannot report its own health: + + * DNSServiceRegister returned success unconditionally until we fixed it, and even now avahi can + report success while publishing nothing, so the only trustworthy test is an external browse. + * A background refresh that finds no server is suppressed by AltStore itself + (BackgroundRefreshAppsOperation sets ignoresServerNotFoundError = true), so the phone stays + quiet too. + * `journalctl -p err` is empty no matter what breaks, because essentially everything is written + to stdout at info level. + +So the first symptom of a broken deployment is an app that will not open, a week later. These +checks exist to turn that into something visible. +""" + +import json +import os +import shutil +import socket +import subprocess +import urllib.error +import urllib.request + +# The exact contract src/AnisetteDataManager.cpp enforces. Casing is inconsistent upstream and +# matched case-sensitively: X-MMe-Client-Info has a capital MM, X-Mme-Device-Id a lowercase m. +ANISETTE_REQUIRED_KEYS = [ + "X-Apple-I-MD-M", + "X-Apple-I-MD", + "X-Apple-I-MD-LU", + "X-Apple-I-MD-RINFO", + "X-Mme-Device-Id", + "X-Apple-I-SRL-NO", + "X-MMe-Client-Info", + "X-Apple-I-Client-Time", + "X-Apple-Locale", + "X-Apple-I-TimeZone", +] + +OK, WARN, FAIL, UNKNOWN = "ok", "warn", "fail", "unknown" + + +def _result(name, state, summary, detail=None, fix=None): + return {"name": name, "state": state, "summary": summary, "detail": detail or "", "fix": fix or ""} + + +def _run(cmd, timeout=10): + """Run a command, returning (rc, stdout+stderr). Never raises.""" + if shutil.which(cmd[0]) is None: + return None, "%s is not installed" % cmd[0] + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return p.returncode, (p.stdout or "") + (p.stderr or "") + except subprocess.TimeoutExpired: + return None, "%s timed out after %ss" % (cmd[0], timeout) + except Exception as exc: # pragma: no cover - defensive + return None, "%s could not be run: %s" % (cmd[0], exc) + + +def check_anisette(url=None): + """Fetch anisette data and validate it against the client's actual contract.""" + url = url or os.environ.get("ALTSERVER_ANISETTE_SERVER", "") + + if not url: + return _result("Anisette server", FAIL, "ALTSERVER_ANISETTE_SERVER is not set", + "There is no default; the server that used to be hardcoded is dead.", + "Set it to a full URL including the scheme, e.g. http://127.0.0.1:6969") + + if not url.startswith(("http://", "https://")): + return _result("Anisette server", FAIL, "URL has no http:// or https:// scheme", + "Configured as %r." % url, + "AltServer's HTTP client constructor rejects a scheme-less URL before " + "sending anything. Use e.g. http://127.0.0.1:6969") + + try: + req = urllib.request.Request(url, headers={"User-Agent": "Xcode"}) + with urllib.request.urlopen(req, timeout=10) as resp: + status = resp.status + body = resp.read(65536).decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + return _result("Anisette server", FAIL, "HTTP %s from %s" % (exc.code, url), + "Server is reachable but unhealthy.", + "Check the anisette container's logs.") + except Exception as exc: + return _result("Anisette server", FAIL, "Cannot reach %s" % url, str(exc), + "Is the anisette container running? Check `docker ps`.") + + if status != 200: + return _result("Anisette server", FAIL, "HTTP %s" % status, body[:200], + "AltServer requires exactly 200.") + + try: + data = json.loads(body) + except ValueError: + return _result("Anisette server", FAIL, "Response is not JSON", body[:200], + "Wrong endpoint? Some servers serve the v1 payload only at /.") + + if not isinstance(data, dict): + return _result("Anisette server", FAIL, "Response is not a JSON object", body[:200]) + + missing = [k for k in ANISETTE_REQUIRED_KEYS if k not in data] + if missing: + return _result("Anisette server", FAIL, "Missing %d required field(s)" % len(missing), + ", ".join(missing), + "This server does not speak the legacy v1 flat-JSON contract.") + + # A numeric value here is the one failure the client swallows: X-Apple-I-MD-RINFO is parsed + # with std::atoi, which returns 0 for a non-string without erroring, and the consequence + # surfaces much later as an opaque Apple -36607. + not_strings = [k for k in ANISETTE_REQUIRED_KEYS if not isinstance(data[k], str)] + if not_strings: + return _result("Anisette server", FAIL, "Field(s) not sent as JSON strings", + ", ".join(not_strings), + "AltServer requires every value to be a string. X-Apple-I-MD-RINFO as a " + "number is the common variant, and it fails silently.") + + client_info = data.get("X-MMe-Client-Info", "") + detail = "Device-Id %s" % data.get("X-Mme-Device-Id", "?") + if "com.apple.dt.Xcode" in client_info: + # Verified against live Apple infrastructure: with this substring present the first GSA + # request returns 503; rewritten to com.apple.akd it returns 200. + detail += " | client-info contains com.apple.dt.Xcode, so the built-in sanitizer is " \ + "load-bearing (leave ALTSERVER_NO_CLIENTINFO_SANITIZE unset)" + + return _result("Anisette server", OK, "All 10 fields present, all strings, HTTP 200", detail) + + +def check_clock(): + """The anisette server's timestamp is forwarded to Apple verbatim, so skew matters.""" + rc, out = _run(["timedatectl", "show", "-p", "NTPSynchronized", "--value"]) + if rc is None: + return _result("Host clock", UNKNOWN, "Could not determine NTP status", out) + if out.strip() == "yes": + return _result("Host clock", OK, "NTP synchronised") + return _result("Host clock", WARN, "Clock is NOT NTP-synchronised", + "Apple sees the anisette server's timestamp verbatim.", + "Skew surfaces as an opaque -36607 with nothing naming time as the cause.") + + +def check_device(): + """Is a device visible, and is the pairing record valid?""" + rc, out = _run(["idevice_id", "-l"]) + if rc is None: + return _result("iPhone pairing", UNKNOWN, "idevicepair/idevice_id not available", out, + "Install libimobiledevice-utils.") + + udids = [line.strip() for line in out.splitlines() if line.strip()] + if not udids: + return _result("iPhone pairing", FAIL, "No device detected", + "usbmuxd sees nothing.", + "For the first pairing the phone must be plugged in by USB -- wireless " + "pairing is not possible in this build. On a VM, check USB passthrough.") + + rc, out = _run(["idevicepair", "validate"]) + if rc == 0: + return _result("iPhone pairing", OK, "Pairing valid", "UDID %s" % udids[0]) + if "passcode" in out.lower(): + return _result("iPhone pairing", WARN, "Device is locked", out.strip(), + "Unlock the phone and re-check; validation needs it unlocked.") + return _result("iPhone pairing", FAIL, "Pairing did not validate", out.strip(), + "Re-pair over USB and tap Trust. Back up BOTH files in /var/lib/lockdown " + "together -- half a pairing is indistinguishable from none.") + + +def check_advertisement(service="_altserver._tcp"): + """The only trustworthy advertisement test: browse for it, do not trust the server.""" + rc, out = _run(["avahi-browse", "-rpt", service], timeout=15) + if rc is None: + return _result("mDNS advertisement", UNKNOWN, "avahi-browse not available", out, + "Install avahi-utils. This is the ONLY reliable check: AltServer cannot " + "detect its own advertisement failing, and avahi can report success " + "while publishing nothing.") + if any(line.startswith("=") for line in out.splitlines()): + hosts = [l.split(";")[6] for l in out.splitlines() + if l.startswith("=") and len(l.split(";")) > 6] + return _result("mDNS advertisement", OK, "%s is published" % service, + "Seen on: %s" % ", ".join(sorted(set(hosts))) if hosts else "") + return _result("mDNS advertisement", FAIL, "%s is NOT published" % service, + "Nothing is advertising it, so AltStore cannot discover this server.", + "Check python3 and libavahi-compat-libdnssd-DEV (not -libdnssd1: the code " + "dlopens the unversioned libdns_sd.so) and that avahi-daemon is running.") + + +def check_altserver_running(port_hint=None): + """Is the process up? Note it binds an EPHEMERAL port, so no fixed port can be checked.""" + rc, out = _run(["pgrep", "-af", "AltServer"]) + if rc is None: + return _result("AltServer process", UNKNOWN, "Could not check", out) + lines = [l for l in out.splitlines() if "AltServer" in l and "pgrep" not in l] + if lines: + return _result("AltServer process", OK, "Running", lines[0][:160]) + return _result("AltServer process", FAIL, "Not running", + "Nothing to discover, and no refreshes will happen.", + "Note AltServer has no liveness signal: Listen() can fail early and the " + "process stays alive with no listener, so 'running' is necessary, not " + "sufficient -- trust the mDNS check above over this one.") + + +def run_all(anisette_url=None): + checks = [ + check_anisette(anisette_url), + check_clock(), + check_device(), + check_advertisement(), + check_altserver_running(), + ] + states = [c["state"] for c in checks] + if FAIL in states: + overall = FAIL + elif WARN in states or UNKNOWN in states: + overall = WARN + else: + overall = OK + return {"overall": overall, "checks": checks, "host": socket.gethostname()} + + +if __name__ == "__main__": + print(json.dumps(run_all(), indent=2)) From af35345a42dc551b2da0d8eee684fa479c3cdc18 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:06:36 -0400 Subject: [PATCH 38/95] web: pairing wizard, and a draft confirmation for upstream PR #135 SLICE 2 of the setup portal. Pairing is where someone without background gets stuck, and its failure modes are unusually opaque, so the wizard's job is not to run commands -- it is to tell the three "nothing is showing up" cases apart: - tools missing, vs usbmuxd not running, vs no device on the cable - device present but LOCKED: `idevicepair validate` reports "a passcode is set", which reads like a permissions problem when it means "press the button" - device present but untrusted: needs `idevicepair pair` then TRUST on-device It states plainly that a USB cable is required exactly once and cannot be avoided (HAVE_WIRELESS_PAIRING is undefined in this build), while making clear that refresh afterwards is wireless -- which is the surprising part for a project whose whole point is wireless refresh. It also covers hypervisor USB passthrough, since on this deployment the VM is the likeliest reason a plugged-in phone never appears, and charge-only cables. Once paired it does not stop at "done": it prompts to back up /var/lib/lockdown and explains that BOTH files must be kept together, because restoring one without the other yields a mismatched HostID/SystemBUID that iOS rejects with the same generic error as every other lockdownd fault. Verified: all routes respond, and on a host without the tools the wizard blocks at step 1 rather than cascading misleading errors. Also drafts docs/pr135-confirmation.md -- a comment for upstream PR #135. That PR applied an unverified claim; this deployment reproduced it end-to-end in both directions against live Apple infrastructure (503 with com.apple.dt.Xcode present, 200 with it rewritten), which is materially stronger evidence than the curl repro currently on the PR. The draft is honest that sign-in still fails afterwards with an unrelated 429, so nobody concludes the patch is ineffective, and notes the misleading "invalid response" error that masks both. Checked to contain no Apple ID, UDID, machine identifier or hostname. Co-Authored-By: Claude Opus 5 --- docs/pr135-confirmation.md | 58 +++++++++ web/__pycache__/pairing.cpython-314.pyc | Bin 0 -> 8014 bytes web/pairing.py | 151 ++++++++++++++++++++++++ web/server.py | 61 +++++++++- 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 docs/pr135-confirmation.md create mode 100644 web/__pycache__/pairing.cpython-314.pyc create mode 100644 web/pairing.py diff --git a/docs/pr135-confirmation.md b/docs/pr135-confirmation.md new file mode 100644 index 0000000..8ee6ba1 --- /dev/null +++ b/docs/pr135-confirmation.md @@ -0,0 +1,58 @@ +# Draft comment for upstream PR #135 + +Post at . + +**Before posting, check:** it contains no Apple ID, no device UDID, and no machine identifier. +The anisette `X-Mme-Device-Id` and your UDID are deliberately omitted below — don't paste raw logs +in without redacting them. + +--- + +Independent end-to-end confirmation of this PR's premise, tested against live Apple +infrastructure rather than by `curl` alone. It reproduces in **both** directions. + +Setup: AltServer-Linux built from `new` with this patch applied, running on Ubuntu 24.04 +(x86_64), anisette supplied locally by `dadoum/anisette-v3-server`, real Apple ID, 2026-09-14. + +The anisette server returns a client-info string containing the substring in question: + +``` +X-MMe-Client-Info: +``` + +I put the sanitization behind an env var so I could A/B it in place. Results, same machine, same +Apple ID, ~80 seconds apart: + +**Sanitization ON** — client-info rewritten to `com.apple.akd`: + +``` +Device Description: +Received auth response status code: 200 +``` + +**Sanitization OFF** — `com.apple.dt.Xcode` sent verbatim: + +``` +Device Description: +Received auth response status code: 503 +``` + +So `gsa.apple.com` rejects the request outright when the substring is present, and accepts it +when rewritten. Without this patch you don't get past the first GSA request at all. + +Two things worth adding for anyone landing here: + +**Sign-in still doesn't complete for me**, but the failure has moved and is unrelated to this +patch. With sanitization on I now get `200` on the first GSA request and **`429` on the second**, +consistently — identical across attempts 28 minutes apart, and on the very first attempt ever made +from this machine, so it doesn't look like ordinary volume throttling. That's a separate problem +from the one this PR fixes; I'm still digging. Mentioning it so nobody concludes this patch is +ineffective when they hit it. + +**The error you'll see is misleading.** `AppleAPI+Authentication.cpp` logs the HTTP status and then +discards it, passing the body to `plist_from_xml` regardless. A 503 or 429 body isn't plist XML, so +it fails to parse and surfaces as `APIErrorCode::InvalidResponse` — "Server returned invalid +response", error 17 — which sends you looking for a protocol bug. Checking the status before +parsing makes both of these diagnosable immediately, and is worth doing independently of this PR. + +Happy to provide more detail if useful. diff --git a/web/__pycache__/pairing.cpython-314.pyc b/web/__pycache__/pairing.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3a325d075f9ce3a8a9ea4b971881a13444e6f07f GIT binary patch literal 8014 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 Mzpmm!TNw-g1GfQWbpQYW literal 0 HcmV?d00001 diff --git a/web/pairing.py b/web/pairing.py new file mode 100644 index 0000000..dbab944 --- /dev/null +++ b/web/pairing.py @@ -0,0 +1,151 @@ +"""Pairing diagnosis for the setup wizard. + +Pairing is where someone without background knowledge gets stuck, and its failure modes are +unusually opaque: + + * Wireless pairing is impossible in this build (HAVE_WIRELESS_PAIRING is undefined, and + upstream libimobiledevice restricts it to Apple TV), so a USB cable is mandatory exactly + once -- which is surprising for a project whose whole point is wireless refresh. + * `idevicepair validate` fails with "a passcode is set" unless the device is UNLOCKED at that + moment, which reads like a permissions error rather than "press the button". + * On a VM the device may never appear at all, and the cause is hypervisor USB passthrough + rather than anything on the Linux side. + * A genuine device fault is later DISPLAYED by AltStore as "AltServer could not be found", + because it remaps deviceNotFound/lostConnection to serverNotFound for any wireless server + that is not isPreferred -- and AltServer-Linux hardcodes serverID "1234567" where + Mac/Windows use a UUID, so isPreferred is permanently false here. That sends people to debug + mDNS when mDNS is fine. + +So this module's job is not to run commands, it is to tell the three "nothing is showing up" +cases apart and say which one you are in. +""" + +import os +import re +import shutil +import subprocess + +STEP_OK, STEP_TODO, STEP_BLOCKED = "ok", "todo", "blocked" + + +def _run(cmd, timeout=15): + if shutil.which(cmd[0]) is None: + return None, "%s is not installed" % cmd[0] + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return p.returncode, ((p.stdout or "") + (p.stderr or "")).strip() + except subprocess.TimeoutExpired: + return None, "%s timed out" % cmd[0] + except Exception as exc: # pragma: no cover + return None, "%s could not be run: %s" % (cmd[0], exc) + + +def _in_container(): + return os.path.exists("/.dockerenv") + + +def diagnose(): + """Return an ordered wizard state: which step you are on, and what to do about it.""" + steps = [] + udids = [] + + # --- 1. tooling ------------------------------------------------------------------------- + missing = [t for t in ("idevice_id", "idevicepair") if shutil.which(t) is None] + if missing: + steps.append({ + "title": "Install the device tools", + "state": STEP_BLOCKED, + "detail": "Missing: %s" % ", ".join(missing), + "action": "sudo apt install -y usbmuxd libimobiledevice-utils", + "note": "Do NOT `systemctl enable usbmuxd` on Ubuntu -- it is udev-activated and has " + "no [Install] section, so enabling it just prints a confusing message.", + }) + return {"steps": steps, "udids": [], "paired": False, "next": steps[-1]["title"]} + steps.append({"title": "Device tools installed", "state": STEP_OK, + "detail": "idevice_id and idevicepair are available", "action": "", "note": ""}) + + # --- 2. is usbmuxd actually listening? --------------------------------------------------- + sock = "/var/run/usbmuxd" + if os.path.exists(sock): + steps.append({"title": "usbmuxd socket present", "state": STEP_OK, + "detail": sock, "action": "", "note": ""}) + else: + steps.append({ + "title": "usbmuxd is not running", + "state": STEP_BLOCKED, + "detail": "%s does not exist." % sock, + "action": "sudo systemctl start usbmuxd # or plug the phone in, which starts it", + "note": "If you are running netmuxd for wireless refresh instead, it OWNS this same " + "socket and usbmuxd must be stopped -- the two collide." + + (" This container needs the socket bind-mounted from the host." + if _in_container() else ""), + }) + return {"steps": steps, "udids": [], "paired": False, "next": steps[-1]["title"]} + + # --- 3. is a device visible? ------------------------------------------------------------- + rc, out = _run(["idevice_id", "-l"]) + udids = [l.strip() for l in (out or "").splitlines() if re.match(r"^[0-9A-Fa-f-]{8,}$", l.strip())] + + if not udids: + steps.append({ + "title": "Plug the iPhone in with a USB cable", + "state": STEP_TODO, + "detail": "No device detected yet.", + "action": "", + "note": "A cable is required for this step and cannot be avoided: wireless pairing is " + "not supported by this build. Once paired, refreshing works over Wi-Fi and the " + "cable is never needed again.\n\n" + "If it is plugged in and still not showing: on a Proxmox/VMware guest the USB " + "device must be passed through to the VM in the hypervisor. Also try a " + "different cable -- charge-only cables carry no data.", + }) + return {"steps": steps, "udids": [], "paired": False, "next": steps[-1]["title"]} + + steps.append({"title": "iPhone detected", "state": STEP_OK, + "detail": "UDID %s" % udids[0], "action": "", "note": ""}) + + # --- 4. pairing --------------------------------------------------------------------------- + rc, out = _run(["idevicepair", "validate"]) + low = (out or "").lower() + + if rc == 0: + steps.append({"title": "Pairing valid", "state": STEP_OK, + "detail": out, "action": "", "note": ""}) + steps.append({ + "title": "Back up the pairing record", + "state": STEP_TODO, + "detail": "Losing it means fetching the cable again.", + "action": "sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/", + "note": "Back up BOTH .plist and SystemConfiguration.plist together. They are " + "not independent -- restoring only one produces a mismatched HostID/SystemBUID " + "that iOS rejects, reported as the same generic error as every other " + "lockdownd fault. Half a pairing is indistinguishable from none.", + }) + return {"steps": steps, "udids": udids, "paired": True, "next": "Back up the pairing record"} + + if "passcode" in low: + steps.append({ + "title": "Unlock the iPhone", + "state": STEP_TODO, + "detail": out, + "action": "", + "note": "The screen must be unlocked at the moment this runs. Unlock it and re-check " + "-- this is not a permissions problem, despite how it reads.", + }) + return {"steps": steps, "udids": udids, "paired": False, "next": "Unlock the iPhone"} + + steps.append({ + "title": "Trust this computer on the iPhone", + "state": STEP_TODO, + "detail": out or "The device is visible but not paired.", + "action": "idevicepair pair", + "note": "Unlock the phone, run the pair command, then tap TRUST on the prompt that " + "appears on the device and enter its passcode. If no prompt appears, unplug and " + "replug the cable with the phone unlocked.", + }) + return {"steps": steps, "udids": udids, "paired": False, "next": "Trust this computer"} + + +if __name__ == "__main__": + import json + print(json.dumps(diagnose(), indent=2)) diff --git a/web/server.py b/web/server.py index 75b8d7f..e160c64 100644 --- a/web/server.py +++ b/web/server.py @@ -34,6 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import status_checks # noqa: E402 +import pairing # noqa: E402 PAGE = """ @@ -96,7 +97,7 @@
Refreshes every 30s. Read-only — this page does not sign in or change anything. - Raw JSON at /api/status. + Raw JSON at /api/status. · Pairing setup →
+ + +""" + + class Handler(BaseHTTPRequestHandler): server_version = "AltServerStatus/0.1" @@ -149,6 +198,16 @@ def do_GET(self): path = self.path.split("?", 1)[0] if path in ("/", "/index.html"): self._send(200, PAGE, "text/html; charset=utf-8") + elif path == "/pairing": + self._send(200, PAIRING_PAGE, "text/html; charset=utf-8") + elif path == "/api/pairing": + try: + data = pairing.diagnose() + except Exception as exc: + data = {"steps": [{"title": "Pairing check failed", "state": "blocked", + "detail": str(exc), "action": "", "note": ""}], + "udids": [], "paired": False, "next": "Pairing check failed"} + self._send(200, json.dumps(data), "application/json") elif path == "/api/status": try: data = status_checks.run_all() From 725677d8ec29a4613ee3ae0a4ced9e1ab183bafe Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:07:29 -0400 Subject: [PATCH 39/95] README: correct the parts that were actively wrong, document what is actually needed The README had accumulated instructions that do not work and warnings that no longer apply, which is worse than having none. Corrected: - Build steps omitted `cd build`. makefiles/main.mak sets BUILD_DIR := $(CURDIR), so running make from the repo root builds into the wrong place. This is what upstream PR #124 fixes; it has been open since 2025. - The note telling ARM builders to remove -mno-default is STALE. The Makefile already guards that flag to i386/i686, so ARM builds work unmodified. Following the old advice means editing a file for no reason. - The corecrypto instructions describe a distribution Apple no longer ships. They tell you to delete lines from a generated Makefile2 that the current archive never gets far enough to produce. Replaced with a pointer to buildenv/Dockerfile, which is verified to work, plus the three actual problems and the fact that the visible error ("No SOURCES given to target") is a follow-on rather than the real one. Added: - A quick-start using the container and the Portainer stack, which is now the simplest path and did not exist before. - A runtime requirements table. The binary alone is not enough, and three of the requirements used to fail silently -- notably libavahi-compat-libdnssd-DEV rather than -libdnssd1, which is the single most likely way to end up with a server that runs, reports nothing wrong, and cannot be discovered. - A pointer to the easier build route: the same prebuilt alpine toolchain CI uses, rather than assembling corecrypto/cpprestsdk/boost/libzip by hand. - Links to BOOTSTRAP.md, REVIVAL.md and deploy/, so someone arriving at this fork can find the setup runbook and the record of what changed. Co-Authored-By: Claude Opus 5 --- README.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a3f494d..5f3e858 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,40 @@ # AltServer-Linux AltServer for AltStore, but on-device +> **This fork** ([`bd/revival`](../../tree/bd/revival)) revives a project whose last real code +> commit predates 2025. CI is working again, several long-standing bugs are fixed, and the goal is +> running unattended on a Linux home server so apps keep refreshing without a Mac or PC powered on. +> +> - **[BOOTSTRAP.md](BOOTSTRAP.md)** — first-time setup, start to finish +> - **[REVIVAL.md](REVIVAL.md)** — what changed, why, and what is still broken +> - **[deploy/](deploy/)** — Portainer/compose stacks +> +> Notable fixes here: Apple's 2026 GSA client-info block (confirmed in both directions against +> live Apple infrastructure), the iOS 26 launch-crash signing bug (#131), corecrypto builds again +> (#111), anisette failures now say what actually went wrong, and mDNS advertisement failure is no +> longer silent. + +## Quick start (Docker / Portainer) + +```bash +# 1. host prerequisites -- only needed if NOT using the container, which bundles them +sudo apt install -y usbmuxd libimobiledevice-utils avahi-daemon libavahi-compat-libdnssd-dev + +# 2. deploy both services +# Portainer: Stacks -> Add stack -> Repository, compose path deploy/altserver-stack.yml +``` + +`libavahi-compat-libdnssd-**dev**`, not `-libdnssd1`: the code dlopens the *unversioned* +`libdns_sd.so`, whose symlink only the `-dev` package provides. Without it the server runs, +reports nothing wrong, and is permanently undiscoverable by your phone. + +There is also a status dashboard that checks anisette, the clock, pairing, mDNS publication and +the process, since none of those report their own failures: + +```bash +python3 web/server.py --host 0.0.0.0 --port 8099 +``` + ## Usage - Install IPA: `./AltServer -u [UDID] -a [AppleID account] -p [AppleID password] [ipaPath.ipa]` @@ -23,6 +57,22 @@ The following environment var can be set for some special situation: - ALTSERVER_NO_SUBSCRIBE: (*unused*) Please enable this for usbmuxd server that do not correctly usbmuxd_listen interfaces ``` +## Runtime requirements + +Beyond the binary itself, on the machine that runs it: + +| Requirement | Why | If missing | +|---|---|---| +| `python3` | The binary is `-static` and cannot dlopen Bonjour, so it shells out to python3 | Advertisement fails | +| `libavahi-compat-libdnssd-dev` | Provides the **unversioned** `libdns_sd.so` the code dlopens | Advertisement fails | +| `avahi-daemon` running | Does the actual mDNS publishing | Advertisement fails | +| `usbmuxd` (or `netmuxd` for Wi-Fi) | Device access | No device found | +| An anisette server | Apple machine identity | Sign-in fails | +| Accurate clock **on the anisette host** | Its timestamp is forwarded to Apple verbatim | Opaque `-36607` | + +The first three used to fail *silently*; they now report themselves. The container image bundles +all of them and verifies `libdns_sd.so` loads at build time. + ## Download - Precompiled static binary can be downloaded in Release ( also have a look at pre-release ;) ) @@ -46,14 +96,24 @@ The following environment var can be set for some special situation: - Install dependencies (see notes below): corecrypto_static, cpprestsdk static lib, boost static lib -- Build: +- Build (note the `cd build` — the Makefile builds into the *current* directory): ``` cd AltServer-Linux mkdir build + cd build make -f ../Makefile -j3 ls AltServer-* ``` + Easier: use the same prebuilt toolchain CI uses, which already has corecrypto, cpprestsdk, + boost and libzip: + ``` + docker run --rm -v "$PWD":/workdir -w /workdir \ + ghcr.io/nyamisty/altserver_builder_alpine_amd64 \ + bash -c 'mkdir -p build; cd build; make -f ../Makefile -j"$(nproc)"' + ``` + Or just build the image: `docker build -t altserver .` + - My own build note for you ``` 1. Run alpine docker (change --platform to corresponding architecture you want): @@ -61,8 +121,14 @@ The following environment var can be set for some special situation: 2. Install dependencies: apk add zsh git curl wget g++ clang boost-static ninja boost-dev cmake make sudo bash vim libressl-dev util-linux-dev zlib-dev zlib-static 3. Install corecrypto - download corecrypto from apple website, unzip corecrypto.zip; cd corecrypto; mkdir build; cd build; CC=clang CXX=clang++ cmake ..; - vim CMakeFiles/Makefile2, delete line starts with "all: corecrypto_perf/....." and "all: corecrypto_test/.....", then make; make install + See buildenv/Dockerfile, which does this correctly and is verified to work. Apple's + current distribution needs three fixes the old notes here did not mention: + a) the archive now extracts to corecrypto-2024/, not corecrypto/ + b) CMakeLists.txt include()s scripts/code-coverage.cmake, which Apple does not ship + c) CoreCryptoSources.cmake still points at corecrypto_static/ccrng_static.c, but that + file moved to the tree root + Symptom of (c) is a confusing "No SOURCES given to target: corecrypto_static"; the real + error is the "Cannot find source file" line above it. 4. Install cpprestsdk git clone --recursive https://github.com/microsoft/cpprestsdk; cd cpprestsdk; mkdir build; cmake -DBUILD_SHARED_LIBS=0 ..; make; make install (if you're compiling for armv7, you have to grep -Wcast-align, and remove it, or the compiling would fail) @@ -71,7 +137,9 @@ The following environment var can be set for some special situation: 6. Compile AltServer-Linux git clone --recursive https://github.com/NyaMisty/AltServer-Linux cd AltServer-Linux + mkdir build; cd build make -f ../Makefile -j3 - (if you're compiling for ARM, i.e. armv7 or aarch64, you'll have to remove the -mno-default flag in Makefile) + (the old note about removing -mno-default for ARM is STALE: the Makefile already + guards that flag to i386/i686, so ARM builds work unmodified) ``` From 3f29bcb89095194966758cd04c2403aa02184fff Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:10:08 -0400 Subject: [PATCH 40/95] web: pairing wizard detects an existing backup instead of nagging The wizard reported "Back up the pairing record" as outstanding even when the backup had already been taken, because it never looked for one. A checklist that cannot see work you already did trains people to ignore it. It now searches the usual locations and marks the step done -- but ONLY if the archive actually contains BOTH .plist and SystemConfiguration.plist. A backup with just one of them is worse than none: restoring it yields a mismatched HostID/SystemBUID that iOS rejects, surfaced as the same generic lockdownd error as everything else, so it would look like a hardware or network fault rather than a bad restore. Counting a partial archive as "backed up" would be exactly the kind of false reassurance the rest of this work exists to remove. Verified: an archive with both plists is accepted, one with only the device plist is rejected, and no archive returns cleanly rather than raising. Co-Authored-By: Claude Opus 5 --- web/pairing.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/web/pairing.py b/web/pairing.py index dbab944..25f788f 100644 --- a/web/pairing.py +++ b/web/pairing.py @@ -44,6 +44,29 @@ def _in_container(): return os.path.exists("/.dockerenv") +def _find_pairing_backup(): + """Find a lockdown backup that contains BOTH required plists. Returns (path, summary).""" + import glob + import tarfile + + candidates = [] + for pattern in ("~/lockdown-backup*.tgz", "~/lockdown-backup*.tar.gz", + "/root/lockdown-backup*.tgz", "~/*lockdown*.tgz"): + candidates.extend(glob.glob(os.path.expanduser(pattern))) + + for path in sorted(set(candidates), key=os.path.getmtime, reverse=True): + try: + with tarfile.open(path) as tf: + names = tf.getnames() + except Exception: + continue + has_system = any(n.endswith("SystemConfiguration.plist") for n in names) + has_device = any(re.search(r"/[0-9A-Fa-f-]{8,}\.plist$", n) for n in names) + if has_system and has_device: + return path, "contains both plists" + return None, "" + + def diagnose(): """Return an ordered wizard state: which step you are on, and what to do about it.""" steps = [] @@ -111,17 +134,23 @@ def diagnose(): if rc == 0: steps.append({"title": "Pairing valid", "state": STEP_OK, "detail": out, "action": "", "note": ""}) + # Look for an existing backup rather than nagging about one already taken. Only counts + # it if the archive actually contains BOTH files -- an archive with one of them is worse + # than none, because it restores a mismatched HostID/SystemBUID that iOS rejects. + backup, why = _find_pairing_backup() steps.append({ "title": "Back up the pairing record", - "state": STEP_TODO, - "detail": "Losing it means fetching the cable again.", - "action": "sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/", + "state": STEP_OK if backup else STEP_TODO, + "detail": ("Found %s (%s)" % (backup, why)) if backup + else "Losing it means fetching the cable again.", + "action": "" if backup else "sudo tar czf ~/lockdown-backup.tgz /var/lib/lockdown/", "note": "Back up BOTH .plist and SystemConfiguration.plist together. They are " "not independent -- restoring only one produces a mismatched HostID/SystemBUID " "that iOS rejects, reported as the same generic error as every other " "lockdownd fault. Half a pairing is indistinguishable from none.", }) - return {"steps": steps, "udids": udids, "paired": True, "next": "Back up the pairing record"} + return {"steps": steps, "udids": udids, "paired": True, + "next": "Nothing -- setup complete" if backup else "Back up the pairing record"} if "passcode" in low: steps.append({ From eb053f14fb70035c54a184fc7583cbf1760a576a Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:12:26 -0400 Subject: [PATCH 41/95] altsign: give every GrandSlam request its own connection (the 429 fix) Diagnosis of the 200-then-429 sign-in failure. My earlier hypothesis in REVIVAL.md -- that the one-time password was being replayed across the two GSA requests -- was WRONG, and is retracted there. Upstream passes a single anisette object to init, complete AND apptokens, so one OTP per transaction is the designed, historically working behaviour. The real cause is HTTP connection reuse. AppleAPI is a process-wide singleton holding one _gsaClient built in its constructor; gsaClient() returns a copy sharing the same cpprestsdk impl and therefore the same asio connection pool, and request 2 is issued from a .then() continuation the instant request 1 completes. No Connection header is ever set. Since ~2026-09 Apple's GrandSlam edge refuses the second request on a reused connection. What makes this convincing rather than merely plausible: between the two requests every header and all ten anisette values are BYTE-IDENTICAL. Only the plist body and the connection position differ. Anything identical in both cannot explain a different outcome, which structurally eliminates the "0" serial number, the timestamp, the User-Agent and the client-info string as sole causes. It also predicts exactly what we observed -- positional, stable, non-cumulative, identical on the first attempt ever and again 28 minutes later, which is not how volume throttling behaves. Corroborated independently: rileytestut/AltSign PR #52 is "Use a separate connection for each GrandSlam request" (AltServer 1.7.6), and nab138/iloader 2.3.3's entire release note is "Disabled reqwest pooling to alleviate http 429 from grandslam" -- on a client that ALREADY carried the com.apple.akd fix, which is why that fix reveals the 429 rather than causing it. gsaClient() now returns a fresh client per call, so each request opens its own connection. Applied through the rewriter since AltSign is vendored, and guarded like the other patches: if the pattern stops matching exactly once the build fails loudly. Costs one extra TLS handshake per GrandSlam request, a handful of times per sign-in. Builds clean and the patch is present in the generated source. UNTESTED against Apple -- that needs the next sign-in attempt. Also gitignores __pycache__, which the web tooling generates. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++ REVIVAL.md | 40 ++++++++++++--- .../AltSign-build/rewrite_altsign_source.py | 46 ++++++++++++++++++ web/__pycache__/pairing.cpython-314.pyc | Bin 8014 -> 0 bytes web/__pycache__/status_checks.cpython-314.pyc | Bin 13773 -> 0 bytes 5 files changed, 84 insertions(+), 6 deletions(-) delete mode 100644 web/__pycache__/pairing.cpython-314.pyc delete mode 100644 web/__pycache__/status_checks.cpython-314.pyc diff --git a/.gitignore b/.gitignore index b7a7910..6f33a66 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ # macOS .DS_Store ._* + +# python +__pycache__/ +*.pyc diff --git a/REVIVAL.md b/REVIVAL.md index 870649a..6c86c2e 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -107,12 +107,40 @@ the SECOND GSA request**, which is unaffected by the client-info value, reproduc minutes apart, and present on the very first attempt ever made from this machine — so it is not cumulative volume throttling. -**Leading hypothesis: the one-time password is being replayed.** `X-Apple-I-MD` is an OTP and -regenerates on every anisette fetch (observed: two fetches 82 seconds apart returned different -values). `FetchAnisetteData` is called ONCE and the resulting object is used for BOTH GSA -requests, so the second request may be presenting an OTP the first already consumed. That would -explain the stable failure point, and it is fixable in our code by re-fetching between SRP steps. -Under investigation. +**CAUSE IDENTIFIED (high confidence): HTTP connection reuse.** An earlier hypothesis recorded +here — that the one-time password was being replayed — was WRONG and is retracted. Upstream passes +a single anisette object to init, complete *and* apptokens +(`AltStore/Dependencies/AltSign/.../ALTAppleAPI+Authentication.swift`, same object at lines 69, 98 +and 165), so one OTP per transaction is the designed and historically working behaviour. + +The real mechanism, verified in this tree: `AppleAPI` is a process-wide singleton holding ONE +`_gsaClient`, built in its constructor (`AppleAPI.cpp:112-117`). `gsaClient()` (`:1013`) returns a +copy sharing the same cpprestsdk impl and therefore the same asio connection pool, and the second +GSA request is issued from a `.then()` continuation the instant the first completes — textbook +keep-alive reuse. No `Connection` header is ever set; the request carries exactly four +(`AppleAPI+Authentication.cpp:963-968`). Since ~2026-09 Apple's GrandSlam edge refuses the second +request on a reused connection. + +The structural argument is what makes this convincing: between request 1 and request 2, every +header and all ten anisette values are **byte-identical**. Only the plist body (`o=init` vs +`o=complete`) and the connection position differ. Anything identical in both cannot by itself +explain a different outcome, which eliminates the `X-Apple-I-SRL-NO` of `"0"`, the timestamp, the +User-Agent and the client-info string as sole causes. + +Corroborated across the ecosystem: rileytestut/AltSign PR #52 is literally *"Use a separate +connection for each GrandSlam request"* (shipped in AltServer 1.7.6); nab138/iloader 2.3.3's +entire release note is *"Disabled reqwest pooling to alleviate http 429 from grandslam"* — on a +client that **already carried** the akd fix, which is why the akd rewrite *reveals* the 429 rather +than causing it; iloader #709 places the 429 at the proof/complete request across five Apple IDs +on five machines, so it is not account-scoped. + +**Fixed** in `makefiles/AltSign-build/rewrite_altsign_source.py`: `gsaClient()` now returns a fresh +client per call. Untested against Apple at time of writing. + +Known remaining divergence, NOT the cause: our sanitizer replaces only the bundle-id substring, so +the wire value is `com.apple.akd/3594.4.19` — akd has never carried an Xcode build number. +Upstream PR #1790 replaces the whole token with `com.apple.akd/1.0`. Worth tightening separately; +it is identical in both requests so it cannot explain 200-then-429. ### Verified facts worth not re-deriving diff --git a/makefiles/AltSign-build/rewrite_altsign_source.py b/makefiles/AltSign-build/rewrite_altsign_source.py index a244aa3..15270a6 100644 --- a/makefiles/AltSign-build/rewrite_altsign_source.py +++ b/makefiles/AltSign-build/rewrite_altsign_source.py @@ -17,6 +17,52 @@ content = content.replace(b'localtime(', b'gmtime(') +# --- Give every GrandSlam request its own TCP connection ------------------------------- +# +# AppleAPI is a process-wide singleton holding ONE _gsaClient, built in the constructor. +# gsaClient() hands back a copy sharing the same cpprestsdk impl and therefore the same asio +# connection pool, and the second GSA request is issued from a .then() continuation the instant +# the first completes -- textbook keep-alive reuse. No Connection header is ever set. +# +# Since ~2026-09 Apple's GrandSlam edge refuses the SECOND request on a reused connection. +# Observed here: request 1 -> 200, request 2 -> 429, identically on the first-ever attempt and +# again 28 minutes later. Positional and non-cumulative, which is not how volume throttling +# behaves. Every header and all ten anisette values are byte-identical between the two requests, +# so the only things that differ are the plist body and the connection position. +# +# This is the same fix as rileytestut/AltSign PR #52 ("Use a separate connection for each +# GrandSlam request", shipped in AltServer 1.7.6) and nab138/iloader 2.3.3 ("Disabled reqwest +# pooling to alleviate http 429 from grandslam"). Note iloader already carried the com.apple.akd +# client-info fix when it hit this, which is why that fix REVEALS the 429 rather than causing it. +# +# Cost: one extra TLS handshake per GrandSlam request, a handful of times per sign-in. +_gsa_old = ( + b'web::http::client::http_client AppleAPI::gsaClient()\n' + b'{\n' + b'\treturn this->_gsaClient;\n' + b'}\n' +) +_gsa_new = ( + b'web::http::client::http_client AppleAPI::gsaClient()\n' + b'{\n' + b'\t// Patched by rewrite_altsign_source.py: a FRESH client per call, so each GrandSlam\n' + b'\t// request opens its own connection instead of reusing the singleton\'s pooled one.\n' + b'\t// Apple 429s the second request on a reused connection. See the note in the rewriter.\n' + b'\thttp_client_config gsaConfig;\n' + b'\tgsaConfig.set_validate_certificates(false);\n' + b'\treturn web::http::client::http_client(U("https://gsa.apple.com"), gsaConfig);\n' + b'}\n' +) + +if F.endswith('AppleAPI.cpp'): + if content.count(_gsa_old) != 1: + sys.stderr.write( + "rewrite_altsign_source.py: gsaClient() connection patch matched %d times, expected 1.\n" + " upstream AppleAPI.cpp changed; re-check before removing this guard.\n" + % content.count(_gsa_old)) + sys.exit(1) + content = content.replace(_gsa_old, _gsa_new) + # --- Make a non-200 from Apple's auth endpoint legible ------------------------------- # AppleAPI+Authentication.cpp logs the HTTP status and then DISCARDS it, feeding the body # straight to plist_from_xml. A 429 body is not plist XML, so it fails to parse and the user diff --git a/web/__pycache__/pairing.cpython-314.pyc b/web/__pycache__/pairing.cpython-314.pyc deleted file mode 100644 index 3a325d075f9ce3a8a9ea4b971881a13444e6f07f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8014 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 Mzpmm!TNw-g1GfQWbpQYW diff --git a/web/__pycache__/status_checks.cpython-314.pyc b/web/__pycache__/status_checks.cpython-314.pyc deleted file mode 100644 index f006d8e5c765ae259414df8fb1cf7885e5501bb5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13773 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 Date: Mon, 14 Sep 2026 13:15:43 -0400 Subject: [PATCH 42/95] docs: zero-credential probe for the GrandSlam connection-reuse theory Tests whether Apple's GrandSlam edge refuses a SECOND request on a REUSED TCP connection, without AltServer and without spending an Apple ID attempt. Safe by construction: it sends only o=init, the opening SRP step, which carries no password -- just a dummy public ephemeral and a username. The username is deliberately nonexistent (RFC 2606 reserved .invalid TLD), so no real account is touched and nothing can be locked out. Four requests total. Run A issues both requests through one curl invocation with --next, reusing the socket. Run B issues them separately with Connection: close. The num_connects field is the discriminator: 0 means the socket was reused. A=200,429 with B=200,200 confirms connection reuse and validates the gsaClient() fix. A=200,200 refutes it and moves the fault onto the o=complete body, saving a pointless rebuild. Both runs 429 means something per-IP or per-identity, which would be worth knowing before spending more attempts. Any 503 means the client-info block instead. Client-info deliberately uses com.apple.akd rather than com.apple.dt.Xcode: with the Xcode form Apple 503s the request outright, and the probe would measure the block we already fixed rather than the connection behaviour it is meant to test. Body mirrors the real request shape from AppleAPI+Authentication.cpp:299-303 and is verified to parse as a valid plist. Co-Authored-By: Claude Opus 5 MSG_END Co-Authored-By: Claude Opus 5 --- docs/gsa-connection-probe.sh | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100755 docs/gsa-connection-probe.sh diff --git a/docs/gsa-connection-probe.sh b/docs/gsa-connection-probe.sh new file mode 100755 index 0000000..c202425 --- /dev/null +++ b/docs/gsa-connection-probe.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Does Apple's GrandSlam edge refuse a SECOND request on a REUSED TCP connection? +# +# WHY THIS IS SAFE. It sends only `o=init`, the opening step of the SRP exchange, which carries +# NO password -- just a public ephemeral value and a username. It uses a deliberately nonexistent +# address, so no real account is touched and nothing can be locked out. Four requests total. +# +# WHAT IT DISTINGUISHES. AltServer-Linux issues its two GrandSlam requests through one pooled +# keep-alive connection (AppleAPI holds a singleton _gsaClient and never sets `Connection`), and +# the second comes back 429. This reproduces that shape without AltServer and without an Apple ID: +# +# Run A -- both requests on ONE socket, via curl --next +# Run B -- each request on its OWN socket, with Connection: close +# +# READING THE RESULT (the num_connects column is the point: 0 means the socket was reused): +# +# A = 200,429 and B = 200,200 -> CONFIRMED. Connection reuse is the cause. The fix in +# rewrite_altsign_source.py (a fresh client per GSA call) +# is correct. +# A = 200,200 and B = 200,200 -> REFUTED. Reuse is fine; the fault is in the o=complete +# body instead, and the fix will not help. +# A and B both 429 on request 2 -> Neither: something per-IP or per-identity. Investigate +# upstream drift before spending more Apple attempts. +# Anything 503 -> The client-info block, not this. Check X-MMe-Client-Info. +# +# Usage: bash docs/gsa-connection-probe.sh +# Needs: curl. Nothing else, and no credentials. + +set -u + +ENDPOINT="https://gsa.apple.com/grandslam/GsService2" +PROBE_USER="altserver-probe@example.invalid" # deliberately nonexistent; RFC 2606 reserved TLD + +# Mirrors what AltServer actually sends. Client-info uses com.apple.akd rather than +# com.apple.dt.Xcode -- with the Xcode form Apple 503s the request outright and the probe would +# measure the wrong thing entirely. +CLIENT_INFO=' ' +UA='akd/1.0 CFNetwork/978.0.7 Darwin/18.7.0' + +BODY_FILE="$(mktemp)" +trap 'rm -f "$BODY_FILE"' EXIT + +# A well-formed o=init body. A2k is a dummy public ephemeral -- the server cannot tell it is not a +# real SRP value until it tries to use it, which is well after the edge has decided a status code. +cat > "$BODY_FILE" <<'PLIST' + + + + + Header + Version1.0.1 + Request + + A2kQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE= + cpd + + bootstrap + icscrec + pbe + prkgen + svctiCloud + + oinit + pss2ks2k_fo + ualtserver-probe@example.invalid + + + +PLIST + +FMT=' request %{url_effective}\n HTTP %{http_code} new-connections=%{num_connects} time=%{time_total}s\n' + +echo "GrandSlam connection-reuse probe" +echo " endpoint : $ENDPOINT" +echo " user : $PROBE_USER (nonexistent -- no account is touched, no password is sent)" +echo + +echo "== Run A: two requests, ONE curl invocation (socket REUSED) ==" +echo " expect new-connections=1 then 0. A 429 on the second is the failure we are chasing." +curl -sS -o /dev/null -w "$FMT" \ + -X POST "$ENDPOINT" \ + -H "Content-Type: text/x-xml-plist" \ + -H "Accept: */*" \ + -H "User-Agent: $UA" \ + -H "X-Mme-Client-Info: $CLIENT_INFO" \ + --data-binary "@$BODY_FILE" \ + --next \ + -sS -o /dev/null -w "$FMT" \ + -X POST "$ENDPOINT" \ + -H "Content-Type: text/x-xml-plist" \ + -H "Accept: */*" \ + -H "User-Agent: $UA" \ + -H "X-Mme-Client-Info: $CLIENT_INFO" \ + --data-binary "@$BODY_FILE" + +echo +echo "== Run B: two requests, SEPARATE connections (Connection: close) ==" +echo " expect new-connections=1 both times." +for i in 1 2; do + curl -sS -o /dev/null -w "$FMT" \ + -X POST "$ENDPOINT" \ + -H "Content-Type: text/x-xml-plist" \ + -H "Accept: */*" \ + -H "User-Agent: $UA" \ + -H "X-Mme-Client-Info: $CLIENT_INFO" \ + -H "Connection: close" \ + --data-binary "@$BODY_FILE" + sleep 1 +done + +echo +echo "Interpretation:" +echo " A=200,429 and B=200,200 -> connection reuse CONFIRMED; the gsaClient() fix is right" +echo " A=200,200 and B=200,200 -> REFUTED; the fault is the o=complete body, not the socket" +echo " both runs 429 on req 2 -> per-IP or per-identity; do not spend more Apple attempts yet" +echo " any 503 -> client-info block instead; check X-MMe-Client-Info" From 1b9b8d5320ca8c9f08c2a97efe27c5df3058d601 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:18:55 -0400 Subject: [PATCH 43/95] docs: the probe must skip TLS validation, as AltServer does First run returned HTTP 000 on all four requests with "self-signed certificate in certificate chain". That is not interception and not a network fault, though it looks like both. gsa.apple.com presents a certificate issued by "Apple Server Authentication CA" -- Apple's own private CA, verified on the host: subject = CN = gsa.apple.com, O = Apple Inc. issuer = CN = Apple Server Authentication CA, O = Apple Inc. That CA is in no public trust store, so curl rejects the chain and never sends the request. DNS is fine: local and public resolvers both return addresses in Apple's 17.0.0.0/8, and the differing addresses are ordinary Akamai geo-routing. This also corrects an assumption worth recording: AppleAPI.cpp's config.set_validate_certificates(false) on the GSA client is NOT carelessness, it is required, because no standard trust store can verify this endpoint. Pinning Apple's CA would be better than disabling validation wholesale, but that is a refinement rather than a bug fix, and it is not the security hole it resembles at first glance. Every curl call now passes -k, with the reasoning documented inline so nobody "tidies it away" later, and HTTP 000 is added to the interpretation table as its own diagnosis rather than being mistaken for an Apple response. Co-Authored-By: Claude Opus 5 --- docs/gsa-connection-probe.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/gsa-connection-probe.sh b/docs/gsa-connection-probe.sh index c202425..f7ee6d4 100755 --- a/docs/gsa-connection-probe.sh +++ b/docs/gsa-connection-probe.sh @@ -24,6 +24,13 @@ # upstream drift before spending more Apple attempts. # Anything 503 -> The client-info block, not this. Check X-MMe-Client-Info. # +# ABOUT -k / --insecure. It is REQUIRED here and is not a shortcut. gsa.apple.com is served with +# a certificate issued by "Apple Server Authentication CA" -- Apple's own private CA, which is in +# no public trust store, so curl rejects the chain as self-signed and never sends the request. +# AltServer hits the same wall and handles it the same way: AppleAPI.cpp sets +# config.set_validate_certificates(false) for exactly this client. Validating here would measure +# TLS trust rather than the connection behaviour we are testing. +# # Usage: bash docs/gsa-connection-probe.sh # Needs: curl. Nothing else, and no credentials. @@ -74,11 +81,13 @@ FMT=' request %{url_effective}\n HTTP %{http_code} new-connections=%{num_c echo "GrandSlam connection-reuse probe" echo " endpoint : $ENDPOINT" echo " user : $PROBE_USER (nonexistent -- no account is touched, no password is sent)" +echo " tls : validation disabled (-k), because Apple serves this endpoint from a private CA" +echo " that is in no public trust store. AltServer does the same. Not a shortcut." echo echo "== Run A: two requests, ONE curl invocation (socket REUSED) ==" echo " expect new-connections=1 then 0. A 429 on the second is the failure we are chasing." -curl -sS -o /dev/null -w "$FMT" \ +curl -k -sS -o /dev/null -w "$FMT" \ -X POST "$ENDPOINT" \ -H "Content-Type: text/x-xml-plist" \ -H "Accept: */*" \ @@ -86,7 +95,7 @@ curl -sS -o /dev/null -w "$FMT" \ -H "X-Mme-Client-Info: $CLIENT_INFO" \ --data-binary "@$BODY_FILE" \ --next \ - -sS -o /dev/null -w "$FMT" \ + -k -sS -o /dev/null -w "$FMT" \ -X POST "$ENDPOINT" \ -H "Content-Type: text/x-xml-plist" \ -H "Accept: */*" \ @@ -98,7 +107,7 @@ echo echo "== Run B: two requests, SEPARATE connections (Connection: close) ==" echo " expect new-connections=1 both times." for i in 1 2; do - curl -sS -o /dev/null -w "$FMT" \ + curl -k -sS -o /dev/null -w "$FMT" \ -X POST "$ENDPOINT" \ -H "Content-Type: text/x-xml-plist" \ -H "Accept: */*" \ @@ -115,3 +124,4 @@ echo " A=200,429 and B=200,200 -> connection reuse CONFIRMED; the gsaClient() echo " A=200,200 and B=200,200 -> REFUTED; the fault is the o=complete body, not the socket" echo " both runs 429 on req 2 -> per-IP or per-identity; do not spend more Apple attempts yet" echo " any 503 -> client-info block instead; check X-MMe-Client-Info" +echo " HTTP 000 everywhere -> TLS or network, not Apple. Re-check that -k is present." From 887d459b4c11f7eb78af56dc53525c0f3a053976 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:20:09 -0400 Subject: [PATCH 44/95] REVIVAL.md: connection-reuse cause PROVEN, at zero cost to the Apple ID The probe settles it. Four o=init requests, no password, nonexistent address: Run A (one curl, --next): 200 (connects=1), 429 (connects=0) Run B (Connection: close): 200 (connects=1), 200 (connects=1) The requests are byte-identical; the only variable is whether the socket was reused, and num_connects=0 on the 429 proves it was. Apple's GrandSlam edge refuses the second request on a reused connection. This is NOT volume throttling. The waiting between sign-in attempts was never going to help, and a per-connection rule explains every observation that confused us: the failure was positional rather than cumulative, stable across 28 minutes, and present on the very first attempt ever made from this machine. The gsaClient() fix in eb053f1 therefore matches a proven mechanism rather than a plausible one. Still to be confirmed by an actual sign-in. Also records two corrections from the probe's first run, which returned HTTP 000 with "self-signed certificate in certificate chain" and looked alarming: - It was NOT interception. gsa.apple.com is served from "Apple Server Authentication CA", Apple's own private CA, which no public trust store contains. DNS is clean -- both resolvers answer inside Apple's 17.0.0.0/8, and the differing addresses are ordinary Akamai geo-routing. - So set_validate_certificates(false) on the GSA client is REQUIRED, not careless. I had called it a security bug; it is not. Pinning Apple's CA would be an improvement, but validation cannot succeed there as things are. Co-Authored-By: Claude Opus 5 --- REVIVAL.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/REVIVAL.md b/REVIVAL.md index 6c86c2e..9981ed4 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -134,8 +134,30 @@ client that **already carried** the akd fix, which is why the akd rewrite *revea than causing it; iloader #709 places the 429 at the proof/complete request across five Apple IDs on five machines, so it is not account-scoped. -**Fixed** in `makefiles/AltSign-build/rewrite_altsign_source.py`: `gsaClient()` now returns a fresh -client per call. Untested against Apple at time of writing. +**PROVEN 2026-09-14, with zero Apple ID attempts.** `docs/gsa-connection-probe.sh` sends four +`o=init` requests (no password, nonexistent `.invalid` address) against the real endpoint: + +| Run | Request | Status | `num_connects` | +|---|---|---|---| +| A — one curl, `--next` | 1 | `200` | 1 | +| A — one curl, `--next` | 2 | **`429`** | **0** ← socket reused | +| B — `Connection: close` | 1 | `200` | 1 | +| B — `Connection: close` | 2 | **`200`** | 1 ← fresh socket | + +Byte-identical requests; the only variable is whether the socket was reused. This is **not volume +throttling** — waiting between attempts was never going to help, and the per-connection rule +explains every observation: positional, stable, non-cumulative, present on the first attempt ever. + +**Fixed** in `makefiles/AltSign-build/rewrite_altsign_source.py`: `gsaClient()` returns a fresh +client per call. The fix matches the proven mechanism; still to be confirmed by an actual sign-in. + +Two corrections recorded so they are not re-derived. The probe's first run returned `HTTP 000` +with "self-signed certificate in certificate chain", which looks like interception but is not: +`gsa.apple.com` is served from **`Apple Server Authentication CA`**, Apple's own private CA, which +no public trust store contains. DNS is clean (both resolvers answer inside Apple's `17.0.0.0/8`; +differing addresses are Akamai geo-routing). Consequently `set_validate_certificates(false)` on the +GSA client in `AppleAPI.cpp` is **required**, not careless — pinning Apple's CA would be an +improvement, but it is not the security hole it resembles. Known remaining divergence, NOT the cause: our sanitizer replaces only the bundle-id substring, so the wire value is `com.apple.akd/3594.4.19` — akd has never carried an Xcode build number. From 38bddab8cf0c55c415c39fec0ea98dd44f64fe44 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:27:15 -0400 Subject: [PATCH 45/95] ci: do not cancel sibling build legs when one fails Matrix fail-fast defaults to true, so a failure in any leg cancels the rest. The emulated architectures (armv7, aarch64, i386 run under QEMU on x86 runners) are both the slowest and the likeliest to break, and cancelling a good amd64 build because armv7 died throws away the one artifact actually being used day to day. Also documents the asymmetry while it is in view: artifacts are published per-job as each finishes, so a slow leg never blocks downloading a fast one. The release job does wait for all four via needs:, which is right for a tagged release and merely slow for iteration. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94ea290..7a2f585 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,10 +73,18 @@ jobs: build: needs: [check] strategy: + # Do not cancel the other legs when one fails: the emulated architectures are the most + # likely to break, and killing a good amd64 build because armv7 died wastes the useful one. + fail-fast: false matrix: # `include:`-only matrix: still exactly 4 legs, but each now carries a short `arch` # label. actions/upload-artifact rejects '/' and ':' in artifact names, so the image # reference cannot be used as the name -- hence this label. + # + # armv7, aarch64 and i386 run under QEMU emulation on x86 runners and take far longer + # than amd64. Artifacts are published per-job as each finishes, so a slow leg never + # blocks downloading a fast one -- but `release` waits for all four via `needs:`, which + # is correct for a tagged release and merely slow for day-to-day iteration. include: - arch: armv7 builder: ghcr.io/nyamisty/altserver_builder_alpine_armv7 From 7f2032e28fe75b34ee6b339b84cb084a8e15302b Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:43:04 -0400 Subject: [PATCH 46/95] deploy: named volumes, so the stack needs no host prep at all The stack previously required two manual steps before deploying -- mkdir the bind-mount directories, and chown the anisette one to 1000:1000 -- which is exactly the kind of out-of-band setup that makes "point Portainer at the repo and it just works" not actually true. Named volumes remove both. Docker creates them on first deploy, and when a named volume is first mounted over a path that already exists in the image it copies that path's contents AND ITS OWNERSHIP into the volume. The anisette image ships /home/Alcoholic/.config/anisette-v3 owned by uid 1000, so the volume inherits that ownership and the chown becomes unnecessary rather than merely automated. The host-path bind mounts remain as commented alternatives, since the operator's convention elsewhere is absolute bind-mount paths and an inspectable directory is genuinely easier to poke at. Documents the migration explicitly, because getting it wrong is expensive: a deployment that already has a provisioned identity in a bind mount must copy it into the new volume BEFORE redeploying. A fresh named volume starts empty, the server mints a new machine, and Apple then demands 2FA on every refresh -- which unattended operation can never answer. Includes the copy command and the Device-Id check that proves it worked. The one prerequisite that genuinely cannot be automated is still called out: the phone must be paired over USB once, which is outside Docker's reach entirely. Co-Authored-By: Claude Opus 5 --- deploy/altserver-stack.yml | 43 +++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/deploy/altserver-stack.yml b/deploy/altserver-stack.yml index 0223a9b..33148b3 100644 --- a/deploy/altserver-stack.yml +++ b/deploy/altserver-stack.yml @@ -7,14 +7,28 @@ # Nginx Proxy Manager and do NOT give them a Cloudflare Tunnel route. # # ---------------------------------------------------------------------------------------------- -# ONE-TIME PREP ON THE HOST, before deploying: +# NO HOST PREP NEEDED. This uses NAMED VOLUMES, which Docker creates on first deploy -- and when a +# named volume is first mounted over a path that already exists in the image, Docker copies that +# path's contents AND ITS OWNERSHIP into the volume. The anisette image ships +# /home/Alcoholic/.config/anisette-v3 owned by uid 1000, so the volume inherits that and the +# chown a bind mount would have required is unnecessary. # -# sudo mkdir -p /opt/stacks/anisette/config /opt/stacks/altserver/data -# sudo chown 1000:1000 /opt/stacks/anisette/config # anisette runs as uid 1000 -# sudo chmod 700 /opt/stacks/anisette/config +# The one genuine prerequisite is outside Docker's reach: the phone must have been paired over USB +# once (BOOTSTRAP.md Phase 3) so /var/lib/lockdown holds a pairing record. Wireless pairing is not +# possible in this build. # -# The phone must also have been paired over USB once (see BOOTSTRAP.md Phase 3) so that -# /var/lib/lockdown holds a pairing record. Wireless pairing is not possible in this build. +# MIGRATING AN EXISTING BIND-MOUNT DEPLOYMENT: if you already have a provisioned identity at +# /opt/stacks/anisette/config, do NOT just redeploy -- the new volume starts empty and the server +# will mint a new machine, which costs you a 2FA prompt on every refresh. Copy it across first: +# +# docker compose stop anisette +# docker run --rm -v /opt/stacks/anisette/config:/from \ +# -v altserver_anisette-config:/to alpine \ +# sh -c 'cp -a /from/. /to/' +# docker compose up -d anisette +# curl -sS http://127.0.0.1:6969/ | grep -o '"X-Mme-Device-Id":"[^"]*"' # must be UNCHANGED +# +# The volume name is _anisette-config; adjust the prefix to your stack's name. # ---------------------------------------------------------------------------------------------- services: @@ -40,7 +54,10 @@ services: # The machine identity. Mount this DIRECTORY, never its lib/ subdirectory — the upstream # README says lib/, which persists only the Apple .so cache and loses device.json + adi.pb # on every stack update. That is issue #86. - - /opt/stacks/anisette/config:/home/Alcoholic/.config/anisette-v3 + # Swap for an absolute bind mount if you prefer an inspectable host path: + # - /opt/stacks/anisette/config:/home/Alcoholic/.config/anisette-v3 + # ...but then you must mkdir it and chown it to 1000:1000 first. + - anisette-config:/home/Alcoholic/.config/anisette-v3 # -------------------------------------------------------------------------------------------- # AltServer itself. @@ -84,7 +101,10 @@ services: volumes: # AltServerData. The app writes to the RELATIVE path ./AltServerData against its working # directory, which the image sets to /data — so this mount is what makes it persist. - - /opt/stacks/altserver/data:/data + # Also where to put AltStore.ipa for the first-time install. + # Swap for a bind mount if you want to drop files in from the host easily: + # - /opt/stacks/altserver/data:/data + - altserver-data:/data # The pairing record from the one-time USB pairing. Read-write: libimobiledevice may # refresh it. Losing this means fetching the cable again. @@ -104,3 +124,10 @@ services: # AltServer -u "$ALTSERVER_UDID" -a "$ALTSERVER_APPLE_ID" -p "$ALTSERVER_APPLE_PASSWORD" /data/AltStore.ipa # It needs an interactive console because the 2FA code is read from stdin. command: [] + +volumes: + # Created automatically on first deploy. `docker volume inspect _anisette-config` shows + # where they live on disk if you need to back them up -- and you SHOULD back up anisette-config, + # since it holds the Apple machine identity. Losing it means re-provisioning and a fresh 2FA. + anisette-config: + altserver-data: From 215885a95c20424b3da6b612cb3f3f1a16b9fc4b Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:53:53 -0400 Subject: [PATCH 47/95] image: fetch the current AltStore IPA automatically on start Removes the last manual file-placement step. Previously someone had to download AltStore.ipa by hand and drop it in the data volume, which is fragile in three ways at once: the volume starts empty on a fresh deploy (so the file silently vanishes after a redeploy, which just happened), a hand-downloaded IPA can be truncated or the wrong artifact entirely, and any URL written down goes stale. The entrypoint now resolves the download URL from AltStore's OWN catalogue at cdn.altstore.io/file/altstore/apps.json and fetches whatever version is current. That matters: the URL is versioned (.../altstore/2_2_2.ipa today), so hardcoding one installs an ever-older AltStore, and guessing one gets you a different app. I guessed 2_0.ipa earlier in this session, which turned out to resolve to a real but older build -- exactly the failure mode this avoids. Details that make it safe to run unattended on every start: - Idempotent. Records the fetched version beside the file and re-downloads only when the catalogue moves ahead, so the usual cost is one small JSON request. - Downloads to a .part file in the same directory and renames on success. A half-written IPA left at the real path would be found, fail to unpack, and report "The app could not be found" -- which reads as a signing problem rather than a truncated download. - Verifies the archive contains a Payload/*.app before accepting it. - NON-FATAL on failure. An existing IPA is better than a server that refuses to start, and the daemon still refreshes already-installed apps without one; only a first-time install needs the file. ALTSTORE_SKIP_FETCH=1 disables it entirely for anyone wanting to pin a build. Stdlib only -- python3 is already required for the Bonjour shim. Also adds curl to the image, which was missing and makes in-container debugging awkward. Verified against a real build: a fresh volume fetches and verifies 2.2.2 (32,770,196 bytes, Payload/AltStore.app), a second start skips it, and AltServer runs normally afterwards. Co-Authored-By: Claude Opus 5 --- Dockerfile | 12 +++- deploy/altserver-stack.yml | 5 ++ docker-entrypoint.sh | 21 ++++++ web/fetch_altstore.py | 127 +++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100755 docker-entrypoint.sh create mode 100644 web/fetch_altstore.py diff --git a/Dockerfile b/Dockerfile index a518427..8eef50a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,7 @@ RUN set -eux; \ python3 \ libavahi-compat-libdnssd-dev \ ca-certificates \ + curl \ tzdata; \ rm -rf /var/lib/apt/lists/*; \ # Fail the BUILD rather than ship an image that cannot advertise. This is the exact call @@ -55,6 +56,12 @@ RUN set -eux; \ COPY --from=build /out/AltServer /usr/local/bin/AltServer +# Fetches the current AltStore Classic IPA, resolving the URL from AltStore's own catalogue rather +# than a hardcoded one -- a pinned URL silently installs an ever-older AltStore. +COPY web/fetch_altstore.py /usr/local/bin/fetch-altstore +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint +RUN chmod +x /usr/local/bin/fetch-altstore /usr/local/bin/docker-entrypoint + # AltServerApp writes to the RELATIVE path ./AltServerData, resolved against the working # directory — so the workdir is load-bearing, not cosmetic. Mount a volume here to persist it. WORKDIR /data @@ -68,5 +75,6 @@ ENV ALTSERVER_ANISETTE_SERVER="" # a published port cannot help, and Bonjour does not cross a bridge. EXPOSE 51820 -# No IPA argument = daemon mode. Add one (or docker exec) for a one-time install. -ENTRYPOINT ["/usr/local/bin/AltServer"] +# The entrypoint refreshes /data/AltStore.ipa, then execs AltServer with whatever arguments were +# given. No IPA argument = daemon mode; add one (or docker exec) for a one-time install. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] diff --git a/deploy/altserver-stack.yml b/deploy/altserver-stack.yml index 33148b3..3c21a8f 100644 --- a/deploy/altserver-stack.yml +++ b/deploy/altserver-stack.yml @@ -91,6 +91,11 @@ services: # Reaches the anisette service above via the host's loopback. ALTSERVER_ANISETTE_SERVER: http://127.0.0.1:6969 + # The entrypoint refreshes /data/AltStore.ipa on every start, resolving the current + # version from AltStore's own catalogue -- so there is no IPA to download by hand and + # no hardcoded URL to go stale. Set to 1 to leave the file alone (e.g. to pin a build). + ALTSTORE_SKIP_FETCH: "0" + # Credentials. Prefer putting these in Portainer's stack environment variables rather than # committing them. They are only needed for sign-in; see BOOTSTRAP.md. # Passing them here beats -p on a command line, which exposes the password in `ps`. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..4cc6d58 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Keeps /data/AltStore.ipa current, then hands off to AltServer. +# +# Deliberately NON-FATAL: a failed refresh must not stop the server from starting. An existing IPA +# is fine, and even without one the daemon still serves refreshes for apps already installed -- +# only a first-time install needs the file. Exiting here would turn a transient network blip into +# an outage. +# +# Set ALTSTORE_SKIP_FETCH=1 to leave the file alone entirely. +set -e + +if [ "${ALTSTORE_SKIP_FETCH:-}" = "1" ]; then + echo "entrypoint: ALTSTORE_SKIP_FETCH=1, not touching ${ALTSTORE_IPA_PATH:-/data/AltStore.ipa}" +else + if ! /usr/local/bin/fetch-altstore --dest "${ALTSTORE_IPA_PATH:-/data/AltStore.ipa}"; then + echo "entrypoint: WARNING -- could not refresh the AltStore IPA; continuing anyway." >&2 + echo "entrypoint: a first-time install needs it; refreshes of installed apps do not." >&2 + fi +fi + +exec /usr/local/bin/AltServer "$@" diff --git a/web/fetch_altstore.py b/web/fetch_altstore.py new file mode 100644 index 0000000..88abb23 --- /dev/null +++ b/web/fetch_altstore.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Fetch the current AltStore Classic IPA, so nobody has to place it by hand. + +Resolves the download URL from Apple-independent metadata rather than hardcoding it: AltStore +publishes a source catalogue, and the IPA URL is versioned inside it +(.../apps/altstore/2_2_2.ipa today). Hardcoding a URL means silently installing an ever-older +AltStore, and guessing one is how you end up with a file that is not the app you wanted. + +Idempotent. It records the version it fetched alongside the file and re-downloads only when the +catalogue moves ahead, so running it on every container start costs one small JSON request. + +Stdlib only -- python3 is already a hard requirement of this image for the Bonjour shim. + + python3 fetch_altstore.py [--dest /data/AltStore.ipa] [--force] [--beta] +""" + +import argparse +import json +import os +import sys +import tempfile +import urllib.request +import zipfile + +SOURCE_URL = "https://cdn.altstore.io/file/altstore/apps.json" + +# AltStore Classic. NOT com.rileytestut.AltStore.Beta, and emphatically not AltStore PAL, which is +# the EU marketplace build and is installed a completely different way. +BUNDLE_ID = "com.rileytestut.AltStore" +BETA_BUNDLE_ID = "com.rileytestut.AltStore.Beta" + + +def resolve(bundle_id): + """Return (version, url) for the requested app from AltStore's own catalogue.""" + req = urllib.request.Request(SOURCE_URL, headers={"User-Agent": "AltServer-Linux"}) + with urllib.request.urlopen(req, timeout=30) as resp: + catalogue = json.load(resp) + + for app in catalogue.get("apps", []): + if app.get("bundleIdentifier") != bundle_id: + continue + versions = app.get("versions") or [] + if versions: + return versions[0].get("version"), versions[0].get("downloadURL") + return app.get("version"), app.get("downloadURL") + + raise SystemExit("could not find %s in %s" % (bundle_id, SOURCE_URL)) + + +def verify(path): + """A downloaded file is only useful if it is actually an IPA. Returns the bundle name.""" + try: + names = zipfile.ZipFile(path).namelist() + except zipfile.BadZipFile as exc: + raise SystemExit("downloaded file is not a zip archive: %s" % exc) + + bundles = sorted({ + n.split("/")[1] for n in names + if n.startswith("Payload/") and n.count("/") > 1 and n.split("/")[1].endswith(".app") + }) + if not bundles: + raise SystemExit("archive contains no Payload/*.app -- not an IPA") + return bundles[0] + + +def main(): + ap = argparse.ArgumentParser(description="Fetch the current AltStore IPA") + ap.add_argument("--dest", default=os.environ.get("ALTSTORE_IPA_PATH", "/data/AltStore.ipa")) + ap.add_argument("--force", action="store_true", help="re-download even if up to date") + ap.add_argument("--beta", action="store_true", help="fetch the beta channel instead") + args = ap.parse_args() + + bundle_id = BETA_BUNDLE_ID if args.beta else BUNDLE_ID + stamp_path = args.dest + ".version" + + try: + version, url = resolve(bundle_id) + except Exception as exc: + # Do not fail the container start over this: an existing IPA is better than no server. + if os.path.exists(args.dest): + print("could not check for updates (%s); keeping the existing %s" % (exc, args.dest)) + return 0 + raise SystemExit("could not resolve the AltStore download URL: %s" % exc) + + have = None + if os.path.exists(stamp_path): + try: + have = open(stamp_path).read().strip() + except OSError: + pass + + if not args.force and have == version and os.path.exists(args.dest): + print("AltStore %s already present at %s" % (version, args.dest)) + return 0 + + print("fetching AltStore %s from %s" % (version, url), flush=True) + os.makedirs(os.path.dirname(args.dest) or ".", exist_ok=True) + + # Download to a temporary file in the SAME directory, then rename. A half-written IPA left at + # the real path would be found, fail to unpack, and report "The app could not be found" -- + # which looks like a signing problem rather than a truncated download. + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(args.dest) or ".", suffix=".part") + os.close(fd) + try: + req = urllib.request.Request(url, headers={"User-Agent": "AltServer-Linux"}) + with urllib.request.urlopen(req, timeout=300) as resp, open(tmp, "wb") as out: + while True: + chunk = resp.read(1 << 16) + if not chunk: + break + out.write(chunk) + + bundle = verify(tmp) + os.replace(tmp, args.dest) + with open(stamp_path, "w") as f: + f.write(version or "") + finally: + if os.path.exists(tmp): + os.remove(tmp) + + print("AltStore %s -> %s (%s, %d bytes)" + % (version, args.dest, bundle, os.path.getsize(args.dest))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5e41eee84a43faa51e93de4d8f9a286126861032 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:56:16 -0400 Subject: [PATCH 48/95] web: install flow with 2FA entry -- slice 3, the setup portal is complete Builds the piece that was blocked: AltServer reads the two-factor code from stdin (std::cin >> _verificationCode), and a detached container has no terminal. A supervisor owns its child's stdin, so a web form can deliver the code to a read that has no tty. No C++ change needed. Held until now deliberately -- sign-in did not work until the GrandSlam connection fix landed, and building a UI around a flow that cannot be exercised tests nothing. It works end to end now, so this is testable. It also answers the OTHER stdin consumer: ShowAlert ends in getchar() ("Press any key to continue..."), which blocks forever against a pipe that stays open. The supervisor writes a newline when it sees that, so an install completes instead of hanging at the last step. REDACTION IS THE PART THAT MATTERS. A successful sign-in prints Apple's entire account record to stdout -- real name, phone number, adsid, and a dozen bearer tokens including com.apple.gs.icloud.auth with a ONE YEAR lifetime. That already lands in docker logs and journald; rendering it into a browser page someone screenshots would be worse. Every line is filtered before leaving the module: the account dump is replaced wholesale, token-bearing lines are withheld, the hundreds of "Byte:-42" SRP debug lines are dropped, and anything absurdly long is truncated. Credentials go to AltServer through the environment, never argv, so they are not visible in ps either. Exit status is deliberately not trusted: AltServer exits 0 even on failure (it catches, logs, prints "Finished!" and falls off the end of main), so success is determined from the output. Verified against a mock reproducing AltServer's real prompts and a fake credential dump: the 2FA prompt is detected, a malformed code is rejected before being sent, a valid one reaches stdin and the process continues, "Press any key" is auto-answered, and the account record and token line are both withheld from what the browser sees. The image now ships the web UI but does NOT start it: it accepts an Apple ID password, so exposing it should be a deliberate act rather than a side effect of deploying. The page says plainly that the password crosses plain HTTP and recommends loopback plus an SSH tunnel. Co-Authored-By: Claude Opus 5 --- Dockerfile | 6 ++ web/installer.py | 187 +++++++++++++++++++++++++++++++++++++++++++++++ web/server.py | 114 ++++++++++++++++++++++++++++- 3 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 web/installer.py diff --git a/Dockerfile b/Dockerfile index 8eef50a..d68c299 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,12 @@ COPY --from=build /out/AltServer /usr/local/bin/AltServer # Fetches the current AltStore Classic IPA, resolving the URL from AltStore's own catalogue rather # than a hardcoded one -- a pinned URL silently installs an ever-older AltStore. +# The setup web UI: status dashboard, pairing wizard and the install flow. Run it with +# docker exec altserver python3 /opt/altserver-web/server.py --host 0.0.0.0 +# It is NOT started automatically -- it accepts an Apple ID password, so exposing it should be a +# deliberate act rather than a side effect of deploying. +COPY web/ /opt/altserver-web/ + COPY web/fetch_altstore.py /usr/local/bin/fetch-altstore COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint RUN chmod +x /usr/local/bin/fetch-altstore /usr/local/bin/docker-entrypoint diff --git a/web/installer.py b/web/installer.py new file mode 100644 index 0000000..47b69da --- /dev/null +++ b/web/installer.py @@ -0,0 +1,187 @@ +"""Supervises an AltServer install so a browser can drive it. + +WHY A SUPERVISOR. AltServer reads the two-factor code from stdin +(`std::cin >> _verificationCode`), and a detached container has no terminal. A parent process owns +its child's stdin, so a web form can deliver the code to a read that has no tty. That is the whole +trick, and it is why the UI does not need any change to the C++. + +It also handles the other stdin consumer: `ShowAlert` ends in `getchar()` ("Press any key to +continue..."), which would block forever against a pipe that stays open. We answer it. + +REDACTION IS NOT OPTIONAL HERE. A successful sign-in prints Apple's full account record to stdout +-- real name, phone number, `adsid`, and a dozen bearer tokens including `com.apple.gs.icloud.auth` +with a 31536000-second (one year) lifetime. Those reach `docker logs` and journald already, which +is bad enough; rendering them into a browser page that someone screenshots would be worse. Every +line is filtered before it leaves this module. +""" + +import os +import re +import subprocess +import threading +import time + +IDLE, RUNNING, AWAITING_2FA, SUCCEEDED, FAILED = "idle", "running", "awaiting_2fa", "succeeded", "failed" + +# AltServer asks for the code with this, then blocks on std::cin. +PROMPT_2FA = "Enter two factor code" +# ShowAlert prints this and then calls getchar(), which blocks against a pipe. +PROMPT_ANYKEY = "Press any key to continue" + +# Lines that carry credentials or account data. Dropped or replaced, never shown. +_SENSITIVE = re.compile( + r"(GsIdmsToken|adsid|DsPrsId|phoneNumber||com\.apple\.gs\.[a-z.]+\s*|" + r"\"token\"|token|sessionKey|Got token for)", + re.IGNORECASE, +) +# The account record arrives as one enormous line beginning with "Data: ". +_ACCOUNT_DUMP = re.compile(r"^\s*Data:\s*") +# The SRP debug spew: hundreds of "Byte:-42" lines. +_BYTE_NOISE = re.compile(r"^\s*(Byte:-?\d+|HMAC_OUT:|NP:)\s*$") + + +def _redact(line): + """Return a display-safe line, or None to drop it entirely.""" + if _BYTE_NOISE.match(line): + return None + if _ACCOUNT_DUMP.match(line): + return "[Apple account record received -- withheld, it contains long-lived tokens]" + if _SENSITIVE.search(line): + return "[line withheld: contains credentials or account data]" + # Anything long and base64-ish that survived the checks above. + if len(line) > 400: + return line[:200] + " ... [truncated]" + return line + + +class Installer: + """Runs one install at a time and exposes its state to the web layer.""" + + def __init__(self, binary="/usr/local/bin/AltServer", ipa="/data/AltStore.ipa"): + self.binary = binary if os.path.exists(binary) else "AltServer" + self.ipa = ipa + self._lock = threading.Lock() + self._reset() + + def _reset(self): + self.state = IDLE + self.lines = [] + self.error = "" + self.started_at = None + self._proc = None + + def _emit(self, text): + shown = _redact(text.rstrip("\n")) + if shown is None: + return + with self._lock: + self.lines.append(shown) + # Bound the buffer; an install is chatty and nobody reads 5000 lines. + if len(self.lines) > 400: + del self.lines[:-400] + + def start(self, udid, apple_id, password, anisette_url=None): + with self._lock: + if self.state in (RUNNING, AWAITING_2FA): + return False, "An install is already running." + if not os.path.exists(self.ipa): + return False, "No IPA at %s. The container fetches it on start; check its logs." % self.ipa + if not (udid and apple_id and password): + return False, "UDID, Apple ID and password are all required." + + self._reset() + self.state = RUNNING + self.started_at = time.time() + + env = dict(os.environ) + if anisette_url: + env["ALTSERVER_ANISETTE_SERVER"] = anisette_url + # Credentials go through the environment, never argv -- argv is world-readable in `ps`. + env["ALTSERVER_UDID"] = udid + env["ALTSERVER_APPLE_ID"] = apple_id + env["ALTSERVER_APPLE_PASSWORD"] = password + + try: + self._proc = subprocess.Popen( + [self.binary, self.ipa], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, text=True, bufsize=1, + ) + except Exception as exc: + self.state = FAILED + self.error = "Could not start AltServer: %s" % exc + return False, self.error + + threading.Thread(target=self._pump, daemon=True).start() + return True, "" + + def _pump(self): + proc = self._proc + try: + for raw in proc.stdout: + line = raw.rstrip("\n") + self._emit(line) + + if PROMPT_2FA in line: + with self._lock: + self.state = AWAITING_2FA + + elif PROMPT_ANYKEY in line: + # getchar() is waiting. Answer it so the process can finish rather than hang. + try: + proc.stdin.write("\n") + proc.stdin.flush() + except Exception: + pass + except Exception as exc: + self._emit("[supervisor: error reading output: %s]" % exc) + + rc = proc.wait() + text = "\n".join(self.lines) + with self._lock: + # AltServer exits 0 even on failure (it catches, logs, prints "Finished!" and falls off + # the end of main), so the exit code is not trustworthy -- read the output instead. + if "Installation Succeeded" in text or "Installed app" in text: + self.state = SUCCEEDED + elif re.search(r"Could not install|Error:|error code", text, re.IGNORECASE): + self.state = FAILED + self.error = "AltServer reported a failure -- see the log below." + else: + self.state = SUCCEEDED if rc == 0 else FAILED + if self.state == FAILED: + self.error = "AltServer exited with status %s." % rc + + def submit_code(self, code): + with self._lock: + if self.state != AWAITING_2FA: + return False, "Not waiting for a code right now." + code = (code or "").strip() + if not re.fullmatch(r"\d{6}", code): + return False, "The code should be six digits." + try: + self._proc.stdin.write(code + "\n") + self._proc.stdin.flush() + except Exception as exc: + return False, "Could not send the code: %s" % exc + with self._lock: + self.state = RUNNING + self._emit("[supervisor: two-factor code submitted]") + return True, "" + + def cancel(self): + if self._proc and self._proc.poll() is None: + self._proc.terminate() + return True + return False + + def snapshot(self): + with self._lock: + return { + "state": self.state, + "error": self.error, + "lines": list(self.lines), + "elapsed": int(time.time() - self.started_at) if self.started_at else 0, + } + + +INSTALLER = Installer() diff --git a/web/server.py b/web/server.py index e160c64..e8d9134 100644 --- a/web/server.py +++ b/web/server.py @@ -35,6 +35,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import status_checks # noqa: E402 import pairing # noqa: E402 +import installer # noqa: E402 PAGE = """ @@ -97,7 +98,7 @@
+ + +""" + + class Handler(BaseHTTPRequestHandler): server_version = "AltServerStatus/0.1" @@ -198,6 +286,10 @@ def do_GET(self): path = self.path.split("?", 1)[0] if path in ("/", "/index.html"): self._send(200, PAGE, "text/html; charset=utf-8") + elif path == "/install": + self._send(200, INSTALL_PAGE, "text/html; charset=utf-8") + elif path == "/api/install/status": + self._send(200, json.dumps(installer.INSTALLER.snapshot()), "application/json") elif path == "/pairing": self._send(200, PAIRING_PAGE, "text/html; charset=utf-8") elif path == "/api/pairing": @@ -219,6 +311,26 @@ def do_GET(self): else: self._send(404, "not found\n", "text/plain; charset=utf-8") + def do_POST(self): + path = self.path.split("?", 1)[0] + try: + length = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(length) or b"{}") + except Exception: + self._send(400, json.dumps({"ok": False, "error": "bad request"}), "application/json") + return + + if path == "/api/install/start": + ok, err = installer.INSTALLER.start( + body.get("udid", ""), body.get("apple_id", ""), body.get("password", ""), + os.environ.get("ALTSERVER_ANISETTE_SERVER")) + self._send(200, json.dumps({"ok": ok, "error": err}), "application/json") + elif path == "/api/install/code": + ok, err = installer.INSTALLER.submit_code(body.get("code", "")) + self._send(200, json.dumps({"ok": ok, "error": err}), "application/json") + else: + self._send(404, json.dumps({"ok": False, "error": "not found"}), "application/json") + def log_message(self, fmt, *args): pass # the dashboard polls every 30s; logging that is pure noise From 023755c49fd05232d4f8d6499ba0b6a2e466ec98 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:00:39 -0400 Subject: [PATCH 49/95] ci: build amd64 only on branch pushes, all four on tags Every push was building four architectures, three of them under QEMU emulation on x86 runners, when day-to-day iteration consumes exactly one. That is minutes of waiting per push for artifacts nobody downloads. Dropping the other three outright would be the wrong fix. armv7 and aarch64 are what make AltServer-Linux useful on a Raspberry Pi, which is much of this project's value to anyone other than us -- so they stay, they just stop being paid for on every commit. A matrix_setup job now selects the architectures and build consumes it via fromJSON. amd64 alone for ordinary branch pushes; the full set for tags (the release job needs all four artifacts), for the scheduled upstream-sync run, and on demand through a new all_arches workflow_dispatch input. The selection shell was extracted and run against all four trigger shapes before committing: branch push yields 1 leg, and tag / schedule / all_arches each yield 4. Worth testing rather than assuming, since a malformed matrix expression fails at workflow-parse time and takes the whole run with it. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 63 ++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a2f585..408f778 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,11 @@ on: required: false type: boolean default: false + all_arches: + description: 'Build every architecture, not just amd64' + required: false + type: boolean + default: false debug_enabled: description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' required: false @@ -70,30 +75,50 @@ jobs: outputs: updated: ${{ steps.check.outputs.updated }} + # Decides which architectures to build. amd64 alone for ordinary branch pushes, which is what + # day-to-day iteration actually consumes; the full set for tags (the release job needs all four), + # for the scheduled upstream-sync run, and on demand via the all_arches input. + # + # The emulated legs (armv7, aarch64, i386 run under QEMU on x86 runners) take several times + # longer than amd64, so paying for them on every push is pure latency for no benefit -- but + # dropping them entirely would make this fork useless on a Raspberry Pi, which is much of the + # point of AltServer-Linux. + matrix_setup: + runs-on: ubuntu-latest + name: "Select architectures" + permissions: + contents: read + outputs: + matrix: ${{ steps.pick.outputs.matrix }} + steps: + - name: Pick architectures + id: pick + run: | + AMD64='{"arch":"amd64","builder":"ghcr.io/nyamisty/altserver_builder_alpine_amd64"}' + AARCH64='{"arch":"aarch64","builder":"ghcr.io/nyamisty/altserver_builder_alpine_aarch64"}' + ARMV7='{"arch":"armv7","builder":"ghcr.io/nyamisty/altserver_builder_alpine_armv7"}' + I386='{"arch":"i386","builder":"ghcr.io/nyamisty/altserver_builder_alpine_i386"}' + + if [[ "${{ github.ref }}" == refs/tags/* ]] \ + || [[ "${{ github.event_name }}" == "schedule" ]] \ + || [[ "${{ inputs.all_arches }}" == "true" ]]; then + echo "Building ALL architectures" + echo "matrix={\"include\":[$ARMV7,$AARCH64,$AMD64,$I386]}" >> "$GITHUB_OUTPUT" + else + echo "Branch push: building amd64 only. Use the all_arches input, or push a tag, for the full set." + echo "matrix={\"include\":[$AMD64]}" >> "$GITHUB_OUTPUT" + fi + build: - needs: [check] + needs: [check, matrix_setup] strategy: # Do not cancel the other legs when one fails: the emulated architectures are the most # likely to break, and killing a good amd64 build because armv7 died wastes the useful one. fail-fast: false - matrix: - # `include:`-only matrix: still exactly 4 legs, but each now carries a short `arch` - # label. actions/upload-artifact rejects '/' and ':' in artifact names, so the image - # reference cannot be used as the name -- hence this label. - # - # armv7, aarch64 and i386 run under QEMU emulation on x86 runners and take far longer - # than amd64. Artifacts are published per-job as each finishes, so a slow leg never - # blocks downloading a fast one -- but `release` waits for all four via `needs:`, which - # is correct for a tagged release and merely slow for day-to-day iteration. - include: - - arch: armv7 - builder: ghcr.io/nyamisty/altserver_builder_alpine_armv7 - - arch: aarch64 - builder: ghcr.io/nyamisty/altserver_builder_alpine_aarch64 - - arch: amd64 - builder: ghcr.io/nyamisty/altserver_builder_alpine_amd64 - - arch: i386 - builder: ghcr.io/nyamisty/altserver_builder_alpine_i386 + # Computed by matrix_setup above. Each leg carries a short `arch` label because + # actions/upload-artifact rejects '/' and ':' in artifact names, so the image reference + # cannot be used as the name. + matrix: ${{ fromJSON(needs.matrix_setup.outputs.matrix) }} runs-on: ubuntu-latest permissions: contents: read From 663cfaf023475a2de99664df79c052a1aa2f5786 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:04:58 -0400 Subject: [PATCH 50/95] deploy: run the web UI as a stack service, reachable at http://:8099 The web UI shipped in the image but had to be started by hand with docker exec, which dies with the exec session -- so in practice it was not available. It is now a service in the stack and starts with everything else. No port mapping: like altserver it uses network_mode: host, so it binds straight onto the host and is reachable at http://:8099. Host networking is also what lets it see the real usbmuxd socket and the real avahi, so the mDNS panel reports on the actual _altserver._tcp advertisement rather than a bridged illusion of one. The image was also missing the tools the status checks shell out to -- idevice_id, idevicepair, avahi-browse and pgrep. Without them the pairing and mDNS panels reported "unknown" from inside the container, which is honest but useless, and those two are the checks people actually need. Added libimobiledevice-utils, avahi-utils and procps; verified all six tools resolve in the built image and that the checks now return real verdicts instead of unknown. Mounts lockdown READ-ONLY: the UI reports on pairing, it does not change it. Shares altserver's data volume so the install flow can see the fetched IPA, and sets ALTSTORE_SKIP_FETCH=1 because fetching is altserver's job, not its. Security is stated in the compose comments rather than assumed: the install page takes an Apple ID password over plain HTTP. On a trusted home LAN that is a considered trade-off; anywhere shared, switch the command to --host 127.0.0.1 and use an SSH tunnel. It must never go behind the reverse proxy or a Cloudflare Tunnel. Also excludes __pycache__ from the build context, which was being copied in. Co-Authored-By: Claude Opus 5 --- .dockerignore | 2 ++ Dockerfile | 9 +++++++- deploy/altserver-stack.yml | 42 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index ce4362b..edfb4e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,5 +3,7 @@ build/ *.o .DS_Store ._* +__pycache__/ +*.pyc REVIVAL.md BOOTSTRAP.md diff --git a/Dockerfile b/Dockerfile index d68c299..2978a78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,14 @@ RUN set -eux; \ libavahi-compat-libdnssd-dev \ ca-certificates \ curl \ - tzdata; \ + tzdata \ + \ + # Needed by the setup web UI, which shells out to these for its health checks. Without + # them the pairing and mDNS panels report "unknown" from inside the container -- which is + # honest but useless, and those two are the checks people actually need. + libimobiledevice-utils \ + avahi-utils \ + procps; \ rm -rf /var/lib/apt/lists/*; \ # Fail the BUILD rather than ship an image that cannot advertise. This is the exact call # dnssd_loader.cpp makes, so if it works here it works at runtime. diff --git a/deploy/altserver-stack.yml b/deploy/altserver-stack.yml index 3c21a8f..cc63a7b 100644 --- a/deploy/altserver-stack.yml +++ b/deploy/altserver-stack.yml @@ -130,6 +130,48 @@ services: # It needs an interactive console because the 2FA code is read from stdin. command: [] + # -------------------------------------------------------------------------------------------- + # Setup web UI: status dashboard, pairing wizard, and the AltStore install flow with 2FA entry. + # Reachable at http://:8099 -- no port mapping, because network_mode: host binds it + # straight onto the host. + # + # SECURITY: the install page accepts an Apple ID password over PLAIN HTTP. On a trusted home LAN + # that is a considered trade-off; on anything shared, change the command below to + # `--host 127.0.0.1` and reach it through an SSH tunnel instead: + # ssh -L 8099:127.0.0.1:8099 you@this-host + # Do NOT put it behind the reverse proxy or a Cloudflare Tunnel -- it must not leave the LAN. + # -------------------------------------------------------------------------------------------- + altserver-web: + image: ghcr.io/ben-diehlci/altserver-linux:latest + container_name: altserver-web + restart: unless-stopped + init: true + + # Same host network as altserver, so it sees the same usbmuxd, the same avahi, and can report + # on the real _altserver._tcp advertisement rather than a bridged illusion of it. + network_mode: host + + depends_on: + - anisette + + environment: + # The status page checks this endpoint against the client's actual contract. + ALTSERVER_ANISETTE_SERVER: http://127.0.0.1:6969 + # Nothing here fetches the IPA; that is altserver's job. + ALTSTORE_SKIP_FETCH: "1" + + volumes: + # Shares altserver's data volume so the install flow can see the fetched AltStore.ipa. + - altserver-data:/data + # Read-only: the UI only reports on pairing, it does not change it. + - /var/lib/lockdown:/var/lib/lockdown:ro + - /var/run/usbmuxd:/var/run/usbmuxd + - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket + - /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket + + entrypoint: ["python3", "/opt/altserver-web/server.py"] + command: ["--host", "0.0.0.0", "--port", "8099"] + volumes: # Created automatically on first deploy. `docker volume inspect _anisette-config` shows # where they live on disk if you need to back them up -- and you SHOULD back up anisette-config, From f6e98de1346cee6329cbf6423dbafc8194ddf9d9 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:07:55 -0400 Subject: [PATCH 51/95] README: rewrite for the current state of the fork The README predated the container, the stack, the web UI and every sign-in fix, so it described a project that no longer exists. Leads with an honest status table rather than a feature list, including the parts that do NOT work: the iOS 26 signing fix is applied but unconfirmed on hardware, and AltJIT on iOS 17+ is not supported at all and points at pymobiledevice3 instead. Overstating either would waste someone's evening. Quick start is now the stack, since that genuinely needs no host preparation -- named volumes, bundled dependencies, and the IPA fetched automatically from AltStore's own catalogue. The one prerequisite Docker cannot remove is called out plainly: the phone must be paired over USB once, because wireless pairing is impossible in this build. That is surprising for a project whose point is wireless refresh, so it is stated rather than buried. Documents the environment variables, including the two that did not exist before (credentials from the environment, and the client-info sanitizer escape hatch), and says why there is deliberately no default anisette server. The runtime requirements table keeps the distinction that costs people the most time: libavahi-compat-libdnssd-DEV, not -libdnssd1, because the code dlopens the unversioned soname. Same for netmuxd needing to OWN /var/run/usbmuxd with usbmuxd stopped, and the widely-copied USBMUXD_SOCKET_ADRESS misspelling that is silently ignored. Adds a short explanation of how the build actually works -- the compile-time rewriting of vendored Windows source -- because it is genuinely unusual, and because anyone patching this code needs to know patches belong in the rewriters rather than the submodule. Verified every referenced path exists and that no personal details, hostnames or device identifiers leaked in. Co-Authored-By: Claude Opus 5 --- README.md | 231 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 138 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 5f3e858..45b6d08 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,76 @@ # AltServer-Linux -AltServer for AltStore, but on-device -> **This fork** ([`bd/revival`](../../tree/bd/revival)) revives a project whose last real code -> commit predates 2025. CI is working again, several long-standing bugs are fixed, and the goal is -> running unattended on a Linux home server so apps keep refreshing without a Mac or PC powered on. +AltServer for AltStore, but on-device. + +> **This is a fork** of [NyaMisty/AltServer-Linux](https://github.com/NyaMisty/AltServer-Linux), +> whose last real code commit predates 2025 and whose CI had been failing on every run. The goal +> here is **running unattended on a Linux home server**, so sideloaded apps keep refreshing on the +> 7-day cycle without a Mac or PC being powered on. > > - **[BOOTSTRAP.md](BOOTSTRAP.md)** — first-time setup, start to finish > - **[REVIVAL.md](REVIVAL.md)** — what changed, why, and what is still broken -> - **[deploy/](deploy/)** — Portainer/compose stacks -> -> Notable fixes here: Apple's 2026 GSA client-info block (confirmed in both directions against -> live Apple infrastructure), the iOS 26 launch-crash signing bug (#131), corecrypto builds again -> (#111), anisette failures now say what actually went wrong, and mDNS advertisement failure is no -> longer silent. +> - **[deploy/](deploy/)** — Portainer / compose stacks -## Quick start (Docker / Portainer) +--- -```bash -# 1. host prerequisites -- only needed if NOT using the container, which bundles them -sudo apt install -y usbmuxd libimobiledevice-utils avahi-daemon libavahi-compat-libdnssd-dev +## Status -# 2. deploy both services -# Portainer: Stacks -> Add stack -> Repository, compose path deploy/altserver-stack.yml -``` +| | | +|---|---| +| CI | **Fixed.** Was dying at "Set up job" on every run; no binaries since 2025-03 | +| Apple sign-in | **Working**, including 2FA, team lookup, device registration and certificate issuance | +| Apple's 2026 GSA client-info block | **Fixed** and confirmed in both directions against live Apple infrastructure | +| GrandSlam `429` on connection reuse | **Fixed**; proven with a zero-credential probe | +| corecrypto build (#111) | **Fixed.** The buildenv image is rebuildable from source again | +| iOS 26 launch crash (#131) | Fixed in code, **not yet confirmed on hardware** | +| AltJIT on iOS 17+ | **Not supported.** Needs personalised DDI, TSS signing and a RemoteXPC tunnel. Use [pymobiledevice3](https://github.com/doronz88/pymobiledevice3) | + +--- + +## Quick start -`libavahi-compat-libdnssd-**dev**`, not `-libdnssd1`: the code dlopens the *unversioned* -`libdns_sd.so`, whose symlink only the `-dev` package provides. Without it the server runs, -reports nothing wrong, and is permanently undiscoverable by your phone. +Everything runs as one stack: AltServer, an anisette server, and a setup web UI. -There is also a status dashboard that checks anisette, the clock, pairing, mDNS publication and -the process, since none of those report their own failures: +**Portainer → Stacks → Add stack → Repository**, pointing at this repo with compose path +`deploy/altserver-stack.yml`. Or with plain compose: ```bash -python3 web/server.py --host 0.0.0.0 --port 8099 +docker compose -f deploy/altserver-stack.yml up -d ``` -## Usage +No host preparation is needed — it uses named volumes, and the image bundles every runtime +dependency. The AltStore IPA is fetched automatically on start, resolved from AltStore's own +catalogue so it is always current. + +Then open **`http://:8099`**. + +The one prerequisite Docker cannot handle: **the phone must be paired over USB once.** Wireless +pairing is not possible in this build. After that, refreshing happens over Wi-Fi and the cable is +never needed again. The web UI walks you through it. + +### The web UI + +| Page | What it does | +|---|---| +| `/` | Health: anisette contract, clock, pairing, mDNS publication, process | +| `/pairing` | Guided pairing, distinguishing the "nothing shows up" cases | +| `/install` | Apple ID sign-in with 2FA entry in the browser | + +The status page exists because **this software cannot report its own health.** avahi can report a +successful registration while publishing nothing; AltStore suppresses the one error it would raise +during a background refresh; and nearly everything is logged to stdout at info level, so +`journalctl -p err` stays empty no matter what breaks. Without an external check, the first symptom +of a dead deployment is an app that will not open, a week later. + +> **The install page takes an Apple ID password over plain HTTP.** On a trusted LAN that is a +> considered trade-off. Anywhere else, set `--host 127.0.0.1` in the stack and use an SSH tunnel +> (`ssh -L 8099:127.0.0.1:8099 you@host`). Never put it behind a reverse proxy or a tunnel — this +> is LAN-only by design. + +--- + +## Running it directly -- Install IPA: `./AltServer -u [UDID] -a [AppleID account] -p [AppleID password] [ipaPath.ipa]` -- Running as AltServer Daemon: `./AltServer` -- Full usage (maybe outdated, refer to `./AltServer -h` for the newest): ``` Usage: AltServer-Linux options [ ipa-file ] -h --help Display this usage information. @@ -47,56 +78,76 @@ Usage: AltServer-Linux options [ ipa-file ] -a --appleID AppleID Apple ID to sign the ipa, only needed when installing IPA. -p --password passwd Password of Apple ID, only needed when installing IPA. -d --debug Print debug output, can be used several times to increase debug level. - -The following environment var can be set for some special situation: - - ALTSERVER_ANISETTE_SERVER: (REQUIRED) URL of an anisette server, including - the scheme, e.g. http://127.0.0.1:6969 - There is no default. The server that used to be hardcoded here has been - returning HTTP 502 since 2026-09, and pointing every user at one shared - anisette identity can get Apple IDs locked. - - ALTSERVER_NO_SUBSCRIBE: (*unused*) Please enable this for usbmuxd server that do not correctly usbmuxd_listen interfaces ``` +No IPA argument starts the daemon. With one, it performs a one-time install — which needs a real +terminal, because the 2FA code is read from stdin. + +### Environment + +| Variable | Purpose | +|---|---| +| `ALTSERVER_ANISETTE_SERVER` | **Required.** Full URL including scheme, e.g. `http://127.0.0.1:6969`. There is no default | +| `ALTSERVER_UDID` / `ALTSERVER_APPLE_ID` / `ALTSERVER_APPLE_PASSWORD` | Alternatives to `-u` / `-a` / `-p`. Prefer these: a password passed as `-p` is visible in `ps` to every user on the host | +| `ALTSERVER_NO_CLIENTINFO_SANITIZE` | Set to `1` to stop rewriting `com.apple.dt.Xcode` in `X-MMe-Client-Info`. Diagnostic only — leave unset | +| `ALTSTORE_SKIP_FETCH` | Set to `1` to stop the container refreshing `AltStore.ipa` on start | + +There is deliberately **no default anisette server**. The one that used to be hardcoded has +returned HTTP 502 since 2026-09, and pointing every user at a single shared anisette identity can +get Apple IDs locked. + +--- + ## Runtime requirements -Beyond the binary itself, on the machine that runs it: +Bundled in the container image. Needed on the host if you run the binary directly: | Requirement | Why | If missing | |---|---|---| | `python3` | The binary is `-static` and cannot dlopen Bonjour, so it shells out to python3 | Advertisement fails | | `libavahi-compat-libdnssd-dev` | Provides the **unversioned** `libdns_sd.so` the code dlopens | Advertisement fails | -| `avahi-daemon` running | Does the actual mDNS publishing | Advertisement fails | -| `usbmuxd` (or `netmuxd` for Wi-Fi) | Device access | No device found | +| `avahi-daemon` running | Performs the actual mDNS publishing | Advertisement fails | +| `usbmuxd`, or `netmuxd` for Wi-Fi | Device access | No device found | | An anisette server | Apple machine identity | Sign-in fails | | Accurate clock **on the anisette host** | Its timestamp is forwarded to Apple verbatim | Opaque `-36607` | -The first three used to fail *silently*; they now report themselves. The container image bundles -all of them and verifies `libdns_sd.so` loads at build time. +Note the **`-dev`** package, not `libavahi-compat-libdnssd1`: the runtime package ships only +`libdns_sd.so.1`, while the code dlopens the unversioned name. This is the single most common way +to end up with a server that runs, reports nothing wrong, and is invisible to your phone. -## Download +### Wi-Fi refresh -- Precompiled static binary can be downloaded in Release ( also have a look at pre-release ;) ) -- Nightly version is available as Github Actions artifacts +Needs [netmuxd](https://github.com/jkcoxson/netmuxd) (≥ 0.3) **owning `/var/run/usbmuxd`, with +`usbmuxd` stopped** — stock usbmuxd never reports a device with ConnectionType `Network`, and the +two collide over that socket. If pointing at TCP instead, the variable is `USBMUXD_SOCKET_ADDRESS`; +the widely-copied instruction spelling it `USBMUXD_SOCKET_ADRESS` (one D) is silently ignored. -## TODO / Special Features -- [x] Track upstream (AltServer-Windows) develop branch (i.e. Beta version) -- [x] Support Offline Anisette Data Generation (i.e. without Sideloadly) - - You must supply your own anisette server and point `ALTSERVER_ANISETTE_SERVER` at it. There is no default. - - This project historically suggested [alt_anisette_server](https://hub.docker.com/r/nyamisty/alt_anisette_server), but that image was last published in **April 2022** and has not been verified against Apple's current authentication flow. Treat it as a starting point, not a recommendation. -- [x] Support Wi-Fi Refresh - - [netmuxd](https://github.com/jkcoxson/netmuxd) now supports network devices (needs version > v0.1.1, be sure to check pre-release) - - Download `netmuxd`, stop the original `usbmuxd`, and run `netmuxd` before running `AltServer-Linux` - - ~If netmuxd does not work, please try using special env var `ALTSERVER_NO_SUBSCRIBE`. Enabling this would disable **auto-refresh when plugged-in** of USB devices~ +--- ----- +## Download + +- Container image: `ghcr.io//altserver-linux:latest`, built by + [`build_image.yml`](.github/workflows/build_image.yml) +- Static binaries: GitHub Actions artifacts. Branch pushes build **amd64** only; tags build all + four architectures. **`chmod +x` after downloading** — artifact upload does not preserve the + executable bit -## Advanced: Build Instruction (check Github Actions if you cannot build) +--- -- Preparation: `git clone --recursive https://github.com/NyaMisty/AltServer-Linux` +## Advanced: building from source -- Install dependencies (see notes below): corecrypto_static, cpprestsdk static lib, boost static lib +- Preparation: `git clone --recursive ` -- Build (note the `cd build` — the Makefile builds into the *current* directory): +- Easiest, using the same prebuilt toolchain CI uses (it already has corecrypto, cpprestsdk, boost + and libzip): + ``` + docker run --rm -v "$PWD":/workdir -w /workdir \ + ghcr.io/nyamisty/altserver_builder_alpine_amd64 \ + bash -c 'mkdir -p build; cd build; make -f ../Makefile -j"$(nproc)"' + ``` + Or build the container image directly: `docker build -t altserver .` + +- By hand (note the `cd build` — the Makefile builds into the *current* directory): ``` cd AltServer-Linux mkdir build @@ -105,41 +156,35 @@ all of them and verifies `libdns_sd.so` loads at build time. ls AltServer-* ``` - Easier: use the same prebuilt toolchain CI uses, which already has corecrypto, cpprestsdk, - boost and libzip: - ``` - docker run --rm -v "$PWD":/workdir -w /workdir \ - ghcr.io/nyamisty/altserver_builder_alpine_amd64 \ - bash -c 'mkdir -p build; cd build; make -f ../Makefile -j"$(nproc)"' - ``` - Or just build the image: `docker build -t altserver .` +### How the build works -- My own build note for you - ``` - 1. Run alpine docker (change --platform to corresponding architecture you want): - docker run --platform=linux/arm/v7 --name altserver-builder-alpine-armv7 -it alpine:3.15 - 2. Install dependencies: - apk add zsh git curl wget g++ clang boost-static ninja boost-dev cmake make sudo bash vim libressl-dev util-linux-dev zlib-dev zlib-static - 3. Install corecrypto - See buildenv/Dockerfile, which does this correctly and is verified to work. Apple's - current distribution needs three fixes the old notes here did not mention: - a) the archive now extracts to corecrypto-2024/, not corecrypto/ - b) CMakeLists.txt include()s scripts/code-coverage.cmake, which Apple does not ship - c) CoreCryptoSources.cmake still points at corecrypto_static/ccrng_static.c, but that - file moved to the tree root - Symptom of (c) is a confusing "No SOURCES given to target: corecrypto_static"; the real - error is the "Cannot find source file" line above it. - 4. Install cpprestsdk - git clone --recursive https://github.com/microsoft/cpprestsdk; cd cpprestsdk; mkdir build; cmake -DBUILD_SHARED_LIBS=0 ..; make; make install - (if you're compiling for armv7, you have to grep -Wcast-align, and remove it, or the compiling would fail) - 5. Install libzip - git clone https://github.com/nih-at/libzip; cd libzip; mkdir build; cd build; cmake -DBUILD_SHARED_LIBS=0 ..; make; make install - 6. Compile AltServer-Linux - git clone --recursive https://github.com/NyaMisty/AltServer-Linux - cd AltServer-Linux - mkdir build; cd build - make -f ../Makefile -j3 - (the old note about removing -mno-default for ARM is STALE: the Makefile already - guards that flag to i386/i686, so ARM builds work unmodified) +This project never forked AltServer-Windows. `upstream_repo/` is a submodule of it, and the build +**rewrites those sources at compile time** — `makefiles/rewrite_altserver_source.py` and friends +convert `L"…"` to `U("…")`, `std::wstring` to `std::string`, `boost::filesystem` to +`std::filesystem`, strip the Win32 GUI and splice in a console implementation. Win32 gaps are +filled by `-include shims/windows_shim.h`. Patches to vendored code live in those rewriters rather +than in the submodule, and each one fails the build loudly if its pattern stops matching. - ``` +### Building the buildenv image + +`buildenv/Dockerfile` builds the toolchain. Apple's current corecrypto distribution needs three +fixes, all applied there: + +1. The archive extracts to `corecrypto-2024/`, not `corecrypto/`. Docker's `WORKDIR` silently + *creates* the missing directory, so the error surfaces one step later as a confusing + "does not appear to contain CMakeLists.txt" +2. `CMakeLists.txt` includes `scripts/code-coverage.cmake`, which Apple does not ship +3. `CoreCryptoSources.cmake` still points at `corecrypto_static/ccrng_static.c`, which moved to the + tree root. The visible error is "No SOURCES given to target"; the real one is the + "Cannot find source file" line above it + +The old note about removing `-mno-default` for ARM is **stale** — the Makefile already guards that +flag to i386/i686, so ARM builds work unmodified. + +--- + +## Credits + +Original Linux port by [NyaMisty](https://github.com/NyaMisty/AltServer-Linux). AltStore, AltServer +and AltSign by [Riley Testut](https://github.com/rileytestut). This fork only revives and extends +that work. Licensed AGPL-3.0, as upstream. From 9baa62bf3fa77eef753a364ca1186eeb9e52ea65 Mon Sep 17 00:00:00 2001 From: bwdiehl <115094967+bwdiehl@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:20:09 -0400 Subject: [PATCH 52/95] web: serve concurrently, and make install errors visible Two bugs with one visible symptom: the UI freezing when switching pages, and the install page's state stuck on its placeholder. HTTPServer is single-threaded. /api/status shells out to avahi-browse (up to 15s) and curls the anisette endpoint (up to 10s), so while it runs EVERY other request queues behind it. Three pages polling at 30s, 5s and 1.5s were serialising against each other, and a click on Install could sit in that queue looking like nothing happened. Now ThreadingHTTPServer. Verified rather than assumed: with anisette pointed at a black-hole address so /api/status takes its full 10s timeout, two concurrent requests returned in ~1.5ms each while it was still running. Before, both would have waited the full 10 seconds. Separately, install errors were rendered as small grey text beside the button, which is easy to miss entirely -- the backend was correctly returning "UDID, Apple ID and password are all required" and it read as silence. Errors are now a proper red block, shown only when there is one, and a failed fetch surfaces as a message instead of being swallowed by an empty catch. Also mounts /var/lib/lockdown read-write in the web service. It was read-only on the reasoning that the UI only reports on pairing -- but the install flow runs a real AltServer install from that container, and libimobiledevice may refresh the pairing record during device work. Read-only there would have failed in a way that looks like a device fault rather than a permissions problem. Co-Authored-By: Claude Opus 5 --- deploy/altserver-stack.yml | 6 ++++-- web/server.py | 32 +++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/deploy/altserver-stack.yml b/deploy/altserver-stack.yml index cc63a7b..b097425 100644 --- a/deploy/altserver-stack.yml +++ b/deploy/altserver-stack.yml @@ -163,8 +163,10 @@ services: volumes: # Shares altserver's data volume so the install flow can see the fetched AltStore.ipa. - altserver-data:/data - # Read-only: the UI only reports on pairing, it does not change it. - - /var/lib/lockdown:/var/lib/lockdown:ro + # Read-WRITE. The status and pairing pages only read, but the install flow runs a real + # AltServer install from this container, and libimobiledevice may refresh the pairing + # record during device work. Read-only here fails in a way that looks like a device fault. + - /var/lib/lockdown:/var/lib/lockdown - /var/run/usbmuxd:/var/run/usbmuxd - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket - /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket diff --git a/web/server.py b/web/server.py index e8d9134..3f1fd8d 100644 --- a/web/server.py +++ b/web/server.py @@ -30,7 +30,7 @@ import json import os import sys -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import status_checks # noqa: E402 @@ -205,7 +205,8 @@ - +