diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d32e975..b9267c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,12 @@ # Compila AltKeeper per Windows, Linux e macOS e pubblica tutto in una GitHub Release. # -# - Windows: `altkeeper.exe` + `altkeeper.exe.sha256`. L'aggiornamento dall'icona nella barra di -# sistema scarica proprio questi due file: i nomi non vanno cambiati. -# - Linux (x86-64 e arm64) e macOS (Apple Silicon e Intel): `altkeeper---.tar.gz` -# con il suo `.sha256`. Sono download manuali: l'auto-aggiornamento è solo per Windows. -# - macOS: in più `AltKeeper--macos-.zip` con `AltKeeper.app` (doppio clic: icona nella -# barra dei menu come AltServer, AltServer e pagina web; vedi scripts/macos-app.sh) e il suo -# `.sha256`. L'icona avvisa delle nuove versioni e apre la pagina della release. +# - Windows: `AltKeeper--Windows-x64.zip` contiene `AltKeeper.exe`, guide ed esempi; +# è disponibile anche il binario singolo `AltKeeper--Windows-x64.exe`. +# L'updater usa inoltre `altkeeper.exe` + `altkeeper.exe.sha256`: non rinominare questi asset. +# - Linux e macOS CLI: archivi con nome prodotto, versione, sistema e architettura, per esempio +# `AltKeeper-vX.Y.Z-Linux-x64.tar.gz` o `AltKeeper-vX.Y.Z-macOS-Apple-Silicon-CLI.tar.gz`. +# - macOS GUI: `AltKeeper-vX.Y.Z-macOS-Apple-Silicon-App.zip` (o `macOS-Intel-App`) contiene +# `AltKeeper.app`; ogni pacchetto ha un file `.sha256` corrispondente. # # Parte SOLO a mano: GitHub -> Actions -> release -> "Run workflow" (scegli il branch). # Il tag della release è `v` + `version` di Cargo.toml e viene creato dalla release stessa. @@ -42,19 +42,26 @@ jobs: matrix: include: - name: windows + package: Windows-x64 runner: windows-latest test: true # ubuntu-22.04: glibc 2.35, così il binario gira anche su distribuzioni non recentissime. - name: linux-x86_64 + package: Linux-x64 runner: ubuntu-22.04 test: true - name: linux-aarch64 + package: Linux-ARM64 runner: ubuntu-22.04-arm test: true - name: macos-aarch64 + package: macOS-Apple-Silicon-CLI + app_package: macOS-Apple-Silicon-App runner: macos-latest test: true - name: macos-x86_64 + package: macOS-Intel-CLI + app_package: macOS-Intel-App runner: macos-15-intel test: true runs-on: ${{ matrix.runner }} @@ -78,16 +85,33 @@ jobs: run: | mkdir dist cp target/release/altkeeper.exe dist/altkeeper.exe + cp target/release/altkeeper.exe "dist/AltKeeper-${TAG}-Windows-x64.exe" + pkg="AltKeeper-${TAG}-Windows-x64" + mkdir -p "dist/$pkg" + cp target/release/altkeeper.exe "dist/$pkg/AltKeeper.exe" + cp LICENSE README.md README.it.md SECURITY.md THIRD-PARTY-NOTICES.md "dist/$pkg/" + cp -r examples "dist/$pkg/examples" + mkdir -p "dist/$pkg/docs" + cp -r docs/en docs/it "dist/$pkg/docs/" + powershell -NoProfile -Command "Compress-Archive -Path 'dist/$pkg/*' -DestinationPath 'dist/$pkg.zip' -Force" cd dist hash=$(sha256sum altkeeper.exe | cut -d' ' -f1) printf '%s altkeeper.exe' "$hash" > altkeeper.exe.sha256 + printf '%s AltKeeper-%s-Windows-x64.exe' "$hash" "$TAG" \ + > "AltKeeper-${TAG}-Windows-x64.exe.sha256" + sha256sum "$pkg.zip" > "$pkg.zip.sha256" - name: Package (Linux, macOS) if: matrix.name != 'windows' run: | - pkg="altkeeper-${TAG}-${{ matrix.name }}" + pkg="AltKeeper-${TAG}-${{ matrix.package }}" mkdir -p "dist/$pkg" - cp target/release/altkeeper LICENSE README.md README.it.md "dist/$pkg/" + cp target/release/altkeeper "dist/$pkg/AltKeeper" + cp target/release/altkeeper "dist/$pkg/altkeeper" + cp LICENSE README.md README.it.md \ + SECURITY.md THIRD-PARTY-NOTICES.md "dist/$pkg/" cp -r examples "dist/$pkg/examples" + mkdir -p "dist/$pkg/docs" + cp -r docs/en docs/it "dist/$pkg/docs/" cd dist tar -czf "$pkg.tar.gz" "$pkg" rm -rf "$pkg" @@ -104,7 +128,7 @@ jobs: # Controllo minimo: l'app deve avviare proprio il binario con l'icona. test "$(plutil -extract CFBundleExecutable raw "$app/Contents/Info.plist")" = altkeeper codesign --verify --strict "$app" - zip="AltKeeper-${TAG}-${{ matrix.name }}.zip" + zip="AltKeeper-${TAG}-${{ matrix.app_package }}.zip" ditto -c -k --keepParent "$app" "dist/$zip" rm -rf "$app" cd dist @@ -134,5 +158,7 @@ jobs: with: tag_name: ${{ needs.prepare.outputs.tag }} target_commitish: ${{ github.sha }} + body: | + Modified MPL-2.0 source: [`vendor/omnisette/`](https://github.com/${{ github.repository }}/tree/${{ github.sha }}/vendor/omnisette). files: dist/* fail_on_unmatched_files: true diff --git a/Cargo.lock b/Cargo.lock index 8be0fb8..095a55f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,18 +75,21 @@ dependencies = [ [[package]] name = "altkeeper" -version = "0.4.0" +version = "0.5.0" dependencies = [ "axum", + "base64 0.21.7", "chrono", "futures-util", + "hex", "idevice", "isideload", "mdns-sd", + "omnisette", "plist", - "reqwest", + "reqwest 0.13.5", "rpassword", - "rustls", + "rustls 0.23.45", "serde", "serde_json", "sha2 0.10.9", @@ -98,13 +101,31 @@ dependencies = [ "tracing-subscriber", "tray-icon", "url", + "uuid", "windows-sys 0.61.2", - "winreg", + "winreg 0.56.0", "winresource", "wry", "zip", ] +[[package]] +name = "android-loader" +version = "0.2.0" +source = "git+https://github.com/Dadoum/android-loader?branch=bigger_pages#dfa86501afca7caa23d5ce15322ac7260d857485" +dependencies = [ + "anyhow", + "lazy_static", + "libc", + "log", + "memmap2 0.5.10", + "rand 0.8.8", + "region", + "sysv64", + "xmas-elf", + "zero", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -130,7 +151,7 @@ dependencies = [ "cms 0.2.3", "const-oid 0.9.6", "der 0.7.10", - "memmap2", + "memmap2 0.9.12", "pem-rfc7468 0.7.0", "plist", "rayon", @@ -285,12 +306,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper", + "hyper 1.11.1", "hyper-util", "itoa", "matchit", @@ -302,8 +324,10 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", - "sync_wrapper", + "sha1 0.10.7", + "sync_wrapper 1.0.2", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -317,12 +341,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", ] @@ -393,6 +417,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -1098,18 +1128,41 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "dlopen2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b121caccfc363e4d9a4589528f3bef7c71b83c6ed01c8dc68cbeeb7fd29ec698" +dependencies = [ + "dlopen2_derive 0.2.0", + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dlopen2" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" dependencies = [ - "dlopen2_derive", + "dlopen2_derive 0.4.3", "libc", "once_cell", "winapi", ] +[[package]] +name = "dlopen2_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a09ac8bb8c16a282264c379dffba707b9c998afc7506009137f3c6136888078" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "dlopen2_derive" version = "0.4.3" @@ -1753,6 +1806,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.19" @@ -1764,7 +1836,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.5.0", "indexmap", "slab", "tokio", @@ -1839,6 +1911,17 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.5.0" @@ -1849,6 +1932,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.1.0" @@ -1856,7 +1950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http", + "http 1.5.0", ] [[package]] @@ -1867,8 +1961,8 @@ checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] @@ -1893,6 +1987,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.11.1" @@ -1903,9 +2021,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.19", + "http 1.5.0", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -1915,18 +2033,32 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ - "http", - "hyper", + "http 1.5.0", + "hyper 1.11.1", "hyper-util", - "rustls", + "rustls 0.23.45", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tower-service", ] @@ -1938,7 +2070,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.11.1", "hyper-util", "native-tls", "tokio", @@ -1956,15 +2088,15 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.5.0", + "http-body 1.1.0", + "hyper 1.11.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.5", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -2099,11 +2231,12 @@ dependencies = [ "indexmap", "jktcp", "libc", + "ns-keyed-archive", "plist", "plist-macro", "rand 0.10.3", "rsa", - "rustls", + "rustls 0.23.45", "serde", "serde_json", "sha1 0.10.7", @@ -2111,7 +2244,7 @@ dependencies = [ "siphasher", "thiserror 2.0.20", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tracing", "uuid", "x25519-dalek", @@ -2214,7 +2347,7 @@ dependencies = [ "futures-util", "hex", "hmac 0.13.0", - "http", + "http 1.5.0", "idevice", "isideload-p12-keystore", "isideload-vfs", @@ -2223,12 +2356,12 @@ dependencies = [ "plist-macro", "rand 0.8.8", "rcgen", - "reqwest", + "reqwest 0.13.5", "reqwest-middleware", "ring", "rootcause", "rsa", - "rustls", + "rustls 0.23.45", "serde", "serde_json", "sha1 0.11.0", @@ -2522,6 +2655,24 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -2570,7 +2721,7 @@ dependencies = [ "log", "mio", "socket-pktinfo", - "socket2", + "socket2 0.6.5", ] [[package]] @@ -2579,6 +2730,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] + [[package]] name = "memmap2" version = "0.9.12" @@ -2670,7 +2830,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.5.0", "httparse", "memchr", "mime", @@ -2768,6 +2928,26 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ns-keyed-archive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815e8c8171d033165fdda6d95630d6f447592ad5e6fbade9db26663970c6fae6" +dependencies = [ + "nskeyedarchiver_converter", + "plist", +] + +[[package]] +name = "nskeyedarchiver_converter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c53158d1bf37bbbdd165f5220fda8bb5757c89eb700107992152c64c6cad7e" +dependencies = [ + "plist", + "thiserror 2.0.20", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2860,6 +3040,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc2" version = "0.6.5" @@ -3054,6 +3254,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -3063,6 +3272,28 @@ dependencies = [ "asn1-rs", ] +[[package]] +name = "omnisette" +version = "0.1.0" +dependencies = [ + "android-loader", + "anyhow", + "async-trait", + "base64 0.21.7", + "dlopen2 0.4.1", + "hex", + "libc", + "log", + "objc", + "objc-foundation", + "plist", + "rand 0.8.8", + "reqwest 0.11.27", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3715,6 +3946,59 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "region" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" +dependencies = [ + "bitflags 1.3.2", + "libc", + "mach2", + "windows-sys 0.52.0", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 0.25.4", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.13.5" @@ -3727,12 +4011,12 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.19", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.11.1", + "hyper-rustls 0.27.10", "hyper-tls", "hyper-util", "js-sys", @@ -3741,15 +4025,15 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.23.45", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.5", "tower", "tower-http", "tower-service", @@ -3767,8 +4051,8 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http", - "reqwest", + "http 1.5.0", + "reqwest 0.13.5", "thiserror 2.0.20", "tower-service", ] @@ -3888,6 +4172,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.45" @@ -3897,7 +4193,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] @@ -3914,6 +4210,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3934,10 +4239,10 @@ dependencies = [ "jni 0.22.4", "log", "once_cell", - "rustls", + "rustls 0.23.45", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki", + "rustls-webpki 0.103.15", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -3950,6 +4255,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3967,6 +4282,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.11.0" @@ -4013,6 +4334,16 @@ dependencies = [ "sha2 0.11.0", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4133,6 +4464,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -4288,10 +4631,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" dependencies = [ "libc", - "socket2", + "socket2 0.6.5", "windows-sys 0.61.2", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.5" @@ -4412,6 +4765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -4437,6 +4791,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4468,6 +4828,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -4476,7 +4847,17 @@ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.13.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -4502,6 +4883,11 @@ dependencies = [ "version-compare", ] +[[package]] +name = "sysv64" +version = "0.1.0" +source = "git+https://github.com/Dadoum/android-loader?branch=bigger_pages#dfa86501afca7caa23d5ce15322ac7260d857485" + [[package]] name = "tao" version = "0.37.2" @@ -4515,7 +4901,7 @@ dependencies = [ "crossbeam-channel", "dbus", "dispatch2", - "dlopen2", + "dlopen2 0.8.2", "dpi", "gdkwayland-sys", "gdkx11-sys", @@ -4714,7 +5100,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -4740,13 +5126,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ - "rustls", + "rustls 0.23.45", "tokio", ] @@ -4758,10 +5154,10 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", - "rustls", + "rustls 0.23.45", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.5", "tungstenite", "webpki-roots 0.26.11", ] @@ -4886,7 +5282,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4903,8 +5299,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "tokio", @@ -5029,11 +5425,11 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.5.0", "httparse", "log", "rand 0.9.5", - "rustls", + "rustls 0.23.45", "rustls-pki-types", "sha1 0.10.7", "thiserror 2.0.20", @@ -5122,6 +5518,7 @@ dependencies = [ "getrandom 0.4.3", "js-sys", "md-5", + "rand 0.10.3", "serde_core", "wasm-bindgen", ] @@ -5337,6 +5734,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5541,6 +5944,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5574,6 +5986,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5614,6 +6041,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5626,6 +6059,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5638,6 +6077,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5656,6 +6101,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5668,6 +6119,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5680,6 +6137,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5692,6 +6155,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5716,6 +6185,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.56.0" @@ -5764,7 +6243,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http", + "http 1.5.0", "javascriptcore-rs", "jni 0.21.1", "libc", @@ -5867,6 +6346,15 @@ dependencies = [ "time", ] +[[package]] +name = "xmas-elf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c49817e78342f7f30a181573d82ff55b88a35f86ccaf07fc64b3008f56d1c6" +dependencies = [ + "zero", +] + [[package]] name = "yasna" version = "0.6.0" @@ -5900,6 +6388,12 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "zero" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe21bcc34ca7fe6dd56cc2cb1261ea59d6b93620215aefb5ea6032265527784" + [[package]] name = "zerocopy" version = "0.8.57" diff --git a/Cargo.toml b/Cargo.toml index 34a2928..e575650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] default-run = "altkeeper" name = "altkeeper" -version = "0.4.0" +version = "0.5.0" edition = "2021" description = "AltServer for iOS 17+ that runs on your home server (Docker): renews and installs sideloaded iPhone apps over Wi-Fi, no computer or VPN" license = "MIT" @@ -9,7 +9,7 @@ readme = "README.md" [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock"] } -axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "json", "multipart"] } +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "json", "multipart", "ws"] } idevice = { version = "0.1.68", default-features = false, features = [ "ring", "remote_pairing", @@ -19,6 +19,8 @@ idevice = { version = "0.1.68", default-features = false, features = [ "core_device_proxy", "misagent", "installation_proxy", + "device_info", + "debug_proxy", "afc", "usbmuxd", ] } @@ -50,11 +52,17 @@ winresource = "0.1" # Icona nella barra di sistema (Windows) o nella barra dei menu (macOS), con la finestra della pagina. [target.'cfg(any(windows, target_os = "macos"))'.dependencies] tao = "0.37" -sha2 = "0.10" reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls-no-provider"] } tray-icon = "0.26" wry = "0.57" +[target.'cfg(any(target_os = "linux", target_os = "macos", windows))'.dependencies] +base64 = "0.21" +hex = "0.4" +omnisette = { git = "https://github.com/SideStore/apple-private-apis.git", rev = "03beb1aa42991ccdad6214dee77e72282bef461f", version = "0.1.0", default-features = false, features = ["async", "remote-anisette"] } +sha2 = "0.10" +uuid = "1" + [target.'cfg(windows)'.dependencies] tauri-winrt-notification = "0.8" winreg = "0.56" @@ -66,6 +74,9 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ [patch.crates-io] isideload = { path = "vendor/isideload" } +[patch."https://github.com/SideStore/apple-private-apis.git"] +omnisette = { path = "vendor/omnisette" } + [profile.release] opt-level = "s" lto = true diff --git a/README.it.md b/README.it.md index ec1a41d..df7a005 100644 --- a/README.it.md +++ b/README.it.md @@ -1,760 +1,41 @@ # AltKeeper -**AltServer per iOS 17+ che gira sul tuo server di casa (Docker).** Rinnova e installa le app -in stile AltStore / SideStore via Wi-Fi, senza un computer acceso e senza VPN. +AltKeeper mantiene utilizzabili le app installate su iPhone rinnovando i loro profili di +provisioning. Può anche comportarsi da AltServer per AltStore Classic, così puoi aggiornare o +installare app via Wi-Fi. Puoi usarlo su un server di casa, su Mac o su Windows. -[English](README.md) +> **Software sperimentale.** AltKeeper usa API e protocolli privati di Apple, che possono cambiare +> senza preavviso. L'attivazione JIT e alcuni flussi di installazione non sono ancora stati +> verificati completamente su dispositivi reali. Usalo con i tuoi dispositivi e il tuo Apple ID; +> non è affiliato ad Apple né ad AltStore. -Tiene in vita le app installate sull'iPhone senza App Store. Le app installate con un **Apple ID -gratuito** smettono di aprirsi dopo 7 giorni, se non si rinnova il loro *profilo di provisioning*. -AltKeeper gira su una piccola macchina sempre accesa in casa e lo fa via Wi-Fi, **senza computer -collegato, senza AltServer su un computer e senza VPN sul telefono**. Può funzionare in due modi, -insieme o separati: +## Scegli una guida -- **Come AltServer per AltStore Classic**: il tasto *Refresh All* di AltStore e l'installazione o - l'aggiornamento delle app da AltStore funzionano, con questa macchina che risponde al posto di - AltServer. -- **Da sola**: rinnova i profili ogni sera (cron), anche se AltStore non viene mai aperto. - -C'è anche una piccola **pagina web** per vedere lo stato delle app e rinnovarle: - -Pagina web di AltKeeper: iPhone raggiungibile, tre app con la scadenza, pulsanti di rinnovo - -> **Stato: sperimentale.** Provato su un iPhone 15 Pro (iOS 27.0) con AltStore Classic (l'ultima -> versione al momento della scrittura), con il programma su un Mac e su un mini PC -> Debian 13 (x86-64, Docker). Usa API private di Apple e il protocollo di AltStore, che possono -> cambiare senza preavviso. Per uso personale con il proprio Apple ID; non affiliato ad Apple né ad -> AltStore. La CLI supporta l'inglese (predefinito) e l'italiano per messaggi, errori e log; -> la pagina web e i messaggi delle librerie esterne hanno impostazioni indipendenti. -> -> Confermato con AltServer dall'immagine Docker sul mini PC Debian: il *Refresh* da AltStore installa -> il profilo nuovo sull'iPhone e l'installazione di un'app da AltStore funziona. Confermato prima con -> il server sul Mac: lo stesso, con un'app di circa 126 MB, più login, `apps`, -> `renew`, e che un'app si apre anche con i soli profili *nuovi* installati (anche dopo il riavvio -> dell'iPhone). Non ancora confermato: cosa succede il giorno in cui scade il profilo *originale*, -> AltServer su un server per un periodo lungo, né l'installazione di un IPA caricato dalla pagina web -> con Apple e un iPhone reale. - -## Lingua della CLI (inglese / italiano) - -La CLI, inclusi i messaggi di errore, le richieste interattive e i log del server, usa -l'inglese per impostazione predefinita. Per usare l'italiano, imposta -`ALTKEEPER_LANG=it`; per l'inglese, `ALTKEEPER_LANG=en`. Funzionano anche `en-US`, -`en_GB`, `it-IT` (senza distinzione maiuscole/minuscole). Lingue sconosciute -ricadono sull'inglese. Se `ALTKEEPER_LANG` manca, funziona anche -`ALTREFRESH_LANG`. Questo cambia solo il testo visibile della CLI, non il -protocollo AltStore, i dati JSON o la lingua della pagina web. - -PowerShell (vale per la sessione corrente e per i processi figli): - -```powershell -$env:ALTKEEPER_LANG = "en" -.\target\release\altkeeper.exe --help -.\target\release\altkeeper.exe login "you@example.com" -# Per usare il valore predefinito: Remove-Item Env:ALTKEEPER_LANG -``` - -Prompt dei comandi (cmd.exe): - -```cmd -set ALTKEEPER_LANG=en -target\release\altkeeper.exe phone -``` - -Linux/macOS: - -```sh -ALTKEEPER_LANG=en ./altkeeper phone -``` - -Il file `examples/docker-compose.yml` usa una lista `environment:`: per selezionare l'italiano aggiungi -`- ALTKEEPER_LANG=it`. Questo cambia i messaggi CLI e i log, **non** il protocollo, -i JSON o la pagina web. I comandi del menu 2FA restano `d`, `r` (se disponibile), `p`. - -## Cosa fa e cosa non fa - -- Rinnova i profili di provisioning delle tue app (e, in modalità AltServer, installa le app che - AltStore gli manda). Il rinnovo non rifirma le app e non crea né revoca certificati. L'installazione - di un IPA caricato dalla pagina web invece lo rifirma e può creare un certificato di sviluppo. -- **La modalità AltServer non ha bisogno dell'Apple ID sul server.** AltStore fa il login - sull'iPhone; questo programma gli dà solo i dati anisette e installa quello che AltStore manda. Il - rinnovo senza controllo (`renew`, cron, il tasto "Rinnova" della pagina web) e la firma di un IPA - caricato dalla pagina web hanno invece bisogno dell'Apple ID e della sua password sul server. -- I due non si pestano i piedi: `renew` guarda il profilo più recente sul telefono. Se AltStore ha - rinnovato da poco, `renew` non fa niente e non accede nemmeno ad Apple. -- Dopo un rinnovo i profili vecchi restano sul telefono (iOS non li toglie), quindi per ogni app - vedrai più profili. È normale. In modalità AltServer non toglie mai profili, nemmeno quando - AltStore lo chiede (`activeProfiles`). -- Non supportato: l'abilitazione del JIT (`EnableUnsignedCodeExecution`); risponde con un errore. -- Dopo un `renew` senza controllo, AltStore può continuare a mostrare la vecchia scadenza: aggiorna - il suo registro solo quando il rinnovo lo fa lui. Le app funzionano. - -## Cosa serve - -- Un iPhone con **iOS 17 o successivo**, con la modalità sviluppatore attiva, sullo stesso Wi-Fi - del server. -- Una macchina sempre accesa nella rete di casa. Provato: Debian 13 (x86-64); va bene anche un Mac - (da terminale, o come app nella barra dei menu simile ad AltServer, vedi - [macOS](#11-app-per-la-barra-dei-menu-di-macos)). - Su **Windows 10** sono stati verificati la compilazione nativa, i test e il login interattivo - con Apple ID e 2FA. Windows 11 e l'intero flusso AltServer/rinnovo su Windows non sono ancora - verificati end-to-end (vedi [Windows](#10-windows-nativo-senza-docker)). -- Per la modalità AltServer: **AltStore Classic** sull'iPhone, con il permesso *Rete locale*. -- Per il rinnovo senza controllo: un **Apple ID gratuito** il cui team ha già firmato le app. -- Solo per il **primo pairing**: l'iPhone collegato via USB a un computer (il più facile è un Mac). -- Facoltativi: `curl`, `jq` e l'app [ntfy](https://ntfy.sh) per le notifiche; Docker. - -## Configurazione - -### 1. Ottieni il programma - -**Già compilato:** ogni [release su GitHub](https://github.com/BartolomeoRusso9/altkeeper/releases) ha -`altkeeper--linux-x86_64.tar.gz`, `-linux-aarch64`, `-macos-aarch64` (Apple Silicon) e -`-macos-x86_64` (Intel), ciascuno con il suo file `.sha256` (verifica con `sha256sum -c`). Decomprimi e -usa il binario `altkeeper`. Le build Linux richiedono glibc 2.35 o più recente; su sistemi più vecchi -o Alpine usa Docker o compila da te. - -- **Windows:** `altkeeper.exe` (doppio clic: icona nella barra di sistema, vedi [Windows](#10-windows-nativo-senza-docker)). -- **macOS:** oltre al tar.gz ci sono `AltKeeper--macos-aarch64.zip` (Apple Silicon) e - `-macos-x86_64.zip` (Intel) con **`AltKeeper.app`**: doppio clic e compare un'icona nella barra - dei menu, come AltServer, con AltServer e la pagina web attivi (vedi - [App per la barra dei menu di macOS](#11-app-per-la-barra-dei-menu-di-macos)). - -Le build macOS non sono autenticate da Apple: se Gatekeeper le blocca, lancia -`xattr -dr com.apple.quarantine AltKeeper.app` (o `altkeeper` per il tar.gz). Si aggiorna da solo -solo l'`.exe` di Windows; su macOS l'icona nella barra dei menu avvisa della nuova versione e ne -apre la pagina. - -**Oppure compilalo tu:** - -```sh -git clone https://github.com/BartolomeoRusso9/altkeeper -cd altkeeper -docker run --rm -v "$PWD":/w -w /w rust:1-bookworm cargo build --release -``` - -Il binario è `target/release/altkeeper`. Note: - -- Su un Mac con Apple Silicon, aggiungi `--platform linux/amd64` per compilare per un server - x86-64 (più lento: circa 5-7 minuti). -- Se compili anche per il Mac nella stessa cartella, aggiungi `-e CARGO_TARGET_DIR=/w/target-linux` - così le due compilazioni non si sovrascrivono. -- Su un Mac puoi semplicemente lanciare `cargo build --release`, e mettere il binario dentro - `AltKeeper.app` con `scripts/macos-app.sh target/release/altkeeper ` (lo - stesso script che usa la release). -- Oppure usa l'immagine Docker (vedi [Docker](#9-docker)). - -Sul server crea una cartella (per esempio `/srv/altkeeper`) e copia lì il binario `altkeeper` e i -file di `examples/` che ti servono. - -### 2. Trova l'iPhone (facoltativo) - -AltKeeper trova l'iPhone da solo e lo ritrova se il suo indirizzo cambia (nelle nostre prove è -cambiato più volte in poche ore). Prova, in ordine: l'indirizzo che indichi tu, l'ultimo che ha -funzionato (`state/phone-addr`), Bonjour (`_remotepairing._tcp`) e infine una scansione della rete -locale cercando la porta del servizio del telefono (49152), che non dipende da Bonjour (su un server -dove un altro programma condivide la porta di Bonjour, le risposte possono andare perse). Puoi quindi -saltare questo passo. Se preferisci indicare l'indirizzo, usa `--phone ip:porta` o `ALTKEEPER_PHONE=ip:porta` -(la porta di solito è 49152). Da un Mac lo trovi così: - -```sh -dns-sd -B _remotepairing._tcp # elenca le istanze, si ferma con Ctrl-C -dns-sd -L "" _remotepairing._tcp local # mostra host e porta (di solito 49152) -dns-sd -G v4 .local # mostra l'indirizzo IP -``` - -### 3. Pairing (una volta sola, via USB) - -`pair-usb` usa l'`usbmuxd` locale (su Windows, quello di iTunes / Dispositivi Apple su -`127.0.0.1:27015`). L'iPhone deve essere collegato, sbloccato e autorizzare il computer. Su Mac -viene usato direttamente il servizio USB integrato: - -```sh -# con l'iPhone collegato, sbloccato e con "Autorizza" già accettato: -./target/release/altkeeper pair-usb -``` - -Crea `rp-pairing.plist` in modo riservato e atomico. Per un server remoto, crealo sul computer con -il cavo e copialo sul server. `USBMUXD_SOCKET_ADDRESS=host:porta` resta supportato per un usbmuxd -inoltrato. La pagina web e il tray offrono anche **Associa iPhone via USB**. L'associazione -automatica opzionale si attiva quando colleghi un nuovo iPhone; in Docker funziona solo se il socket -usbmuxd dell'host è montato nel container. -Imposta `ALTKEEPER_NO_AUTO_PAIR=1` per spegnere il monitor (le connessioni USB restano disponibili). -Se dopo 60 secondi un rinnovo non riceve risposta all'installazione, AltKeeper verifica il profilo -con una connessione separata e continua ad aspettare fino a 12 ore, finché l'iPhone resta -raggiungibile; nella pagina puoi premere **Annulla attesa**. - -Poi controlla il collegamento, dalla cartella del server: - -```sh -./altkeeper phone # elenca i profili installati sul telefono -``` - -### 4. AltServer per AltStore (consigliato) - -```sh -./altkeeper altserver # oppure: ./altkeeper serve --altserver (con la pagina web) -``` - -Lascialo acceso (come servizio o in Docker). Sull'iPhone, in AltStore, apri **My Apps** e tocca -**Refresh All**, oppure installa un'app: AltStore trova il server da solo. Ascolta sulla porta -**49500** (`--port N` per cambiarla) e si annuncia via Bonjour come `_altserver._tcp`. - -- Sulla rete deve esserci un solo AltServer: ferma quello sul computer e non far girare due copie di - questo programma. -- Provalo prima con una sola app poco importante, non con *Refresh All* su tutto. -- AltStore mostra *"AltServer could not be found"* per diversi tipi di connessione persa quando il - server non è quello che preferisce, quindi il messaggio può nascondere la causa vera. Guarda - l'output del server: registra ogni connessione, richiesta e risposta. -- La prima richiesta crea l'identità anisette in `state/` se non c'è ancora. L'abbiamo usato con - un'identità creata da `login`; una cartella pulita non è stata provata. - -### 5. Accesso (per il rinnovo automatico e l'installazione di IPA dal PC) - -```sh -./altkeeper login tuo.apple.id@example.com -``` - -Chiede la password (**il terminale nasconde ciò che digiti**) e il codice 2FA a 6 cifre che arriva -agli altri tuoi dispositivi. Rispondi `s` alla domanda sul salvare la password: è quello che permette -al rinnovo di girare senza di te. Viene salvata **in chiaro** in `account.json` (permessi 600). -Accedi una volta sola; se Apple risponde con errori, aspetta prima di riprovare invece di ripetere. - -```sh -./altkeeper apps # i tuoi App ID e le scadenze -./altkeeper renew --dry-run --min-days 7 # mostra cosa rinnoverebbe, non cambia niente -./altkeeper renew --min-days 7 # rinnova ora quello che scade entro 7 giorni -``` - -`renew` guarda il profilo più recente di ogni app, rinnova quelle con `--min-days` giorni o meno -(default 3) e accede ad Apple **solo se c'è qualcosa da rinnovare**. - -### 6. Rinnovo automatico (facoltativo) - -Una rete di sicurezza che non dipende dall'iPhone che esegue AltStore. - -1. Copia `renew.sh` e `notifica.sh` da `examples/` accanto al binario e fai `chmod +x renew.sh`. - Lascia `ALTKEEPER_PHONE` vuota in `renew.sh` (il telefono si trova da solo) oppure metti il suo - `IP:porta`. -2. Installa il cron (modifica prima il percorso): `cp altkeeper.cron /etc/cron.d/altkeeper` - -Parte ogni sera alle 21:17, rinnova quello che serve e aggiunge data ed esito a `renew.log`. -L'iPhone deve essere sul Wi-Fi di casa a quell'ora. Con profili da 7 giorni e soglia a 3 hai diverse -sere di margine prima che un'app scada. - -### 7. Notifiche (facoltativo) - -Crea `notify.conf` accanto agli script, con un nome di topic lungo e casuale: - -```sh -echo 'NTFY_TOPIC=scegli-un-nome-lungo-e-casuale' > notify.conf && chmod 600 notify.conf -``` - -Installa l'app ntfy sul telefono e iscriviti a quel topic. Ricevi un messaggio quando le app vengono -rinnovate e quando un giro fallisce (al massimo un messaggio di errore ogni 24 ore, perché il -telefono può essere semplicemente fuori casa). Se non c'è niente da rinnovare non arriva nulla. -Chiunque conosca il topic può leggerlo: tienilo segreto. - -### 8. Pagina web (facoltativa) - -```sh -ALTKEEPER_WEB_PIN=123456 ./altkeeper serve --bind 0.0.0.0:8787 --altserver -``` - -Apri `http://:8787` (nome utente qualsiasi, il PIN come password). Mostra se l'iPhone è -raggiungibile e le app con i giorni che mancano, permette di rinnovarle ("Rinnova quelle in scadenza" -o "Rinnova tutte adesso", con il log in diretta), di accedere con l'Apple ID (il codice 2FA si scrive -nel browser) e di vedere gli ultimi rinnovi da `renew.log`. Senza `--bind`, o con un indirizzo di -loopback, ascolta solo sulla macchina stessa e non chiede il PIN; su qualsiasi altro indirizzo -**rifiuta di partire senza PIN** (almeno 4 caratteri). Usa la variabile d'ambiente e non `--pin`, che -si vede con `ps`. La pagina web è in inglese e non c'è HTTPS: tienila dentro la rete di casa. L'accesso -e il rinnovo dalla pagina web non sono ancora stati provati con Apple. - -Il selettore in alto permette di gestire più iPhone con account Apple separati. Scegli **Add device**, -assegna un nome al profilo e selezionalo per associare il telefono e accedere al suo account. Ogni -profilo conserva account, pairing, indirizzi salvati, impostazioni, pianificazione dei rinnovi, log e -backup separati. I file già esistenti restano nel profilo predefinito; i nuovi profili vengono salvati -in `devices/`. Il rinnovo automatico è configurato separatamente per ogni profilo e le operazioni sui -telefoni vengono eseguite una alla volta. AltServer resta un unico servizio in rete e usa il profilo -selezionato in quel momento. - -Da un PC puoi anche caricare un file `.ipa` nella sezione **Install an app from this PC**: AltKeeper -lo firma con l'Apple ID configurato e lo installa sull'iPhone via Wi-Fi. Per farlo, l'account deve -avere la password salvata in `account.json`; il codice 2FA, se richiesto, si inserisce nella pagina. -La firma può registrare l'iPhone e creare identificativi e certificati di sviluppo sul team Apple. -L'upload è limitato a 1 GiB. Proteggi la pagina con PIN quando la esponi in rete e non inoltrare la -porta su Internet. - -### 9. Docker - -```sh -docker build --platform linux/amd64 -t altkeeper:latest . -``` - -L'immagine non contiene niente di segreto: pairing, account e stato stanno nel volume `/data`. Copia -`examples/docker-compose.yml` in `/srv/altkeeper/docker-compose.yml`, metti nella stessa cartella il -tuo `rp-pairing.plist` (e `account.json` e `state/` se usi `renew`), scrivi -`ALTKEEPER_WEB_PIN=...` in `/srv/altkeeper/.env` (permessi 600) e lancia `docker compose up -d`. -Usa la rete dell'host, perché serve Bonjour per trovare l'iPhone e per farsi trovare da AltStore; le -porte 8787 (web) e 49500 (AltServer) devono essere libere. - -Un workflow di GitHub Actions (`.github/workflows/docker.yml`) esegue i test su Linux e Windows, -controlla `--help` nelle due lingue della CLI e pubblica l'immagine su `ghcr.io//altkeeper` -a ogni push su `main` (amd64) e sui tag `vX.Y.Z` (amd64 e arm64). Se il repository è privato, -anche il pacchetto lo è. - -### 10. Windows (nativo, senza Docker) - -Docker Desktop su Windows non può usare la rete dell'host, che AltKeeper richiede per Bonjour: -su Windows si lancia quindi il programma direttamente. **Verificato su Windows 10:** -`cargo test --locked` (63/63), `cargo build --release --locked`, `ALTKEEPER_LANG=en` con -`--help`, e login reale da PowerShell con codice 2FA di sei cifre e comandi del menu 2FA. -Queste verifiche **non** coprono Windows 11, tutti i terminali, AltServer o rinnovo automatico -end-to-end. - -1. Installa [Rust](https://rustup.rs) (la toolchain MSVC predefinita) e i *Build Tools per Visual - Studio* con il carico di lavoro **Sviluppo di applicazioni desktop con C++**. Poi, in PowerShell: - - ```powershell - git clone https://github.com/BartolomeoRusso9/altkeeper - cd altkeeper - cargo test --locked - cargo build --release --locked - ``` - - Il programma è `target\release\altkeeper.exe`. Crea una cartella (per esempio `C:\altkeeper`) e - copiaci l'`.exe` e, da `examples\`, `renew.ps1`. - -2. **Pairing (una volta sola, via USB).** Se hai iTunes o l'app *Dispositivi Apple*, Windows ha già - un usbmuxd in ascolto su `127.0.0.1:27015`: non serve nessun ponte (`usbmux-bridge.py` è per il - Mac). Con l'iPhone collegato, sbloccato e autorizzato: - - ```powershell - .\altkeeper.exe pair-usb - ``` - - Poi `.\altkeeper.exe phone` deve elencare i profili sul telefono. - -3. **Avvio.** Dalla cartella che hai creato: - - ```powershell - .\altkeeper.exe serve --altserver # pagina web su http://127.0.0.1:8787 + AltServer - # oppure solo AltServer: - .\altkeeper.exe altserver - ``` - - La prima volta Windows chiede di consentire il programma nel firewall: consentilo sulle reti - **Private** (il tuo Wi-Fi deve essere impostato come *Privato*, non *Pubblico*). A mano, per - AltServer (TCP 49500) e Bonjour (UDP 5353), da PowerShell come amministratore: - - ```powershell - New-NetFirewallRule -DisplayName "AltKeeper" -Direction Inbound -Action Allow -Program "C:\altkeeper\altkeeper.exe" -Profile Private - ``` - - Se su quel PC un altro servizio Bonjour usa già la UDP 5353 (il *Bonjour Service* di Apple, - che arriva con iTunes, va bene), guarda "Se qualcosa va storto". Si ferma con Ctrl-C. - -4. **Accesso e rinnovo automatico.** `.\altkeeper.exe login tu@esempio.it` funziona come sugli altri - sistemi. In PowerShell e cmd.exe inserisci il codice 2FA di sei cifre e premi Invio; - funzionano anche i comandi `d`, `r` e `p`. AltKeeper legge le risposte - direttamente dalla console di Windows: non è necessario passare a cmd.exe. - **Login e menu 2FA verificati manualmente su Windows 10 PowerShell.** Avvia `login` in - una console interattiva, non come attività in background. Consulta la - [guida alla verifica Windows 2FA](docs/windows-2fa-smoke-test.md). - `account.json`, `state\` e `profili-salvati\` restano riservati al tuo utente Windows - (l'equivalente di `chmod 600/700`). Per il rinnovo serale usa l'Utilità di pianificazione con - `renew.ps1` al posto di cron: - - ```powershell - schtasks /Create /TN "AltKeeper renew" /SC DAILY /ST 21:17 /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\altkeeper\renew.ps1" - ``` - - Per avviare AltServer quando accedi a Windows: - - ```powershell - schtasks /Create /TN "AltKeeper" /SC ONLOGON /TR "C:\altkeeper\altkeeper.exe altserver --dir C:\altkeeper" - ``` - - `renew.ps1` usa lo stesso `renew.log`, lo stesso `notify.conf` facoltativo (ntfy) e la stessa - notifica d'errore al massimo una volta al giorno di `renew.sh`. Il PC deve essere acceso e non in - sospensione a quell'ora. - -5. **Icona nella barra di sistema (senza terminale).** Fai doppio clic su `altkeeper.exe`, oppure - lancia `.\altkeeper.exe tray`: avvia AltServer e la pagina web come `serve --altserver`, ma con - un'icona accanto all'orologio (il logo di AltKeeper; grigio finché l'iPhone non è raggiungibile) - e senza finestra di console. Doppio clic sull'icona, oppure dal menu: *Open AltKeeper* (la pagina - in una finestra propria; chiuderla la nasconde soltanto), *Show logs*, *Start with Windows* - (attivo di default alla prima esecuzione), *Check for updates* e *Quit*. Le notifiche toast - avvisano quando i profili sono rinnovati, un'app è installata o qualcosa fallisce. La finestra - richiede il runtime Microsoft Edge WebView2, già presente in Windows 11 e nei Windows 10 - recenti. `--console` tiene la console, `--dir` sceglie la cartella dei dati (di default quella - dell'`.exe`). - - L'icona controlla anche, una volta al giorno, le release GitHub; *Update now* scarica il - nuovo `altkeeper.exe`, ne verifica lo SHA-256, si riavvia e torna alla versione vecchia se la - nuova non parte (vedi [Release](#release)). - - Ogni riga del server (AltServer, pagina web, DNS-SD) viene scritta con data e ora anche in - `altkeeper.log` (ruotato a 5 MB) e mostrata nella sezione **Logs** della pagina web. - -Tutto il resto di questa guida (comandi, variabili d'ambiente, uso da fuori casa) vale uguale; cambia -solo che i percorsi usano `\`, e che `cron` e `renew.sh` si sostituiscono con l'Utilità di -pianificazione e `renew.ps1`. - -### 11. App per la barra dei menu di macOS - -`AltKeeper.app` funziona come AltServer su un Mac: niente icona nel Dock, solo un'icona nella -barra dei menu, e finché è aperta il Mac risponde ad AltStore e serve la pagina web. È lo stesso -programma dell'icona di Windows (`altkeeper tray`), quindi si comporta allo stesso modo. - -1. **Installazione.** Scarica `AltKeeper--macos-aarch64.zip` (Apple Silicon) o - `-macos-x86_64.zip` (Intel) dalle [release](https://github.com/BartolomeoRusso9/altkeeper/releases), - decomprimilo e **sposta `AltKeeper.app` in Applicazioni** (aperta da Download, macOS la esegue da - una copia temporanea in sola lettura e *Start at login* non si può attivare). -2. **Primo avvio.** L'app ha una firma ad-hoc e non è autenticata da Apple: la prima volta fai clic - destro → *Apri* → *Apri* (da macOS 15: *Impostazioni di Sistema → Privacy e sicurezza → Apri - comunque*), oppure lancia `xattr -dr com.apple.quarantine /Applications/AltKeeper.app`. Consenti - l'accesso alla **Rete locale** quando macOS lo chiede (serve per trovare l'iPhone e farsi - trovare da AltStore), e le connessioni in entrata se lo chiede il firewall. -3. **Chiudi AltServer** se è installato su questo Mac: in rete deve esserci un solo AltServer. -4. **Uso.** Fai clic sul logo di AltKeeper nella barra dei menu (grigio finché l'iPhone non è - raggiungibile; passandoci sopra mostra quante app ci sono e la prossima scadenza): - - *Open AltKeeper*: la pagina web in una finestra propria (chiuderla la nasconde soltanto). - ⌘C / ⌘V funzionano, quindi puoi incollare la password dell'Apple ID. - - *Show logs*: la stessa finestra, sulla sezione **Logs**. - - *Start at login*: attivo di default alla prima esecuzione; scrive - `~/Library/LaunchAgents/io.github.altkeeper.plist` (togliendo la spunta il file viene rimosso). - - *Check for updates*: se c'è una release nuova, *Download vX.Y.Z* ne apre la pagina. Su macOS - l'app non si sostituisce da sola: scarica il nuovo zip e sostituisci `AltKeeper.app`. - - *Quit*: ferma AltServer (togliendo l'annuncio Bonjour) e la pagina web. - - Le notifiche (profili rinnovati, app installata, errori, nuova versione) compaiono nel Centro - notifiche; partono tramite AppleScript, quindi macOS può elencarle sotto *Script Editor*. - -**Cartella dei dati.** L'app tiene tutto in `~/Library/Application Support/AltKeeper`: mettici -`rp-pairing.plist` (vedi [Pairing](#3-pairing-una-volta-sola-via-usb)); lì finiscono anche -`account.json`, `state/`, `profili-salvati/` e `altkeeper.log`. Per accedere con l'Apple ID per il -rinnovo automatico, lancia il programma contenuto nell'app da quella cartella: - -```sh -cd ~/Library/Application\ Support/AltKeeper -/Applications/AltKeeper.app/Contents/MacOS/altkeeper login tuo.apple.id@example.com -``` - -Oppure accedi dalla pagina web (*Open AltKeeper*). Tutti gli altri comandi funzionano allo stesso -modo da quella cartella. `altkeeper tray --dir ` (da terminale, con il binario del tar.gz -o quello dentro l'app) usa un'altra cartella. - -La pagina web ascolta solo su `127.0.0.1:8787` (niente PIN); AltServer ascolta sulla porta 49500 per -tutta la rete, esattamente come `serve --altserver`. - -**Compilarla da te.** Dalla cartella del progetto (la compilazione release richiede qualche minuto): - -```sh -cargo build --release -scripts/macos-app.sh target/release/altkeeper 0.3.1 dist # crea dist/AltKeeper.app -ditto dist/AltKeeper.app /Applications/AltKeeper.app -mkdir -p ~/Library/Application\ Support/AltKeeper -cp rp-pairing.plist ~/Library/Application\ Support/AltKeeper/ # se hai già fatto il pairing -open /Applications/AltKeeper.app -``` - -Un'app compilata sul tuo Mac non ha la quarantena, quindi Gatekeeper non chiede niente. Per vedere -i messaggi mentre la provi, avviala invece da terminale: -`/Applications/AltKeeper.app/Contents/MacOS/altkeeper tray` (si ferma con *Quit* o Ctrl-C). -Ricorda che il primo avvio attiva *Start at login*: se vuoi solo provare l'app, togli la spunta -dal menu. - -**Stato:** compilata e con i test unitari superati su macOS (Apple Silicon); l'app nella barra dei -menu non è ancora stata confermata end-to-end con un iPhone e AltStore veri. - -## Release - -Le release le compila `.github/workflows/release.yml`, avviato a mano: GitHub → Actions → release → -*Run workflow*. Il tag è `v` + la `version` di `Cargo.toml` e lo crea la release stessa: alza prima -la versione (e `Cargo.lock`, con `cargo build`), altrimenti il workflow si ferma perché il tag -esiste già. Esegue i test su Windows, Linux e macOS, e pubblica: - -| File | Cos'è | +| Voglio… | Guida | | --- | --- | -| `altkeeper.exe` + `.sha256` | Windows; l'icona scarica proprio questi due nomi per aggiornarsi | -| `altkeeper--linux-x86_64.tar.gz`, `-linux-aarch64` + `.sha256` | binario Linux (glibc 2.35+), `LICENSE`, README, `examples/` | -| `altkeeper--macos-aarch64.tar.gz`, `-macos-x86_64` + `.sha256` | binario macOS da riga di comando, stesso contenuto | -| `AltKeeper--macos-aarch64.zip`, `-macos-x86_64` + `.sha256` | `AltKeeper.app`, l'app per la barra dei menu di macOS (creata da `scripts/macos-app.sh`) | - -L'immagine Docker la pubblica a parte `.github/workflows/docker.yml` (vedi [Docker](#9-docker)). - -## Usarlo da fuori casa - -AltStore cerca i server con Bonjour, che di solito vuol dire multicast — e **iOS il multicast non -lo manda mai dentro una VPN**. Per questo installare Tailscale o ZeroTier da solo non basta: il -telefono non fa proprio la domanda. - -La via d'uscita è la seconda modalità di Bonjour, più vecchia: il **DNS-SD unicast**, in cui i -servizi si pubblicano come normali record DNS. AltStore cerca in un dominio vuoto, cioè "tutti i -domini predefiniti", e iOS capisce quali siano chiedendolo al server DNS della rete in cui si -trova. Rispondendo a quella domanda, il telefono trova il server da solo — senza impostare niente -sul telefono. - -AltKeeper quei record li serve da sé. Ecco tutto il procedimento. - -### 1. Una VPN tra telefono e server - -Va bene qualunque VPN, purché il telefono ci prenda un indirizzo. WireGuard è la scelta migliore: -è dentro il kernel Linux, l'app iOS è gratuita e regge meglio delle altre i cambi di rete. - -Sul server: - -``` -apt install wireguard-tools -cd /etc/wireguard -umask 077 -wg genkey > server.key && wg pubkey < server.key > server.pub -wg genkey > phone.key && wg pubkey < phone.key > phone.pub -wg genpsk > psk.key -``` - -`/etc/wireguard/wg0.conf`: - -```ini -[Interface] -Address = 10.7.0.1/24 -ListenPort = 51820 -PrivateKey = - -[Peer] -PublicKey = -PresharedKey = -AllowedIPs = 10.7.0.2/32 -``` - -``` -systemctl enable --now wg-quick@wg0 -``` - -Sul router inoltra la porta **UDP 51820** a questa macchina. WireGuard non risponde mai ai -pacchetti che non sono crittograficamente validi, quindi la porta resta invisibile a chi -scansiona — ma è pur sempre una porta aperta, e conviene limitare cosa può raggiungere chi entra. -Aggiungi in `[Interface]`: - -```ini -PostUp = iptables -I INPUT 1 -i wg0 -p udp --dport 53 -j ACCEPT -PostUp = iptables -I INPUT 2 -i wg0 -p tcp --dport 49500 -j ACCEPT -PostUp = iptables -I INPUT 3 -i wg0 -p tcp --dport 8787 -j ACCEPT -PostUp = iptables -I INPUT 4 -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -PostUp = iptables -A INPUT -i wg0 -j DROP -PostUp = iptables -I FORWARD 1 -i wg0 -j DROP -``` - -Così dal tunnel si raggiungono solo DNS, AltServer e la pagina web, e nient'altro: niente SSH e -nessuna via verso il resto della rete di casa. Ogni regola va tolta con un `PostDown = iptables -D …` -corrispondente. - -La configurazione del telefono, che importi inquadrando un codice QR -(`qrencode -t ansiutf8 < phone.conf`): - -```ini -[Interface] -PrivateKey = -Address = 10.7.0.2/32 -DNS = 10.7.0.1 - -[Peer] -PublicKey = -PresharedKey = -Endpoint = indirizzo-di-casa:51820 -AllowedIPs = 10.7.0.0/24 -PersistentKeepalive = 25 -``` - -`AllowedIPs = 10.7.0.0/24` manda nel tunnel solo la sua rete, quindi la normale navigazione del -telefono non cambia. `Endpoint` vuole un indirizzo che segua la tua connessione di casa: se -l'operatore te lo cambia, serve un nome DNS dinamico. - -### 2. Pubblicare i record - -``` -altkeeper altserver --dns-domain casa.internal --dns-address 10.7.0.1 --dns-clients 10.7.0.0/24 -``` - -- `--dns-address` è l'indirizzo a cui il telefono deve collegarsi: quello di questa macchina - **sulla VPN**. -- `--dns-clients` è la rete da cui arrivano i telefoni. Conta perché iOS scopre il dominio - chiedendo del "rovescio" del proprio indirizzo; se non lo indichi, AltKeeper assume la `/24` - attorno a `--dns-address`. -- Il server DNS ascolta sulla porta UDP 5533 (`--dns-port`), risponde solo per i propri nomi e - non inoltra niente. - -### 3. Far arrivare quelle risposte al telefono - -Il DNS del telefono è il server (`DNS = 10.7.0.1` qui sopra), cioè il resolver che già usi. Basta -inoltrargli questo solo dominio — e la zona inversa della rete della VPN. - -Con **AdGuard Home**, in *Server DNS upstream*: - -``` -[/casa.internal/]127.0.0.1:5533 -``` - -e, siccome AdGuard manda le richieste inverse private a un elenco a parte, aggiungi anche in -*Server DNS inversi privati*: - -``` -[/7.10.in-addr.arpa/]127.0.0.1:5533 -``` - -Con **dnsmasq**: - -``` -server=/casa.internal/127.0.0.1#5533 -server=/7.10.in-addr.arpa/127.0.0.1#5533 -``` - -Quella seconda riga è quella che sfugge a tutti, e senza di lei il telefono non scopre mai il -dominio. - -### 4. La prova - -Accendi la VPN, collega il telefono a un Wi-Fi che non sia quello di casa e premi *Refresh All*. -Sul server dovresti vedere prima la scoperta e poi il lavoro: - -``` -[altserver] 10.7.0.2: AnisetteDataRequest -> AnisetteDataResponse -[altserver] 10.7.0.2: InstallProvisioningProfilesRequest -[altserver] profilo installato: com.esempio.app -``` - -Se dice che non trova il server, guarda le richieste DNS con `tcpdump -i wg0 -n port 53` e controlla -quale nome sta chiedendo il telefono. - -### Cosa aspettarsi - -- **Il telefono deve essere su Wi-Fi.** iOS tiene acceso il servizio di pairing che serve ad - AltKeeper solo quando è collegato a una rete Wi-Fi; con la sola rete dati la porta è chiusa, e - non c'è modo di aggirarlo. -- **Il rinnovo automatico attraverso il tunnel non è ancora affidabile.** iOS sospende le app VPN - in background, quindi il cron notturno può trovare il telefono irraggiungibile. Il rinnovo fatto - a mano con il tunnel attivo funziona bene. -- Se il server trovava il telefono scandagliando la rete, scrivi il suo indirizzo VPN in - `state/phone-addr` (`10.7.0.2:49152`), così lo prova per primo. - -## Comandi - -| Comando | Cosa fa | -| --- | --- | -| `phone [--phone ip:porta]` | elenca i profili installati sull'iPhone | -| `pair-usb [file]` | crea il pairing remoto via USB (una volta) | -| `login ` | accede con 2FA e salva l'account | -| `apps` | elenca i tuoi App ID e le scadenze | -| `renew [--dry-run] [--force] [--min-days N] [--phone ip:porta]` | rinnova i profili in scadenza | -| `altserver [--port N] [--dir cartella] [--phone ip:porta] [--dns-domain D --dns-address IP]` | fa da AltServer per AltStore | -| `serve [--bind ip:porta] [--pin PIN] [--dir cartella] [--phone ip:porta] [--altserver] [--altserver-port N]` | pagina web (e, con `--altserver`, AltServer) | -| `profile-remove ` | toglie un profilo dal telefono, salvandone una copia in `profili-salvati/` | -| `profile-restore ` | rimette un profilo salvato | -| `tray [--dir cartella] [--phone ip:porta] [--console]` | Windows e macOS: AltServer + pagina web con icona nella barra di sistema / dei menu, senza terminale (è quello che parte con il doppio clic su `altkeeper.exe` o `AltKeeper.app`). Cartella predefinita: quella dell'`.exe` su Windows, `~/Library/Application Support/AltKeeper` su macOS; `--console` solo su Windows | - -`ALTKEEPER_PHONE=ip:porta` può sostituire `--phone`; senza nessuno dei due il telefono si trova da -solo (vedi il passo 2). `ALTKEEPER_NO_MDNS=1` spegne Bonjour (reti che bloccano il multicast, o per -provare la scansione). `ALTKEEPER_DEBUG=1` stampa cosa risponde Apple a ogni richiesta del login (stato, -intestazioni, corpo degli errori; mai il corpo delle richieste né le risposte riuscite). -Per anisette il server predefinito è `https://ani3.stikstore.app`. Puoi sceglierne un altro nella -pagina web o nel tray: il server pubblico di isideload (`https://ani.jkcoxson.com`) oppure un URL -HTTP(S) personalizzato. La scelta della pagina viene salvata in modo riservato in -`state/anisette-url`; `ALTKEEPER_ANISETTE_URL` ha la precedenza e blocca la scelta. Cancellandola si -ripristina il server predefinito. AltKeeper mette da parte l'identità anisette quando cambia il -server effettivo. Nel riquadro iPhone puoi anche salvare fino a due indirizzi IP:porta fissi (per -esempio casa e VPN). - -`altkeeper --help` (anche `altkeeper login --help`) mostra la sintassi nella lingua configurata. - -Il progetto si chiamava altrefresh: i vecchi nomi `ALTREFRESH_*` funzionano ancora (se ci sono -entrambi vince `ALTKEEPER_*`). `altkeeper --version` stampa la versione. - -## Se qualcosa va storto - -- **"manca il pairing remoto"**: nella cartella non c'è `rp-pairing.plist`. Usa **Associa iPhone via - USB** nella pagina web o nel tray, oppure esegui `altkeeper pair-usb` (passo 3). -- **"pairing con l'iPhone non riuscito" / `early eof`**: l'iPhone non riconosce più quel pairing. - Rifallo, oppure copia un `rp-pairing.plist` funzionante da un altro computer. -- **"non trovo l'iPhone in rete", "No route to host", timeout**: l'iPhone non è in rete (in standby, - fuori casa). Se avevi indicato un indirizzo che non è più giusto, il programma cerca il telefono da - solo; se vuoi un indirizzo fisso, sull'iPhone apri Impostazioni, Wi-Fi, la (i) della tua rete e - imposta l'indirizzo Wi-Fi privato su **Fisso** o disattivato (la dicitura dipende dalla versione di - iOS), poi dai all'iPhone un IP fisso nel router. -- **AltStore dice "AltServer could not be found"**: vedi il passo 4. Controlla che il server sia - acceso, sulla stessa rete, che AltStore abbia il permesso Rete locale, e leggi l'output del server. -- **AltStore segnala un errore anisette (`-45061` o `reprovision required`)**: usa il riquadro - **Server anisette** per provare o scegliere un altro server. AltKeeper mette da parte la vecchia - identità quando cambia l'URL effettivo. Dopo averne creata una nuova, Apple potrebbe chiedere di - nuovo il codice di verifica. I server pubblici vanno e vengono; puoi configurare - `ALTKEEPER_ANISETTE_URL` o usare un tuo `anisette-v3-server`. -- **Un refresh da AltStore sembra bloccato subito dopo `PrepareAppRequest`**: AltStore manda prima - l'app al server, e può pesare ben più di 100 MB. Con un Wi-Fi debole ci vogliono minuti; il server - scrive `app in arrivo: X di Y byte` ogni pochi secondi mentre arriva, e rinuncia dopo 10 minuti in - totale. Tieni AltStore aperto con lo schermo acceso e l'iPhone vicino al router. -- **Errori 429 o 503 al login**: Apple sta limitando le richieste. Non riprovare a ripetizione. - Lancia con `ALTKEEPER_DEBUG=1` e, se persiste, apri una issue con le righe che iniziano con - `[gs #` (non contengono segreti). -- **Un'app non si apre dopo un rinnovo**: reinstallala con AltStore o con lo strumento che usi di - solito. Se avevi tolto un profilo con `profile-remove`, rimettilo con `profile-restore`. - -## Sicurezza - -Questi file contengono segreti. Sono ignorati da git e vanno tenuti con permessi `600`: - -- `rp-pairing.plist`: le chiavi del pairing con il telefono. -- `account.json`: l'Apple ID e, se hai scelto di salvarla, la password **in chiaro**. -- `state/`: l'identità anisette del "dispositivo" e il `serverID` di AltServer. -- `notify.conf`: il topic di ntfy. -- `profili-salvati/`: le copie dei profili tolti dal telefono con `profile-remove`. - -Per segnalare una vulnerabilità in privato vedi [SECURITY.md](SECURITY.md). - -Non incollare mai la password, `account.json` o il file del pairing in una issue o in una chat. -Non usare `RUST_LOG=debug` su log condivisi: a quel livello `idevice` stampa le chiavi del pairing. -Tieni la macchina che li conserva dentro la rete di casa. - -**La porta di AltServer (49500) non ha un accesso, come il vero AltServer**: chiunque sulla tua rete -parli il protocollo può chiedere i dati anisette o mandare profili e app all'iPhone accoppiato. Non -esporre quella porta fuori dalla rete di casa. - -## Come funziona - -1. L'iPhone (iOS 17+) annuncia in rete il servizio Bonjour `_remotepairing._tcp`. -2. Con un pairing remoto già creato, il programma fa il *pair-verify*, chiede al telefono di aprire - un tunnel e lo raggiunge con TLS-PSK ([`idevice`](https://github.com/jkcoxson/idevice)). -3. Dentro il tunnel legge e installa i profili con `misagent`, e installa o toglie le app con - `installation_proxy`. -4. In modalità AltServer, AltStore trova la macchina con Bonjour (`_altserver._tcp`, con un record - `serverID`) e manda richieste JSON su TCP (la lunghezza a 32 bit little-endian, poi il JSON), una - connessione per operazione: dati anisette, profili da installare o togliere, e l'app firmata. -5. Per il rinnovo senza controllo, con l'Apple ID ([`isideload`](https://github.com/nab138/isideload)) - scarica i profili nuovi per gli App ID esistenti. - -Il vecchio Wi-Fi sync (`netmuxd`, AltServer-Linux) su iOS recenti viene rifiutato dal telefono: il -pairing remoto è il canale che funziona. - -## Note sulla copia di `isideload` in `vendor/` - -`isideload` 0.3.17 ha due problemi con i server di login di Apple, quindi `vendor/isideload` è -una copia modificata dell'originale (MIT, © nab138): - -- **503 al login.** Si presenta come `com.apple.dt.Xcode`, che Apple rifiuta. - `src/anisette/remote_v3/mod.rs` ora dichiara `com.apple.akd/1.0` (identità `Mac15,7`, - macOS 27.0) e l'User-Agent AuthKit che usa AltSign. -- **429 alla prova della password.** Il bordo di Apple lascia passare poche richieste su una - stessa connessione e rifiuta le altre. Il login ne manda tre di fila (URL bag, `init`, - `complete`), quindi la terza prendeva 429. AltSign lo evita con una connessione nuova per - ogni richiesta (`ALTAppleAPI+Authentication.swift`, ramo notarized); `src/auth/grandslam.rs` - ora fa lo stesso (solo HTTP/1.1, niente riuso delle connessioni, `Connection: close`). Su - macOS usa anche il TLS di sistema. Quale di queste modifiche sia decisiva non è stato isolato. - -Espone anche alcuni accessori in sola lettura di `AnisetteData`, usati per rispondere ad AltStore. - -## Crediti e licenza - -Costruito su [`idevice`](https://github.com/jkcoxson/idevice) (Jackson Coxson) e -[`isideload`](https://github.com/nab138/isideload) (nab138), entrambi con licenza MIT. La -correzione del login segue quello che fa [AltSign](https://github.com/rileytestut/AltSign) -(Riley Testut), e i messaggi di AltServer seguono il protocollo di -[AltStore](https://github.com/altstoreio/AltStore). Licenza di questo progetto: [MIT](LICENSE). +| Configurare AltKeeper e associare l'iPhone | [Guida introduttiva](docs/it/quick-start.md) | +| Scegliere Docker, Windows o macOS | [Installazione e avvio](docs/it/deployment.md) | +| Ospitare un server Anisette V3 | [Server Anisette locale](docs/it/anisette-server.md) | +| Accedere, rinnovare le app o gestire più dispositivi | [Account e rinnovi](docs/it/accounts-and-renewals.md) | +| Usare la pagina web, installare IPA o attivare il JIT | [Web e JIT](docs/it/web-and-jit.md) | +| Collegarsi da fuori casa | [Accesso remoto](docs/it/remote-access.md) | +| Risolvere problemi o proteggere i dati | [Problemi e sicurezza](docs/it/troubleshooting-and-security.md) | + +Preferisci l'inglese? Apri il [README in inglese](README.md) o le [guide in inglese](docs/en/README.md). + +## In breve + +- **Modalità AltServer:** AltStore Classic può aggiornare e installare app tramite AltKeeper. +- **Rinnovo indipendente:** rinnova i profili dalla CLI, dalla pagina web o con una pianificazione + facoltativa. +- **Profili dispositivo:** pairing, account, impostazioni e rinnovi restano separati per ogni iPhone. +- **Anisette V3 locale:** server facoltativo per Linux, macOS e Windows; le librerie native vanno + fornite dall'operatore. +- **JIT:** disponibile tramite pagina web e API HTTP per client compatibili. Servono Modalità + sviluppatore e un'immagine Developer Disk Image compatibile già montata. + +CLI e server supportano Linux, macOS e Windows; l'app nella barra dei menu è disponibile su macOS e +Windows. Docker è pensato per host Linux che supportano rete host e Bonjour. + +Per segnalare una vulnerabilità in privato, consulta [SECURITY.md](SECURITY.md). Le licenze di terze +parti sono elencate in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). diff --git a/README.md b/README.md index 2a34992..7686309 100644 --- a/README.md +++ b/README.md @@ -1,839 +1,40 @@ # AltKeeper -**AltServer for iOS 17+ that runs on your home server (Docker).** Refresh and install your -AltStore / SideStore-style apps over Wi-Fi, with no computer left on and no VPN. +AltKeeper keeps apps installed on an iPhone available by renewing their provisioning profiles. +It can also act as an AltServer for AltStore Classic, so apps can be refreshed or installed over +Wi-Fi. It runs on a home server, a Mac, or Windows. -Keeps sideloaded iPhone apps alive. Apps installed with a **free Apple ID** stop opening after -7 days unless their *provisioning profile* is renewed. AltKeeper runs on a small always-on -machine at home and does it over Wi-Fi, with **no computer plugged in, no AltServer on a -computer and no VPN on the phone**. It can work in two ways, together or separately: +> **Experimental software.** AltKeeper uses private Apple APIs and protocols that may change +> without notice. JIT activation and some installation flows have not been verified end to end on +> physical devices. Use it with your own devices and Apple ID; it is not affiliated with Apple or +> AltStore. -- **As AltServer for AltStore Classic**: AltStore's *Refresh All* button and installing or - updating apps from AltStore work, with this machine answering instead of AltServer. -- **On its own**: it renews the profiles by itself every evening (cron), even if AltStore is - never opened. +## Choose a guide -There is also a small **web page** to see the state of your apps and renew them: - -AltKeeper web page: iPhone reachable, three apps with their expiry, renew buttons - -> **Status: experimental.** Tested on an iPhone 15 Pro (iOS 27.0) with AltStore Classic (the latest -> version at the time of writing), running on a Mac and on a Debian 13 -> mini PC (x86-64, Docker). It relies on private Apple APIs and on AltStore's own protocol, which can -> change without notice. For personal use with your own Apple ID; not affiliated with Apple or -> AltStore. The CLI supports English (default) and Italian; the web UI and third-party -> diagnostic messages have separate language behavior. -> -> Confirmed with AltServer running from the Docker image on the Debian mini PC: *Refresh* from -> AltStore installs the new profile on the iPhone, and installing an app from AltStore works. -> Confirmed earlier with the server on a Mac: the same, with an app of about 126 MB, plus sign-in, -> `apps`, `renew`, and that an app still opens with only the *newer* profiles installed (also after -> rebooting the iPhone). Not confirmed yet: what happens on the day the *original* profile -> expires, running AltServer on a server for a longer time, or installing an IPA uploaded from the -> web page against Apple and a real iPhone. - -## CLI language (English / Italian) - -AltKeeper's command-line prompts, errors, and service logs default to English. Set -`ALTKEEPER_LANG=it` to use Italian, or `ALTKEEPER_LANG=en` for English. -Language is read for each invocation (no rebuild necessary). Values such as `en-US`, -`en_GB`, and `it-IT` are accepted case-insensitively; unknown languages fall back to English. -The legacy `ALTREFRESH_LANG` variable is also accepted when `ALTKEEPER_LANG` is unset. -This changes human-readable CLI output, **not** protocol messages, machine-readable data, -or the web application's language. - -PowerShell (applies to the current session and child processes): - -```powershell -$env:ALTKEEPER_LANG = "en" -.\target\release\altkeeper.exe --help -.\target\release\altkeeper.exe login "your-apple-id@example.com" -# Use the default: Remove-Item Env:ALTKEEPER_LANG -``` - -Command Prompt (cmd.exe): - -```cmd -set ALTKEEPER_LANG=en -target\release\altkeeper.exe phone -``` - -Linux, macOS, or a container environment: - -```sh -ALTKEEPER_LANG=en ./altkeeper phone -``` - -The provided `examples/docker-compose.yml` uses list-style `environment:`. Add -`- ALTKEEPER_LANG=it` there to select Italian. The environment variable controls human-readable CLI -and server logs, **not** the HTML web page, protocol or JSON. The same 2FA menu commands -(`d`, `r` when offered, `p`) are available in both languages. - -## What it does, and what it does not - -- Renews the provisioning profiles of your apps (and, in AltServer mode, installs the apps AltStore - sends it). The renewal flow does not re-sign apps or create/revoke certificates. Installing an - IPA uploaded from the web page does sign it and may create a development certificate. -- **AltServer mode does not need your Apple ID on the server.** AltStore signs in on the iPhone; - this program only gives it anisette data and installs what AltStore sends. The unattended renewal - (`renew`, cron, and the web page's renew action) and signing an IPA from the web page do need the - Apple ID and its password on the server. -- The two do not clash: `renew` looks at the newest profile on the phone. If AltStore renewed - recently, `renew` does nothing and does not even sign in to Apple. -- Old profiles stay on the phone after a renewal (iOS does not remove them), so you will see - several profiles per app. That is expected. In AltServer mode it never removes profiles, even - when AltStore asks (`activeProfiles`). -- Not supported: enabling JIT (`EnableUnsignedCodeExecution`); it answers with an error. -- After an unattended `renew`, AltStore may keep showing the old expiry date: it only updates its - own record when it does the refresh itself. The apps are fine. - -## What you need - -- An iPhone on **iOS 17 or later**, with Developer Mode on, on the same Wi-Fi as the server. -- A machine that is always on in your home network. Tested: Debian 13 (x86-64); a Mac also - works (from the terminal, or as a menu-bar app like AltServer, see - [macOS](#11-macos-menu-bar-app)). On **Windows 10**, the native build, tests and interactive Apple ID / 2FA login have - been verified. Windows 11 and the full Windows AltServer/renewal workflow remain unverified - end to end (see [Windows](#10-windows-native-no-docker)). -- For AltServer mode: **AltStore Classic** on the iPhone, with the *Local Network* permission. -- For the unattended renewal: a **free Apple ID** whose team already signed the apps. -- For the **first pairing only**: the iPhone plugged in by USB to a computer (a Mac is easiest). -- Optional: `curl`, `jq` and the [ntfy](https://ntfy.sh) app for notifications; Docker. - -## Set up - -### 1. Get the program - -**Prebuilt:** each [GitHub release](https://github.com/BartolomeoRusso9/altkeeper/releases) has -`altkeeper--linux-x86_64.tar.gz`, `-linux-aarch64`, `-macos-aarch64` (Apple Silicon) and -`-macos-x86_64` (Intel), each with a `.sha256` file (check with `sha256sum -c`). Unpack it and use the -`altkeeper` binary inside. The Linux builds need glibc 2.35 or newer; on older systems or Alpine, use -Docker or build it yourself. - -- **Windows:** `altkeeper.exe` (double-click: system tray icon, see [Windows](#10-windows-native-no-docker)). -- **macOS:** besides the tar.gz there is `AltKeeper--macos-aarch64.zip` (Apple Silicon) and - `-macos-x86_64.zip` (Intel) with **`AltKeeper.app`**: double-click it and an icon appears in the - menu bar, like AltServer, with AltServer and the web page running behind it (see - [macOS menu-bar app](#11-macos-menu-bar-app)). - -The macOS builds are not notarized: if Gatekeeper blocks them, run -`xattr -dr com.apple.quarantine AltKeeper.app` (or `altkeeper` for the tar.gz). Only the Windows -`.exe` updates itself; on macOS the menu-bar icon tells you about a new version and opens its page. - -**Or build it yourself:** - -```sh -git clone https://github.com/BartolomeoRusso9/altkeeper -cd altkeeper -docker run --rm -v "$PWD":/w -w /w rust:1-bookworm cargo build --release -``` - -The binary is `target/release/altkeeper`. Notes: - -- On an Apple Silicon Mac, add `--platform linux/amd64` to build for an x86-64 server (slower: - about 5 to 7 minutes). -- If you also build for the Mac in the same folder, add `-e CARGO_TARGET_DIR=/w/target-linux` so - the two builds do not overwrite each other. -- On a Mac you can simply run `cargo build --release`, and wrap the binary in `AltKeeper.app` with - `scripts/macos-app.sh target/release/altkeeper ` (the same script the - release uses). -- Or use the Docker image (see [Docker](#9-docker)). - -On the server, create a folder (for example `/srv/altkeeper`) and copy there the `altkeeper` -binary and the files in `examples/` you need. - -### 2. Find your iPhone (optional) - -AltKeeper finds the iPhone by itself, and finds it again if its address changes (it changed -several times in a few hours in our tests). It tries, in order: the address you give, the last one -that worked (`state/phone-addr`), Bonjour (`_remotepairing._tcp`), and finally a scan of your local -network for the phone's service port (49152), which does not depend on Bonjour (on a server where -another program shares the Bonjour port, the answers can go missing). So you can skip this step. If you prefer to give the address, use `--phone ip:port` or `ALTKEEPER_PHONE=ip:port` -(the port is usually 49152). On a Mac you can look it up: - -```sh -dns-sd -B _remotepairing._tcp # lists the instances, stop with Ctrl-C -dns-sd -L "" _remotepairing._tcp local # shows host and port (usually 49152) -dns-sd -G v4 .local # shows the IP address -``` - -### 3. Pair (once, over USB) - -`pair-usb` uses the local `usbmuxd` (on Windows, iTunes / Apple Devices on -`127.0.0.1:27015`). The iPhone must be connected, unlocked, and trusted by the computer. On a Mac, -the built-in USB mux is used directly: - -```sh -# with the iPhone connected, unlocked, and "Trust" accepted: -./target/release/altkeeper pair-usb -``` - -This writes `rp-pairing.plist` privately and atomically. For a remote server, create it on the -computer with the USB cable and copy it to the server. `USBMUXD_SOCKET_ADDRESS=host:port` is still -supported for a forwarded usbmuxd. The web page and tray also have a **Pair iPhone over USB** -action. The optional automatic-pairing switch handles a newly connected phone; in Docker it works -only when the host's usbmuxd socket is mounted into the container. -Set `ALTKEEPER_NO_AUTO_PAIR=1` to disable the monitor (USB connections remain available). If a -renewal receives no install response after 60 seconds, AltKeeper verifies the profile separately -and keeps waiting up to 12 hours while the iPhone remains reachable; the dashboard offers -**Cancel waiting**. - -Then check the connection, from the server folder: - -```sh -./altkeeper phone # lists the profiles installed on the phone -``` - -### 4. AltServer for AltStore (recommended) - -```sh -./altkeeper altserver # or: ./altkeeper serve --altserver (with the web page) -``` - -Leave it running (as a service or in Docker). On the iPhone, in AltStore, open **My Apps** and tap -**Refresh All**, or install an app: AltStore finds the server by itself. It listens on port -**49500** (`--port N` to change it) and announces itself with Bonjour as `_altserver._tcp`. - -- Only one AltServer should be on the network: stop any AltServer on a computer, and do not run - two copies of this program. -- Try it first with one unimportant app, not with *Refresh All* on everything. -- AltStore shows *"AltServer could not be found"* for several kinds of lost connection when the - server is not the one it prefers, so that message can hide the real cause. Look at the server's - output: it logs every connection, request and answer. -- The first request creates the anisette identity in `state/` if it does not exist yet. We have - used it with an identity created by `login`; a clean folder has not been tried. - -### 5. Sign in (for unattended renewal and PC IPA installation) - -```sh -./altkeeper login your.apple.id@example.com -``` - -It asks for your password (**input is hidden by the terminal**) and the 6-digit 2FA code from -your other devices. Answer `y` when it asks to save the password (`s` if the prompt is in Italian; -both are accepted): that is what lets the renewal run without you. It is stored **in clear text** in `account.json` (mode 600). Sign in once; if Apple -answers with errors, wait before trying again instead of repeating it. - -```sh -./altkeeper apps # your App IDs and expiry dates -./altkeeper renew --dry-run --min-days 7 # shows what it would renew, changes nothing -./altkeeper renew --min-days 7 # renews now what expires within 7 days -``` - -`renew` looks at each app's newest profile, renews the ones with `--min-days` days left or fewer -(default 3), and signs in to Apple **only if something is due**. - -**Installed-app safety filter (default for scheduled/CLI `renew` and web “Renew expiring apps”):** -Before checking expiry, AltKeeper queries the phone's installed user apps through installation_proxy. -Profiles that do not correspond to installed apps are skipped. **If that lookup fails (including -a timeout), AltKeeper explicitly falls back to renewing *all* known profiles for the configured -Apple team, ignoring the expiry threshold.** This fallback is logged as WARNING/FALLBACK. -A successful lookup returning zero apps skips all renewals. - -To renew a specific app, use `altkeeper renew --bundle --force` or the per-app -**Renew** button on the web page. The manual `--force` and web “Renew all now” operations -do not check installed apps; `--installed-only` can be used with CLI `--force` to opt in. -No unattended schedule is enabled by merely running the Docker container (default: OFF). - -**Optional built-in scheduler in the web UI:** Use **Automatic renewal** to enable/disable a -once-daily job and choose a time in **UTC**. The default clock -time is 06:00 UTC and the switch is initially OFF. When enabled, AltKeeper runs -the same installed-app-aware renewal as **Renew expiring apps**, with the documented -all-profile fallback if installed-app listing fails. The server needs a saved -Apple ID password, must be running, and the iPhone must be reachable. -Only one scheduled *attempt* is made per UTC calendar day (including failures). -A server that starts after the scheduled time on a day with no attempt will run -that day's job when it starts; enabling or changing the scheduled time after -today's slot does **not** trigger an immediate refresh. The next slot is tomorrow. -The schedule and last attempted day are stored in `state/auto-renew.json` under -the persistent `/data` volume, surviving Docker restarts. No host systemd, -cron, privileged host socket, or extra package is needed. **Do not enable -an external cron/systemd renewal job at the same time** unless deliberate. - - -The web page also lists **individual provisioning profiles** (including older versions) with a -**Delete profile** button. Every removal requires confirmation, is limited to the saved Apple team, -and saves then verifies a private `profili-salvati/.mobileprovision` backup before removal. -Removing a profile currently needed by an installed app can prevent that app from opening. -To restore a backup: `altkeeper profile-restore profili-salvati/.mobileprovision`. -Neither profile deletion nor renewal removes Apple Developer App IDs. - -**Grouped app management and retry controls:** The web UI groups extension profiles with -their host app when their bundle ID contains the saved team's extension marker -(e.g. `com.kdt.livecontainer.TEAM.LiveProcess` under `com.kdt.livecontainer`). -The expandable **Your apps** card renews *every* member profile in a group, rather -than only the selected host profile. Installed-app filtering also checks the -host app for an extension; the earliest-expiring member determines when the entire -group is due. The web preview lists groups that appear due, but the actual run -performs a fresh phone check and may fall back to all team profiles if the -installed-app inventory fails. - -The web scheduler's **renewal threshold** is adjustable from **1 to 6 days**, -default **3 days**. On automatic renewal failure it retries up to **two** more -times, **30 minutes apart** (up to 3 attempts in total). Authentication and -rate-limit failures are not automatically retried. Enabling Discord failure -notifications requires saving a webhook URL in the web UI. The secret is saved in -the private `state/discord-webhook` file and is never returned to the browser; -only a boolean indicates configuration. The Docker image includes `curl` to send -an alert only after the last failed attempt (or a non-retryable failure). -Successful automatic renewals do not send Discord messages. - -The web UI also lists previously saved profile backups and offers an explicit -**Restore** action after confirmation. Restoration verifies the saved profile -UUID and Apple Team ID and does not change its expiry date. The original file -is retained. The app summary shows the last known per-group profile-install -result from `state/app-renew-status.json`, and **Preview renewals** provides a -non-authoritative overview without signing in to Apple. - - -**Monthly backup cleanup:** When the web service (`serve`, including Docker `serve --altserver`) -is running, it checks once a day whether a cleanup is due. Every **30 days after the last -successful sweep** (including the first startup), it removes only saved `.mobileprovision` -files whose embedded expiry date is at least **30 days in the past**. A verified UUID/file name -match is required; malformed, unreadable, future-dated or unrelated files are preserved. -A cleanup log line reports the number removed. The last successful sweep is recorded at -`state/profile-backup-last-cleanup` so Docker restarts do not reset the schedule. -No iPhone communication, Apple login, cron job, or new systemd timer is required. -This cleanup does not touch live iPhone profiles or Apple Developer App IDs. - -### 6. Automatic renewal (optional) - -A safety net that does not depend on the iPhone running AltStore. - -1. Copy `renew.sh` and `notifica.sh` from `examples/` next to the binary, and `chmod +x renew.sh`. - Leave `ALTKEEPER_PHONE` unset in `renew.sh` (the phone is found by itself) or put its `IP:port`. -2. Install the cron job (edit the path first): `cp altkeeper.cron /etc/cron.d/altkeeper` - -It runs every evening at 21:17, renews what is due and appends date and result to `renew.log`. -The iPhone must be on the home Wi-Fi at that time. With profiles lasting 7 days and a threshold of -3, you get several evenings of margin before an app expires. - -### 7. Notifications (optional) - -Create `notify.conf` next to the scripts, with a long random topic name: - -```sh -echo 'NTFY_TOPIC=pick-a-long-random-name-here' > notify.conf && chmod 600 notify.conf -``` - -Install the ntfy app on your phone and subscribe to that topic. You get a message when apps are -renewed and when a run fails (at most one error message every 24 hours, because the phone may just -be away from home). Nothing is sent when there is nothing to renew. Anyone who knows the topic can -read it, so keep it secret. - -### 8. Web page (optional) - -```sh -ALTKEEPER_WEB_PIN=123456 ./altkeeper serve --bind 0.0.0.0:8787 --altserver -``` - -Open `http://:8787` (any user name, the PIN as password). It shows whether the iPhone is -reachable and the apps with the days left, lets you renew them ("Renew expiring apps" or "Renew all -now", with a live log), sign in with your Apple ID (the 2FA code is typed in the browser) and see -the latest renewals from `renew.log`. Without `--bind`, or with a loopback address, it listens only -on the machine itself and needs no PIN; on any other address it **refuses to start without a PIN** -(at least 4 characters). Use the environment variable rather than `--pin`, which is visible in `ps`. -The web page has no HTTPS: keep it inside your home network. The web page's own sign-in and renew -were not run against Apple yet. - -The device selector at the top lets you manage multiple iPhones with separate Apple accounts. -Choose **Add device**, name the profile, then select it to pair the phone and sign in to its -account. Each profile keeps its own account, pairing, saved phone addresses, settings, renewal -schedule, logs and backups. Existing files remain in the original/default profile; new profiles are -stored under `devices/`. Automatic renewal is scheduled separately for each profile, with only one -phone operation running at a time. AltServer remains a single network service and uses the currently -selected profile. - -From a PC, you can also upload an `.ipa` in **Install an app from this PC**. AltKeeper signs it with -the configured Apple ID and installs it on the iPhone over Wi-Fi. This requires the Apple ID -password to be saved in `account.json`; enter the 2FA code in the page if Apple asks for it. Signing -may register the iPhone and create development app IDs and certificates on the Apple team. Uploads -are limited to 1 GiB. Protect the page with a PIN when exposing it on your network, and never -forward its port to the internet. - -### 9. Docker - -```sh -docker build --platform linux/amd64 -t altkeeper:latest . -``` - -The image holds nothing secret: pairing, account and state live in the `/data` volume. Copy -`examples/docker-compose.yml` to `/srv/altkeeper/docker-compose.yml`, put your -`rp-pairing.plist` (and `account.json`/`state/` if you use `renew`) in the same folder, write -`ALTKEEPER_WEB_PIN=...` in `/srv/altkeeper/.env` (mode 600) and run `docker compose up -d`. It -uses the host network, because it needs Bonjour to find the iPhone and to be found by AltStore; -ports 8787 (web) and 49500 (AltServer) must be free. - - -**Fast IPA upload over Wi-Fi (experimental):** After signing, AltKeeper repackages the -signed `.app` (including any custom name/Bundle ID and selected extensions) into one -temporary IPA, stages that file over AFC, then asks iOS to install it. This avoids -thousands of remote AFC operations for apps with many small files. The temporary -IPA is removed automatically; peak memory is bounded by streaming both packaging -and upload. USB installs keep the existing directory-based path. - -For an immediate rollback on Linux, set `ALTKEEPER_INSTALL_MODE=directory` in the -AltKeeper container's `environment:` in `docker-compose.yml`, then recreate -the container. This restores the previous Wi-Fi directory-transfer behavior -without re-signing existing apps. Test on a disposable app first; compatibility -with a real iPhone must be confirmed before considering it production-verified. - -A GitHub Actions workflow (`.github/workflows/docker.yml`) runs the tests on Linux and Windows, -checks `--help` in both CLI languages, and publishes the image to `ghcr.io//altkeeper` on -pushes to `main` (amd64) and on `vX.Y.Z` tags (amd64 and arm64). If the repository is private, the -package is too. - -### 10. Windows (native, no Docker) - -Docker Desktop on Windows cannot use the host network that AltKeeper needs for Bonjour, so on -Windows run the program directly. **Verified on Windows 10:** `cargo test --locked` (63/63 -passed), `cargo build --release --locked`, English `--help`, and a real Apple ID login from -PowerShell, including six-digit 2FA entry and commands in the 2FA menu. This does **not** -prove Windows 11, other shell hosts, AltServer installation or unattended renewal end to end. - -1. Install [Rust](https://rustup.rs) (the default MSVC toolchain) and the *Build Tools for Visual - Studio* with the **Desktop development with C++** workload. Then, in PowerShell: - - ```powershell - git clone https://github.com/BartolomeoRusso9/altkeeper - cd altkeeper - cargo test --locked - cargo build --release --locked - ``` - - The binary is `target\release\altkeeper.exe`. Make a folder (for example `C:\altkeeper`) and - copy there the `.exe` and, from `examples\`, `renew.ps1`. - -2. **Pair (once, over USB).** If iTunes or the *Apple Devices* app is installed, Windows already - has a usbmuxd listening on `127.0.0.1:27015`: no bridge needed (`usbmux-bridge.py` is for the - Mac). With the iPhone plugged in, unlocked and trusted: - - ```powershell - .\altkeeper.exe pair-usb - ``` - - Then `.\altkeeper.exe phone` should list the profiles on the phone. - -3. **Run it.** From the folder you made: - - ```powershell - .\altkeeper.exe serve --altserver # web page on http://127.0.0.1:8787 + AltServer - # or only AltServer: - .\altkeeper.exe altserver - ``` - - The first time, Windows asks to allow the program through the firewall: allow it on **Private** - networks (your Wi-Fi must be set as *Private*, not *Public*). By hand, for AltServer (TCP - 49500) and Bonjour (UDP 5353), from an administrator PowerShell: - - ```powershell - New-NetFirewallRule -DisplayName "AltKeeper" -Direction Inbound -Action Allow -Program "C:\altkeeper\altkeeper.exe" -Profile Private - ``` - - If another Bonjour service already uses UDP 5353 on that PC (Apple's *Bonjour Service*, which - comes with iTunes, is fine), see "If something goes wrong". Stop with Ctrl-C. - -4. **Sign in and automatic renewal.** `.\altkeeper.exe login you@example.com` works as on the - other systems. In PowerShell and cmd.exe, type the six-digit 2FA code and press Enter; - the `d`, `r`, and `p` options also work. Interactive prompts are read directly - from the Windows console, so switching from PowerShell to cmd.exe is unnecessary. - **Login and the 2FA menu were manually verified on Windows 10 PowerShell.** Run - `login` in an interactive console rather than a background task. See the - [Windows 2FA test guide](docs/windows-2fa-smoke-test.md) for verification details. - `account.json`, `state\` and `profili-salvati\` are restricted to your Windows - user (the equivalent of `chmod 600/700`). For the nightly renewal use the Task Scheduler with - `renew.ps1` instead of cron: - - ```powershell - schtasks /Create /TN "AltKeeper renew" /SC DAILY /ST 21:17 /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\altkeeper\renew.ps1" - ``` - - To start AltServer when you log in: - - ```powershell - schtasks /Create /TN "AltKeeper" /SC ONLOGON /TR "C:\altkeeper\altkeeper.exe altserver --dir C:\altkeeper" - ``` - - `renew.ps1` keeps the same `renew.log`, the same optional `notify.conf` (ntfy) and the same - once-a-day error notification as `renew.sh`. It needs the PC to be on and awake at that time. - -5. **System tray icon (no terminal).** Double-click `altkeeper.exe`, or run - `.\altkeeper.exe tray`: it starts AltServer and the web page like `serve --altserver`, but - with an icon next to the clock (the AltKeeper logo; grey while the iPhone is not reachable) - and no console window. Double-click the icon, or use the menu: *Open AltKeeper* (the page in - its own window; closing it only hides it), *Show logs*, *Start with Windows* (on by default - the first time), *Check for updates* and *Quit*. Toast notifications tell you when profiles - are renewed, an app is installed or something fails. The window needs the Microsoft Edge - WebView2 runtime, which Windows 11 and recent Windows 10 already have. `--console` keeps the - console, `--dir` chooses the data folder (default: the folder of the `.exe`). - - The icon also checks the GitHub releases once a day; *Update now* downloads the new - `altkeeper.exe`, verifies its SHA-256, restarts, and goes back to the old version if the new - one does not start (see [Releases](#releases)). - - Every server line (AltServer, web page, DNS-SD) is also written, with date and time, to - `altkeeper.log` (rotated at 5 MB) and shown in the **Logs** section of the web page. - -Everything else in this guide (commands, environment variables, outside-the-home setup) is the -same; only the paths use `\`, and `cron` and `renew.sh` are replaced by the Task Scheduler and -`renew.ps1`. - -### 11. macOS menu-bar app - -`AltKeeper.app` works like AltServer on a Mac: it has no Dock icon, only an icon in the menu bar, -and while it runs the Mac answers AltStore and serves the web page. It is the same program as the -Windows tray (`altkeeper tray`), so it behaves the same way. - -1. **Install.** Download `AltKeeper--macos-aarch64.zip` (Apple Silicon) or - `-macos-x86_64.zip` (Intel) from the [releases](https://github.com/BartolomeoRusso9/altkeeper/releases), - unzip it and **move `AltKeeper.app` to Applications** (started from Downloads, macOS runs it from - a temporary read-only copy and *Start at login* cannot be turned on). -2. **First start.** The app is signed ad hoc, not notarized: the first time, right-click it → - *Open* → *Open* (on macOS 15 and later: *System Settings → Privacy & Security → Open Anyway*), - or run `xattr -dr com.apple.quarantine /Applications/AltKeeper.app`. Allow **Local Network** - access when macOS asks (needed to find the iPhone and to be found by AltStore), and incoming - connections if the firewall asks. -3. **Quit AltServer** if it is installed on this Mac: only one AltServer should run on the network. -4. **Use it.** Click the AltKeeper logo in the menu bar (grey while the iPhone is not reachable; - hover it for the number of apps and the next expiry): - - *Open AltKeeper*: the web page in its own window (closing it only hides it). ⌘C / ⌘V work, - so you can paste the Apple ID password. - - *Show logs*: the same window, on the **Logs** section. - - *Start at login*: on by default the first time; it writes - `~/Library/LaunchAgents/io.github.altkeeper.plist` (unchecking it removes the file). - - *Check for updates*: when a new release exists, *Download vX.Y.Z* opens its page. The app - does not replace itself on macOS: download the new zip and replace `AltKeeper.app`. - - *Quit*: stops AltServer (removing its Bonjour announcement) and the web page. - - Notifications (profiles renewed, app installed, failures, new version) appear in Notification - Center; they are sent through AppleScript, so macOS may list them under *Script Editor*. - -**Data folder.** The app keeps everything in `~/Library/Application Support/AltKeeper`: put -`rp-pairing.plist` there (see [Pair](#3-pair-once-over-usb)); `account.json`, `state/`, -`profili-salvati/` and `altkeeper.log` end up there too. To sign in for unattended renewal, run -the program inside the app from that folder: - -```sh -cd ~/Library/Application\ Support/AltKeeper -/Applications/AltKeeper.app/Contents/MacOS/altkeeper login your.apple.id@example.com -``` - -Or sign in from the web page (*Open AltKeeper*). Every other command works the same way from that -folder. `altkeeper tray --dir ` (from a terminal, with the binary from the tar.gz or from -inside the app) uses another folder. - -The web page listens only on `127.0.0.1:8787` (no PIN needed); AltServer listens on port 49500 for -the whole network, exactly like `serve --altserver`. - -**Build it yourself.** From the project folder (the release build takes a few minutes): - -```sh -cargo build --release -scripts/macos-app.sh target/release/altkeeper 0.3.1 dist # creates dist/AltKeeper.app -ditto dist/AltKeeper.app /Applications/AltKeeper.app -mkdir -p ~/Library/Application\ Support/AltKeeper -cp rp-pairing.plist ~/Library/Application\ Support/AltKeeper/ # if you already paired -open /Applications/AltKeeper.app -``` - -An app built on your own Mac has no quarantine flag, so Gatekeeper does not ask. To see its -output while trying it, start it from a terminal instead: -`/Applications/AltKeeper.app/Contents/MacOS/altkeeper tray` (stop it with *Quit* or Ctrl-C). -Remember that the first start turns *Start at login* on; uncheck it in the menu if you only want -to try the app. - -**Status:** built and unit-tested on macOS (Apple Silicon); the menu-bar app has not yet been -confirmed end to end with a real iPhone and AltStore. - -## Releases - -Releases are built by `.github/workflows/release.yml`, started by hand: GitHub → Actions → -release → *Run workflow*. The tag is `v` + the `version` in `Cargo.toml` and is created by the -release itself: bump the version (and `Cargo.lock`, with `cargo build`) first, or the run stops -because the tag already exists. It runs the tests on Windows, Linux and macOS, and publishes: - -| File | What it is | -| --- | --- | -| `altkeeper.exe` + `.sha256` | Windows; the tray downloads exactly these two names to update itself | -| `altkeeper--linux-x86_64.tar.gz`, `-linux-aarch64` + `.sha256` | Linux binary (glibc 2.35+), `LICENSE`, READMEs, `examples/` | -| `altkeeper--macos-aarch64.tar.gz`, `-macos-x86_64` + `.sha256` | macOS command-line binary, same contents | -| `AltKeeper--macos-aarch64.zip`, `-macos-x86_64` + `.sha256` | `AltKeeper.app`, the macOS menu-bar app (built by `scripts/macos-app.sh`) | - -The Docker image is published separately by `.github/workflows/docker.yml` (see [Docker](#9-docker)). - -## Using it from outside your home network - -AltStore finds servers with Bonjour, which normally means multicast — and **iOS never sends -multicast discovery over a VPN interface**. That is why installing Tailscale or ZeroTier is not -enough by itself: the phone never even asks. - -The way around it is Bonjour's second, older mode: **unicast DNS-SD**, where services are -published as ordinary DNS records. AltStore browses with an empty domain, meaning "all default -domains", and iOS works out which domains those are by asking the DNS server about the network -it is on. Answer that question and the phone finds the server on its own — nothing to configure -on the phone at all. - -AltKeeper serves those records itself. Here is the whole setup. - -### 1. A VPN between the phone and the server - -Any VPN works as long as the phone gets an address on it. WireGuard is a good fit: it is in the -Linux kernel, the iOS app is free, and it holds up better than the alternatives when the phone -changes network. - -On the server: - -``` -apt install wireguard-tools -cd /etc/wireguard -umask 077 -wg genkey > server.key && wg pubkey < server.key > server.pub -wg genkey > phone.key && wg pubkey < phone.key > phone.pub -wg genpsk > psk.key -``` - -`/etc/wireguard/wg0.conf`: - -```ini -[Interface] -Address = 10.7.0.1/24 -ListenPort = 51820 -PrivateKey = - -[Peer] -PublicKey = -PresharedKey = -AllowedIPs = 10.7.0.2/32 -``` - -``` -systemctl enable --now wg-quick@wg0 -``` - -Forward **UDP 51820** to this machine on your router. WireGuard never answers packets that are -not cryptographically valid, so the port stays invisible to scanners — but it is still an open -door, so it is worth restricting what the tunnel can reach. Add to `[Interface]`: - -```ini -PostUp = iptables -I INPUT 1 -i wg0 -p udp --dport 53 -j ACCEPT -PostUp = iptables -I INPUT 2 -i wg0 -p tcp --dport 49500 -j ACCEPT -PostUp = iptables -I INPUT 3 -i wg0 -p tcp --dport 8787 -j ACCEPT -PostUp = iptables -I INPUT 4 -i wg0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -PostUp = iptables -A INPUT -i wg0 -j DROP -PostUp = iptables -I FORWARD 1 -i wg0 -j DROP -``` - -That leaves DNS, AltServer and the web page reachable and nothing else — not SSH, and no route -into the rest of your LAN. Mirror each rule with `PostDown = iptables -D …`. - -The phone's config, which you import by scanning a QR code -(`qrencode -t ansiutf8 < phone.conf`): - -```ini -[Interface] -PrivateKey = -Address = 10.7.0.2/32 -DNS = 10.7.0.1 - -[Peer] -PublicKey = -PresharedKey = -Endpoint = your-home-address:51820 -AllowedIPs = 10.7.0.0/24 -PersistentKeepalive = 25 -``` - -`AllowedIPs = 10.7.0.0/24` sends only the tunnel's own subnet through the VPN, so the phone's -normal browsing is untouched. `Endpoint` needs an address that follows your home connection — -a dynamic DNS name if your ISP changes your IP. - -### 2. Publishing the records - -``` -altkeeper altserver --dns-domain home.internal --dns-address 10.7.0.1 --dns-clients 10.7.0.0/24 -``` - -- `--dns-address` is where the phone should connect: this machine's address **on the VPN**. -- `--dns-clients` is the subnet the phones are on. It matters because iOS discovers the domain by - asking about the reverse name of its own address; without it AltKeeper assumes the `/24` around - `--dns-address`. -- The responder listens on UDP 5533 (`--dns-port`), answers only for its own names and forwards - nothing. - -### 3. Sending those answers to the phone - -The phone's DNS is the server (`DNS = 10.7.0.1` above), which is whatever resolver you already -run. Point just this one domain — and the reverse zone of the VPN subnet — at AltKeeper. - -With **AdGuard Home**, in *Upstream DNS servers*: - -``` -[/home.internal/]127.0.0.1:5533 -``` - -and, because AdGuard sends private reverse lookups to a separate list, also add to -*Private reverse DNS servers*: - -``` -[/7.10.in-addr.arpa/]127.0.0.1:5533 -``` - -With **dnsmasq**: - -``` -server=/home.internal/127.0.0.1#5533 -server=/7.10.in-addr.arpa/127.0.0.1#5533 -``` - -That second line is the one people miss, and without it the phone never learns the domain. - -### 4. Try it - -Turn on the VPN, connect the phone to any Wi-Fi that is not your home one, and press *Refresh -All*. On the server you should see the discovery and then the work: - -``` -[altserver] 10.7.0.2: AnisetteDataRequest -> AnisetteDataResponse -[altserver] 10.7.0.2: InstallProvisioningProfilesRequest -[altserver] profile installed: com.example.app -``` - -If it says it cannot find the server, watch the DNS with -`tcpdump -i wg0 -n port 53` and see which name the phone asks for. - -### What to expect - -- **The phone must be on Wi-Fi.** iOS only runs the pairing service AltKeeper needs while - connected to a Wi-Fi network; on cellular alone the port is closed, and nothing can change that. -- **Unattended renewals over the tunnel are not dependable yet.** iOS suspends VPN apps in the - background, so a nightly cron may find the phone unreachable. Renewing by hand while the tunnel - is up works fine. -- If the server was finding the phone by scanning the LAN, put its VPN address in - `state/phone-addr` (`10.7.0.2:49152`) so it tries that first. - -## Commands - -| Command | What it does | +| I want to… | Guide | | --- | --- | -| `phone [--phone ip:port]` | lists the profiles installed on the iPhone | -| `pair-usb [file]` | creates the remote pairing over USB (once) | -| `login ` | signs in with 2FA and saves the account | -| `apps` | lists your App IDs and their expiry dates | -| `renew [--dry-run] [--force] [--min-days N] [--phone ip:port] [--bundle ID] [--installed-only]` | renews profiles, optionally one bundle or with installed-app filtering | -| `altserver [--port N] [--dir folder] [--phone ip:port] [--dns-domain D --dns-address IP]` | acts as AltServer for AltStore | -| `serve [--bind ip:port] [--pin PIN] [--dir folder] [--phone ip:port] [--altserver] [--altserver-port N]` | web page (and, with `--altserver`, AltServer) | -| `profile-remove ` | takes one profile off the phone, saving a copy in `profili-salvati/` | -| `profile-restore ` | puts a saved profile back | -| `tray [--dir folder] [--phone ip:port] [--console]` | Windows and macOS: AltServer + web page with an icon in the system tray / menu bar, no terminal (what a double-click on `altkeeper.exe` or `AltKeeper.app` starts). Default folder: the `.exe`'s on Windows, `~/Library/Application Support/AltKeeper` on macOS; `--console` is Windows only | - -`ALTKEEPER_PHONE=ip:port` can replace `--phone`; without either the phone is found by itself (see -step 2). `ALTKEEPER_NO_MDNS=1` turns Bonjour off (networks that block multicast, or to test the scan). - -**USB.** If the iPhone is plugged into the same computer that runs AltKeeper, it is used instead of -Wi-Fi (faster, especially for installing IPAs). AltKeeper looks for it through the local `usbmuxd` -(installed with iTunes / Apple Devices on Windows, built in on macOS, the `usbmuxd` package on -Linux) or through `USBMUXD_SOCKET_ADDRESS`; the phone must already trust the computer. If there is -no phone on USB, or it fails, Wi-Fi is used as before. `--phone` / `ALTKEEPER_PHONE` force Wi-Fi, -and `ALTKEEPER_NO_USB=1` turns USB off. In Docker, mount `/var/run/usbmuxd` into the container. - -`altkeeper --help` (also `altkeeper login --help`) prints usage in the selected CLI language. -`ALTKEEPER_DEBUG=1` prints what Apple answers to each sign-in request (status, headers, error -bodies; never request bodies or successful responses). -Anisette uses `https://ani3.stikstore.app` by default. Choose another server in the web page or tray: -the isideload public server (`https://ani.jkcoxson.com`) or a custom HTTP(S) URL. The web choice is -saved privately in `state/anisette-url`; `ALTKEEPER_ANISETTE_URL` takes precedence and locks the -choice. Clearing the saved choice restores the default. AltKeeper retires the saved anisette -identity when the effective server changes. Fixed IP:port addresses (up to two, e.g. home and VPN) -can also be saved from the iPhone panel. - -The project used to be called altrefresh: the old `ALTREFRESH_*` variable names still work (the -`ALTKEEPER_*` one wins if both are set). `altkeeper --version` prints the version. - -## If something goes wrong - -- **"remote pairing is missing"**: there is no `rp-pairing.plist` in the folder. Use **Pair iPhone - over USB** in the web page or tray, or run `altkeeper pair-usb` (step 3). -- **"phone pairing failed" / `early eof`**: the iPhone no longer recognises that pairing. Pair - again, or copy a working `rp-pairing.plist` from another computer. -- **"cannot find the iPhone on the network", "No route to host", timeouts**: the iPhone is not on - the network (asleep, away). If you gave an address that is no longer right, the program looks for - the phone by itself; if you want a fixed address, on the iPhone open Settings, Wi‑Fi, the (i) of - your network, and set the private Wi‑Fi address to **Fixed** or off (the wording depends on the iOS - version), then give the iPhone a fixed IP in your router. -- **AltStore says "AltServer could not be found"**: see step 4. Check that the server is running, on - the same network, that AltStore has the Local Network permission, and read the server's output. -- **AltStore reports an anisette error (`-45061` or `reprovision required`)**: use the **Anisette - server** panel to test or select another server. AltKeeper moves the old identity aside when the - effective URL changes. Apple may ask for a verification code again after a new identity is - created. Public servers come and go; you can also configure `ALTKEEPER_ANISETTE_URL` or run your - own `anisette-v3-server`. -- **A refresh from AltStore seems stuck right after `PrepareAppRequest`**: AltStore first sends the - app to the server, and it can be well over 100 MB. On a weak Wi‑Fi that takes minutes; the server - prints `app in arrivo: X di Y byte` every few seconds while it arrives, and gives up after 10 - minutes in total. Keep AltStore open on screen and the iPhone close to the router. -- **Sign-in errors 429 or 503**: Apple is limiting requests. Do not retry in a loop. Run with - `ALTKEEPER_DEBUG=1` and, if it persists, open an issue with the lines starting with `[gs #` - (they contain no secrets). -- **An app does not open after a renewal**: reinstall it with AltStore or the tool you normally - use. If you removed a profile with `profile-remove`, put it back with `profile-restore`. - -## Security - -These files hold secrets. They are ignored by git and should be mode `600`: - -- `rp-pairing.plist`: the pairing keys for the phone. -- `account.json`: the Apple ID and, if you chose to save it, the password **in clear text**. -- `state/`: the anisette "device" identity and the AltServer `serverID`. -- `notify.conf`: the ntfy topic. -- `profili-salvati/`: copies of profiles taken off the phone with `profile-remove`. - -See also [SECURITY.md](SECURITY.md) for reporting a vulnerability privately. - -Never paste your password, `account.json` or the pairing file in an issue or a chat. Do not use -`RUST_LOG=debug` on shared logs: at that level `idevice` prints the pairing keys. Keep the machine -that stores them inside your home network. - -**The AltServer port (49500) has no login, exactly like the real AltServer**: anyone on your network -who speaks the protocol can ask for anisette data or send profiles and apps to the paired iPhone. -Do not expose that port outside your home network. - -## How it works - -1. The iPhone (iOS 17+) advertises the Bonjour service `_remotepairing._tcp`. -2. Using an existing remote pairing, the program does the *pair-verify*, asks the phone to open a - tunnel and connects to it with TLS-PSK ([`idevice`](https://github.com/jkcoxson/idevice)). -3. Inside the tunnel it reads and installs profiles with `misagent`, and installs or removes apps - with `installation_proxy`. -4. In AltServer mode, AltStore finds the machine through Bonjour (`_altserver._tcp`, with a `serverID` - record) and sends JSON requests over TCP (a 32-bit little-endian length, then the JSON), one - connection per operation: anisette data, profiles to install or remove, and the signed app. -5. For the unattended renewal, with your Apple ID ([`isideload`](https://github.com/nab138/isideload)) - it downloads new profiles for the existing App IDs. - -The old Wi-Fi sync used by `netmuxd` and AltServer-Linux is rejected by recent iOS versions; remote -pairing is the channel that works. - -## Notes on the copy of `isideload` in `vendor/` - -`isideload` 0.3.17 has two problems with Apple's sign-in servers, so `vendor/isideload` is a -patched copy of the original (MIT, © nab138): - -- **503 at sign-in.** It presents itself as `com.apple.dt.Xcode`, which Apple rejects. - `src/anisette/remote_v3/mod.rs` now declares `com.apple.akd/1.0` (client identity - `Mac15,7`, macOS 27.0) and the AuthKit User-Agent AltSign uses. -- **429 at the password proof.** Apple's edge lets only a couple of requests through on one - connection and refuses the rest. Sign-in sends three in a row (URL bag, `init`, `complete`), - so the third got a 429. AltSign avoids it with one fresh connection per request - (`ALTAppleAPI+Authentication.swift`, notarized branch); `src/auth/grandslam.rs` now does the - same (HTTP/1.1, no connection reuse, `Connection: close`). On macOS it also uses the system - TLS. Which of these changes is the decisive one has not been isolated. - -It also exposes a few read-only accessors of `AnisetteData`, used to answer AltStore. - -## Credits and license - -Built on [`idevice`](https://github.com/jkcoxson/idevice) (Jackson Coxson) and -[`isideload`](https://github.com/nab138/isideload) (nab138), both MIT-licensed. The sign-in fix -follows what [AltSign](https://github.com/rileytestut/AltSign) (Riley Testut) does, and the AltServer -messages follow [AltStore](https://github.com/altstoreio/AltStore)'s server protocol. -This project is licensed under the [MIT license](LICENSE). +| Set up AltKeeper and pair my iPhone | [Quick start](docs/en/quick-start.md) | +| Choose Docker, Windows, or macOS deployment | [Deployment](docs/en/deployment.md) | +| Host my own Anisette V3 server | [Local Anisette server](docs/en/anisette-server.md) | +| Sign in, renew apps, or manage multiple devices | [Accounts and renewals](docs/en/accounts-and-renewals.md) | +| Use the web dashboard, install an IPA, or enable JIT | [Web and JIT](docs/en/web-and-jit.md) | +| Connect from outside my home network | [Remote access](docs/en/remote-access.md) | +| Diagnose a problem or protect my data | [Troubleshooting and security](docs/en/troubleshooting-and-security.md) | + +Prefer Italian? Start at [README in italiano](README.it.md) or browse the [Italian guides](docs/it/README.md). + +## At a glance + +- **AltServer mode:** AltStore Classic can refresh and install apps through AltKeeper. +- **Independent renewal:** renew profiles from the CLI, dashboard, or an optional schedule. +- **Device profiles:** keep pairing, account, settings, and renewal state separate for each iPhone. +- **Local Anisette V3:** an optional server for Linux, macOS, and Windows; native libraries must be + supplied by the operator. +- **JIT:** dashboard actions and an HTTP API are available to compatible clients. Developer Mode + and a compatible mounted Developer Disk Image are required. + +The CLI and server support Linux, macOS, and Windows; the tray app is available on macOS and +Windows. Docker is intended for Linux hosts that support host networking and Bonjour. + +For private vulnerability reports, see [SECURITY.md](SECURITY.md). Third-party licenses are listed +in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md). diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 0000000..b4d4213 --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +The optional cross-platform Anisette V3 engine uses `omnisette` from SideStore's +[`apple-private-apis`](https://github.com/SideStore/apple-private-apis) repository, pinned to +commit [`03beb1aa42991ccdad6214dee77e72282bef461f`](https://github.com/SideStore/apple-private-apis/tree/03beb1aa42991ccdad6214dee77e72282bef461f/omnisette). +It is licensed under the Mozilla Public License 2.0 (MPL-2.0). The license text is available at + and in the pinned source repository's `LICENSE` file. + +The native Apple library files required by this feature are not included, downloaded, or modified +by AltKeeper; the operator must provide them separately. + +The pinned `omnisette` crate source is included in `vendor/omnisette/` under the MPL-2.0, with its +upstream license retained. AltKeeper's local copy adjusts the AOSKit provider's error type and +async-trait annotation so the upstream crate builds on macOS. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..14438e0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,8 @@ +# Documentation + +Choose a language for the user guides: + +- [English](en/README.md) +- [Italiano](it/README.md) + +The other Markdown files in this directory are focused engineering notes and test procedures. diff --git a/docs/en/README.md b/docs/en/README.md new file mode 100644 index 0000000..8738291 --- /dev/null +++ b/docs/en/README.md @@ -0,0 +1,17 @@ +# AltKeeper guides + +Start with [Quick start](quick-start.md) if this is a new installation. + +| Guide | Covers | +| --- | --- | +| [Quick start](quick-start.md) | Requirements, first run, USB pairing, and phone discovery | +| [Deployment](deployment.md) | Docker, native Windows, and the macOS menu-bar app | +| [Local Anisette server](anisette-server.md) | Native libraries, configuration, client URLs, and network safety | +| [Accounts and renewals](accounts-and-renewals.md) | Apple ID, renewal modes, multiple device profiles, and backups | +| [Web and JIT](web-and-jit.md) | Dashboard, IPA installation, JIT requirements, and API | +| [Remote access](remote-access.md) | VPN access and AltServer discovery outside the home network | +| [Troubleshooting and security](troubleshooting-and-security.md) | Common errors, sensitive files, and network exposure | + +Italian documentation: [indice delle guide](../it/README.md). Project overview: [README](../../README.md). + +The guides describe user-facing behavior. For exact command options, run `altkeeper --help`. diff --git a/docs/en/accounts-and-renewals.md b/docs/en/accounts-and-renewals.md new file mode 100644 index 0000000..b8beae9 --- /dev/null +++ b/docs/en/accounts-and-renewals.md @@ -0,0 +1,88 @@ +# Accounts and renewals + +AltKeeper has two separate workflows: + +- **AltServer refresh:** AltStore handles Apple sign-in on the iPhone. AltKeeper does not need the + Apple ID password for this workflow. +- **Independent renewal:** AltKeeper signs in to Apple to renew profiles without waiting for + AltStore. This requires an Apple ID whose team signed the apps. + +## Sign in + +From the persistent data directory, run: + +```sh +./AltKeeper login your-apple-id@example.com +``` + +Complete the password and two-factor prompts. Saving the password enables unattended renewal. +AltKeeper stores a saved password in `account.json` **in clear text**; restrict access to the data +directory and back it up securely. Do not send this file to anyone. + +Check the account and installed profiles: + +```sh +./AltKeeper apps +./AltKeeper phone +``` + +## Renew manually + +Start with a dry run: + +```sh +./AltKeeper renew --dry-run --min-days 7 +``` + +Then renew profiles that expire within the selected threshold: + +```sh +./AltKeeper renew --min-days 7 +``` + +The default threshold is 3 days. A normal renewal checks installed apps first and skips profiles +that do not match installed apps. If the phone's installed-app inventory cannot be read, AltKeeper +logs a warning and falls back to all known profiles for the configured team. A successful lookup +that returns no apps skips renewals. + +Use `--bundle ` to target an app. `--force` bypasses the normal expiry threshold. +`--installed-only` requests installed-app filtering, including with `--force`. If the lookup +fails, AltKeeper logs a warning and falls back to all known profiles for the configured team, +regardless of expiry, even with `--force --installed-only`. It does not guarantee renewal of only +installed profiles. Read `altkeeper renew --help` for the complete option list. + +## Schedule renewals + +Choose one scheduler: + +- The dashboard's **Automatic renewal** setting runs once per UTC day at the selected time. It is + off by default and needs the server to stay running, a saved Apple ID password, and a reachable + iPhone. Each device profile has its own schedule. Only one phone operation runs at a time. +- The example scripts [`renew.sh`](../../examples/renew.sh) and + [`renew.ps1`](../../examples/renew.ps1) can be run from cron or Windows Task Scheduler. + +Do not enable both the built-in schedule and an external cron/task unless you intentionally want +two attempts. An external scheduler must run from the correct data directory and keep its logs and +credentials private. + +## Multiple iPhones and accounts + +In the dashboard, use the device selector and **Add device** to create a named profile for another +iPhone. Select a profile before pairing its phone or signing in to its Apple ID. Each profile keeps +its own account, pairing, saved phone addresses, settings, schedule, logs, and profile backups. +The default profile keeps existing data; added profiles are stored under `devices/`. + +AltServer remains a single network service and uses the currently selected device profile. Do not +run multiple copies of AltKeeper to serve several profiles concurrently. + +## Remove and restore profiles + +The dashboard can remove individual provisioning profiles after confirmation. Before removal, +AltKeeper saves and verifies a backup under `profili-salvati/`. Removing a profile that an +installed app still needs may stop that app from opening. Restore a saved profile with: + +```sh +./AltKeeper profile-restore profili-salvati/ +``` + +Renewal does not remove older profiles or revoke Apple Developer App IDs. diff --git a/docs/en/anisette-server.md b/docs/en/anisette-server.md new file mode 100644 index 0000000..f71ed4f --- /dev/null +++ b/docs/en/anisette-server.md @@ -0,0 +1,64 @@ +# Local Anisette V3 server + +AltKeeper can optionally host an Anisette V3 service. It is not started by default. This is +separate from AltServer and the web dashboard. + +## Provide the native libraries + +The operator must supply both native libraries; AltKeeper does not download or bundle them: + +```text +/anisette-libraries/lib//libstoreservicescore.so +/anisette-libraries/lib//libCoreADI.so +``` + +Use the architecture directory expected by the native engine: + +| Host architecture | Directory name | +| --- | --- | +| x86-64 | `x86_64` | +| x86 | `x86` | +| ARM64 | `arm64-v8a` | +| ARM 32-bit | `armeabi-v7a` | + +Use libraries you are authorized to use and follow their license terms. The default library root +is `/anisette-libraries`; `--anisette-libs ` overrides it. The path +must contain the `lib//` subdirectories shown above. + +## Start the listener + +Keep it on a trusted LAN or VPN interface. Example with the web dashboard: + +```sh +ALTKEEPER_WEB_PIN='your-private-pin' ./AltKeeper serve \ + --bind 0.0.0.0:8787 \ + --anisette-bind 192.168.1.20:8788 +``` + +On Windows, use the native executable and PowerShell syntax. `--anisette-libs` is optional when +using the default directory. For Docker on Linux, mount the libraries read-only at +`/data/anisette-libraries` and add `--anisette-bind 192.168.1.20:8788` to the `serve` command. + +Select **AltKeeper local server** in the dashboard's Anisette settings when AltKeeper should use +this server. Another client on the LAN can use `http://192.168.1.20:8788`. + +## Choose which Anisette server AltKeeper uses + +The dashboard can use AltKeeper's local server, the configured public isideload server +(`https://ani.jkcoxson.com`), or a custom HTTP(S) URL. The default is +`https://ani3.stikstore.app`. The selected URL is saved in `state/anisette-url`. + +If `ALTKEEPER_ANISETTE_URL` is set, it overrides and locks the dashboard selection. Remove that +environment variable to manage the choice in the UI. Clearing the saved selection restores the +default. When the effective server changes, AltKeeper retires the saved Anisette identity; Apple +may ask you to verify the account again. + +## Network and data safety + +The Anisette V3 protocol has no authentication or TLS. The web PIN does **not** protect this +listener. Bind only to an interface reachable by trusted devices, restrict access with the host +firewall, and never expose the port directly to the Internet. + +Provisioning state is temporary and stored under `state/anisette-provisioning/`. If the service +fails at startup, check that both library files exist in the exact architecture directory and +that the process can read them. diff --git a/docs/en/deployment.md b/docs/en/deployment.md new file mode 100644 index 0000000..804e3f2 --- /dev/null +++ b/docs/en/deployment.md @@ -0,0 +1,84 @@ +# Deployment + +AltKeeper needs a persistent data directory and network access to the iPhone. Choose one deployment +method; do not run multiple AltKeeper instances on the same network unless you know how to isolate +their AltServer advertisements. + +## Linux with Docker Compose + +The sample Compose file uses host networking for Bonjour discovery and AltStore service +advertisement. This is intended for Linux Docker hosts: + +1. Copy [`examples/docker-compose.yml`](../../examples/docker-compose.yml) to a directory such as + `/srv/altkeeper`. +2. Create `/srv/altkeeper/.env` with `ALTKEEPER_WEB_PIN=` and restrict the file's + permissions. +3. Put `rp-pairing.plist` in `/srv/altkeeper`. If you use independent renewals, sign in once so + `account.json` and `state/` are created in the mounted data directory. +4. Provide the `altkeeper:latest` image declared by the Compose file. From a repository checkout, + build it with `docker build -t altkeeper:latest .`. Alternatively, pull an image from an + available registry and tag it locally, for example `docker pull //altkeeper:latest` + followed by `docker tag //altkeeper:latest altkeeper:latest`. Authenticate to + the registry first if required. +5. From `/srv/altkeeper`, run `docker compose up -d`. + +The sample mounts `/srv/altkeeper` at `/data`; pairing, account, state, logs, and profile backups +persist there. The image does not include these files. For optional USB access from the container, +see the commented `usbmuxd` mount in the Compose file. For local Anisette, follow +[Local Anisette server](anisette-server.md). + +## Windows + +Use the native Windows executable rather than Docker Desktop: AltKeeper relies on Bonjour and +Docker Desktop does not provide the host networking behavior the service needs. Install the +Microsoft Visual C++ build tools if building from source; alternatively download a Windows +executable from [Releases](https://github.com/BartolomeoRusso9/altkeeper/releases). The manually +downloadable package is `AltKeeper-v-Windows-x64.zip`; it contains `AltKeeper.exe`, the +guides, and examples. A standalone binary is also available as +`AltKeeper-v-Windows-x64.exe`; both downloads have matching `.sha256` files. The release +also includes `altkeeper.exe` and `altkeeper.exe.sha256` for the in-app updater; those legacy asset +names are intentionally fixed. + +From PowerShell, pairing and starting the services look like this: + +```powershell +.\AltKeeper.exe pair-usb +.\AltKeeper.exe serve --altserver +``` + +Install iTunes or Apple Devices for the local USB mux service. Allow AltKeeper through Windows +Firewall on **Private** networks only. Keep the computer awake for scheduled renewal. Use Task +Scheduler for unattended startup or renewal; see [`examples/renew.ps1`](../../examples/renew.ps1). + +The tray mode starts the web page and AltServer without a terminal: + +```powershell +.\AltKeeper.exe tray +``` + +The tray app uses the executable's folder for data by default; `--dir ` changes it. Its +embedded web page listens on loopback. WebView2 is required to show the app window. + +## macOS + +Download the archive matching your Mac from +[Releases](https://github.com/BartolomeoRusso9/altkeeper/releases): +`AltKeeper-v-macOS-Apple-Silicon-App.zip` for Apple Silicon or +`AltKeeper-v-macOS-Intel-App.zip` for Intel. Move `AltKeeper.app` from the archive to +`/Applications`. The app runs in the menu bar and starts AltServer plus the local web page. Grant +Local Network access when prompted. The app is not notarized; follow the Gatekeeper instructions +in the release notes if macOS blocks the first launch. + +The matching command-line archives are labeled `...-macOS-Apple-Silicon-CLI.tar.gz` and +`...-macOS-Intel-CLI.tar.gz`. Linux archives are labeled `...-Linux-x64.tar.gz` or +`...-Linux-ARM64.tar.gz`. Each archive has a matching `.sha256` checksum file. + +The app's data directory is `~/Library/Application Support/AltKeeper`. The menu option **Start at +login** controls its LaunchAgent. A command-line build can also run with `cargo build --release`; +see the project scripts for packaging a custom `.app`. + +## Data and backups + +Keep the data directory persistent and private. Back up `rp-pairing.plist`, `account.json`, +`state/`, and any profile backups securely. Do not copy these files into a public image or commit +them to source control. See [Troubleshooting and security](troubleshooting-and-security.md). diff --git a/docs/en/quick-start.md b/docs/en/quick-start.md new file mode 100644 index 0000000..fe3453c --- /dev/null +++ b/docs/en/quick-start.md @@ -0,0 +1,89 @@ +# Quick start + +This guide gets one iPhone paired and reachable. Choose a long-running setup in +[Deployment](deployment.md) after confirming the basic connection. + +## Requirements + +- iPhone on iOS 17 or later, with Developer Mode enabled. +- AltKeeper running on a machine the iPhone can reach on the same Wi-Fi or VPN. +- A one-time USB connection to create remote pairing. +- AltStore Classic is only needed if you want AltKeeper to act as AltServer. +- A saved Apple ID is only needed for independent renewals or signing an IPA from the dashboard. + +## Get AltKeeper + +Download a binary from [GitHub Releases](https://github.com/BartolomeoRusso9/altkeeper/releases), +or build it with Rust. On Windows, the `AltKeeper-v-Windows-x64.zip` archive +contains `AltKeeper.exe`. If you choose the standalone `AltKeeper-v-Windows-x64.exe`, +run that versioned filename in place of `AltKeeper.exe` in the commands below, or rename it to +`AltKeeper.exe` first. + +To build from source: + +```sh +git clone https://github.com/BartolomeoRusso9/altkeeper +cd altkeeper +cargo build --release --locked +``` + +Release packages use the executable name `AltKeeper` (`AltKeeper.exe` on Windows). A source build +outputs `target/release/altkeeper` (`target\release\altkeeper.exe` on Windows). For platform-specific +installation and persistence, see [Deployment](deployment.md). The commands below assume the +release-package name; if building from source, copy the binary as `AltKeeper` or use its +`target/release/` path. + +## Pair the iPhone over USB + +Connect, unlock, and trust the iPhone on the computer that runs AltKeeper, then run: + +```sh +./AltKeeper pair-usb +./AltKeeper phone +``` + +On Windows, use `.\AltKeeper.exe`. Pairing writes `rp-pairing.plist` to the data +directory. If AltKeeper will run on another machine, transfer the pairing file securely to that +machine's data directory. Treat it as a secret. + +## Start AltServer and the web page + +On a trusted home network, start both services: + +```sh +ALTKEEPER_WEB_PIN='choose-a-private-pin' ./AltKeeper serve \ + --bind 0.0.0.0:8787 --altserver +``` + +On Windows, use PowerShell: + +```powershell +$env:ALTKEEPER_WEB_PIN = 'choose-a-private-pin' +.\AltKeeper.exe serve --bind 0.0.0.0:8787 --altserver +``` + +Then: + +1. Open `http://:8787` to use the dashboard. +2. In AltStore Classic, allow Local Network access and try **Refresh All** with one app first. +3. Keep only one AltServer active on the local network. + +Without `--altserver`, `serve` starts only the web service. For local-only access, omit `--bind` or +bind to `127.0.0.1`. A non-loopback web bind requires `ALTKEEPER_WEB_PIN`; the web service has no +HTTPS, so do not publish it directly to the Internet. + +## Phone discovery + +AltKeeper normally discovers the iPhone automatically. To set a fixed address, pass +`--phone ip:port` or set `ALTKEEPER_PHONE=ip:port`; the pairing service usually uses port `49152`. +The iPhone must be on Wi-Fi for its remote pairing service to be reachable. + +CLI messages default to English. Set `ALTKEEPER_LANG=it` for Italian; this does not change the +dashboard language. + +## Next steps + +- [Deployment](deployment.md) for Docker, Windows, and macOS startup options. +- [Accounts and renewals](accounts-and-renewals.md) to sign in and configure renewals. +- [Web and JIT](web-and-jit.md) for dashboard actions and JIT prerequisites. +- [Troubleshooting and security](troubleshooting-and-security.md) before exposing any service. diff --git a/docs/en/remote-access.md b/docs/en/remote-access.md new file mode 100644 index 0000000..b497f52 --- /dev/null +++ b/docs/en/remote-access.md @@ -0,0 +1,52 @@ +# Remote access + +The simplest safe setup is to keep AltKeeper and the iPhone on the same trusted Wi-Fi. Remote +access adds VPN, routing, firewall, and discovery requirements; it is not just a matter of +forwarding a port. + +## Recommended approach: private VPN + +Connect the phone and server through a private VPN such as WireGuard or a managed mesh VPN. Allow +only the required traffic between those VPN peers: + +- The phone's remote-pairing service (usually TCP `49152`) must be reachable from the server. +- AltStore must be able to reach AltServer on TCP `49500`. +- The dashboard uses TCP `8787`, if enabled. +- The optional Anisette listener uses the port configured with `--anisette-bind`. + +The iPhone must be connected to Wi-Fi for its remote-pairing service to be available. Cellular +connectivity alone is not sufficient. Background iOS VPN suspension can also make unattended +renewal unreliable; test manual access first. + +## AltStore discovery over a VPN + +AltStore normally discovers AltServer using Bonjour multicast. iOS does not send this discovery +traffic over a VPN interface, so a VPN connection by itself may not be enough. AltKeeper's +optional unicast DNS-SD responder can publish AltServer records to a DNS resolver: + +```sh +./AltKeeper altserver \ + --dns-domain home.internal \ + --dns-address 10.7.0.1 \ + --dns-clients 10.7.0.0/24 +``` + +`--dns-address` is the server's VPN address. `--dns-clients` is the VPN subnet used by the phone. +The DNS-SD responder listens on UDP `5533` by default; `--dns-port` changes it. Configure the +resolver used by the phone to forward both the chosen service domain and the VPN subnet's reverse +DNS zone to the AltKeeper responder. For example, with `home.internal` and `10.7.0.0/24`, forward +`home.internal` and `7.10.in-addr.arpa`. Exact configuration depends on the DNS resolver. + +Do not expose the DNS-SD responder, AltServer, dashboard, or Anisette listener to the public +Internet. Restrict access to the VPN peers in both the VPN and host firewall. + +## Verify and diagnose + +1. Connect the VPN on both devices and confirm the phone can reach the server's VPN address. +2. Confirm the remote-pairing port is reachable while the phone is on Wi-Fi. +3. If phone pairing works but AltStore cannot find AltServer, inspect DNS-SD forwarding for both + the service domain and reverse zone. +4. Check AltKeeper logs while pressing **Refresh All** in AltStore. + +If automatic renewal cannot reach the phone while iOS has suspended the VPN app, use manual +renewal when the VPN is active or keep the phone on the local network. diff --git a/docs/en/troubleshooting-and-security.md b/docs/en/troubleshooting-and-security.md new file mode 100644 index 0000000..e23daf5 --- /dev/null +++ b/docs/en/troubleshooting-and-security.md @@ -0,0 +1,43 @@ +# Troubleshooting and security + +## Common problems + +| Symptom | Checks | +| --- | --- | +| Remote pairing is missing | Confirm `rp-pairing.plist` is in the active data directory. Pair again over USB if needed. | +| Pairing fails or reports `early eof` | The phone may no longer trust that pairing. Pair again and keep the phone unlocked during setup. | +| Phone cannot be found or times out | Put the iPhone on Wi-Fi, check both devices are on a reachable network, and verify any fixed `--phone ip:port` address. | +| AltStore says “AltServer could not be found” | Check AltKeeper is running, Local Network permission is enabled for AltStore, and only one AltServer is advertising. Read the server log. | +| AltStore reports Anisette `-45061` or `reprovision required` | Test another Anisette URL in the dashboard. A server change retires the saved identity, so Apple may request verification again. | +| Refresh stalls after `PrepareAppRequest` | The iPhone may still be uploading a large app. Keep AltStore open, use a stable Wi-Fi connection, and watch AltKeeper's transfer log. | +| Apple sign-in returns 429 or 503 | Apple may be rate-limiting requests. Stop retrying in a loop; wait before trying again. | +| An app will not open after profile removal | Restore its saved provisioning profile with `profile-restore`. | +| Local Anisette server fails to start | Check both native library files, the architecture directory, and read permissions. See [Local Anisette server](anisette-server.md). | + +For detailed CLI syntax, run `altkeeper --help`. Set `ALTKEEPER_DEBUG=1` for sign-in diagnostics; +share only the specific `[gs #...]` error lines after checking that they contain no private data. +Never enable `RUST_LOG=debug` on logs you plan to share: low-level device logs can contain pairing +keys. + +## Protect the data directory + +Treat these files and directories as secrets: + +- `rp-pairing.plist`: keys used to pair with the iPhone. +- `account.json`: Apple ID and, if saved, its password in clear text. +- `state/`: Anisette identity and AltServer state. +- `notify.conf`: notification topic. +- `profili-salvati/`: backups of removed provisioning profiles. + +Restrict filesystem access, encrypt backups, and do not attach these files to issues or chats. For +private vulnerability reporting, follow [SECURITY.md](../../SECURITY.md). + +## Network exposure + +- **AltServer TCP 49500:** no authentication, like AltServer itself. Do not expose it outside a + trusted LAN or VPN. +- **Web dashboard TCP 8787:** use `ALTKEEPER_WEB_PIN` on a non-loopback bind. It has no HTTPS; keep + it on a trusted network. +- **Anisette listener:** has no authentication or TLS, and the dashboard PIN does not protect it. + Bind it only to trusted interfaces. +- Avoid public port forwarding. Use a private VPN and host firewall rules instead. diff --git a/docs/en/web-and-jit.md b/docs/en/web-and-jit.md new file mode 100644 index 0000000..3dbe90f --- /dev/null +++ b/docs/en/web-and-jit.md @@ -0,0 +1,72 @@ +# Web dashboard and JIT + +## Start the dashboard + +For access from other trusted devices on the LAN, set a PIN and bind the web server to a LAN +address: + +```sh +ALTKEEPER_WEB_PIN='your-private-pin' ./AltKeeper serve \ + --bind 0.0.0.0:8787 --altserver +``` + +Open `http://:8787`. Use any username and the PIN as the password. A non-loopback +web bind requires `ALTKEEPER_WEB_PIN` (at least four characters). A loopback-only server does not +require a PIN. The web service has no HTTPS; keep it on a trusted network and never forward its +port directly to the Internet. + +The dashboard displays phone reachability, apps and expiry dates, logs, and renewal status. It +also provides Apple sign-in, pairing, device profiles, Anisette selection, renewal scheduling, and +profile management. See [Accounts and renewals](accounts-and-renewals.md) for credential and +scheduling behavior. + +## Install an IPA from a computer + +The dashboard's IPA upload signs and installs the selected IPA using the Apple ID for the active +device profile. This may register the iPhone and create development App IDs and certificates on +the Apple team. Uploads are limited to 1 GiB. Keep the dashboard private and review its job log. + +The optional JIT setting attempts to launch the installed app and enable JIT. If JIT activation +fails, the app remains installed and the job log reports the failure. + +## JIT requirements and status + +JIT can be requested for a running app from the dashboard, during supported IPA installation, or +by an AltStore request. The iPhone must have Developer Mode enabled and a compatible Developer +Disk Image already mounted. AltKeeper does not mount the image automatically. JIT activation has +not yet been verified end to end on a physical device. + +The HTTP API is specific to AltKeeper. It is not the Husk/StikJIT protocol; clients must implement +AltKeeper's API explicitly. + +## API for compatible clients + +Base URL: `http://:8787`. When a PIN is set, authenticate with HTTP Basic (any +username, PIN as password). POST requests must include `X-Requested-With: altkeeper`. API jobs run +in the background: start a job, then poll `/api/state` until `job.running` is `false`. + +First fetch the current process list: + +```sh +export ALTKEEPER_URL='http://192.168.1.20:8787' +export ALTKEEPER_WEB_PIN='your-pin' +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + -H 'X-Requested-With: altkeeper' -H 'Content-Type: application/json' \ + -d '{}' "$ALTKEEPER_URL/api/jit/apps" +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + "$ALTKEEPER_URL/api/state" | jq '{job, jit_apps}' +``` + +After the job completes, use a PID from the returned `jit_apps` list: + +```sh +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + -H 'X-Requested-With: altkeeper' -H 'Content-Type: application/json' \ + -d '{"pid":1234}' "$ALTKEEPER_URL/api/jit/enable" +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + "$ALTKEEPER_URL/api/state" | jq '.job' +``` + +Refresh the process list before enabling JIT. AltKeeper accepts only a PID from the latest list and +checks that the process is still a running app. For a non-default device profile, add +`X-AltKeeper-Device: ` to each request. Never expose the API to the public Internet. diff --git a/docs/it/README.md b/docs/it/README.md new file mode 100644 index 0000000..18de940 --- /dev/null +++ b/docs/it/README.md @@ -0,0 +1,17 @@ +# Guide di AltKeeper + +Se stai installando AltKeeper da zero, inizia dalla [guida introduttiva](quick-start.md). + +| Guida | Argomenti | +| --- | --- | +| [Guida introduttiva](quick-start.md) | Requisiti, primo avvio, pairing USB e rilevamento dell'iPhone | +| [Installazione e avvio](deployment.md) | Docker, Windows nativo e app per la barra dei menu di macOS | +| [Server Anisette locale](anisette-server.md) | Librerie native, configurazione, URL client e sicurezza di rete | +| [Account e rinnovi](accounts-and-renewals.md) | Apple ID, modalità di rinnovo, più dispositivi e backup | +| [Web e JIT](web-and-jit.md) | Pagina web, installazione IPA, requisiti JIT e API | +| [Accesso remoto](remote-access.md) | VPN e rilevamento AltServer fuori dalla rete di casa | +| [Problemi e sicurezza](troubleshooting-and-security.md) | Errori comuni, file sensibili ed esposizione di rete | + +Documentazione inglese: [guide index](../en/README.md). Panoramica del progetto: [README](../../README.it.md). + +Per l'elenco esatto delle opzioni, esegui `altkeeper --help`. diff --git a/docs/it/accounts-and-renewals.md b/docs/it/accounts-and-renewals.md new file mode 100644 index 0000000..33ff9d7 --- /dev/null +++ b/docs/it/accounts-and-renewals.md @@ -0,0 +1,89 @@ +# Account e rinnovi + +AltKeeper offre due flussi distinti: + +- **Aggiornamento AltServer:** AltStore gestisce l'accesso Apple sull'iPhone. Per questo flusso + AltKeeper non ha bisogno della password dell'Apple ID. +- **Rinnovo indipendente:** AltKeeper accede ad Apple per rinnovare i profili senza aspettare + AltStore. Serve un Apple ID il cui team abbia firmato le app. + +## Accedi + +Dalla cartella dati persistente, esegui: + +```sh +./AltKeeper login tuo-apple-id@example.com +``` + +Completa le richieste per password e verifica a due fattori. Salvare la password consente i rinnovi +senza intervento. AltKeeper salva l'eventuale password in `account.json` **in chiaro**: limita +l'accesso alla cartella dati e proteggi i backup. Non inviare questo file ad altri. + +Controlla account e profili installati: + +```sh +./AltKeeper apps +./AltKeeper phone +``` + +## Rinnova manualmente + +Prima simula il rinnovo: + +```sh +./AltKeeper renew --dry-run --min-days 7 +``` + +Poi rinnova i profili che scadono entro la soglia scelta: + +```sh +./AltKeeper renew --min-days 7 +``` + +La soglia predefinita è 3 giorni. Un rinnovo normale controlla prima le app installate e salta i +profili che non corrispondono ad app presenti. Se non riesce a leggere l'elenco delle app installate, +AltKeeper registra un avviso e passa a tutti i profili noti del team configurato. Se la lettura +riesce ma restituisce zero app, non esegue rinnovi. + +Usa `--bundle ` per scegliere un'app. `--force` ignora la normale soglia di scadenza. +Con `--installed-only` puoi richiedere un controllo riuscito delle app installate anche durante un +rinnovo CLI forzato. Per tutte le opzioni esegui `altkeeper renew --help`. + +## Pianifica i rinnovi + +Scegli un solo sistema di pianificazione: + +- L'opzione **Automatic renewal** della pagina web esegue un tentativo al giorno, all'ora UTC + selezionata. È disattivata inizialmente e richiede server acceso, password Apple ID salvata e + iPhone raggiungibile. Ogni profilo dispositivo ha la propria pianificazione. È consentita una + sola operazione sul telefono alla volta. +- Gli script di esempio [`renew.sh`](../../examples/renew.sh) e + [`renew.ps1`](../../examples/renew.ps1) possono essere avviati da cron o dall'Utilità di + pianificazione di Windows. + +Non attivare sia la pianificazione integrata sia cron/Utilità di pianificazione, a meno che tu non +voglia deliberatamente due tentativi. Lo scheduler esterno deve usare la cartella dati corretta e +proteggere log e credenziali. + +## Più iPhone e account + +Nella pagina web usa il selettore dispositivi e **Add device** per creare un profilo nominativo per +un altro iPhone. Seleziona un profilo prima di associare il telefono o accedere con il relativo +Apple ID. Ogni profilo conserva account, pairing, indirizzi salvati, impostazioni, pianificazione, +log e backup separati. Il profilo predefinito mantiene i dati esistenti; i nuovi profili sono +salvati in `devices/`. + +AltServer resta un singolo servizio di rete e usa il profilo dispositivo selezionato. Non avviare +più copie di AltKeeper per servire più profili contemporaneamente. + +## Rimuovi e ripristina profili + +La pagina web può rimuovere i singoli profili di provisioning dopo una conferma. Prima della +rimozione, AltKeeper salva e verifica una copia in `profili-salvati/`. Se rimuovi un profilo ancora +necessario a un'app installata, l'app potrebbe non aprirsi. Ripristina una copia con: + +```sh +./AltKeeper profile-restore profili-salvati/ +``` + +Il rinnovo non rimuove i vecchi profili e non revoca gli App ID Apple Developer. diff --git a/docs/it/anisette-server.md b/docs/it/anisette-server.md new file mode 100644 index 0000000..a800c15 --- /dev/null +++ b/docs/it/anisette-server.md @@ -0,0 +1,64 @@ +# Server Anisette V3 locale + +AltKeeper può ospitare facoltativamente un servizio Anisette V3. Non si avvia da solo ed è +indipendente da AltServer e dalla pagina web. + +## Fornisci le librerie native + +L'operatore deve fornire entrambe le librerie native: AltKeeper non le scarica e non le include. + +```text +/anisette-libraries/lib//libstoreservicescore.so +/anisette-libraries/lib//libCoreADI.so +``` + +Usa il nome di cartella previsto dal motore: + +| Architettura host | Nome cartella | +| --- | --- | +| x86-64 | `x86_64` | +| x86 | `x86` | +| ARM64 | `arm64-v8a` | +| ARM 32 bit | `armeabi-v7a` | + +Usa librerie che sei autorizzato a utilizzare e rispetta le relative licenze. La cartella radice +predefinita è `/anisette-libraries`; `--anisette-libs ` la sostituisce. +Il percorso deve contenere le sottocartelle `lib//` mostrate sopra. + +## Avvia il listener + +Mantienilo su un'interfaccia LAN o VPN fidata. Esempio con la pagina web: + +```sh +ALTKEEPER_WEB_PIN='il-tuo-pin-privato' ./AltKeeper serve \ + --bind 0.0.0.0:8787 \ + --anisette-bind 192.168.1.20:8788 +``` + +Su Windows usa l'eseguibile nativo da PowerShell. `--anisette-libs` è facoltativo se usi il +percorso predefinito. In Docker su Linux, monta le librerie in sola lettura in +`/data/anisette-libraries` e aggiungi `--anisette-bind 192.168.1.20:8788` al comando `serve`. + +Seleziona **AltKeeper local server** nelle impostazioni Anisette della pagina web quando vuoi che +AltKeeper usi questo server. Un altro client sulla LAN può usare `http://192.168.1.20:8788`. + +## Scegli quale server Anisette usa AltKeeper + +Dalla pagina web puoi scegliere il server locale AltKeeper, il server pubblico isideload configurato +(`https://ani.jkcoxson.com`) oppure un URL HTTP(S) personalizzato. Il server predefinito è +`https://ani3.stikstore.app`. L'URL selezionato viene salvato in `state/anisette-url`. + +Se imposti `ALTKEEPER_ANISETTE_URL`, questa variabile prevale e blocca la scelta dalla pagina web. +Rimuovila per poter scegliere dall'interfaccia. Cancellando la selezione salvata si ripristina il +server predefinito. Quando cambia il server effettivo, AltKeeper mette da parte la vecchia identità +Anisette; Apple potrebbe chiederti di verificare di nuovo l'account. + +## Sicurezza di rete e dati + +Il protocollo Anisette V3 non prevede autenticazione né TLS. Il PIN della pagina web **non** +protegge questo listener. Ascolta solo su un'interfaccia raggiungibile da dispositivi fidati, +limita l'accesso con il firewall e non esporre la porta direttamente su Internet. + +Lo stato di provisioning è temporaneo e salvato in `state/anisette-provisioning/`. Se il servizio +non si avvia, controlla che entrambi i file si trovino nella cartella esatta dell'architettura e +che il processo possa leggerli. diff --git a/docs/it/deployment.md b/docs/it/deployment.md new file mode 100644 index 0000000..e92dcc2 --- /dev/null +++ b/docs/it/deployment.md @@ -0,0 +1,78 @@ +# Installazione e avvio + +AltKeeper richiede una cartella dati persistente e accesso di rete all'iPhone. Scegli un solo metodo +di avvio; non eseguire più istanze AltKeeper nella stessa rete, a meno che tu non sappia isolare i +loro annunci AltServer. + +## Linux con Docker Compose + +Il file Compose di esempio usa la rete host per Bonjour e per annunciare il servizio ad AltStore. +È pensato per host Docker Linux: + +1. Copia [`examples/docker-compose.yml`](../../examples/docker-compose.yml) in una cartella, per + esempio `/srv/altkeeper`. +2. Crea `/srv/altkeeper/.env` con `ALTKEEPER_WEB_PIN=` e limita i permessi del file. +3. Metti `rp-pairing.plist` in `/srv/altkeeper`. Se vuoi i rinnovi autonomi, accedi una volta per + creare `account.json` e `state/` nella cartella dati montata. +4. Da quella cartella esegui `docker compose up -d`. + +L'esempio monta `/srv/altkeeper` in `/data`; pairing, account, stato, log e backup dei profili +rimangono persistenti. L'immagine non contiene questi file. Per l'accesso USB dal container, vedi +il mount `usbmuxd` commentato nel Compose. Per Anisette locale, consulta +[Server Anisette locale](anisette-server.md). + +## Windows + +Usa l'eseguibile nativo Windows, non Docker Desktop: AltKeeper usa Bonjour e Docker Desktop non +fornisce il comportamento di rete host necessario. Se compili dai sorgenti, installa gli strumenti +di build Microsoft Visual C++; altrimenti scarica l'eseguibile Windows dalle +[release](https://github.com/BartolomeoRusso9/altkeeper/releases). Il pacchetto Windows da scaricare +è `AltKeeper-v-Windows-x64.zip`: contiene `AltKeeper.exe`, le guide e gli esempi. È +disponibile anche il binario singolo `AltKeeper-v-Windows-x64.exe`; entrambi hanno il +relativo file `.sha256`. La release contiene inoltre `altkeeper.exe` e `altkeeper.exe.sha256` per +l'aggiornamento integrato: questi nomi legacy restano fissi. + +Da PowerShell, pairing e avvio dei servizi: + +```powershell +.\AltKeeper.exe pair-usb +.\AltKeeper.exe serve --altserver +``` + +Installa iTunes o Dispositivi Apple per il servizio USB mux locale. Consenti AltKeeper nel firewall +Windows solo sulle reti **Private**. Per i rinnovi pianificati, il computer deve essere acceso e non +in sospensione. Usa l'Utilità di pianificazione per l'avvio o i rinnovi; vedi +[`examples/renew.ps1`](../../examples/renew.ps1). + +La modalità tray avvia pagina web e AltServer senza terminale: + +```powershell +.\AltKeeper.exe tray +``` + +Di default, la cartella dati è quella dell'eseguibile; `--dir ` permette di cambiarla. +La pagina web integrata ascolta solo su loopback. Per la finestra dell'app serve WebView2. + +## macOS + +Scarica l'archivio corretto per il tuo Mac dalle +[release](https://github.com/BartolomeoRusso9/altkeeper/releases): +`AltKeeper-v-macOS-Apple-Silicon-App.zip` per Apple Silicon o +`AltKeeper-v-macOS-Intel-App.zip` per Intel. Estrai `AltKeeper.app` e spostala in +`/Applications`. L'app resta nella barra dei menu e avvia AltServer e la pagina web locale. +Consenti l'accesso alla rete locale quando richiesto. L'app non è autenticata da Apple: se macOS +blocca il primo avvio, segui le istruzioni Gatekeeper nelle note della release. + +Gli archivi CLI corrispondenti si chiamano `...-macOS-Apple-Silicon-CLI.tar.gz` e +`...-macOS-Intel-CLI.tar.gz`. Gli archivi Linux si chiamano `...-Linux-x64.tar.gz` o +`...-Linux-ARM64.tar.gz`. Ogni archivio ha un file checksum `.sha256` con lo stesso nome. + +La cartella dati dell'app è `~/Library/Application Support/AltKeeper`. L'opzione **Start at login** +nel menu gestisce il LaunchAgent. Puoi anche compilare la versione CLI con +`cargo build --release`; gli script del progetto permettono di creare un'app `.app` personalizzata. + +## Dati e backup + +Mantieni privata e persistente la cartella dati. Esegui backup sicuri di `rp-pairing.plist`, +`account.json`, `state/` e dei backup dei profili. Non inserirli in immagini pubbliche né nel +repository. Vedi [Problemi e sicurezza](troubleshooting-and-security.md). diff --git a/docs/it/quick-start.md b/docs/it/quick-start.md new file mode 100644 index 0000000..7da175a --- /dev/null +++ b/docs/it/quick-start.md @@ -0,0 +1,78 @@ +# Guida introduttiva + +Questa guida associa un iPhone e ne verifica la connessione. Dopo la prova, scegli come mantenere +AltKeeper in esecuzione nella guida [Installazione e avvio](deployment.md). + +## Requisiti + +- iPhone con iOS 17 o successivo e Modalità sviluppatore attiva. +- AltKeeper in esecuzione su una macchina raggiungibile dall'iPhone via Wi-Fi o VPN. +- Un collegamento USB iniziale per creare il pairing remoto. +- AltStore Classic serve solo per usare AltKeeper come AltServer. +- Un Apple ID salvato serve solo per rinnovare le app autonomamente o firmare IPA dalla pagina web. + +## Ottieni AltKeeper + +Scarica un binario dalle [release GitHub](https://github.com/BartolomeoRusso9/altkeeper/releases) +oppure compilalo con Rust: + +```sh +git clone https://github.com/BartolomeoRusso9/altkeeper +cd altkeeper +cargo build --release --locked +``` + +I pacchetti release usano l'eseguibile `AltKeeper` (`AltKeeper.exe` su Windows). Compilando dai +sorgenti si ottiene `target/release/altkeeper` (`target\release\altkeeper.exe` su Windows). Per +installazione e avvio persistente, vedi +[Installazione e avvio](deployment.md). I comandi qui sotto presuppongono il nome del pacchetto +release; se compili dai sorgenti, copia il binario come `AltKeeper` oppure usa il percorso +`target/release/`. + +## Associa l'iPhone via USB + +Collega e sblocca l'iPhone, autorizza il computer, poi esegui: + +```sh +./AltKeeper pair-usb +./AltKeeper phone +``` + +Su Windows usa `.\AltKeeper.exe`. Il pairing crea `rp-pairing.plist` nella cartella +dati. Se AltKeeper verrà eseguito su un'altra macchina, trasferisci il file in modo sicuro nella +cartella dati di quella macchina. È un file segreto. + +## Avvia AltServer e la pagina web + +Su una rete domestica fidata, avvia entrambi i servizi: + +```sh +ALTKEEPER_WEB_PIN='scegli-un-pin-privato' ./AltKeeper serve \ + --bind 0.0.0.0:8787 --altserver +``` + +Su Windows, da PowerShell esegui `.\AltKeeper.exe serve --altserver`. Poi: + +1. Apri `http://:8787` per usare la pagina web. +2. In AltStore Classic consenti l'accesso alla rete locale e prova prima con una sola app. +3. Mantieni attivo un solo AltServer nella rete. + +Senza `--altserver`, `serve` avvia solo la pagina web. Per l'accesso solo locale, ometti `--bind` +oppure usa `127.0.0.1`. Un bind web non locale richiede `ALTKEEPER_WEB_PIN`; il servizio non usa +HTTPS, quindi non pubblicarlo direttamente su Internet. + +## Rilevamento dell'iPhone + +Di norma AltKeeper trova l'iPhone automaticamente. Per impostare un indirizzo fisso, passa +`--phone ip:porta` oppure imposta `ALTKEEPER_PHONE=ip:porta`; il servizio di pairing usa di solito +la porta `49152`. L'iPhone deve essere collegato al Wi-Fi per rendere raggiungibile il pairing remoto. + +I messaggi della CLI sono in inglese per impostazione predefinita. Imposta `ALTKEEPER_LANG=it` per +usare l'italiano; l'impostazione non cambia la lingua della pagina web. + +## Prossimi passi + +- [Installazione e avvio](deployment.md) per Docker, Windows e macOS. +- [Account e rinnovi](accounts-and-renewals.md) per accedere e pianificare i rinnovi. +- [Web e JIT](web-and-jit.md) per le funzioni della pagina web e i requisiti JIT. +- [Problemi e sicurezza](troubleshooting-and-security.md) prima di esporre un servizio in rete. diff --git a/docs/it/remote-access.md b/docs/it/remote-access.md new file mode 100644 index 0000000..dc05214 --- /dev/null +++ b/docs/it/remote-access.md @@ -0,0 +1,54 @@ +# Accesso remoto + +La configurazione più semplice e sicura è mantenere AltKeeper e l'iPhone sulla stessa rete Wi-Fi +fidata. L'accesso remoto aggiunge requisiti di VPN, routing, firewall e rilevamento: non basta +inoltrare una porta. + +## Approccio consigliato: VPN privata + +Collega telefono e server con una VPN privata come WireGuard o una VPN mesh gestita. Consenti solo +il traffico necessario tra i peer: + +- Il servizio di pairing remoto dell'iPhone (di solito TCP `49152`) deve essere raggiungibile dal + server. +- AltStore deve raggiungere AltServer su TCP `49500`. +- La pagina web usa TCP `8787`, se attiva. +- Il listener Anisette facoltativo usa la porta scelta con `--anisette-bind`. + +L'iPhone deve essere collegato al Wi-Fi perché il servizio di pairing remoto sia disponibile. La +sola rete cellulare non basta. Inoltre iOS può sospendere in background l'app VPN e rendere +inaffidabili i rinnovi automatici: verifica prima l'accesso manuale. + +## Rilevamento AltStore tramite VPN + +AltStore normalmente trova AltServer con il multicast Bonjour. iOS non invia questo traffico di +rilevamento attraverso l'interfaccia VPN, quindi la sola VPN potrebbe non bastare. Il responder +DNS-SD unicast facoltativo di AltKeeper può pubblicare i record AltServer tramite un resolver DNS: + +```sh +./AltKeeper altserver \ + --dns-domain casa.internal \ + --dns-address 10.7.0.1 \ + --dns-clients 10.7.0.0/24 +``` + +`--dns-address` è l'indirizzo VPN del server. `--dns-clients` è la sottorete VPN usata dall'iPhone. +Il responder DNS-SD ascolta di default sulla porta UDP `5533`; `--dns-port` la cambia. Configura il +resolver DNS usato dall'iPhone per inoltrare al responder AltKeeper sia il dominio del servizio sia +la zona DNS inversa della sottorete VPN. Per `casa.internal` e `10.7.0.0/24`, inoltra ad esempio +`casa.internal` e `7.10.in-addr.arpa`. La configurazione precisa dipende dal resolver DNS. + +Non esporre DNS-SD, AltServer, pagina web o listener Anisette direttamente su Internet. Limita +l'accesso ai peer VPN sia nella VPN sia nel firewall dell'host. + +## Verifica e diagnosi + +1. Collega entrambi i dispositivi alla VPN e verifica che l'iPhone raggiunga l'indirizzo VPN del + server. +2. Controlla che la porta di pairing remoto sia raggiungibile mentre l'iPhone è collegato al Wi-Fi. +3. Se il pairing funziona ma AltStore non trova AltServer, controlla l'inoltro DNS-SD sia per il + dominio del servizio sia per la zona inversa. +4. Controlla i log AltKeeper mentre premi **Refresh All** in AltStore. + +Se il rinnovo automatico non riesce a raggiungere l'iPhone perché iOS ha sospeso l'app VPN, rinnova +manualmente con la VPN attiva oppure mantieni il telefono sulla rete locale. diff --git a/docs/it/troubleshooting-and-security.md b/docs/it/troubleshooting-and-security.md new file mode 100644 index 0000000..377d1d3 --- /dev/null +++ b/docs/it/troubleshooting-and-security.md @@ -0,0 +1,43 @@ +# Problemi e sicurezza + +## Problemi comuni + +| Problema | Controlli | +| --- | --- | +| Manca il pairing remoto | Controlla che `rp-pairing.plist` sia nella cartella dati attiva. Se serve, rifai il pairing via USB. | +| Il pairing fallisce o mostra `early eof` | Il telefono potrebbe non riconoscere più il pairing. Ripetilo e tieni l'iPhone sbloccato durante la procedura. | +| L'iPhone non viene trovato o va in timeout | Collega l'iPhone al Wi-Fi, verifica che i dispositivi si raggiungano e controlla l'eventuale indirizzo fisso `--phone ip:porta`. | +| AltStore dice “AltServer could not be found” | Controlla che AltKeeper sia attivo, che AltStore abbia il permesso Rete locale e che ci sia un solo AltServer. Leggi il log del server. | +| AltStore segnala Anisette `-45061` o `reprovision required` | Prova un altro URL Anisette dalla pagina web. Cambiando server l'identità salvata viene sostituita e Apple potrebbe chiedere di nuovo la verifica. | +| Il refresh si blocca dopo `PrepareAppRequest` | L'iPhone potrebbe stare ancora caricando un'app pesante. Tieni AltStore aperto, usa una rete Wi-Fi stabile e controlla il log di trasferimento. | +| L'accesso Apple restituisce 429 o 503 | Apple potrebbe limitare le richieste. Non riprovare in ciclo; aspetta prima di tentare di nuovo. | +| Un'app non si apre dopo la rimozione di un profilo | Ripristina il profilo di provisioning salvato con `profile-restore`. | +| Il server Anisette locale non si avvia | Controlla entrambi i file nativi, la cartella dell'architettura e i permessi di lettura. Vedi [Server Anisette locale](anisette-server.md). | + +Per la sintassi completa, esegui `altkeeper --help`. Imposta `ALTKEEPER_DEBUG=1` per i problemi di +accesso; condividi solo le righe specifiche `[gs #...]` dopo aver verificato che non contengano dati +privati. Non attivare `RUST_LOG=debug` nei log da condividere: i log di basso livello possono +contenere le chiavi di pairing. + +## Proteggi la cartella dati + +Considera segreti questi file e cartelle: + +- `rp-pairing.plist`: chiavi usate per associare l'iPhone. +- `account.json`: Apple ID e, se salvata, password in chiaro. +- `state/`: identità Anisette e stato AltServer. +- `notify.conf`: topic delle notifiche. +- `profili-salvati/`: copie dei profili di provisioning rimossi. + +Limita l'accesso ai file, cifra i backup e non allegarli a issue o chat. Per segnalare una +vulnerabilità in privato, segui [SECURITY.md](../../SECURITY.md). + +## Esposizione in rete + +- **AltServer TCP 49500:** non ha autenticazione, come AltServer stesso. Non esporlo fuori da una + LAN o VPN fidata. +- **Pagina web TCP 8787:** usa `ALTKEEPER_WEB_PIN` con bind non locale. Non usa HTTPS: mantienila su + una rete fidata. +- **Listener Anisette:** non ha autenticazione né TLS e il PIN web non lo protegge. Associalo solo + a interfacce fidate. +- Evita l'inoltro di porte pubbliche. Usa una VPN privata e regole firewall sull'host. diff --git a/docs/it/web-and-jit.md b/docs/it/web-and-jit.md new file mode 100644 index 0000000..9a1ffdb --- /dev/null +++ b/docs/it/web-and-jit.md @@ -0,0 +1,80 @@ +# Pagina web e JIT + +## Avvia la pagina web + +Per accedere da altri dispositivi fidati della LAN, imposta un PIN e associa il server web a un +indirizzo di rete: + +```sh +ALTKEEPER_WEB_PIN='il-tuo-pin-privato' ./AltKeeper serve \ + --bind 0.0.0.0:8787 --altserver +``` + +Apri `http://:8787`. Usa un nome utente qualsiasi e il PIN come password. Un +bind web non locale richiede `ALTKEEPER_WEB_PIN` (almeno quattro caratteri). Un server limitato a +loopback non richiede il PIN. Il servizio web non usa HTTPS: mantienilo su una rete fidata e non +inoltrare la porta direttamente su Internet. + +La pagina mostra raggiungibilità del telefono, app e scadenze, log e stato dei rinnovi. Offre anche +accesso Apple, pairing, profili dispositivo, scelta Anisette, pianificazione rinnovi e gestione dei +profili. Per password e pianificazione, vedi [Account e rinnovi](accounts-and-renewals.md). + +## Installa un IPA dal computer + +Il caricamento IPA dalla pagina web firma e installa il file selezionato usando l'Apple ID del +profilo dispositivo attivo. L'operazione può registrare l'iPhone e creare App ID e certificati di +sviluppo nel team Apple. Il limite di caricamento è 1 GiB. Mantieni privata la pagina e controlla +il log dell'operazione. + +L'opzione JIT facoltativa prova ad avviare l'app installata e ad abilitare il JIT. Se l'attivazione +fallisce, l'app resta installata e il log segnala l'errore. + +## Requisiti e stato del JIT + +Il JIT può essere richiesto dalla pagina per un'app in esecuzione, durante l'installazione IPA +supportata o da una richiesta AltStore. Servono Modalità sviluppatore attiva e un'immagine +Developer Disk Image compatibile già montata sull'iPhone. AltKeeper non monta automaticamente +l'immagine. L'attivazione JIT non è ancora stata verificata completamente su un dispositivo reale. + +L'API HTTP è specifica di AltKeeper. Non implementa il protocollo Husk/StikJIT: i client devono +integrare esplicitamente l'API di AltKeeper. + +## API per client compatibili + +URL base: `http://:8787`. Se è impostato un PIN, usa l'autenticazione HTTP Basic +(nome utente qualsiasi, PIN come password). Le richieste POST devono contenere +`X-Requested-With: altkeeper`. I lavori partono in background: avvia un lavoro e interroga +`/api/state` finché `job.running` diventa `false`. + +Prima carica l'elenco dei processi attualmente in esecuzione: + +```sh +export ALTKEEPER_URL='http://192.168.1.20:8787' +export ALTKEEPER_WEB_PIN='il-tuo-pin' +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + -H 'X-Requested-With: altkeeper' -H 'Content-Type: application/json' \ + -d '{}' "$ALTKEEPER_URL/api/jit/apps" +while :; do + state=$(curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + "$ALTKEEPER_URL/api/state") || exit 1 + printf '%s\n' "$state" | jq '{job, jit_apps}' || exit 1 + running=$(printf '%s\n' "$state" | jq -er '.job.running | tostring') || exit 1 + [ "$running" = false ] && break + sleep 1 +done +``` + +Quando il lavoro è terminato, usa un PID presente nell'elenco `jit_apps` restituito: + +```sh +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + -H 'X-Requested-With: altkeeper' -H 'Content-Type: application/json' \ + -d '{"pid":1234}' "$ALTKEEPER_URL/api/jit/enable" +curl -fsS -u "altkeeper:$ALTKEEPER_WEB_PIN" \ + "$ALTKEEPER_URL/api/state" | jq '.job' +``` + +Aggiorna l'elenco prima di abilitare il JIT. AltKeeper accetta solo un PID dell'elenco caricato più +di recente e verifica che il processo sia ancora un'app in esecuzione. Per un profilo diverso da +quello predefinito, aggiungi `X-AltKeeper-Device: ` a ogni richiesta. Non esporre mai +l'API su Internet. diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index e171d9d..195780f 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -14,6 +14,8 @@ services: working_dir: /data volumes: - /srv/altkeeper:/data + # For the local Anisette engine, place the supplied libraries under this read-only mount: + # - /srv/anisette-libraries:/data/anisette-libraries:ro # Optional: required for USB access and automatic pairing inside the container. # - /var/run/usbmuxd:/var/run/usbmuxd environment: @@ -21,6 +23,7 @@ services: # - ALTKEEPER_LANG=en - ALTKEEPER_WEB_PIN=${ALTKEEPER_WEB_PIN:?imposta ALTKEEPER_WEB_PIN in .env} # - ALTKEEPER_NO_AUTO_PAIR=1 + # Add "--anisette-bind", "0.0.0.0:8788" only when intentionally hosting local Anisette. command: ["serve", "--bind", "0.0.0.0:8787", "--altserver"] logging: driver: json-file diff --git a/examples/renew.ps1 b/examples/renew.ps1 index 988b0ee..8d113ff 100644 --- a/examples/renew.ps1 +++ b/examples/renew.ps1 @@ -5,7 +5,7 @@ # il telefono puo' essere fuori casa). Il topic sta in notify.conf, su una riga: # NTFY_TOPIC=un-nome-lungo-e-casuale # -# Mettilo nella stessa cartella di altkeeper.exe, rp-pairing.plist e account.json. +# Mettilo nella stessa cartella di AltKeeper.exe, rp-pairing.plist e account.json. $Dir = Split-Path -Parent $MyInvocation.MyCommand.Path Set-Location $Dir @@ -42,7 +42,7 @@ function Notifica([string]$Titolo, [string]$Messaggio, [int]$Priorita, [string]$ } } -$Exe = Join-Path $Dir 'altkeeper.exe' +$Exe = Join-Path $Dir 'AltKeeper.exe' $Out = @(& $Exe renew --min-days 3 2>&1 | ForEach-Object { "$_" }) $Rc = $LASTEXITCODE diff --git a/examples/renew.sh b/examples/renew.sh index 33537a4..9d218f9 100755 --- a/examples/renew.sh +++ b/examples/renew.sh @@ -4,7 +4,7 @@ # Notifiche su ntfy (opzionali, vedi notifica.sh): rinnovi riusciti ed errori # (al massimo uno ogni 24 ore, perché il telefono può essere fuori casa). # -# Mettilo nella stessa cartella di `altkeeper`, `rp-pairing.plist` e `account.json`. +# Mettilo nella stessa cartella di `AltKeeper`, `rp-pairing.plist` e `account.json`. DIR="$(cd "$(dirname "$0")" && pwd)" cd "$DIR" || exit 1 @@ -15,7 +15,7 @@ export ALTKEEPER_PHONE="${ALTKEEPER_PHONE:-}" . ./notifica.sh -OUT=$(./altkeeper renew --min-days 3 2>&1) +OUT=$(./AltKeeper renew --min-days 3 2>&1) RC=$? { echo "=== $(date "+%F %T")" diff --git a/scripts/check-web-ui.cjs b/scripts/check-web-ui.cjs index 2b940cd..654f26c 100644 --- a/scripts/check-web-ui.cjs +++ b/scripts/check-web-ui.cjs @@ -15,6 +15,11 @@ for (const snippet of [ '', 'function profilesText(n)', 'data-i18n="Your apps"', + 'class="brand-mark" src="/apple-touch-icon.png" alt="" aria-hidden="true"', + 'data-i18n="Just-in-time (JIT)"', + 'data-i18n="Load running apps"', + 'data-i18n="Enable JIT"', + 'data-i18n="Enable JIT after installation (requires a mounted Developer Disk Image)"', 'data-i18n="Send test notification"', 'data-i18n="Sign and install"', 'data-i18n="Discord failure notifications"', @@ -28,6 +33,8 @@ for (const snippet of [ '"/api/phone-addresses"', '"/api/auto-pair"', '"/api/pair-usb"', + '"/api/jit/apps"', + '"/api/jit/enable"', '"/api/renew/cancel-wait"', '"/api/anisette"', '"/api/anisette/test"', @@ -64,7 +71,8 @@ for (const id of [ "discord-save", "discord-clear", "discord-test", "phone-addresses-save", "btn-pair-usb", "anisette-save", "anisette-clear", "anisette-test", "auto-pair-enabled", - "btn-install", "btn-login", "btn-code", + "btn-install", "btn-login", "btn-code", "jit-refresh", "jit-enable", "jit-job-slot", + "install-enable-jit", ]) { assert(html.includes('id="' + id + '"'), "Missing control: " + id); } @@ -78,6 +86,7 @@ assert(html.includes('
')); // Safari icon and already-approved Discord test stay intact through localization. assert(html.includes('rel="apple-touch-icon"')); assert(html.includes('rel="icon"')); +assert(!html.includes('class="brand-mark" aria-hidden="true">AK')); assert(html.includes('"/api/discord/test"')); console.log("Localized dashboard JavaScript syntax, actions, labels and layout OK"); @@ -85,8 +94,11 @@ console.log("Localized dashboard JavaScript syntax, actions, labels and layout O // failure/success. A later renewal moves the same panel back without copying // log nodes or losing the reader's scroll position. { - const nodes = { "app-job-slot": {}, "install-job-slot": {}, "job": { scrollTop: 42 } }; - for (const id of ["app-job-slot", "install-job-slot"]) { + const nodes = { + "app-job-slot": {}, "install-job-slot": {}, "jit-job-slot": {}, + "job": { scrollTop: 42 }, + }; + for (const id of ["app-job-slot", "install-job-slot", "jit-job-slot"]) { nodes[id].append = panel => { panel.parentElement = nodes[id]; }; } nodes.job.parentElement = nodes["app-job-slot"]; @@ -103,6 +115,8 @@ console.log("Localized dashboard JavaScript syntax, actions, labels and layout O assert.equal(nodes.job.scrollTop, 42); vm.runInContext('placeJob({kind:"renew",running:true})', context); assert.equal(nodes.job.parentElement, nodes["app-job-slot"]); + vm.runInContext('placeJob({kind:"jit_enable",running:true})', context); + assert.equal(nodes.job.parentElement, nodes["jit-job-slot"]); const hint = vm.runInContext('installHint({busy:true,job:{kind:"install",running:true}})', context); assert(hint.includes("Signing and installing")); assert.equal(vm.runInContext('installHint({busy:true,job:{kind:"install",running:false}})', context), "Checking…"); @@ -234,3 +248,56 @@ async function checkLogPolling() { assert.deepEqual(timers, [10000, 2000, 10000, 2000, 10000, 2000, 10000, 2000]); } checkLogPolling().catch(error => { console.error(error); process.exitCode = 1; }); + +// Polling must leave the running-app options intact while a user chooses a PID. +// A removed PID must fall back to a visible empty option and disable activation. +{ + let replacements = 0; + const select = { + value: "", children: [], disabled: true, + replaceChildren(...options) { this.children = options; replacements++; }, + }; + const controls = { "jit-app-select": select, "jit-refresh": {}, "jit-enable": {}, "jit-hint": {} }; + const context = vm.createContext({ + $: id => controls[id], t: text => text, + document: { createElement: () => ({ value: "", textContent: "" }) }, + }); + const jitRender = embedded[1].match(/ const jitSelect = \$\("jit-app-select"\);[\s\S]*?(?=\r?\n\r?\n \/\/ Free-account limits)/); + assert(jitRender, "JIT rendering missing"); + vm.runInContext('let renderedJitApps = null; function renderJit(s) {\n' + jitRender[0] + '\n}', context); + const render = state => { context.snapshot = state; vm.runInContext('renderJit(snapshot)', context); }; + render({ jit_apps: [] }); + assert.equal(select.children[0].value, ""); + assert.equal(select.children[0].textContent, "Load running apps first"); + assert.equal(select.disabled, true); + render({ jit_apps: [{ pid: 12, name: "First" }, { pid: 34, name: "Second" }] }); + assert.equal(select.children[0].value, ""); + assert.equal(select.children[0].textContent, "Select a running app"); + assert.equal(select.disabled, false); + assert.equal(controls["jit-enable"].disabled, true); + select.value = "34"; + const options = select.children; + const beforePolling = replacements; + render({ jit_apps: [{ pid: 12, name: "First" }, { pid: 34, name: "Second" }] }); + assert.equal(select.children, options, "unchanged responses must preserve the option nodes"); + assert.equal(replacements, beforePolling); + assert.equal(select.value, "34"); + assert.equal(controls["jit-enable"].disabled, false); + render({ busy: true, jit_apps: [{ pid: 12, name: "First" }, { pid: 34, name: "Second" }] }); + assert.equal(replacements, beforePolling, "busy-state updates must not rebuild options"); + assert.equal(select.value, "34"); + assert.equal(select.disabled, true); + assert.equal(controls["jit-refresh"].disabled, true); + assert.equal(controls["jit-enable"].disabled, true); + render({ jit_apps: [{ pid: 34, name: "Renamed" }] }); + assert.equal(select.value, "34", "retain a PID that is still present after a list change"); + assert.equal(select.children[1].textContent, "Renamed · PID 34"); + render({ jit_apps: [{ pid: 12, name: "First" }] }); + assert.equal(select.value, "", "a removed selection must show the placeholder"); + assert(select.children.some(option => option.value === select.value)); + assert.equal(controls["jit-enable"].disabled, true); + render({}); + assert.equal(select.disabled, true); + assert.equal(select.children[0].textContent, "Load running apps first"); + console.log("JIT options, selection and busy-state polling OK"); +} diff --git a/src/altserver.rs b/src/altserver.rs index f6d3394..e9e6144 100644 --- a/src/altserver.rs +++ b/src/altserver.rs @@ -15,7 +15,7 @@ //! - `PrepareAppRequest` + byte dell'IPA + `BeginInstallationRequest`: installa l'app già firmata //! da AltStore, con risposte di avanzamento fino a 1.0. //! - `RemoveAppRequest`: disinstalla un'app. -//! - `EnableUnsignedCodeExecutionRequest` (JIT): non supportata, risponde con un errore. +//! - `EnableUnsignedCodeExecutionRequest`: abilita JIT collegando brevemente il debugger al processo. //! //! Ogni connessione gira in un thread con un runtime suo (il collegamento al telefono non è //! `Send`) e una richiesta alla volta. @@ -346,11 +346,7 @@ async fn handle_connection(stream: TcpStream, ctx: Ctx, peer: &str) { "RemoveProvisioningProfilesRequest" => Box::pin(remove_profiles(&ctx, &req)).await, "RemoveAppRequest" => Box::pin(remove_app(&ctx, &req)).await, "EnableUnsignedCodeExecutionRequest" => { - log(crate::tr!( - "JIT (EnableUnsignedCodeExecution) non supportato", - "JIT (EnableUnsignedCodeExecution) is not supported" - )); - error(ERR_UNSUPPORTED_IOS) + Box::pin(enable_unsigned_code_execution(&ctx, &req)).await } "PrepareAppRequest" => { match Box::pin(receive_and_install(&mut rd, &wr, &ctx, &req)).await { @@ -379,6 +375,115 @@ async fn handle_connection(stream: TcpStream, ctx: Ctx, peer: &str) { )); } +fn requested_jit_pid(req: &Value) -> Result, String> { + if let Some(pid) = req.get("processID") { + let pid = pid + .as_u64() + .and_then(|pid| u32::try_from(pid).ok()) + .filter(|pid| *pid > 0) + .ok_or_else(|| "JIT request contains an invalid process ID.".to_string())?; + return Ok(Some(pid)); + } + if req + .get("processName") + .and_then(Value::as_str) + .is_some_and(|name| !name.trim().is_empty()) + { + return Ok(None); + } + Err("JIT request must contain a process ID or process name.".into()) +} + +async fn enable_unsigned_code_execution(ctx: &Ctx, req: &Value) -> Value { + let Some(udid) = req + .get("udid") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + else { + return error(ERR_INVALID_REQUEST); + }; + let process_id = match requested_jit_pid(req) { + Ok(pid) => pid, + Err(message) => { + log(&message); + return error(ERR_INVALID_REQUEST); + } + }; + let process_name = req.get("processName").and_then(Value::as_str); + + let _work = phone_lock(); + let mut link = match phone_link(ctx).await { + Ok(link) => link, + Err(code) => return error(code), + }; + let identity = match phone::with_timeout( + "Reading the iPhone identity for JIT", + Duration::from_secs(15), + link.device_identity(), + ) + .await + { + Ok(identity) => identity, + Err(message) => { + log(&format!("JIT failed: {message}")); + return error(ERR_DEVICE_NOT_FOUND); + } + }; + if identity.1 != udid { + log(&format!( + "JIT request was for device {udid}, but connected device is {}", + identity.1 + )); + return error(ERR_DEVICE_NOT_FOUND); + } + + let pid = match process_id { + Some(pid) => pid, + None => { + let processes = match phone::with_timeout( + "Listing running apps for JIT", + Duration::from_secs(30), + link.running_apps(), + ) + .await + { + Ok(processes) => processes, + Err(message) => { + log(&format!("JIT failed: {message}")); + return error(ERR_UNSUPPORTED_IOS); + } + }; + match phone::resolve_running_app_name(&processes, process_name.unwrap_or_default()) { + Some(pid) => pid, + None => { + log(&format!( + "JIT failed: process {:?} is not running or is ambiguous", + process_name.unwrap_or_default() + )); + return error(ERR_INVALID_APP); + } + } + } + }; + + match phone::with_timeout( + "Enabling JIT", + Duration::from_secs(30), + link.enable_jit(pid), + ) + .await + { + Ok(()) => { + log(&format!("enabled JIT for process {pid}")); + simple("EnableUnsignedCodeExecutionResponse") + } + Err(message) => { + log(&format!("JIT failed for process {pid}: {message}")); + error(ERR_UNSUPPORTED_IOS) + } + } +} + async fn send(wr: &Rc>, v: &Value) -> Result<(), String> { let mut g = wr.lock().await; write_frame(&mut *g, v).await @@ -719,6 +824,20 @@ mod tests { use super::*; use tokio::io::duplex; + #[test] + fn valida_target_jit() { + assert_eq!( + requested_jit_pid(&json!({"processID": 42})).unwrap(), + Some(42) + ); + assert_eq!( + requested_jit_pid(&json!({"processName": "Example"})).unwrap(), + None + ); + assert!(requested_jit_pid(&json!({"processID": 0})).is_err()); + assert!(requested_jit_pid(&json!({})).is_err()); + } + #[tokio::test] async fn messaggi_con_la_lunghezza_davanti_in_little_endian() { let (mut a, mut b) = duplex(1024); @@ -825,20 +944,29 @@ mod tests { let out = dialogo( vec![ frame(json!({"version": 1, "identifier": "QualcosaDiNuovo"})), - frame(json!({"version": 1, "identifier": "EnableUnsignedCodeExecutionRequest", "udid": "x"})), - frame(json!({"version": 1, "identifier": "InstallProvisioningProfilesRequest", - "udid": "x", "provisioningProfiles": []})), - frame(json!({"version": 1, "identifier": "RemoveProvisioningProfilesRequest", - "udid": "x", "bundleIdentifiers": []})), - frame(json!({"version": 1, "identifier": "InstallProvisioningProfilesRequest", - "udid": "x", "provisioningProfiles": ["a$b"]})), + frame( + json!({"version": 1, "identifier": "EnableUnsignedCodeExecutionRequest", + "udid": "x", "processID": 0}), + ), + frame( + json!({"version": 1, "identifier": "InstallProvisioningProfilesRequest", + "udid": "x", "provisioningProfiles": []}), + ), + frame( + json!({"version": 1, "identifier": "RemoveProvisioningProfilesRequest", + "udid": "x", "bundleIdentifiers": []}), + ), + frame( + json!({"version": 1, "identifier": "InstallProvisioningProfilesRequest", + "udid": "x", "provisioningProfiles": ["a$b"]}), + ), ], 5, ) .await; assert_eq!(out[0]["identifier"], "ErrorResponse"); assert_eq!(out[0]["errorCode"], ERR_UNKNOWN_REQUEST); - assert_eq!(out[1]["errorCode"], ERR_UNSUPPORTED_IOS); + assert_eq!(out[1]["errorCode"], ERR_INVALID_REQUEST); assert_eq!(out[2]["identifier"], "InstallProvisioningProfilesResponse"); assert_eq!(out[3]["identifier"], "RemoveProvisioningProfilesResponse"); assert_eq!(out[4]["errorCode"], ERR_INVALID_REQUEST); diff --git a/src/anisette_server.rs b/src/anisette_server.rs new file mode 100644 index 0000000..029346c --- /dev/null +++ b/src/anisette_server.rs @@ -0,0 +1,678 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + body::Bytes, + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + DefaultBodyLimit, State, + }, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use futures_util::{Stream, StreamExt}; +use omnisette::{ + adi_proxy::{ + ADIProxy, ADIProxyAnisetteProvider, Identifier, AKD_USER_AGENT, CLIENT_INFO_HEADER, DS_ID, + IDENTIFIER_LENGTH, + }, + anisette_headers_provider::AnisetteHeadersProvider, + store_services_core::StoreServicesCoreADIProxy, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::time::{timeout_at, Instant}; +use uuid::Uuid; + +const MAX_PAYLOAD_BYTES: usize = 16 * 1024; +const SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(120); +const SESSION_MAX_DURATION: Duration = Duration::from_secs(300); + +type Provider = ADIProxyAnisetteProvider>; + +struct Engine { + libraries: PathBuf, + provisioning_root: PathBuf, + serial: tokio::sync::Mutex<()>, + sequence: std::sync::atomic::AtomicU64, +} + +struct ProviderSession { + provider: Provider, + provisioning_dir: TemporaryProvisioningDir, +} + +struct TemporaryProvisioningDir(PathBuf); + +impl Drop for TemporaryProvisioningDir { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_dir_all(&self.0) { + if error.kind() != std::io::ErrorKind::NotFound { + crate::log::line(&format!( + "[anisette-local] Could not remove temporary provisioning state: {error}" + )); + } + } + } +} + +impl Engine { + fn new(libraries: PathBuf, data_dir: &Path) -> Result { + let architecture = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "x86" => "x86", + "aarch64" => "arm64-v8a", + "arm" => "armeabi-v7a", + other => return Err(format!("Unsupported host architecture: {other}")), + }; + let native_dir = libraries.join("lib").join(architecture); + for name in ["libstoreservicescore.so", "libCoreADI.so"] { + if !native_dir.join(name).is_file() { + return Err(format!( + "Missing native Anisette library: {}", + native_dir.join(name).display() + )); + } + } + + let provisioning_root = data_dir.join("state").join("anisette-provisioning"); + std::fs::create_dir_all(&provisioning_root) + .map_err(|error| format!("Cannot create Anisette state directory: {error}"))?; + crate::fsperm::restrict_dir(&provisioning_root) + .map_err(|error| format!("Cannot protect Anisette state directory: {error}"))?; + + Ok(Self { + libraries, + provisioning_root, + serial: tokio::sync::Mutex::new(()), + sequence: std::sync::atomic::AtomicU64::new(0), + }) + } + + fn provider( + &self, + identifier: Identifier, + adi_pb: Option<&[u8]>, + ) -> Result { + let identity = Uuid::from_bytes(identifier); + let sequence = self + .sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let provisioning_dir = TemporaryProvisioningDir(self.provisioning_root.join(format!( + "{}-{}-{sequence}", + identity, + std::process::id() + ))); + std::fs::create_dir_all(&provisioning_dir.0) + .map_err(|error| format!("Cannot create provisioning state: {error}"))?; + crate::fsperm::restrict_dir(&provisioning_dir.0) + .map_err(|error| format!("Cannot protect provisioning state: {error}"))?; + if let Some(adi_pb) = adi_pb { + crate::fsperm::write_private(&provisioning_dir.0.join("adi.pb"), adi_pb) + .map_err(|error| format!("Cannot save provisioning data: {error}"))?; + } + + let mut proxy = StoreServicesCoreADIProxy::with_custom_provisioning_path( + &self.libraries, + &provisioning_dir.0, + ) + .map_err(|error| format!("Cannot load native Anisette libraries: {error}"))?; + proxy + .set_device_identifier(identity.to_string().to_uppercase()) + .map_err(|error| format!("Cannot configure Anisette device identity: {error}"))?; + proxy.set_local_user_uuid(hex::encode_upper(Sha256::digest(identifier))); + let provider = ADIProxyAnisetteProvider::without_identifier(proxy) + .map_err(|error| format!("Cannot create Anisette provider: {error}"))?; + Ok(ProviderSession { + provider, + provisioning_dir, + }) + } +} + +pub async fn serve( + bind: std::net::SocketAddr, + libraries: PathBuf, + data_dir: PathBuf, +) -> Result<(), String> { + let engine = Arc::new(Engine::new(libraries, &data_dir)?); + let router = Router::new() + .route("/v3/client_info", get(client_info)) + .route( + "/v3/get_headers", + post(get_headers).layer(DefaultBodyLimit::max(MAX_PAYLOAD_BYTES)), + ) + .route("/v3/provisioning_session", get(provisioning_session)) + .with_state(engine); + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(|error| format!("Cannot listen for local Anisette on {bind}: {error}"))?; + crate::log::line(&format!( + "[anisette-local] V3 server listening at http://{bind}. This protocol has no authentication or TLS; restrict network access." + )); + axum::serve(listener, router) + .await + .map_err(|error| format!("Local Anisette server failed: {error}")) +} + +fn client_info_body() -> Value { + json!({ + "client_info": CLIENT_INFO_HEADER, + "user_agent": AKD_USER_AGENT, + }) +} + +async fn client_info() -> Json { + Json(client_info_body()) +} + +#[derive(Deserialize)] +struct HeadersRequest { + identifier: String, + adi_pb: String, +} + +async fn get_headers(State(engine): State>, body: Bytes) -> (StatusCode, Json) { + let request: HeadersRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(error) => { + return get_headers_error( + StatusCode::BAD_REQUEST, + &format!("Invalid request: {error}"), + ) + } + }; + let identifier = match BASE64.decode(request.identifier) { + Ok(bytes) if bytes.len() == IDENTIFIER_LENGTH => { + let mut identifier = [0; IDENTIFIER_LENGTH]; + identifier.copy_from_slice(&bytes); + identifier + } + Ok(_) => return get_headers_error(StatusCode::BAD_REQUEST, "Invalid identifier length."), + Err(error) => { + return get_headers_error( + StatusCode::BAD_REQUEST, + &format!("Invalid identifier: {error}"), + ) + } + }; + let adi_pb = match BASE64.decode(request.adi_pb) { + Ok(bytes) if !bytes.is_empty() && bytes.len() <= MAX_PAYLOAD_BYTES => bytes, + Ok(_) => { + return get_headers_error(StatusCode::BAD_REQUEST, "Invalid provisioning data size.") + } + Err(error) => { + return get_headers_error( + StatusCode::BAD_REQUEST, + &format!("Invalid provisioning data: {error}"), + ) + } + }; + + let _serial = match engine.serial.try_lock() { + Ok(guard) => guard, + Err(_) => { + return get_headers_error( + StatusCode::SERVICE_UNAVAILABLE, + "The local Anisette engine is busy.", + ) + } + }; + let result = async { + let mut provider = engine.provider(identifier, Some(&adi_pb))?; + let headers = generate_headers(&mut provider) + .await + .map_err(|error| format!("Could not generate Anisette headers: {error}"))?; + Ok::, String>(headers) + } + .await; + + match result { + Ok(headers) => { + let mut response = serde_json::Map::new(); + response.insert("result".into(), json!("Headers")); + for name in ["X-Apple-I-MD-M", "X-Apple-I-MD", "X-Apple-I-MD-RINFO"] { + let Some(value) = headers.get(name) else { + return get_headers_error( + StatusCode::BAD_GATEWAY, + "The native engine omitted a required Anisette header.", + ); + }; + response.insert(name.into(), json!(value)); + } + (StatusCode::OK, Json(Value::Object(response))) + } + Err(error) => { + crate::log::line(&format!( + "[anisette-local] Header generation failed: {error}" + )); + get_headers_error(StatusCode::BAD_GATEWAY, "The local Anisette engine failed.") + } + } +} + +async fn generate_headers( + provider: &mut ProviderSession, +) -> Result, String> { + let headers = provider + .provider + .get_anisette_headers(true) + .await + .map_err(|error| error.to_string())?; + Ok(provider.provider.normalize_headers(headers)) +} + +fn get_headers_error(status: StatusCode, message: &str) -> (StatusCode, Json) { + ( + status, + Json(json!({ + "result": "GetHeadersError", + "message": message, + })), + ) +} + +async fn provisioning_session( + State(engine): State>, + upgrade: WebSocketUpgrade, +) -> impl IntoResponse { + upgrade + .max_message_size(MAX_PAYLOAD_BYTES) + .max_frame_size(MAX_PAYLOAD_BYTES) + .on_upgrade(move |socket| handle_provisioning(socket, engine)) +} + +#[derive(Deserialize)] +struct IdentifierRequest { + identifier: String, +} + +#[derive(Deserialize)] +struct StartProvisioningRequest { + spim: String, +} + +#[derive(Deserialize)] +struct EndProvisioningRequest { + ptm: String, + tk: String, +} + +#[derive(Clone, Copy)] +enum SessionStep { + Identifier, + Start, + End, +} + +async fn receive_provisioning_message( + socket: &mut S, + session_deadline: Instant, +) -> Result, ()> { + let now = Instant::now(); + // A ready message must not bypass an already-expired session deadline. + if now >= session_deadline { + return Err(()); + } + let receive_deadline = session_deadline.min(now + SESSION_IDLE_TIMEOUT); + timeout_at(receive_deadline, socket.next()) + .await + .map_err(|_| ()) +} + +async fn handle_provisioning(mut socket: WebSocket, engine: Arc) { + let session_deadline = Instant::now() + SESSION_MAX_DURATION; + let _serial = match engine.serial.try_lock() { + Ok(guard) => guard, + Err(_) => { + let _ = send_ws( + &mut socket, + json!({ + "result": "StartProvisioningError", + "message": "The local Anisette engine is busy.", + }), + ) + .await; + return; + } + }; + let mut provider: Option = None; + let mut session = 0; + let mut step = SessionStep::Identifier; + + if send_ws(&mut socket, json!({"result": "GiveIdentifier"})) + .await + .is_err() + { + return; + } + + loop { + let message = match receive_provisioning_message(&mut socket, session_deadline).await { + Ok(Some(Ok(message))) => message, + Ok(Some(Err(error))) => { + crate::log::line(&format!( + "[anisette-local] Provisioning WebSocket failed: {error}" + )); + break; + } + Ok(None) => break, + Err(_) => { + let _ = send_ws(&mut socket, json!({"result": "Timeout"})).await; + break; + } + }; + + match message { + Message::Text(text) => { + let outcome = match step { + SessionStep::Identifier => { + let input = match serde_json::from_str::(text.as_str()) { + Ok(input) => input, + Err(error) => { + send_ws_error(&mut socket, "InvalidIdentifier", error.to_string()) + .await; + break; + } + }; + let identifier = match BASE64.decode(input.identifier) { + Ok(bytes) if bytes.len() == IDENTIFIER_LENGTH => { + let mut identifier = [0; IDENTIFIER_LENGTH]; + identifier.copy_from_slice(&bytes); + identifier + } + Ok(_) => { + send_ws_error( + &mut socket, + "InvalidIdentifier", + "Identifier has an invalid length.".into(), + ) + .await; + break; + } + Err(error) => { + send_ws_error(&mut socket, "InvalidIdentifier", error.to_string()) + .await; + break; + } + }; + match engine.provider(identifier, None) { + Ok(instance) => { + provider = Some(instance); + step = SessionStep::Start; + json!({"result": "GiveStartProvisioningData"}) + } + Err(error) => { + crate::log::line(&format!( + "[anisette-local] Provider initialization failed: {error}" + )); + send_ws_error( + &mut socket, + "InvalidIdentifier", + "Could not initialize the local Anisette engine.".into(), + ) + .await; + break; + } + } + } + SessionStep::Start => { + let input = + match serde_json::from_str::(text.as_str()) { + Ok(input) => input, + Err(error) => { + send_ws_error( + &mut socket, + "StartProvisioningError", + error.to_string(), + ) + .await; + break; + } + }; + let spim = match decode_payload(&input.spim) { + Ok(bytes) => bytes, + Err(error) => { + send_ws_error(&mut socket, "StartProvisioningError", error).await; + break; + } + }; + let Some(provider) = provider.as_mut() else { + send_ws_error( + &mut socket, + "StartProvisioningError", + "Provisioning state was lost.".into(), + ) + .await; + break; + }; + match provider + .provider + .adi_proxy() + .start_provisioning(DS_ID, &spim) + { + Ok(result) => { + session = result.session; + step = SessionStep::End; + json!({ + "result": "GiveEndProvisioningData", + "cpim": BASE64.encode(result.cpim), + }) + } + Err(error) => { + crate::log::line(&format!( + "[anisette-local] Provisioning start failed: {error}" + )); + send_ws_error( + &mut socket, + "StartProvisioningError", + "Native provisioning could not start.".into(), + ) + .await; + break; + } + } + } + SessionStep::End => { + let input = + match serde_json::from_str::(text.as_str()) { + Ok(input) => input, + Err(error) => { + send_ws_error( + &mut socket, + "EndProvisioningError", + error.to_string(), + ) + .await; + break; + } + }; + let ptm = match decode_payload(&input.ptm) { + Ok(bytes) => bytes, + Err(error) => { + send_ws_error(&mut socket, "EndProvisioningError", error).await; + break; + } + }; + let tk = match decode_payload(&input.tk) { + Ok(bytes) => bytes, + Err(error) => { + send_ws_error(&mut socket, "EndProvisioningError", error).await; + break; + } + }; + let Some(provider) = provider.as_mut() else { + send_ws_error( + &mut socket, + "EndProvisioningError", + "Provisioning state was lost.".into(), + ) + .await; + break; + }; + if let Err(error) = provider + .provider + .adi_proxy() + .end_provisioning(session, &ptm, &tk) + { + crate::log::line(&format!( + "[anisette-local] Provisioning completion failed: {error}" + )); + send_ws_error( + &mut socket, + "EndProvisioningError", + "Native provisioning could not finish.".into(), + ) + .await; + break; + } + match std::fs::read(provider.provisioning_dir.0.join("adi.pb")) { + Ok(adi_pb) => { + session = 0; + json!({ + "result": "ProvisioningSuccess", + "adi_pb": BASE64.encode(adi_pb), + }) + } + Err(error) => { + crate::log::line(&format!( + "[anisette-local] Could not read adi.pb: {error}" + )); + send_ws_error( + &mut socket, + "EndProvisioningError", + "Could not read the completed provisioning data.".into(), + ) + .await; + break; + } + } + } + }; + + if send_ws(&mut socket, outcome).await.is_err() { + break; + } + } + Message::Ping(payload) => { + if socket.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Message::Close(_) => break, + _ => { + let _ = send_ws(&mut socket, json!({"result": "TextOnly"})).await; + break; + } + } + } + + if session != 0 { + if let Some(provider) = provider.as_mut() { + if let Err(error) = provider + .provider + .adi_proxy() + .destroy_provisioning_session(session) + { + crate::log::line(&format!( + "[anisette-local] Could not destroy provisioning session: {error}" + )); + } + } + } +} + +fn decode_payload(payload: &str) -> Result, String> { + if payload.len() > MAX_PAYLOAD_BYTES { + return Err("Provisioning payload is too large.".into()); + } + BASE64 + .decode(payload) + .map_err(|error| format!("Invalid base64 provisioning payload: {error}")) +} + +async fn send_ws(socket: &mut WebSocket, value: Value) -> Result<(), String> { + let text = serde_json::to_string(&value).map_err(|error| error.to_string())?; + socket + .send(Message::Text(text.into())) + .await + .map_err(|error| error.to_string()) +} + +async fn send_ws_error(socket: &mut WebSocket, result: &str, message: String) { + if let Err(error) = send_ws( + socket, + json!({ + "result": result, + "message": message, + }), + ) + .await + { + crate::log::line(&format!( + "[anisette-local] Could not send provisioning error: {error}" + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(start_paused = true)] + async fn provisioning_preserves_idle_timeout() { + let start = Instant::now(); + let mut socket = futures_util::stream::pending::(); + assert!( + receive_provisioning_message(&mut socket, start + SESSION_MAX_DURATION) + .await + .is_err() + ); + assert_eq!(Instant::now() - start, SESSION_IDLE_TIMEOUT); + } + + #[tokio::test(start_paused = true)] + async fn provisioning_activity_cannot_extend_session_deadline() { + let start = Instant::now(); + let deadline = start + SESSION_MAX_DURATION; + let mut socket = futures_util::stream::iter([ + Message::Ping(Bytes::new()), + Message::Text("{}".into()), + Message::Ping(Bytes::new()), + ]); + for _ in 0..2 { + tokio::time::advance(SESSION_IDLE_TIMEOUT - Duration::from_secs(1)).await; + assert!(receive_provisioning_message(&mut socket, deadline) + .await + .unwrap() + .is_some()); + } + let mut idle_socket = futures_util::stream::pending::(); + assert!(receive_provisioning_message(&mut idle_socket, deadline) + .await + .is_err()); + assert_eq!(Instant::now(), deadline); + // Even a queued Ping is rejected once the total duration has elapsed. + assert!(receive_provisioning_message(&mut socket, deadline) + .await + .is_err()); + } + + #[test] + fn client_info_matches_v3_shape() { + let value = client_info_body(); + assert!(value["client_info"].as_str().is_some_and(|s| !s.is_empty())); + assert!(value["user_agent"].as_str().is_some_and(|s| !s.is_empty())); + } + + #[test] + fn rejects_oversized_provisioning_payloads() { + assert!(decode_payload(&"A".repeat(MAX_PAYLOAD_BYTES + 1)).is_err()); + } +} diff --git a/src/install_archive.rs b/src/install_archive.rs index 230b208..ffad643 100644 --- a/src/install_archive.rs +++ b/src/install_archive.rs @@ -121,7 +121,7 @@ fn prepare_signed_app_archive(app_path: &Path) -> Result Ok(temporary) } -fn signed_bundle_id(app_path: &Path) -> Result { +pub(crate) fn signed_bundle_id(app_path: &Path) -> Result { let info = plist::Value::from_file(app_path.join("Info.plist")).map_err(err)?; info.as_dictionary() .and_then(|values| values.get("CFBundleIdentifier")) diff --git a/src/main.rs b/src/main.rs index f4f889e..40fd081 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ //! altkeeper profile-restore [--phone ip:porta] rimette un profilo salvato //! altkeeper serve [--bind ip:porta] [--pin PIN] [--dir cartella] [--phone ip:porta] //! [--altserver] [--altserver-port N] [--dns-domain D --dns-address IP] +//! [--anisette-bind ip:porta] [--anisette-libs cartella] //! interfaccia web (stato, rinnovo, accesso Apple ID) //! altkeeper altserver [--port N] [--dir cartella] [--phone ip:porta] //! [--dns-domain D --dns-address IP [--dns-port N]] @@ -24,6 +25,8 @@ //! da fuori casa attraverso una VPN (iOS non manda Bonjour multicast nei tunnel, le query DNS sì). mod altserver; +#[cfg(any(target_os = "linux", target_os = "macos", windows))] +mod anisette_server; mod apple; mod devices; mod dnssd; @@ -58,9 +61,9 @@ fn save_password_confirmed(answer: &str) -> bool { } const USAGE: &str = "uso: altkeeper [opzioni]"; + profile-restore|serve [--anisette-bind ip:porta]|altserver|version> [opzioni]"; const USAGE_EN: &str = "usage: altkeeper [options]"; + profile-restore|serve [--anisette-bind ip:port]|altserver|version> [options]"; /// Dove `profile-remove` tiene la copia dei profili tolti dal telefono. const SAVED_DIR: &str = "profili-salvati"; @@ -855,6 +858,33 @@ async fn run() -> Result<(), String> { format!("invalid --bind (expected ip:port): {e}") ) })?; + let anisette_bind = opt("--anisette-bind") + .map(|value| { + value.parse::().map_err(|error| { + crate::tr!( + format!("--anisette-bind non valido (serve ip:porta): {error}"), + format!("invalid --anisette-bind (expected ip:port): {error}") + ) + }) + }) + .transpose()?; + let anisette_libraries = opt("--anisette-libs"); + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + if anisette_bind.is_some() || anisette_libraries.is_some() { + return Err(crate::tr!( + "il server anisette locale non è disponibile su questo sistema", + "the local Anisette server is not available on this operating system" + ) + .into()); + } + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + if anisette_libraries.is_some() && anisette_bind.is_none() { + return Err(crate::tr!( + "--anisette-libs richiede --anisette-bind", + "--anisette-libs requires --anisette-bind" + ) + .into()); + } // Meglio la variabile d'ambiente: un PIN sulla riga di comando si vede con `ps`. let pin = opt("--pin") .or_else(|| env::var("WEB_PIN")) @@ -880,6 +910,17 @@ async fn run() -> Result<(), String> { .into()); } let dir = std::env::current_dir().map_err(dbg)?; + #[cfg(any(target_os = "linux", target_os = "macos", windows))] + if let Some(anisette_bind) = anisette_bind { + let libraries = anisette_libraries + .map(std::path::PathBuf::from) + .unwrap_or_else(|| dir.join("anisette-libraries")); + return tokio::try_join!( + web::serve(bind, pin, phone, dir.clone()), + anisette_server::serve(anisette_bind, libraries, dir), + ) + .map(|_| ()); + } web::serve(bind, pin, phone, dir).await } #[cfg(any(windows, target_os = "macos"))] diff --git a/src/phone.rs b/src/phone.rs index 86e6568..61efce3 100644 --- a/src/phone.rs +++ b/src/phone.rs @@ -10,6 +10,12 @@ use std::time::{Duration, Instant}; use futures_util::StreamExt; use idevice::{ core_device_proxy::CoreDeviceProxy, + debug_proxy::DebugProxyClient, + dvt::{ + device_info::{DeviceInfoClient, RunningProcess}, + process_control::ProcessControlClient, + remote_server::RemoteServerClient, + }, installation_proxy::InstallationProxyClient, misagent::MisagentClient, provider::{IdeviceProvider, UsbmuxdProvider}, @@ -130,7 +136,149 @@ async fn identity_via_rsd( Ok((name, udid, version)) } +fn jit_requires_rsd() -> String { + "JIT requires iOS 17 or later and Developer Mode. A compatible Developer Disk Image must already be mounted; AltKeeper does not mount it automatically.".into() +} + +fn jit_service_error(error: impl std::fmt::Debug) -> String { + format!( + "Could not access iOS developer services. Enable Developer Mode and mount a compatible Developer Disk Image, then try again: {error:?}" + ) +} + +fn gdb_packet(payload: &str) -> Vec { + let checksum = payload + .bytes() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)); + format!("${payload}#{checksum:02x}").into_bytes() +} + +pub fn resolve_running_app_name(processes: &[RunningProcess], name: &str) -> Option { + let matches = processes + .iter() + .filter(|process| { + process.is_application + && process.pid > 0 + && (process.name == name || process.real_app_name == name) + }) + .map(|process| process.pid) + .collect::>(); + (matches.len() == 1).then(|| *matches.iter().next().unwrap()) +} + impl PhoneLink { + pub async fn running_apps(&mut self) -> Result, String> { + match self { + Self::Rsd { adapter, hs, .. } => { + let mut remote = + RemoteServerClient::>::connect_rsd(adapter, hs) + .await + .map_err(jit_service_error)?; + let mut info = DeviceInfoClient::new(&mut remote) + .await + .map_err(jit_service_error)?; + info.running_processes() + .await + .map(|processes| { + processes + .into_iter() + .filter(|process| process.is_application && process.pid > 0) + .collect() + }) + .map_err(jit_service_error) + } + Self::Legacy { .. } => Err(jit_requires_rsd()), + } + } + + pub async fn launch_app(&mut self, bundle_id: &str) -> Result { + match self { + Self::Rsd { adapter, hs, .. } => { + let mut remote = + RemoteServerClient::>::connect_rsd(adapter, hs) + .await + .map_err(jit_service_error)?; + let mut process_control = ProcessControlClient::new(&mut remote) + .await + .map_err(jit_service_error)?; + let pid = process_control + .launch_app(bundle_id, None, None, false, false) + .await + .map_err(jit_service_error)?; + u32::try_from(pid).map_err(|_| "iOS returned an invalid app process ID.".into()) + } + Self::Legacy { .. } => Err(jit_requires_rsd()), + } + } + + pub async fn enable_jit(&mut self, pid: u32) -> Result<(), String> { + match self { + Self::Rsd { adapter, hs, .. } => { + let processes = { + let mut remote = + RemoteServerClient::>::connect_rsd(adapter, hs) + .await + .map_err(jit_service_error)?; + let mut info = DeviceInfoClient::new(&mut remote) + .await + .map_err(jit_service_error)?; + info.running_processes().await.map_err(jit_service_error)? + }; + if !processes + .iter() + .any(|process| process.pid == pid && process.is_application) + { + return Err("The selected app is no longer running on the iPhone.".into()); + } + + let mut debug = + DebugProxyClient::>::connect_rsd(adapter, hs) + .await + .map_err(jit_service_error)?; + debug + .send_raw(&gdb_packet("QStartNoAckMode")) + .await + .map_err(jit_service_error)?; + let response = debug + .read_response() + .await + .map_err(jit_service_error)? + .ok_or_else(|| { + "The iPhone did not acknowledge the JIT debug session.".to_string() + })?; + if response != "OK" { + return Err(format!( + "The iPhone rejected the JIT debug session: {response}" + )); + } + debug.set_ack_mode(false); + + debug + .send_raw(&gdb_packet(&format!("vAttach;{pid:x}"))) + .await + .map_err(jit_service_error)?; + let response = debug + .read_response() + .await + .map_err(jit_service_error)? + .ok_or_else(|| { + "The iPhone did not confirm the app debug attachment.".to_string() + })?; + if !response.starts_with('T') && !response.starts_with('S') { + return Err(format!( + "The iPhone could not attach to the app: {response}" + )); + } + debug + .send_raw(&gdb_packet("c")) + .await + .map_err(jit_service_error)?; + Ok(()) + } + Self::Legacy { .. } => Err(jit_requires_rsd()), + } + } + pub async fn profiles(&mut self) -> Result>, String> { let misagent = match self { Self::Rsd { misagent, .. } | Self::Legacy { misagent, .. } => misagent, @@ -1271,6 +1419,57 @@ mod tests { assert_eq!(fallback_transport(true), TransportKind::Legacy); } + #[test] + fn crea_packet_gdb_con_checksum() { + assert_eq!(gdb_packet("c"), b"$c#63"); + } + + #[test] + fn risolve_solo_un_app_in_esecuzione_non_ambiguo() { + let processes = vec![ + RunningProcess { + pid: 12, + name: "Example".into(), + real_app_name: "com.example.app".into(), + is_application: true, + start_page_count: 0, + }, + RunningProcess { + pid: 13, + name: "ExampleExtension".into(), + real_app_name: "com.example.app.extension".into(), + is_application: true, + start_page_count: 0, + }, + RunningProcess { + pid: 14, + name: "Example".into(), + real_app_name: "com.example.app".into(), + is_application: false, + start_page_count: 0, + }, + ]; + assert_eq!( + resolve_running_app_name(&processes, "com.example.app"), + Some(12) + ); + assert_eq!(resolve_running_app_name(&processes, "Example"), Some(12)); + assert_eq!(resolve_running_app_name(&processes, "missing"), None); + assert_eq!( + resolve_running_app_name( + &[ + processes[0].clone(), + RunningProcess { + pid: 15, + ..processes[0].clone() + }, + ], + "Example" + ), + None + ); + } + #[test] fn il_wifi_spiega_che_ios_12_vuole_il_cavo() { let e = with_wifi_hint("non trovo l'iPhone in rete"); diff --git a/src/web.rs b/src/web.rs index ab26515..f11b5c1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -96,6 +96,14 @@ struct Snapshot { profiles_total: usize, apps: Vec, profiles: Vec, + jit_apps: Vec, +} + +#[derive(Clone)] +struct JitAppView { + pid: u32, + name: String, + real_app_name: String, } #[derive(Clone, PartialEq, Debug)] @@ -623,6 +631,8 @@ fn profile_router(app: Arc) -> Router { .route("/api/state", get(state)) .route("/api/logs", get(logs)) .route("/api/check", post(check)) + .route("/api/jit/apps", post(load_jit_apps)) + .route("/api/jit/enable", post(enable_jit)) .route("/api/phone-addresses", post(update_phone_addresses)) .route("/api/auto-pair", post(update_auto_pair)) .route("/api/renew", post(renew)) @@ -920,6 +930,13 @@ fn json_ok() -> (StatusCode, Json) { (StatusCode::OK, Json(json!({ "ok": true }))) } +fn json_job_accepted(job: &str) -> (StatusCode, Json) { + ( + StatusCode::ACCEPTED, + Json(json!({ "ok": true, "job": job })), + ) +} + const BUSY_MSG: &str = "Another operation is already running: please wait for it to finish."; /// Le righe di log dopo `?since=N` (0 = tutte quelle in memoria), per la sezione Logs. @@ -1038,6 +1055,9 @@ async fn state(State(app): State>) -> Json { "uuid": p.uuid, "bundle": p.bundle, "root": p.root, "label": p.label, "days": p.days, "expires": p.expires, })).collect::>(), + "jit_apps": g.snapshot.jit_apps.iter().map(|app| json!({ + "pid": app.pid, "name": app.name, "real_app_name": app.real_app_name, + })).collect::>(), "job": { "kind": g.job.kind, "running": g.job.running, "lines": g.job.lines, "ok": g.job.ok, @@ -1068,6 +1088,116 @@ async fn check(State(app): State>) -> (StatusCode, Json) { json_ok() } +async fn load_jit_apps(State(app): State>) -> (StatusCode, Json) { + let Some(guard) = try_busy(&app) else { + return json_err(StatusCode::CONFLICT, BUSY_MSG); + }; + app.lock().job = JobView { + kind: "jit_apps".into(), + running: true, + lines: vec!["Reading apps currently running on the iPhone…".into()], + ok: None, + }; + spawn_job(guard, run_load_jit_apps); + json_job_accepted("jit_apps") +} + +async fn run_load_jit_apps(app: Arc) { + let _work = crate::altserver::phone_lock(); + let result = phone::with_timeout("Listing running apps", Duration::from_secs(45), async { + let (_, mut link) = + phone::connect_auto_in(&app.dir, app.phone, PAIRING_FILE, |_: &str| {}).await?; + link.running_apps().await + }) + .await; + let mut g = app.lock(); + match result { + Ok(mut processes) => { + processes.sort_by(|left, right| { + left.real_app_name + .cmp(&right.real_app_name) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.pid.cmp(&right.pid)) + }); + g.snapshot.jit_apps = processes + .into_iter() + .map(|process| JitAppView { + pid: process.pid, + name: process.name, + real_app_name: process.real_app_name, + }) + .collect(); + let count = g.snapshot.jit_apps.len(); + g.job.lines.push(format!("Found {} running app(s).", count)); + g.job.ok = Some(true); + } + Err(error) => { + g.snapshot.jit_apps.clear(); + g.job.lines.push(format!("Error: {}", short_error(&error))); + g.job.ok = Some(false); + } + } + g.job.running = false; +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EnableJitReq { + pid: u32, +} + +async fn enable_jit( + State(app): State>, + Json(req): Json, +) -> (StatusCode, Json) { + if req.pid == 0 + || !app + .lock() + .snapshot + .jit_apps + .iter() + .any(|process| process.pid == req.pid) + { + return json_err( + StatusCode::BAD_REQUEST, + "Refresh the running-app list and select an app.", + ); + } + let Some(guard) = try_busy(&app) else { + return json_err(StatusCode::CONFLICT, BUSY_MSG); + }; + app.lock().job = JobView { + kind: "jit_enable".into(), + running: true, + lines: vec![format!("Enabling JIT for process {}…", req.pid)], + ok: None, + }; + spawn_job(guard, move |app| run_enable_jit(app, req.pid)); + json_job_accepted("jit_enable") +} + +async fn run_enable_jit(app: Arc, pid: u32) { + let _work = crate::altserver::phone_lock(); + let result = phone::with_timeout("Enabling JIT", Duration::from_secs(60), async { + let (_, mut link) = + phone::connect_auto_in(&app.dir, app.phone, PAIRING_FILE, |_: &str| {}).await?; + link.enable_jit(pid).await + }) + .await; + let mut g = app.lock(); + match result { + Ok(()) => { + g.job.lines.push(format!("JIT enabled for process {pid}.")); + g.job.ok = Some(true); + } + Err(error) => { + g.job.lines.push(format!("Error: {}", short_error(&error))); + g.job.ok = Some(false); + } + } + g.job.running = false; +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PhoneAddressesReq { @@ -1797,7 +1927,9 @@ async fn install( ok: None, }; } - spawn_job(guard, move |app| run_install(app, uploaded, None, None)); + spawn_job(guard, move |app| { + run_install(app, uploaded, None, None, false) + }); json_ok() } @@ -1838,6 +1970,9 @@ async fn inspect_install( let customize = uri .query() .is_some_and(|q| q.split('&').any(|part| part == "customize=true")); + let enable_jit = uri + .query() + .is_some_and(|q| q.split('&').any(|part| part == "enable_jit=true")); if manifest.extensions.is_empty() && !customize { app.lock().job = JobView { kind: "install".into(), @@ -1845,7 +1980,9 @@ async fn inspect_install( lines: vec!["IPA uploaded (no extensions). Signing and installing…".into()], ok: None, }; - spawn_job(guard, move |app| run_install(app, uploaded, None, None)); + spawn_job(guard, move |app| { + run_install(app, uploaded, None, None, enable_jit) + }); return (StatusCode::OK, Json(json!({ "installing": true }))); } // For the rare archive containing many extensions, cap the preview surface. @@ -1893,6 +2030,8 @@ struct ConfirmInstallReq { token: String, keep_extensions: Vec, #[serde(default)] + enable_jit: bool, + #[serde(default)] display_name: Option, #[serde(default)] bundle_id: Option, @@ -1996,7 +2135,13 @@ async fn confirm_install( ok: None, }; spawn_job(guard, move |app| { - run_install(app, prepared.upload, Some(req.keep_extensions), identity) + run_install( + app, + prepared.upload, + Some(req.keep_extensions), + identity, + req.enable_jit, + ) }); json_ok() } @@ -2301,20 +2446,31 @@ async fn run_install( upload: UploadedIpa, selected_extensions: Option>, identity: Option, + enable_jit: bool, ) { let result = install_ipa( &app, &upload, selected_extensions.as_deref(), identity.as_ref(), + enable_jit, ) .await; - let ok = result.is_ok(); - if let Err(e) = result { - app.push_line(&format!("Error: {}", short_error(&e))); - } else { - app.push_line("Installation completed."); - } + let ok = match result { + Ok(None) => { + app.push_line("Installation completed."); + true + } + Ok(Some(error)) => { + app.push_line("Installation completed, but JIT could not be enabled."); + app.push_line(&format!("JIT error: {}", short_error(&error))); + false + } + Err(error) => { + app.push_line(&format!("Error: {}", short_error(&error))); + false + } + }; { let mut g = app.lock(); g.job.running = false; @@ -2338,7 +2494,8 @@ async fn install_ipa( upload: &UploadedIpa, selected_extensions: Option<&[String]>, identity: Option<&NewInstallIdentity>, -) -> Result<(), String> { + enable_jit: bool, +) -> Result, String> { let account = apple::load_account(&app.dir)?; let mut session = apple::open_session_web( &app.dir, @@ -2434,7 +2591,29 @@ async fn install_ipa( }; let install = link.install_app(&signed_app.path, &progress); - phone::with_timeout("Installation", INSTALL_TIMEOUT, install).await + phone::with_timeout("Installation", INSTALL_TIMEOUT, install).await?; + if !enable_jit { + return Ok(None); + } + + app.push_line("Launching the installed app to enable JIT…"); + let jit_result = async { + let bundle_id = crate::install_archive::signed_bundle_id(&signed_app.path)?; + let pid = phone::with_timeout( + "Launching app for JIT", + Duration::from_secs(30), + link.launch_app(&bundle_id), + ) + .await?; + phone::with_timeout( + "Enabling JIT", + Duration::from_secs(30), + link.enable_jit(pid), + ) + .await + } + .await; + Ok(jit_result.err()) } /// Tempo massimo per l'installazione di un'app (upload AFC + installazione). diff --git a/src/web/altkeeper.png b/src/web/altkeeper.png new file mode 100644 index 0000000..83110b3 Binary files /dev/null and b/src/web/altkeeper.png differ diff --git a/src/web/apple-touch-icon.png b/src/web/apple-touch-icon.png index 7970386..2090a97 100644 Binary files a/src/web/apple-touch-icon.png and b/src/web/apple-touch-icon.png differ diff --git a/src/web/favicon-32.png b/src/web/favicon-32.png index b02dfd1..3b2b4c7 100644 Binary files a/src/web/favicon-32.png and b/src/web/favicon-32.png differ diff --git a/src/web/index.html b/src/web/index.html index 08310f3..dd75144 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -30,8 +30,7 @@ } .brand { display: flex; align-items: center; gap: 13px; min-width: 0; } .brand-mark { - display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; - background: var(--accent); color: white; font-size: .88rem; font-weight: 750; letter-spacing: -.04em; + display: block; width: 42px; height: 42px; border-radius: 12px; object-fit: cover; } h1 { font-size: 1.55rem; line-height: 1.15; letter-spacing: -.045em; margin: 0; font-weight: 720; } .sub { color: var(--muted); margin: 4px 0 0; font-size: .9rem; } @@ -64,6 +63,7 @@ padding: 8px 12px; text-align: center; white-space: normal; word-break: keep-all; overflow-wrap: anywhere; } + .jit-actions select { flex: 1 1 190px; min-width: 0; } #btn-check, #btn-change, #btn-install, #btn-login, #btn-code, #btn-resend { min-width: 150px; } #code-form .row { justify-content: flex-start; flex-wrap: nowrap; gap: 8px; } .item-head button { width: 108px; flex: 0 0 108px; } @@ -160,7 +160,7 @@
- +

AltKeeper

Keeps your iPhone apps alive by renewing their profiles.

@@ -245,6 +245,23 @@

Your apps

+
+

Just-in-time (JIT)

+

+ Enable JIT for an app that is already running on the iPhone. A compatible Developer Disk Image must be mounted and Developer Mode enabled. +

+
+ + + +
+

+ +
+
+

Automatic renewal

Runs once daily while this server is online. Renews expiring installed apps. If installed-app lookup fails, it falls back to all saved-team profiles. Default is OFF.

@@ -294,11 +311,12 @@

Anisette server

- +
@@ -336,6 +354,10 @@

Install an app from this PC

+

These settings are for new installations only. Existing apps and automatic renewal are never modified. A new Bundle ID allows a separate copy; the Apple team suffix is added automatically.

@@ -430,6 +452,7 @@

Logs

let anisetteDraftDirty = false, anisetteSavePending = false, anisettePendingUrl = null; let selectedDeviceId = localStorage.getItem("altkeeper-device") || "default"; let deviceDrafts = []; +let renderedJitApps = null; function deviceHeaders() { return { "X-AltKeeper-Device": selectedDeviceId }; @@ -478,7 +501,8 @@

Logs

function placeJob(job) { const panel = $("job"); - const slot = $(job.kind === "install" ? "install-job-slot" : "app-job-slot"); + const slot = $(job.kind === "install" ? "install-job-slot" + : job.kind.startsWith("jit_") ? "jit-job-slot" : "app-job-slot"); if (panel.parentElement !== slot) slot.append(panel); } @@ -491,7 +515,7 @@

Logs

} if (!s.account.present) return t("Sign in with your Apple ID first."); if (!s.account.password_saved) return t("Save the Apple ID password on the server to sign and install."); - if (s.phone.reachable !== true) return t("The iPhone must be reachable on Wi-Fi."); + if (s.phone.reachable !== true) return t("The iPhone must be reachable over USB or Wi-Fi."); return s.busy ? t("Another operation is in progress.") : t("The Apple ID is used to sign the app."); } @@ -555,7 +579,7 @@

Logs

const anisette = s.anisette || {}; const select = $("anisette-server"); - const knownAnisette = ["https://ani3.stikstore.app", "https://ani.jkcoxson.com"]; + const knownAnisette = ["https://ani3.stikstore.app", "https://ani.jkcoxson.com", "http://127.0.0.1:8788"]; if (anisetteDraftDirty && anisetteSavePending && anisette.url === anisettePendingUrl) { anisetteDraftDirty = false; anisetteSavePending = false; @@ -583,6 +607,30 @@

Logs

const err = p.reachable === false ? p.error : ""; banner("phone-error", err, "bad"); $("phone-error").title = p.detail || ""; + const jitSelect = $("jit-app-select"); + const jitApps = s.jit_apps || []; + const jitAppsKey = JSON.stringify(jitApps); + if (jitAppsKey !== renderedJitApps) { + const selectedJitPid = jitSelect.value; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = jitApps.length ? t("Select a running app") : t("Load running apps first"); + const jitOptions = jitApps.map(app => { + const option = document.createElement("option"); + option.value = String(app.pid); + option.textContent = (app.real_app_name || app.name) + " · PID " + app.pid; + return option; + }); + jitSelect.replaceChildren(placeholder, ...jitOptions); + jitSelect.value = jitApps.some(app => String(app.pid) === selectedJitPid) ? selectedJitPid : ""; + renderedJitApps = jitAppsKey; + } + jitSelect.disabled = !!s.busy || !jitApps.length; + $("jit-refresh").disabled = !!s.busy; + $("jit-enable").disabled = !!s.busy || !jitSelect.value; + $("jit-hint").textContent = jitApps.length + ? t("JIT applies to the selected app's current running session.") + : t("Load running apps from a connected iPhone to get started."); // Free-account limits: 3 active sideloaded apps, 10 App IDs per week. const qRow = $("quotas-row"); @@ -785,13 +833,15 @@

Logs

: job.kind === "pair_usb" ? t("Pairing iPhone over USB…") : job.kind === "install" ? t("Signing and installing the app. Keep this page open until it finishes…") + : job.kind === "jit_apps" ? t("Reading apps currently running on the iPhone…") + : job.kind === "jit_enable" ? t("Enabling JIT for the selected app…") : job.kind === "remove_profile" ? t("Backing up and deleting the selected profile…") : job.kind === "restore_profile" ? t("Restoring the selected profile…") : t("Renewal in progress. Keep this page open until it finishes…"); } else if (job.canceled) { st.className = "banner"; st.textContent = t("Waiting canceled."); } - else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? t("App installed.") : job.kind === "remove_profile" ? t("Profile removed and backed up.") : job.kind === "restore_profile" ? t("Profile restored.") : job.kind === "pair_usb" ? t("iPhone paired over USB.") : t("Done."); } + else if (job.ok) { st.className = "banner good"; st.textContent = job.kind === "install" ? t("App installed.") : job.kind === "jit_apps" ? t("Running app list updated.") : job.kind === "jit_enable" ? t("JIT enabled for the selected app.") : job.kind === "remove_profile" ? t("Profile removed and backed up.") : job.kind === "restore_profile" ? t("Profile restored.") : job.kind === "pair_usb" ? t("iPhone paired over USB.") : t("Done."); } else { st.className = "banner bad"; st.textContent = t("It did not complete successfully. Check the last line."); } const log = $("job-log"); const atEnd = log.scrollTop + log.clientHeight >= log.scrollHeight - 8; @@ -987,6 +1037,17 @@

Logs

}; $("btn-preview").onclick = () => {showPreview = !showPreview; if(state)render(state);}; $("btn-check").onclick = () => act(() => api("/api/check", {})); +$("jit-refresh").onclick = () => { + banner("jit-error", ""); + act(() => api("/api/jit/apps", {}), "jit-error"); +}; +$("jit-app-select").onchange = () => { if (state) render(state); }; +$("jit-enable").onclick = () => { + const pid = Number($("jit-app-select").value); + if (!Number.isSafeInteger(pid) || pid <= 0) return; + banner("jit-error", ""); + act(() => api("/api/jit/enable", {pid}), "jit-error"); +}; $("btn-due").onclick = () => act(() => api("/api/renew", { mode: "due" })); $("btn-all").onclick = () => { if (confirm(t("Renew all apps now? This takes about a minute and usually does not need to be done often."))) { @@ -1047,10 +1108,12 @@

Logs

token: pendingInstall.token, keep_extensions: keep, display_name: $("install-display-name").value.trim() || null, bundle_id: $("install-bundle-id").value.trim() || null, + enable_jit: $("install-enable-jit").checked, }); pendingInstall = null; $("install-display-name").value = ""; $("install-bundle-id").value = ""; + $("install-enable-jit").checked = false; banner("install-error", ""); } catch(e) { banner("install-error", e.message, "bad"); @@ -1074,7 +1137,9 @@

Logs

const form = new FormData(); form.append("ipa", file); const customize = !!($("install-display-name").value.trim() || $("install-bundle-id").value.trim()); - const r = await fetch("/api/install/inspect?customize=" + String(customize), { + const enableJit = $("install-enable-jit").checked; + const r = await fetch("/api/install/inspect?customize=" + String(customize) + + "&enable_jit=" + String(enableJit), { method: "POST", headers: { "X-Requested-With": "altkeeper", ...deviceHeaders() }, body: form, @@ -1087,6 +1152,7 @@

Logs

else { $("install-display-name").value = ""; $("install-bundle-id").value = ""; + $("install-enable-jit").checked = false; } $("install-error").hidden = true; } catch (e) { diff --git a/vendor/omnisette/Cargo.toml b/vendor/omnisette/Cargo.toml new file mode 100644 index 0000000..1f54873 --- /dev/null +++ b/vendor/omnisette/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "omnisette" +version = "0.1.0" +edition = "2021" + +[features] +remote-anisette = [] +async = ["dep:async-trait"] +default = ["remote-anisette", "dep:remove-async-await"] +remote-anisette-v3 = ["async", "dep:serde", "dep:serde_json", "dep:tokio-tungstenite", "dep:futures-util", "dep:chrono"] + +[dependencies] +base64 = "0.21" +hex = "0.4" +plist = "1.4" +reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] } +rand = "0.8" +sha2 = "0.10" +uuid = { version = "1.3", features = [ "v4", "fast-rng", "macro-diagnostics" ] } +android-loader = { git = "https://github.com/Dadoum/android-loader", branch = "bigger_pages" } +libc = "0.2" +log = "0.4" +async-trait = { version = "0.1", optional = true } +remove-async-await = { version = "1.0", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0.115", optional = true } +tokio-tungstenite = { version = "0.20.1", optional = true, features = ["rustls-tls-webpki-roots"] } +futures-util = { version = "0.3.28", optional = true } +chrono = { version = "0.4.37", optional = true } +thiserror = "1.0.58" +anyhow = "1.0.81" + +[target.'cfg(target_os = "macos")'.dependencies] +dlopen2 = "0.4" +objc = "0.2" +objc-foundation = "0.1" + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } +simplelog = "0.12" diff --git a/vendor/omnisette/LICENSE b/vendor/omnisette/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/vendor/omnisette/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/vendor/omnisette/src/adi_proxy.rs b/vendor/omnisette/src/adi_proxy.rs new file mode 100644 index 0000000..d489626 --- /dev/null +++ b/vendor/omnisette/src/adi_proxy.rs @@ -0,0 +1,380 @@ +use crate::adi_proxy::ProvisioningError::InvalidResponse; +use crate::anisette_headers_provider::AnisetteHeadersProvider; +use crate::AnisetteError; +use base64::engine::general_purpose::STANDARD as base64_engine; +use base64::Engine; +use log::debug; +use plist::{Dictionary, Value}; +use rand::RngCore; +#[cfg(not(feature = "async"))] +use reqwest::blocking::{Client, ClientBuilder, Response}; +use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; +#[cfg(feature = "async")] +use reqwest::{Client, ClientBuilder, Response}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug)] +pub struct ServerError { + pub code: i64, + pub description: String, +} + +#[derive(Debug)] +pub enum ProvisioningError { + InvalidResponse, + ServerError(ServerError), +} + +impl std::fmt::Display for ProvisioningError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for ProvisioningError {} + +#[derive(Debug, Error)] +pub enum ADIError { + Unknown(i32), + ProvisioningError(#[from] ProvisioningError), + PlistError(#[from] plist::Error), + ReqwestError(#[from] reqwest::Error), + Base64Error(#[from] base64::DecodeError), + InvalidHeaderValue(#[from] InvalidHeaderValue), + IOError(#[from] io::Error) +} + +impl ADIError { + pub fn resolve(error_number: i32) -> ADIError { + ADIError::Unknown(error_number) + } +} + +#[cfg_attr(feature = "async", async_trait::async_trait)] +trait ToPlist { + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn plist(self) -> Result; +} + +#[cfg_attr(feature = "async", async_trait::async_trait)] +impl ToPlist for Response { + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn plist(self) -> Result { + if let Ok(property_list) = Value::from_reader_xml(&*self.bytes().await?) { + Ok(property_list.as_dictionary().unwrap().to_owned()) + } else { + Err(ProvisioningError::InvalidResponse.into()) + } + } +} + +impl Display for ADIError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +pub struct SynchronizeData { + pub mid: Vec, + pub srm: Vec, +} + +pub struct StartProvisioningData { + pub cpim: Vec, + pub session: u32, +} + +pub struct RequestOTPData { + pub otp: Vec, + pub mid: Vec, +} + +#[cfg_attr(feature = "async", async_trait::async_trait(?Send))] +pub trait ADIProxy: Send + Sync { + fn erase_provisioning(&mut self, ds_id: i64) -> Result<(), ADIError>; + fn synchronize(&mut self, ds_id: i64, sim: &[u8]) -> Result; + fn destroy_provisioning_session(&mut self, session: u32) -> Result<(), ADIError>; + fn end_provisioning(&mut self, session: u32, ptm: &[u8], tk: &[u8]) -> Result<(), ADIError>; + fn start_provisioning( + &mut self, + ds_id: i64, + spim: &[u8], + ) -> Result; + fn is_machine_provisioned(&self, ds_id: i64) -> bool; + fn request_otp(&self, ds_id: i64) -> Result; + + fn set_local_user_uuid(&mut self, local_user_uuid: String); + fn set_device_identifier(&mut self, device_identifier: String) -> Result<(), ADIError>; + + fn get_local_user_uuid(&self) -> String; + fn get_device_identifier(&self) -> String; + fn get_serial_number(&self) -> String; +} + +pub trait ConfigurableADIProxy: ADIProxy { + fn set_identifier(&mut self, identifier: &str) -> Result<(), ADIError>; + fn set_provisioning_path(&mut self, path: &str) -> Result<(), ADIError>; +} + +pub const AKD_USER_AGENT: &str = "akd/1.0 CFNetwork/808.1.4"; +pub const CLIENT_INFO_HEADER: &str = + " "; +pub const DS_ID: i64 = -2; +pub const IDENTIFIER_LENGTH: usize = 16; +pub type Identifier = [u8; IDENTIFIER_LENGTH]; + +trait AppleRequestResult { + fn check_status(&self) -> Result<(), ADIError>; + fn get_response(&self) -> Result<&Dictionary, ADIError>; +} + +impl AppleRequestResult for Dictionary { + fn check_status(&self) -> Result<(), ADIError> { + let status = self + .get("Status") + .ok_or(InvalidResponse)? + .as_dictionary() + .unwrap(); + let code = status.get("ec").unwrap().as_signed_integer().unwrap(); + if code != 0 { + let description = status.get("em").unwrap().as_string().unwrap().to_string(); + Err(ProvisioningError::ServerError(ServerError { code, description }).into()) + } else { + Ok(()) + } + } + + fn get_response(&self) -> Result<&Dictionary, ADIError> { + if let Some(response) = self.get("Response") { + let response = response.as_dictionary().unwrap(); + response.check_status()?; + Ok(response) + } else { + Err(InvalidResponse.into()) + } + } +} + +impl dyn ADIProxy { + fn make_http_client(&mut self) -> Result { + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_str("text/x-xml-plist")?); + + headers.insert( + "X-Mme-Client-Info", + HeaderValue::from_str(CLIENT_INFO_HEADER)?, + ); + headers.insert( + "X-Mme-Device-Id", + HeaderValue::from_str(self.get_device_identifier().as_str())?, + ); + headers.insert( + "X-Apple-I-MD-LU", + HeaderValue::from_str(self.get_local_user_uuid().as_str())?, + ); + headers.insert( + "X-Apple-I-SRL-NO", + HeaderValue::from_str(self.get_serial_number().as_str())?, + ); + + debug!("Headers sent: {headers:?}"); + + let http_client = ClientBuilder::new() + .http1_title_case_headers() + .user_agent(AKD_USER_AGENT) + .default_headers(headers) + .build()?; + + Ok(http_client) + } + + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn provision_device(&mut self) -> Result<(), ADIError> { + let client = self.make_http_client()?; + + let url_bag_res = client + .get("https://gsa.apple.com/grandslam/GsService2/lookup") + .send() + .await? + .plist() + .await?; + + let urls = url_bag_res.get("urls").unwrap().as_dictionary().unwrap(); + + let start_provisioning_url = urls + .get("midStartProvisioning") + .unwrap() + .as_string() + .unwrap(); + let finish_provisioning_url = urls + .get("midFinishProvisioning") + .unwrap() + .as_string() + .unwrap(); + + let mut body = plist::Dictionary::new(); + body.insert( + "Header".to_string(), + plist::Value::Dictionary(plist::Dictionary::new()), + ); + body.insert( + "Request".to_string(), + plist::Value::Dictionary(plist::Dictionary::new()), + ); + + let mut sp_request = Vec::new(); + plist::Value::Dictionary(body).to_writer_xml(&mut sp_request)?; + + debug!("First provisioning request..."); + let response = client + .post(start_provisioning_url) + .body(sp_request) + .send() + .await? + .plist() + .await?; + + let response = response.get_response()?; + + let spim = response + .get("spim") + .unwrap() + .as_string() + .unwrap() + .to_owned(); + + let spim = base64_engine.decode(spim)?; + let first_step = self.start_provisioning(DS_ID, spim.as_slice())?; + + let mut body = Dictionary::new(); + let mut request = Dictionary::new(); + request.insert( + "cpim".to_owned(), + Value::String(base64_engine.encode(first_step.cpim)), + ); + body.insert("Header".to_owned(), Value::Dictionary(Dictionary::new())); + body.insert("Request".to_owned(), Value::Dictionary(request)); + + let mut fp_request = Vec::new(); + Value::Dictionary(body).to_writer_xml(&mut fp_request)?; + + debug!("Second provisioning request..."); + let response = client + .post(finish_provisioning_url) + .body(fp_request) + .send() + .await? + .plist() + .await?; + + let response = response.get_response()?; + + let ptm = base64_engine.decode(response.get("ptm").unwrap().as_string().unwrap())?; + let tk = base64_engine.decode(response.get("tk").unwrap().as_string().unwrap())?; + + self.end_provisioning(first_step.session, ptm.as_slice(), tk.as_slice())?; + debug!("Done."); + + Ok(()) + } +} + +pub struct ADIProxyAnisetteProvider { + adi_proxy: ProxyType, +} + +impl ADIProxyAnisetteProvider { + /// If you use this method, you are expected to set the identifier yourself. + pub fn without_identifier(adi_proxy: ProxyType) -> Result, ADIError> { + Ok(ADIProxyAnisetteProvider { adi_proxy }) + } + + pub fn new( + mut adi_proxy: ProxyType, + configuration_path: PathBuf, + ) -> Result, ADIError> { + let identifier_file_path = configuration_path.join("identifier"); + let mut identifier_file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(identifier_file_path)?; + let mut identifier = [0u8; IDENTIFIER_LENGTH]; + if identifier_file.metadata()?.len() == IDENTIFIER_LENGTH as u64 { + identifier_file.read_exact(&mut identifier)?; + } else { + rand::thread_rng().fill_bytes(&mut identifier); + identifier_file.write_all(&identifier)?; + } + + let mut local_user_uuid_hasher = Sha256::new(); + local_user_uuid_hasher.update(identifier); + + adi_proxy.set_device_identifier( + uuid::Uuid::from_bytes(identifier) + .to_string() + .to_uppercase(), + )?; // UUID, uppercase + adi_proxy + .set_local_user_uuid(hex::encode(local_user_uuid_hasher.finalize()).to_uppercase()); // 64 uppercase character hex + + Ok(ADIProxyAnisetteProvider { adi_proxy }) + } + + pub fn adi_proxy(&mut self) -> &mut ProxyType { + &mut self.adi_proxy + } +} + +#[cfg_attr(feature = "async", async_trait::async_trait)] +impl AnisetteHeadersProvider + for ADIProxyAnisetteProvider +{ + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn get_anisette_headers( + &mut self, + skip_provisioning: bool, + ) -> Result, AnisetteError> { + let adi_proxy = &mut self.adi_proxy as &mut dyn ADIProxy; + + if !adi_proxy.is_machine_provisioned(DS_ID) && !skip_provisioning { + adi_proxy.provision_device().await?; + } + + let machine_data = adi_proxy.request_otp(DS_ID)?; + + let mut headers = HashMap::new(); + headers.insert( + "X-Apple-I-MD".to_string(), + base64_engine.encode(machine_data.otp), + ); + headers.insert( + "X-Apple-I-MD-M".to_string(), + base64_engine.encode(machine_data.mid), + ); + headers.insert("X-Apple-I-MD-RINFO".to_string(), "17106176".to_string()); + headers.insert( + "X-Apple-I-MD-LU".to_string(), + adi_proxy.get_local_user_uuid(), + ); + headers.insert( + "X-Apple-I-SRL-NO".to_string(), + adi_proxy.get_serial_number(), + ); + headers.insert( + "X-Mme-Client-Info".to_string(), + CLIENT_INFO_HEADER.to_string(), + ); + headers.insert( + "X-Mme-Device-Id".to_string(), + adi_proxy.get_device_identifier(), + ); + + Ok(headers) + } +} diff --git a/vendor/omnisette/src/anisette_headers_provider.rs b/vendor/omnisette/src/anisette_headers_provider.rs new file mode 100644 index 0000000..903203e --- /dev/null +++ b/vendor/omnisette/src/anisette_headers_provider.rs @@ -0,0 +1,31 @@ + +use std::collections::HashMap; + +use crate::AnisetteError; + +#[cfg_attr(feature = "async", async_trait::async_trait)] +pub trait AnisetteHeadersProvider: Send + Sync { + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn get_anisette_headers( + &mut self, + skip_provisioning: bool, + ) -> Result, AnisetteError>; + + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn get_authentication_headers(&mut self) -> Result, AnisetteError> { + let headers = self.get_anisette_headers(false).await?; + Ok(self.normalize_headers(headers)) + } + + /// Normalizes headers to ensure that all the required headers are given. + fn normalize_headers( + &mut self, + mut headers: HashMap, + ) -> HashMap { + if let Some(client_info) = headers.remove("X-MMe-Client-Info") { + headers.insert("X-Mme-Client-Info".to_string(), client_info); + } + + headers + } +} diff --git a/vendor/omnisette/src/aos_kit.rs b/vendor/omnisette/src/aos_kit.rs new file mode 100644 index 0000000..c5b4b1a --- /dev/null +++ b/vendor/omnisette/src/aos_kit.rs @@ -0,0 +1,164 @@ +use crate::anisette_headers_provider::AnisetteHeadersProvider; +use anyhow::Result; + +use dlopen2::symbor::Library; +use objc::{msg_send, runtime::Class, sel, sel_impl}; +use objc_foundation::{INSString, NSObject, NSString}; +use std::collections::HashMap; +use std::error::Error; +use std::fmt::{Display, Formatter}; +pub struct AOSKitAnisetteProvider<'lt> { + aos_utilities: &'lt Class, + ak_device: &'lt Class, +} + +impl<'lt> AOSKitAnisetteProvider<'lt> { + pub fn new() -> Result> { + Library::open("/System/Library/PrivateFrameworks/AOSKit.framework/AOSKit")?; + Library::open("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit")?; + Ok(AOSKitAnisetteProvider { + aos_utilities: Class::get("AOSUtilities").ok_or(AOSKitError::ClassLoadFailed)?, + ak_device: Class::get("AKDevice").ok_or(AOSKitError::ClassLoadFailed)?, + }) + } +} + +#[cfg_attr(feature = "async", async_trait::async_trait)] +impl<'lt> AnisetteHeadersProvider for AOSKitAnisetteProvider<'lt> { + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn get_anisette_headers( + &mut self, + _skip_provisioning: bool, + ) -> Result, crate::AnisetteError> { + let mut headers_map = HashMap::new(); + + let headers: *const NSObject = unsafe { + msg_send![self.aos_utilities, retrieveOTPHeadersForDSID: NSString::from_str("-2")] + }; + + if headers.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + let otp: *const NSString = + unsafe { msg_send![headers, valueForKey: NSString::from_str("X-Apple-MD")] }; + if otp.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Apple-I-MD".to_string(), + unsafe { (*otp).as_str() }.to_string(), + ); + + let mid: *const NSString = + unsafe { msg_send![headers, valueForKey: NSString::from_str("X-Apple-MD-M")] }; + if mid.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Apple-I-MD-M".to_string(), + unsafe { (*mid).as_str() }.to_string(), + ); + + let machine_serial_number: *const NSString = + unsafe { msg_send![self.aos_utilities, machineSerialNumber] }; + if machine_serial_number.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Apple-SRL-NO".to_string(), + unsafe { (*machine_serial_number).as_str() }.to_string(), + ); + + let current_device: *const NSObject = unsafe { msg_send![self.ak_device, currentDevice] }; + + if current_device.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + let local_user_uuid: *const NSString = unsafe { msg_send![current_device, localUserUUID] }; + if local_user_uuid.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Apple-I-MD-LU".to_string(), + unsafe { (*local_user_uuid).as_str() }.to_string(), + ); + + let locale: *const NSObject = unsafe { msg_send![current_device, locale] }; + if locale.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + let locale: *const NSString = unsafe { msg_send![locale, localeIdentifier] }; + if locale.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Apple-Locale".to_string(), + unsafe { (*locale).as_str() }.to_string(), + ); // FIXME maybe not the right header name + + let server_friendly_description: *const NSString = + unsafe { msg_send![current_device, serverFriendlyDescription] }; + if server_friendly_description.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Mme-Client-Info".to_string(), + unsafe { (*server_friendly_description).as_str() }.to_string(), + ); + + let unique_device_identifier: *const NSString = + unsafe { msg_send![current_device, uniqueDeviceIdentifier] }; + if unique_device_identifier.is_null() { + return Err(crate::AnisetteError::AnisetteNotProvisioned); + } + + headers_map.insert( + "X-Mme-Device-Id".to_string(), + unsafe { (*unique_device_identifier).as_str() }.to_string(), + ); + + Ok(headers_map) + } +} + +#[derive(Debug)] +enum AOSKitError { + ClassLoadFailed, +} + +impl Display for AOSKitError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl Error for AOSKitError {} + +#[cfg(all(test, not(feature = "async")))] +mod tests { + use crate::anisette_headers_provider::AnisetteHeadersProvider; + use crate::aos_kit::AOSKitAnisetteProvider; + use anyhow::Result; + use log::info; + + #[test] + fn fetch_anisette_aoskit() -> Result<()> { + crate::tests::init_logger(); + + let mut provider = AOSKitAnisetteProvider::new()?; + info!( + "AOSKit headers: {:?}", + (&mut provider as &mut dyn AnisetteHeadersProvider).get_authentication_headers()? + ); + Ok(()) + } +} diff --git a/vendor/omnisette/src/lib.rs b/vendor/omnisette/src/lib.rs new file mode 100644 index 0000000..bfd2681 --- /dev/null +++ b/vendor/omnisette/src/lib.rs @@ -0,0 +1,226 @@ +//! A library to generate "anisette" data. Docs are coming soon. +//! +//! If you want an async API, enable the `async` feature. +//! +//! If you want remote anisette, make sure the `remote-anisette` feature is enabled. (it's currently on by default) + +use crate::adi_proxy::{ADIProxyAnisetteProvider, ConfigurableADIProxy}; +use crate::anisette_headers_provider::AnisetteHeadersProvider; +use std::io; +use std::path::PathBuf; +use adi_proxy::ADIError; +use thiserror::Error; + +pub mod adi_proxy; +pub mod anisette_headers_provider; +pub mod store_services_core; + +#[cfg(feature = "remote-anisette-v3")] +pub mod remote_anisette_v3; + +#[cfg(target_os = "macos")] +pub mod aos_kit; + +#[cfg(feature = "remote-anisette")] +pub mod remote_anisette; + +#[allow(dead_code)] +pub struct AnisetteHeaders; + +#[allow(dead_code)] +#[derive(Debug, Error)] +pub enum AnisetteError { + #[allow(dead_code)] + #[error("Unsupported device")] + UnsupportedDevice, + #[error("Invalid argument {0}")] + InvalidArgument(String), + #[error("Anisette not provisioned!")] + AnisetteNotProvisioned, + #[error("Plist serialization error {0}")] + PlistError(#[from] plist::Error), + #[error("Request Error {0}")] + ReqwestError(#[from] reqwest::Error), + #[cfg(feature = "remote-anisette-v3")] + #[error("Provisioning socket error {0}")] + WsError(#[from] tokio_tungstenite::tungstenite::error::Error), + #[cfg(feature = "remote-anisette-v3")] + #[error("JSON error {0}")] + SerdeError(#[from] serde_json::Error), + #[error("IO error {0}")] + IOError(#[from] io::Error), + #[error("ADI error {0}")] + ADIError(#[from] ADIError), + #[error("Invalid library format")] + InvalidLibraryFormat, + #[error("Misc")] + Misc, + #[error("Missing Libraries")] + MissingLibraries, + #[error("{0}")] + Anyhow(#[from] anyhow::Error) +} + +pub const DEFAULT_ANISETTE_URL: &str = "https://ani.f1sh.me/"; + +pub const DEFAULT_ANISETTE_URL_V3: &str = "https://ani.sidestore.io"; + +#[derive(Clone, Debug)] +pub struct AnisetteConfiguration { + anisette_url: String, + anisette_url_v3: String, + configuration_path: PathBuf, + macos_serial: String, +} + +impl Default for AnisetteConfiguration { + fn default() -> Self { + AnisetteConfiguration::new() + } +} + +impl AnisetteConfiguration { + pub fn new() -> AnisetteConfiguration { + AnisetteConfiguration { + anisette_url: DEFAULT_ANISETTE_URL.to_string(), + anisette_url_v3: DEFAULT_ANISETTE_URL_V3.to_string(), + configuration_path: PathBuf::new(), + macos_serial: "0".to_string() + } + } + + pub fn anisette_url(&self) -> &String { + &self.anisette_url + } + + pub fn configuration_path(&self) -> &PathBuf { + &self.configuration_path + } + + pub fn set_anisette_url(mut self, anisette_url: String) -> AnisetteConfiguration { + self.anisette_url = anisette_url; + self + } + + pub fn set_macos_serial(mut self, macos_serial: String) -> AnisetteConfiguration { + self.macos_serial = macos_serial; + self + } + + pub fn set_configuration_path(mut self, configuration_path: PathBuf) -> AnisetteConfiguration { + self.configuration_path = configuration_path; + self + } +} + +pub enum AnisetteHeadersProviderType { + Local, + Remote, +} + +pub struct AnisetteHeadersProviderRes { + pub provider: Box, + pub provider_type: AnisetteHeadersProviderType, +} + +impl AnisetteHeadersProviderRes { + pub fn local(provider: Box) -> AnisetteHeadersProviderRes { + AnisetteHeadersProviderRes { + provider, + provider_type: AnisetteHeadersProviderType::Local, + } + } + + pub fn remote(provider: Box) -> AnisetteHeadersProviderRes { + AnisetteHeadersProviderRes { + provider, + provider_type: AnisetteHeadersProviderType::Remote, + } + } +} + +impl AnisetteHeaders { + pub fn get_anisette_headers_provider( + configuration: AnisetteConfiguration, + ) -> Result { + #[cfg(target_os = "macos")] + if let Ok(prov) = aos_kit::AOSKitAnisetteProvider::new() { + return Ok(AnisetteHeadersProviderRes::local(Box::new(prov))); + } + + // TODO: handle Err because it will just go to remote anisette and not tell the user anything + if let Ok(ssc_anisette_headers_provider) = + AnisetteHeaders::get_ssc_anisette_headers_provider(configuration.clone()) + { + return Ok(ssc_anisette_headers_provider); + } + + #[cfg(feature = "remote-anisette-v3")] + return Ok(AnisetteHeadersProviderRes::remote(Box::new( + remote_anisette_v3::RemoteAnisetteProviderV3::new(configuration.anisette_url_v3, configuration.configuration_path.clone(), configuration.macos_serial.clone()), + ))); + + #[cfg(feature = "remote-anisette")] + return Ok(AnisetteHeadersProviderRes::remote(Box::new( + remote_anisette::RemoteAnisetteProvider::new(configuration.anisette_url), + ))); + + #[cfg(not(feature = "remote-anisette"))] + bail!(AnisetteMetaError::UnsupportedDevice) + } + + pub fn get_ssc_anisette_headers_provider( + configuration: AnisetteConfiguration, + ) -> Result { + let mut ssc_adi_proxy = store_services_core::StoreServicesCoreADIProxy::new( + configuration.configuration_path(), + )?; + let config_path = configuration.configuration_path(); + ssc_adi_proxy.set_provisioning_path(config_path.to_str().ok_or( + AnisetteError::InvalidArgument("configuration.configuration_path".to_string()), + )?)?; + Ok(AnisetteHeadersProviderRes::local(Box::new( + ADIProxyAnisetteProvider::new(ssc_adi_proxy, config_path.to_path_buf())?, + ))) + } +} + +#[cfg(test)] +mod tests { + use log::LevelFilter; + use simplelog::{ColorChoice, ConfigBuilder, TermLogger, TerminalMode}; + + pub fn init_logger() { + if TermLogger::init( + LevelFilter::Trace, + ConfigBuilder::new() + .set_target_level(LevelFilter::Error) + .add_filter_allow_str("omnisette") + .build(), + TerminalMode::Mixed, + ColorChoice::Auto, + ) + .is_ok() + {} + } + + #[cfg(not(feature = "async"))] + #[test] + fn fetch_anisette_auto() -> Result<()> { + use crate::{AnisetteConfiguration, AnisetteHeaders}; + use log::info; + use std::path::PathBuf; + + crate::tests::init_logger(); + + let mut provider = AnisetteHeaders::get_anisette_headers_provider( + AnisetteConfiguration::new() + .set_configuration_path(PathBuf::new().join("anisette_test")), + )?; + info!( + "Headers: {:?}", + provider.provider.get_authentication_headers()? + ); + Ok(()) + } +} diff --git a/vendor/omnisette/src/remote_anisette.rs b/vendor/omnisette/src/remote_anisette.rs new file mode 100644 index 0000000..b061bd9 --- /dev/null +++ b/vendor/omnisette/src/remote_anisette.rs @@ -0,0 +1,47 @@ +use crate::{anisette_headers_provider::AnisetteHeadersProvider, AnisetteError}; +#[cfg(not(feature = "async"))] +use reqwest::blocking::get; +#[cfg(feature = "async")] +use reqwest::get; +use std::collections::HashMap; + +pub struct RemoteAnisetteProvider { + url: String, +} + +impl RemoteAnisetteProvider { + pub fn new(url: String) -> RemoteAnisetteProvider { + RemoteAnisetteProvider { url } + } +} + +#[cfg_attr(feature = "async", async_trait::async_trait)] +impl AnisetteHeadersProvider for RemoteAnisetteProvider { + #[cfg_attr(not(feature = "async"), remove_async_await::remove_async_await)] + async fn get_anisette_headers( + &mut self, + _skip_provisioning: bool, + ) -> Result, AnisetteError> { + Ok(get(&self.url).await?.json().await?) + } +} + +#[cfg(all(test, not(feature = "async")))] +mod tests { + use crate::anisette_headers_provider::AnisetteHeadersProvider; + use crate::remote_anisette::RemoteAnisetteProvider; + use crate::DEFAULT_ANISETTE_URL; + use log::info; + + #[test] + fn fetch_anisette_remote() -> Result<(), AnisetteError> { + crate::tests::init_logger(); + + let mut provider = RemoteAnisetteProvider::new(DEFAULT_ANISETTE_URL.to_string()); + info!( + "Remote headers: {:?}", + (&mut provider as &mut dyn AnisetteHeadersProvider).get_authentication_headers()? + ); + Ok(()) + } +} diff --git a/vendor/omnisette/src/remote_anisette_v3.rs b/vendor/omnisette/src/remote_anisette_v3.rs new file mode 100644 index 0000000..d5f28a9 --- /dev/null +++ b/vendor/omnisette/src/remote_anisette_v3.rs @@ -0,0 +1,445 @@ + +// Implementing the SideStore Anisette v3 protocol + +use std::{collections::HashMap, fs, io::Cursor, path::PathBuf}; + +use base64::engine::general_purpose; +use chrono::{DateTime, SubsecRound, Utc}; +use log::debug; +use plist::{Data, Dictionary}; +use reqwest::{Client, ClientBuilder, RequestBuilder}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use rand::Rng; +use sha2::{Sha256, Digest}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use uuid::Uuid; +use futures_util::{stream::StreamExt, SinkExt}; +use std::fmt::Write; +use base64::Engine; +use async_trait::async_trait; + +use crate::{anisette_headers_provider::AnisetteHeadersProvider, AnisetteError}; + + +fn plist_to_string(value: &T) -> Result { + plist_to_buf(value).map(|val| String::from_utf8(val).unwrap()) +} + +fn plist_to_buf(value: &T) -> Result, plist::Error> { + let mut buf: Vec = Vec::new(); + let writer = Cursor::new(&mut buf); + plist::to_writer_xml(writer, &value)?; + Ok(buf) +} + +fn bin_serialize(x: &[u8], s: S) -> Result +where + S: Serializer, +{ + s.serialize_bytes(x) +} + +fn bin_serialize_opt(x: &Option>, s: S) -> Result +where + S: Serializer, +{ + x.clone().map(|i| Data::new(i)).serialize(s) +} + +fn bin_deserialize_opt<'de, D>(d: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Deserialize::deserialize(d)?; + Ok(s.map(|i| i.into())) +} + +fn bin_deserialize_16<'de, D>(d: D) -> Result<[u8; 16], D::Error> +where + D: Deserializer<'de>, +{ + let s: Data = Deserialize::deserialize(d)?; + let s: Vec = s.into(); + Ok(s.try_into().unwrap()) +} + +fn encode_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + write!(&mut s, "{:02x}", b).unwrap(); + } + s +} +fn base64_encode(data: &[u8]) -> String { + general_purpose::STANDARD.encode(data) +} + +fn base64_decode(data: &str) -> Vec { + general_purpose::STANDARD.decode(data.trim()).unwrap() +} + + + +#[derive(Deserialize)] +struct AnisetteClientInfo { + client_info: String, + user_agent: String, +} + +#[derive(Serialize, Deserialize)] +pub struct AnisetteState { + #[serde(serialize_with = "bin_serialize", deserialize_with = "bin_deserialize_16")] + keychain_identifier: [u8; 16], + #[serde(serialize_with = "bin_serialize_opt", deserialize_with = "bin_deserialize_opt")] + adi_pb: Option>, +} + +impl Default for AnisetteState { + fn default() -> Self { + AnisetteState { + keychain_identifier: rand::thread_rng().gen::<[u8; 16]>(), + adi_pb: None + } + } +} + +impl AnisetteState { + pub fn new() -> AnisetteState { + AnisetteState::default() + } + + pub fn is_provisioned(&self) -> bool { + self.adi_pb.is_some() + } + + fn md_lu(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(&self.keychain_identifier); + hasher.finalize().into() + } + + fn device_id(&self) -> String { + Uuid::from_bytes(self.keychain_identifier).to_string() + } +} +pub struct AnisetteClient { + client_info: AnisetteClientInfo, + url: String +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct ProvisionBodyData { + header: Dictionary, + request: Dictionary, +} + +#[derive(Debug)] +pub struct AnisetteData { + machine_id: String, + one_time_password: String, + routing_info: String, + device_description: String, + local_user_id: String, + device_unique_identifier: String +} + +impl AnisetteData { + pub fn get_headers(&self, serial: String) -> HashMap { + let dt: DateTime = Utc::now().round_subsecs(0); + + HashMap::from_iter([ + ("X-Apple-I-Client-Time".to_string(), dt.format("%+").to_string().replace("+00:00", "Z")), + ("X-Apple-I-SRL-NO".to_string(), serial), + ("X-Apple-I-TimeZone".to_string(), "UTC".to_string()), + ("X-Apple-Locale".to_string(), "en_US".to_string()), + ("X-Apple-I-MD-RINFO".to_string(), self.routing_info.clone()), + ("X-Apple-I-MD-LU".to_string(), self.local_user_id.clone()), + ("X-Mme-Device-Id".to_string(), self.device_unique_identifier.clone()), + ("X-Apple-I-MD".to_string(), self.one_time_password.clone()), + ("X-Apple-I-MD-M".to_string(), self.machine_id.clone()), + ("X-Mme-Client-Info".to_string(), self.device_description.clone()), + ].into_iter()) + } +} + +fn make_reqwest() -> Result { + Ok(ClientBuilder::new() + .http1_title_case_headers() + .danger_accept_invalid_certs(true) // TODO: pin the apple certificate + .build()?) +} + +impl AnisetteClient { + pub async fn new(url: String) -> Result { + let path = format!("{}/v3/client_info", url); + let http_client = make_reqwest()?; + let client_info = http_client.get(path) + .send().await? + .json::().await?; + Ok(AnisetteClient { + client_info, + url + }) + } + + fn build_apple_request(&self, state: &AnisetteState, builder: RequestBuilder) -> RequestBuilder { + let dt: DateTime = Utc::now().round_subsecs(0); + + builder.header("X-Mme-Client-Info", &self.client_info.client_info) + .header("User-Agent", &self.client_info.user_agent) + .header("Content-Type", "text/x-xml-plist") + .header("X-Apple-I-MD-LU", encode_hex(&state.md_lu())) + .header("X-Mme-Device-Id", state.device_id()) + .header("X-Apple-I-Client-Time", dt.format("%+").to_string()) + .header("X-Apple-I-TimeZone", "UTC") + .header("X-Apple-Locale", "en_US") + } + + pub async fn get_headers(&self, state: &AnisetteState) -> Result { + let path = format!("{}/v3/get_headers", self.url); + let http_client = make_reqwest()?; + + #[derive(Serialize)] + struct GetHeadersBody { + identifier: String, + adi_pb: String, + } + let body = GetHeadersBody { + identifier: base64_encode(&state.keychain_identifier), + adi_pb: base64_encode(state.adi_pb.as_ref().ok_or(AnisetteError::AnisetteNotProvisioned)?), + }; + + #[derive(Deserialize)] + #[serde(tag = "result")] + enum AnisetteHeaders { + GetHeadersError { + message: String + }, + Headers { + #[serde(rename = "X-Apple-I-MD-M")] + machine_id: String, + #[serde(rename = "X-Apple-I-MD")] + one_time_password: String, + #[serde(rename = "X-Apple-I-MD-RINFO")] + routing_info: String, + } + } + + let headers = http_client.post(path) + .json(&body) + .send().await? + .json::().await?; + match headers { + AnisetteHeaders::GetHeadersError { message } => { + if message.contains("-45061") { + Err(AnisetteError::AnisetteNotProvisioned) + } else { + panic!("Unknown error {}", message) + } + }, + AnisetteHeaders::Headers { machine_id, one_time_password, routing_info } => { + Ok(AnisetteData { + machine_id, + one_time_password, + routing_info, + device_description: self.client_info.client_info.clone(), + local_user_id: encode_hex(&state.md_lu()), + device_unique_identifier: state.device_id() + }) + } + } + } + + pub async fn provision(&self, state: &mut AnisetteState) -> Result<(), AnisetteError> { + debug!("Provisioning Anisette"); + let http_client = make_reqwest()?; + let resp = self.build_apple_request(&state, http_client.get("https://gsa.apple.com/grandslam/GsService2/lookup")) + .send().await?; + let text = resp.text().await?; + + let protocol_val = plist::Value::from_reader(Cursor::new(text.as_str()))?; + let urls = protocol_val.as_dictionary().unwrap().get("urls").unwrap().as_dictionary().unwrap(); + + let start_provisioning_url = urls.get("midStartProvisioning").unwrap().as_string().unwrap(); + let end_provisioning_url = urls.get("midFinishProvisioning").unwrap().as_string().unwrap(); + debug!("Got provisioning urls: {} and {}", start_provisioning_url, end_provisioning_url); + + let provision_ws_url = format!("{}/v3/provisioning_session", self.url).replace("https://", "wss://"); + let (mut connection, _) = connect_async(&provision_ws_url).await?; + + + #[derive(Deserialize)] + #[serde(tag = "result")] + enum ProvisionInput { + GiveIdentifier, + GiveStartProvisioningData, + GiveEndProvisioningData { + #[allow(dead_code)] // it's not even dead, rust just has problems + cpim: String + }, + ProvisioningSuccess { + #[allow(dead_code)] // it's not even dead, rust just has problems + adi_pb: String + } + } + + loop { + let Some(Ok(data)) = connection.next().await else { + continue + }; + if data.is_text() { + let txt = data.to_text().unwrap(); + let msg: ProvisionInput = serde_json::from_str(txt)?; + match msg { + ProvisionInput::GiveIdentifier => { + #[derive(Serialize)] + struct Identifier { + identifier: String // base64 + } + let identifier = Identifier { identifier: base64_encode(&state.keychain_identifier) }; + connection.send(Message::Text(serde_json::to_string(&identifier)?)).await?; + }, + ProvisionInput::GiveStartProvisioningData => { + let http_client = make_reqwest()?; + let body_data = ProvisionBodyData { header: Dictionary::new(), request: Dictionary::new() }; + let resp = self.build_apple_request(state, http_client.post(start_provisioning_url)) + .body(plist_to_string(&body_data)?) + .send().await?; + let text = resp.text().await?; + + let protocol_val = plist::Value::from_reader(Cursor::new(text.as_str()))?; + let spim = protocol_val.as_dictionary().unwrap().get("Response").unwrap().as_dictionary().unwrap() + .get("spim").unwrap().as_string().unwrap(); + + debug!("GiveStartProvisioningData"); + #[derive(Serialize)] + struct Spim { + spim: String // base64 + } + let spim = Spim { spim: spim.to_string() }; + connection.send(Message::Text(serde_json::to_string(&spim)?)).await?; + }, + ProvisionInput::GiveEndProvisioningData { cpim } => { + let http_client = make_reqwest()?; + let body_data = ProvisionBodyData { header: Dictionary::new(), request: Dictionary::from_iter([("cpim", cpim)].into_iter()) }; + let resp = self.build_apple_request(state, http_client.post(end_provisioning_url)) + .body(plist_to_string(&body_data)?) + .send().await?; + let text = resp.text().await?; + + let protocol_val = plist::Value::from_reader(Cursor::new(text.as_str()))?; + let response = protocol_val.as_dictionary().unwrap().get("Response").unwrap().as_dictionary().unwrap(); + + debug!("GiveEndProvisioningData"); + + #[derive(Serialize)] + struct EndProvisioning<'t> { + ptm: &'t str, + tk: &'t str, + } + let end_provisioning = EndProvisioning { + ptm: response.get("ptm").unwrap().as_string().unwrap(), + tk: response.get("tk").unwrap().as_string().unwrap(), + }; + connection.send(Message::Text(serde_json::to_string(&end_provisioning)?)).await?; + }, + ProvisionInput::ProvisioningSuccess { adi_pb } => { + debug!("ProvisioningSuccess"); + state.adi_pb = Some(base64_decode(&adi_pb)); + connection.close(None).await?; + break; + } + } + } else if data.is_close() { + break; + } + } + + Ok(()) + } +} + + +pub struct RemoteAnisetteProviderV3 { + client_url: String, + client: Option, + pub state: Option, + configuration_path: PathBuf, + serial: String +} + +impl RemoteAnisetteProviderV3 { + pub fn new(url: String, configuration_path: PathBuf, serial: String) -> RemoteAnisetteProviderV3 { + RemoteAnisetteProviderV3 { + client_url: url, + client: None, + state: None, + configuration_path, + serial + } + } +} + +#[async_trait] +impl AnisetteHeadersProvider for RemoteAnisetteProviderV3 { + async fn get_anisette_headers( + &mut self, + _skip_provisioning: bool, + ) -> Result, AnisetteError> { + if self.client.is_none() { + self.client = Some(AnisetteClient::new(self.client_url.clone()).await?); + } + let client = self.client.as_ref().unwrap(); + + fs::create_dir_all(&self.configuration_path)?; + + let config_path = self.configuration_path.join("state.plist"); + if self.state.is_none() { + self.state = Some(if let Ok(text) = plist::from_file(&config_path) { + text + } else { + AnisetteState::new() + }); + } + + let state = self.state.as_mut().unwrap(); + if !state.is_provisioned() { + client.provision(state).await?; + plist::to_file_xml(&config_path, state)?; + } + let data = match client.get_headers(&state).await { + Ok(data) => data, + Err(err) => { + if matches!(err, AnisetteError::AnisetteNotProvisioned) { + state.adi_pb = None; + client.provision(state).await?; + plist::to_file_xml(config_path, state)?; + client.get_headers(&state).await? + } else { panic!() } + }, + }; + Ok(data.get_headers(self.serial.clone())) + } +} + +#[cfg(test)] +mod tests { + use crate::anisette_headers_provider::AnisetteHeadersProvider; + use crate::remote_anisette_v3::RemoteAnisetteProviderV3; + use crate::{AnisetteError, DEFAULT_ANISETTE_URL_V3}; + use log::info; + + #[tokio::test] + async fn fetch_anisette_remote_v3() -> Result<(), AnisetteError> { + crate::tests::init_logger(); + + let mut provider = RemoteAnisetteProviderV3::new(DEFAULT_ANISETTE_URL_V3.to_string(), "anisette_test".into(), "0".to_string()); + info!( + "Remote headers: {:?}", + (&mut provider as &mut dyn AnisetteHeadersProvider).get_authentication_headers().await? + ); + Ok(()) + } +} + diff --git a/vendor/omnisette/src/store_services_core.rs b/vendor/omnisette/src/store_services_core.rs new file mode 100644 index 0000000..2315b5d --- /dev/null +++ b/vendor/omnisette/src/store_services_core.rs @@ -0,0 +1,455 @@ +#[cfg(target_os = "macos")] +mod posix_macos; +#[cfg(target_family = "windows")] +mod posix_windows; + +use crate::adi_proxy::{ + ADIError, ADIProxy, ConfigurableADIProxy, RequestOTPData, StartProvisioningData, + SynchronizeData, +}; +use crate::AnisetteError; + +use android_loader::android_library::AndroidLibrary; +use android_loader::sysv64_type; +use android_loader::{hook_manager, sysv64}; +use std::collections::HashMap; +use std::ffi::{c_char, CString}; +use std::path::PathBuf; + +pub struct StoreServicesCoreADIProxy<'lt> { + #[allow(dead_code)] + store_services_core: AndroidLibrary<'lt>, + + local_user_uuid: String, + device_identifier: String, + + adi_set_android_id: sysv64_type!(fn(id: *const u8, length: u32) -> i32), + adi_set_provisioning_path: sysv64_type!(fn(path: *const u8) -> i32), + + adi_provisioning_erase: sysv64_type!(fn(ds_id: i64) -> i32), + adi_synchronize: sysv64_type!( + fn( + ds_id: i64, + sim: *const u8, + sim_length: u32, + out_mid: *mut *const u8, + out_mid_length: *mut u32, + out_srm: *mut *const u8, + out_srm_length: *mut u32, + ) -> i32 + ), + adi_provisioning_destroy: sysv64_type!(fn(session: u32) -> i32), + adi_provisioning_end: sysv64_type!( + fn(session: u32, ptm: *const u8, ptm_length: u32, tk: *const u8, tk_length: u32) -> i32 + ), + adi_provisioning_start: sysv64_type!( + fn( + ds_id: i64, + spim: *const u8, + spim_length: u32, + out_cpim: *mut *const u8, + out_cpim_length: *mut u32, + out_session: *mut u32, + ) -> i32 + ), + adi_get_login_code: sysv64_type!(fn(ds_id: i64) -> i32), + adi_dispose: sysv64_type!(fn(ptr: *const u8) -> i32), + adi_otp_request: sysv64_type!( + fn( + ds_id: i64, + out_mid: *mut *const u8, + out_mid_size: *mut u32, + out_otp: *mut *const u8, + out_otp_size: *mut u32, + ) -> i32 + ), +} + +impl StoreServicesCoreADIProxy<'_> { + pub fn new<'lt>(library_path: &PathBuf) -> Result, AnisetteError> { + Self::with_custom_provisioning_path(library_path, library_path) + } + + pub fn with_custom_provisioning_path<'lt>(library_path: &PathBuf, provisioning_path: &PathBuf) -> Result, AnisetteError> { + // Should be safe if the library is correct. + unsafe { + LoaderHelpers::setup_hooks(); + + if !library_path.exists() { + std::fs::create_dir(library_path)?; + return Err(AnisetteError::MissingLibraries.into()); + } + + let library_path = library_path.canonicalize()?; + + #[cfg(target_arch = "x86_64")] + const ARCH: &str = "x86_64"; + #[cfg(target_arch = "x86")] + const ARCH: &str = "x86"; + #[cfg(target_arch = "arm")] + const ARCH: &str = "armeabi-v7a"; + #[cfg(target_arch = "aarch64")] + const ARCH: &str = "arm64-v8a"; + + let native_library_path = library_path.join("lib").join(ARCH); + + let path = native_library_path.join("libstoreservicescore.so"); + let path = path.to_str().ok_or(AnisetteError::Misc)?; + let store_services_core = AndroidLibrary::load(path)?; + + let adi_load_library_with_path: sysv64_type!(fn(path: *const u8) -> i32) = + std::mem::transmute( + store_services_core + .get_symbol("kq56gsgHG6") + .ok_or(AnisetteError::InvalidLibraryFormat)?, + ); + + let path = CString::new( + native_library_path + .to_str() + .ok_or(AnisetteError::Misc)?, + ) + .unwrap(); + let result = (adi_load_library_with_path)(path.as_ptr() as *const u8); + if result != 0 { + return Err(AnisetteError::ADIError(ADIError::resolve(result))); + } + + let adi_set_android_id = store_services_core + .get_symbol("Sph98paBcz") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_set_provisioning_path = store_services_core + .get_symbol("nf92ngaK92") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + + let adi_provisioning_erase = store_services_core + .get_symbol("p435tmhbla") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_synchronize = store_services_core + .get_symbol("tn46gtiuhw") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_provisioning_destroy = store_services_core + .get_symbol("fy34trz2st") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_provisioning_end = store_services_core + .get_symbol("uv5t6nhkui") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_provisioning_start = store_services_core + .get_symbol("rsegvyrt87") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_get_login_code = store_services_core + .get_symbol("aslgmuibau") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_dispose = store_services_core + .get_symbol("jk24uiwqrg") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + let adi_otp_request = store_services_core + .get_symbol("qi864985u0") + .ok_or(AnisetteError::InvalidLibraryFormat)?; + + let mut proxy = StoreServicesCoreADIProxy { + store_services_core, + + local_user_uuid: String::new(), + device_identifier: String::new(), + + adi_set_android_id: std::mem::transmute(adi_set_android_id), + adi_set_provisioning_path: std::mem::transmute(adi_set_provisioning_path), + + adi_provisioning_erase: std::mem::transmute(adi_provisioning_erase), + adi_synchronize: std::mem::transmute(adi_synchronize), + adi_provisioning_destroy: std::mem::transmute(adi_provisioning_destroy), + adi_provisioning_end: std::mem::transmute(adi_provisioning_end), + adi_provisioning_start: std::mem::transmute(adi_provisioning_start), + adi_get_login_code: std::mem::transmute(adi_get_login_code), + adi_dispose: std::mem::transmute(adi_dispose), + adi_otp_request: std::mem::transmute(adi_otp_request), + }; + + proxy.set_provisioning_path( + provisioning_path.to_str().ok_or(AnisetteError::Misc)?, + )?; + + Ok(proxy) + } + } +} + +impl ADIProxy for StoreServicesCoreADIProxy<'_> { + fn erase_provisioning(&mut self, ds_id: i64) -> Result<(), ADIError> { + match (self.adi_provisioning_erase)(ds_id) { + 0 => Ok(()), + err => Err(ADIError::resolve(err)), + } + } + + fn synchronize(&mut self, ds_id: i64, sim: &[u8]) -> Result { + unsafe { + let sim_size = sim.len() as u32; + let sim_ptr = sim.as_ptr(); + + let mut mid_size: u32 = 0; + let mut mid_ptr: *const u8 = std::ptr::null(); + let mut srm_size: u32 = 0; + let mut srm_ptr: *const u8 = std::ptr::null(); + + match (self.adi_synchronize)( + ds_id, + sim_ptr, + sim_size, + &mut mid_ptr, + &mut mid_size, + &mut srm_ptr, + &mut srm_size, + ) { + 0 => { + let mut mid = vec![0; mid_size as usize]; + let mut srm = vec![0; srm_size as usize]; + + mid.copy_from_slice(std::slice::from_raw_parts(mid_ptr, mid_size as usize)); + srm.copy_from_slice(std::slice::from_raw_parts(srm_ptr, srm_size as usize)); + + (self.adi_dispose)(mid_ptr); + (self.adi_dispose)(srm_ptr); + + Ok(SynchronizeData { mid, srm }) + } + err => Err(ADIError::resolve(err)), + } + } + } + + fn destroy_provisioning_session(&mut self, session: u32) -> Result<(), ADIError> { + match (self.adi_provisioning_destroy)(session) { + 0 => Ok(()), + err => Err(ADIError::resolve(err)), + } + } + + fn end_provisioning(&mut self, session: u32, ptm: &[u8], tk: &[u8]) -> Result<(), ADIError> { + let ptm_size = ptm.len() as u32; + let ptm_ptr = ptm.as_ptr(); + + let tk_size = tk.len() as u32; + let tk_ptr = tk.as_ptr(); + + match (self.adi_provisioning_end)(session, ptm_ptr, ptm_size, tk_ptr, tk_size) { + 0 => Ok(()), + err => Err(ADIError::resolve(err)), + } + } + + fn start_provisioning( + &mut self, + ds_id: i64, + spim: &[u8], + ) -> Result { + unsafe { + let spim_size = spim.len() as u32; + let spim_ptr = spim.as_ptr(); + + let mut cpim_size: u32 = 0; + let mut cpim_ptr: *const u8 = std::ptr::null(); + + let mut session: u32 = 0; + + match (self.adi_provisioning_start)( + ds_id, + spim_ptr, + spim_size, + &mut cpim_ptr, + &mut cpim_size, + &mut session, + ) { + 0 => { + let mut cpim = vec![0; cpim_size as usize]; + + cpim.copy_from_slice(std::slice::from_raw_parts(cpim_ptr, cpim_size as usize)); + + (self.adi_dispose)(cpim_ptr); + + Ok(StartProvisioningData { cpim, session }) + } + err => Err(ADIError::resolve(err)), + } + } + } + + fn is_machine_provisioned(&self, ds_id: i64) -> bool { + (self.adi_get_login_code)(ds_id) == 0 + } + + fn request_otp(&self, ds_id: i64) -> Result { + unsafe { + let mut mid_size: u32 = 0; + let mut mid_ptr: *const u8 = std::ptr::null(); + let mut otp_size: u32 = 0; + let mut otp_ptr: *const u8 = std::ptr::null(); + + match (self.adi_otp_request)( + ds_id, + &mut mid_ptr, + &mut mid_size, + &mut otp_ptr, + &mut otp_size, + ) { + 0 => { + let mut mid = vec![0; mid_size as usize]; + let mut otp = vec![0; otp_size as usize]; + + mid.copy_from_slice(std::slice::from_raw_parts(mid_ptr, mid_size as usize)); + otp.copy_from_slice(std::slice::from_raw_parts(otp_ptr, otp_size as usize)); + + (self.adi_dispose)(mid_ptr); + (self.adi_dispose)(otp_ptr); + + Ok(RequestOTPData { mid, otp }) + } + err => Err(ADIError::resolve(err)), + } + } + } + + fn set_local_user_uuid(&mut self, local_user_uuid: String) { + self.local_user_uuid = local_user_uuid; + } + + fn set_device_identifier(&mut self, device_identifier: String) -> Result<(), ADIError> { + self.set_identifier(&device_identifier[0..16])?; + self.device_identifier = device_identifier; + Ok(()) + } + + fn get_local_user_uuid(&self) -> String { + self.local_user_uuid.clone() + } + + fn get_device_identifier(&self) -> String { + self.device_identifier.clone() + } + + fn get_serial_number(&self) -> String { + "0".to_string() + } +} + +impl ConfigurableADIProxy for StoreServicesCoreADIProxy<'_> { + fn set_identifier(&mut self, identifier: &str) -> Result<(), ADIError> { + match (self.adi_set_android_id)(identifier.as_ptr(), identifier.len() as u32) { + 0 => Ok(()), + err => Err(ADIError::resolve(err)), + } + } + + fn set_provisioning_path(&mut self, path: &str) -> Result<(), ADIError> { + let path = CString::new(path).unwrap(); + match (self.adi_set_provisioning_path)(path.as_ptr() as *const u8) { + 0 => Ok(()), + err => Err(ADIError::resolve(err)), + } + } +} + +struct LoaderHelpers; + +use rand::Rng; + +#[cfg(all(target_family = "unix", not(target_os = "macos")))] +use libc::{ + chmod, close, free, fstat, ftruncate, gettimeofday, lstat, malloc, mkdir, open, read, strncpy, + umask, write, +}; +#[cfg(target_os = "macos")] +use posix_macos::*; + +static mut ERRNO: i32 = 0; + +#[allow(unreachable_code)] +#[sysv64] +unsafe fn __errno_location() -> *mut i32 { + ERRNO = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); + &mut ERRNO +} + +#[sysv64] +fn arc4random() -> u32 { + rand::thread_rng().gen() +} + +#[sysv64] +unsafe fn __system_property_get(_name: *const c_char, value: *mut c_char) -> i32 { + *value = '0' as c_char; + return 1; +} + +#[cfg(target_family = "windows")] +use posix_windows::*; + +impl LoaderHelpers { + pub fn setup_hooks() { + let mut hooks = HashMap::new(); + hooks.insert("arc4random".to_owned(), arc4random as usize); + hooks.insert("chmod".to_owned(), chmod as usize); + hooks.insert( + "__system_property_get".to_owned(), + __system_property_get as usize, + ); + hooks.insert("__errno".to_owned(), __errno_location as usize); + hooks.insert("close".to_owned(), close as usize); + hooks.insert("free".to_owned(), free as usize); + hooks.insert("fstat".to_owned(), fstat as usize); + hooks.insert("ftruncate".to_owned(), ftruncate as usize); + hooks.insert("gettimeofday".to_owned(), gettimeofday as usize); + hooks.insert("lstat".to_owned(), lstat as usize); + hooks.insert("malloc".to_owned(), malloc as usize); + hooks.insert("mkdir".to_owned(), mkdir as usize); + hooks.insert("open".to_owned(), open as usize); + hooks.insert("read".to_owned(), read as usize); + hooks.insert("strncpy".to_owned(), strncpy as usize); + hooks.insert("umask".to_owned(), umask as usize); + hooks.insert("write".to_owned(), write as usize); + + hook_manager::add_hooks(hooks); + } +} + +#[cfg(test)] +mod tests { + use crate::{AnisetteConfiguration, AnisetteHeaders}; + use log::info; + use std::path::PathBuf; + use crate::AnisetteError; + + #[cfg(not(feature = "async"))] + #[test] + fn fetch_anisette_ssc() -> Result<(), AnisetteError> { + crate::tests::init_logger(); + + let mut provider = AnisetteHeaders::get_ssc_anisette_headers_provider( + AnisetteConfiguration::new() + .set_configuration_path(PathBuf::new().join("anisette_test")), + )?; + info!( + "Headers: {:?}", + provider.provider.get_authentication_headers()? + ); + Ok(()) + } + + #[cfg(feature = "async")] + #[tokio::test] + async fn fetch_anisette_ssc_async() -> Result<(), AnisetteError> { + + crate::tests::init_logger(); + + let mut provider = AnisetteHeaders::get_ssc_anisette_headers_provider( + AnisetteConfiguration::new() + .set_configuration_path(PathBuf::new().join("anisette_test")), + )?; + info!( + "Headers: {:?}", + provider.provider.get_authentication_headers().await? + ); + Ok(()) + } +} diff --git a/vendor/omnisette/src/store_services_core/posix_macos.rs b/vendor/omnisette/src/store_services_core/posix_macos.rs new file mode 100644 index 0000000..b961bb8 --- /dev/null +++ b/vendor/omnisette/src/store_services_core/posix_macos.rs @@ -0,0 +1,120 @@ +pub use libc::{chmod, close, free, ftruncate, gettimeofday, malloc, mkdir, read, strncpy, umask, write}; + +use libc::{lstat as lstat_macos, fstat as fstat_macos, stat as stat_macos, open as open_macos, O_APPEND, O_CREAT, O_EXCL, O_TRUNC, O_WRONLY, O_RDWR, O_RDONLY}; + +use android_loader::sysv64; + +#[repr(C)] +pub struct StatLinux { + pub st_dev: u64, + pub st_ino: u64, + pub st_nlink: u64, + pub st_mode: u32, + pub st_uid: u32, + pub st_gid: u32, + __pad0: libc::c_int, + pub st_rdev: u64, + pub st_size: i64, + pub st_blksize: i64, + pub st_blocks: i64, + pub st_atime: i64, + pub st_atime_nsec: i64, + pub st_mtime: i64, + pub st_mtime_nsec: i64, + pub st_ctime: i64, + pub st_ctime_nsec: i64, + __unused: [i64; 3], +} + +#[sysv64] +pub unsafe fn lstat(path: *const libc::c_char, buf: *mut StatLinux) -> libc::c_int { + let mut st: stat_macos = std::mem::zeroed(); + let result = lstat_macos(path, &mut st); + if result != 0 { + return result; + } + *buf = StatLinux { + st_dev: st.st_dev as _, + st_ino: st.st_ino as _, + st_nlink: st.st_nlink as _, + st_mode: st.st_mode as _, + st_uid: st.st_uid as _, + st_gid: st.st_gid as _, + __pad0: 0 as _, + st_rdev: st.st_rdev as _, + st_size: st.st_size as _, + st_blksize: st.st_blksize as _, + st_blocks: st.st_blocks as _, + st_atime: st.st_atime as _, + st_atime_nsec: st.st_atime_nsec as _, + st_mtime: st.st_mtime as _, + st_mtime_nsec: st.st_mtime_nsec as _, + st_ctime: st.st_ctime as _, + st_ctime_nsec: st.st_ctime_nsec as _, + __unused: [0, 0, 0], + }; + result +} + +#[sysv64] +pub unsafe fn fstat(fildes: libc::c_int, buf: *mut StatLinux) -> libc::c_int { + let mut st: stat_macos = std::mem::zeroed(); + let result = fstat_macos(fildes, &mut st); + if result != 0 { + return result; + } + *buf = StatLinux { + st_dev: st.st_dev as _, + st_ino: st.st_ino as _, + st_nlink: st.st_nlink as _, + st_mode: st.st_mode as _, + st_uid: st.st_uid as _, + st_gid: st.st_gid as _, + __pad0: 0 as _, + st_rdev: st.st_rdev as _, + st_size: st.st_size as _, + st_blksize: st.st_blksize as _, + st_blocks: st.st_blocks as _, + st_atime: st.st_atime as _, + st_atime_nsec: st.st_atime_nsec as _, + st_mtime: st.st_mtime as _, + st_mtime_nsec: st.st_mtime_nsec as _, + st_ctime: st.st_ctime as _, + st_ctime_nsec: st.st_ctime_nsec as _, + __unused: [0, 0, 0], + }; + result +} + +#[sysv64] +pub unsafe fn open(path: *const libc::c_char, oflag: libc::c_int, mode: libc::c_uint) -> libc::c_int { + let mut macos_flag = 0; + + if oflag & 0o100 != 0 { + macos_flag |= O_CREAT; + } + + if oflag & 0o1 == 1 { + macos_flag |= O_WRONLY; + } else if oflag & 0o2 != 0 { + macos_flag |= O_RDWR; + } else { + macos_flag |= O_RDONLY; + } + + if oflag & 0o200 != 0 { + macos_flag |= O_EXCL; + } + if oflag & 0o1000 != 0 { + macos_flag |= O_TRUNC; + } + if oflag & 0o2000 != 0 { + macos_flag |= O_APPEND; + } + + if oflag & 0o100 != 0 { + open_macos(path, macos_flag, mode) + } else { + open_macos(path, macos_flag) + } +} diff --git a/vendor/omnisette/src/store_services_core/posix_windows.rs b/vendor/omnisette/src/store_services_core/posix_windows.rs new file mode 100644 index 0000000..486e2b5 --- /dev/null +++ b/vendor/omnisette/src/store_services_core/posix_windows.rs @@ -0,0 +1,268 @@ +use android_loader::sysv64; +use libc::{O_CREAT, O_RDONLY, O_RDWR, O_WRONLY}; +use log::debug; +use std::ffi::{CStr, CString}; +use std::mem::MaybeUninit; + +#[link(name = "ucrt")] +extern "C" { + fn _errno() -> *mut libc::c_int; + fn _timespec64_get(__ts: *mut libc::timespec, __base: libc::c_int) -> libc::c_int; + fn _chsize(handle: i64, length: u64) -> usize; +} + +// took from cosmopolitan libc +#[sysv64] +pub unsafe fn umask(mask: usize) -> usize { + debug!("umask: Windows specific implementation called!"); + mask +} + +#[sysv64] +pub unsafe fn ftruncate(handle: i64, length: u64) -> usize { + debug!( + "ftruncate: Windows translate-call. handle: {}, length: {}", + handle, length + ); + let ftr = _chsize(handle, length); + + ftr +} + +#[repr(C)] +pub struct PosixTimeval { + tv_sec: u64, + tv_usec: u64, /* microseconds */ +} + +#[repr(C)] +pub struct PosixTimespec { + tv_sec: i64, + tv_nsec: i64, /* microseconds */ +} + +#[repr(C)] +pub struct PosixTimezone { + tz_minuteswest: u32, + tz_dsttime: u32, /* microseconds */ +} + +static HECTONANOSECONDS: u64 = 10000000; + +impl PosixTimespec { + pub fn from_windows_time(time: u64) -> PosixTimespec { + PosixTimespec { + tv_sec: (time / HECTONANOSECONDS) as i64, + tv_nsec: (time % HECTONANOSECONDS) as i64 * 100, + } + } +} + +#[sysv64] +pub unsafe fn gettimeofday(timeval: *mut PosixTimeval, _tz: *mut PosixTimezone) -> isize { + debug!("gettimeofday: Windows specific implementation called!"); + let mut ts = MaybeUninit::::zeroed(); + + let ret = _timespec64_get(ts.as_mut_ptr(), 1); + let ts = ts.assume_init(); + + *timeval = PosixTimeval { + tv_sec: ts.tv_sec as _, + tv_usec: (ts.tv_nsec / 1000) as _, + }; + + ret as _ +} + +#[repr(C)] +pub struct StatLinux { + pub st_dev: u64, + pub st_ino: u64, + pub st_nlink: u64, + pub st_mode: u32, + pub st_uid: u32, + pub st_gid: u32, + __pad0: libc::c_int, + pub st_rdev: u64, + pub st_size: i64, + pub st_blksize: i64, + pub st_blocks: i64, + pub st_atime: i64, + pub st_atime_nsec: i64, + pub st_mtime: i64, + pub st_mtime_nsec: i64, + pub st_ctime: i64, + pub st_ctime_nsec: i64, + __unused: [i64; 3], +} + +trait ToWindows { + unsafe fn to_windows(&self) -> T; +} + +impl ToWindows for CStr { + unsafe fn to_windows(&self) -> CString { + let path = self + .to_str() + .unwrap() + .to_string() + .chars() + .map(|x| match x { + '/' => '\\', + c => c, + }) + .collect::(); + + let path = path.trim_start_matches("\\\\?\\").to_string(); + + CString::new(path).unwrap() + } +} + +#[sysv64] +pub unsafe fn lstat(path: *const libc::c_char, buf: *mut StatLinux) -> libc::c_int { + debug!( + "lstat: Windows translate-call, path: {:?}", + CStr::from_ptr(path) + ); + let mut stat_win = MaybeUninit::::zeroed(); + let path = CStr::from_ptr(path).to_windows(); + + let ret = libc::stat(path.as_ptr(), stat_win.as_mut_ptr()); + let stat_win = stat_win.assume_init(); + + *buf = stat_win.to_windows(); + + ret +} + +impl ToWindows for libc::stat { + unsafe fn to_windows(&self) -> StatLinux { + let atime = PosixTimespec::from_windows_time(self.st_atime as u64); + let mtime = PosixTimespec::from_windows_time(self.st_mtime as u64); + let ctime = PosixTimespec::from_windows_time(self.st_ctime as u64); + + let mut mode = 0o555; + let win_mode = self.st_mode; + + if win_mode & 0b11 != 0 { + mode |= 0o200; + } + + if win_mode & 0x4000 != 0 { + mode |= 0o40000; + } + + StatLinux { + st_dev: self.st_dev as _, + st_ino: self.st_ino as _, + st_nlink: self.st_nlink as _, + st_mode: mode as _, + st_uid: self.st_uid as _, + st_gid: self.st_gid as _, + __pad0: 0, + st_rdev: self.st_rdev as _, + st_size: self.st_size as _, + st_blksize: 0, + st_blocks: 0, + st_atime: atime.tv_sec, + st_atime_nsec: 0, + st_mtime: mtime.tv_sec, + st_mtime_nsec: 0, + st_ctime: ctime.tv_sec, + st_ctime_nsec: 0, + __unused: [0, 0, 0], + } + } +} + +#[sysv64] +pub unsafe fn fstat(fildes: libc::c_int, buf: *mut StatLinux) -> libc::c_int { + debug!("fstat: Windows translate-call"); + let mut stat_win = MaybeUninit::::zeroed(); + let ret = libc::fstat(fildes, stat_win.as_mut_ptr()); + let stat_win = stat_win.assume_init(); + + *buf = stat_win.to_windows(); + + ret +} + +#[sysv64] +pub unsafe fn malloc(size: libc::size_t) -> *mut libc::c_void { + // debug!("malloc: Windows translate-call"); + libc::malloc(size) +} + +#[sysv64] +pub unsafe fn free(p: *mut libc::c_void) { + // debug!("free: Windows translate-call"); + libc::free(p) +} + +#[sysv64] +pub unsafe fn strncpy( + dst: *mut libc::c_char, + src: *const libc::c_char, + n: libc::size_t, +) -> *mut libc::c_char { + debug!("strncpy: Windows translate-call"); + libc::strncpy(dst, src, n) +} + +#[sysv64] +pub unsafe fn chmod(path: *const libc::c_char, mode: libc::c_int) -> libc::c_int { + debug!("chmod: Windows translate-call"); + libc::chmod(path, mode) +} + +#[sysv64] +pub unsafe fn mkdir(path: *const libc::c_char) -> libc::c_int { + debug!("mkdir: Windows translate-call"); + libc::mkdir(path) +} + +#[sysv64] +pub unsafe fn open(path: *const libc::c_char, oflag: libc::c_int) -> libc::c_int { + debug!("open: Windows translate-call oflag 0o{:o}", oflag); + + let path = CStr::from_ptr(path).to_windows(); + + let mut win_flag = 0x8000; // binary mode + + if oflag & 0o100 != 0 { + win_flag |= O_CREAT; + } + + if oflag & 0o1 == 1 { + win_flag |= O_WRONLY; + } else if oflag & 0o2 != 0 { + win_flag |= O_RDWR; + } else { + win_flag |= O_RDONLY; + } + + let val = libc::open(path.as_ptr(), win_flag); + + val +} + +#[sysv64] +pub unsafe fn close(fd: libc::c_int) -> libc::c_int { + debug!("close: Windows translate-call"); + libc::close(fd) +} + +#[sysv64] +pub unsafe fn read(fd: libc::c_int, buf: *mut libc::c_void, count: libc::c_uint) -> libc::c_int { + debug!("read: Windows translate-call"); + + let r = libc::read(fd, buf, count); + r +} + +#[sysv64] +pub unsafe fn write(fd: libc::c_int, buf: *const libc::c_void, count: libc::c_uint) -> libc::c_int { + debug!("write: Windows translate-call"); + libc::write(fd, buf, count) +}