From f02352e421cfc69638b546d27d36c796ee507367 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:54:21 +0530 Subject: [PATCH 1/2] daemon: replace exit(1) callbacks with recoverable state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, both SVPlaybackLeaseManager callbacks (endLeaseCb, pbErrCb) called exit(1) unconditionally, making every Apple lease termination a fatal process death. wrapper-rootless was behaving like a short-lived utility rather than a persistent background service. This commit introduces a proper recovery lifecycle: Recovery state machine (main.cpp) - RecoveryState enum: Running / Scheduled / Refreshing / Failed - get_recovery_state() exported as extern C int for status endpoints and Electron IPC (0=Running, 1=Scheduled, 2=Refreshing, 3=Failed) - is_recovery_active() derived from state, used to gate HTTP requests Non-blocking callbacks - endLeaseCb / pbErrCb now push a code onto a queue and return immediately — no library calls from within the lease manager thread - Eliminates reentrancy and deadlock risk from callback context Dedicated recovery worker thread - Drains the entire queue on each wake (coalescing): a burst of 3084+3084+PLAYBACK_ERR produces one refresh cycle, not three - Exponential backoff: 1s -> 2s -> 5s -> 10s -> 30s (clamped, never stops) - Calls refresh_decrypt_ctx() as the sole owner of reacquisition logic - Verifies success via is_preshare_ctx_ready() — resets consec_fails only when preshareCtx is non-null after the refresh - Self-schedules retry on failure (kRetryInternal) so the daemon keeps retrying even when Apple sends no further lease-end event (e.g. transient network failure during refresh) Thread safety (main.c) - g_ctx_mutex (PTHREAD_MUTEX_INITIALIZER) protects all preshareCtx reads and writes against concurrent access from the decrypt thread and the recovery worker - Lock is released before FairPlay network calls to avoid blocking decryption during reacquisition - is_preshare_ctx_ready() reads preshareCtx under g_ctx_mutex Client-visible state gating (main.c) - handle() and handle_m3u8() check is_recovery_active() per request - Decrypt server: returns immediately (EOF) during recovery - M3U8 server: writes empty line, continues loop — no hanging requests - Prevents FairPlay key-delivery calls from racing the recovery worker HTTP servers (decrypt, m3u8, account) remain alive across all lease events. The Electron supervisor continues to provide the outer safety net for true process deaths (segfault, OOM, etc.). --- main.c | 84 ++++++++++++++++-- main.cpp | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 330 insertions(+), 13 deletions(-) diff --git a/main.c b/main.c index fbc7931..38b746d 100644 --- a/main.c +++ b/main.c @@ -34,6 +34,11 @@ static char *g_storefront_id = NULL; static char *g_dev_token = NULL; static char *g_music_token = NULL; +/* Protects preshareCtx against concurrent access from the main decrypt + * thread (handle/getKdContext) and the recovery worker (refresh_decrypt_ctx). + * Using PTHREAD_MUTEX_INITIALIZER avoids the need for an explicit init call. */ +static pthread_mutex_t g_ctx_mutex = PTHREAD_MUTEX_INITIALIZER; + #ifndef MyRelease static int (*orig_debug_log_enabled)(void); static int (*orig_android_log_print)(int prio, const char *tag, const char *fmt, ...); @@ -363,6 +368,10 @@ static inline struct shared_ptr init_ctx() { extern void *endLeaseCallback; extern void *pbErrCallback; +extern void start_recovery_thread(void); +extern int is_recovery_active(void); +/* Returns current RecoveryState as int: 0=Running 1=Scheduled 2=Refreshing 3=Failed */ +extern int get_recovery_state(void); inline static uint8_t login(struct shared_ptr reqCtx) { fprintf(stderr, "[+] logging in...\n"); @@ -439,9 +448,19 @@ static void *preshareCtx = NULL; inline static void *getKdContext(const char *const adam, const char *const uri) { uint8_t isPreshare = (strcmp("0", adam) == 0); - if (isPreshare && preshareCtx != NULL) { - return preshareCtx; + + /* Fast-path: return cached preshare context if available. + * Lock only long enough to read the pointer — the long FairPlay + * network operations below must NOT be performed under this lock, + * or the recovery worker would block all decryption during reacquisition. */ + if (isPreshare) { + pthread_mutex_lock(&g_ctx_mutex); + void *cached = preshareCtx; + pthread_mutex_unlock(&g_ctx_mutex); + if (cached != NULL) + return cached; } + fprintf(stderr, "[.] adamId: %s, uri: %s\n", adam, uri); union std_string defaultId = new_std_string(adam); @@ -471,22 +490,61 @@ inline static void *getKdContext(const char *const adam, void *kdContext = *_ZNK18SVFootHillPContext9kdContextEv(SVFootHillPContext.obj); - if (kdContext != NULL && isPreshare) + + /* Store result under lock so the recovery worker sees a consistent value + * if it concurrently resets preshareCtx to NULL. */ + if (kdContext != NULL && isPreshare) { + pthread_mutex_lock(&g_ctx_mutex); preshareCtx = kdContext; + pthread_mutex_unlock(&g_ctx_mutex); + } + return kdContext; } -void refresh_decrypt_ctx() { +void refresh_decrypt_ctx(void) { uint8_t autom = 1; + + /* Request a new playback lease from Apple. */ _ZN22SVPlaybackLeaseManager12requestLeaseERKb(leaseMgr, &autom); + + /* Tear down all cached FairPlay key contexts so fresh keys are derived. */ _ZN21SVFootHillSessionCtrl16resetAllContextsEv(FHinstance); + + /* Invalidate the preshare cache under lock before rebuilding it. + * Any concurrent getKdContext() call will miss the cache and fall through + * to a full key derivation — correct behaviour during recovery. */ + pthread_mutex_lock(&g_ctx_mutex); preshareCtx = NULL; - preshareCtx = getKdContext("0", "skd://itunes.apple.com/P000000000/s1/e1"); + pthread_mutex_unlock(&g_ctx_mutex); + + /* Rebuild the preshare context. getKdContext() will write the new pointer + * under g_ctx_mutex internally. */ + getKdContext("0", "skd://itunes.apple.com/P000000000/s1/e1"); + fprintf(stderr, "[!] refreshed context\n"); } +/* Called by the recovery worker to determine whether reacquisition produced + * a usable decrypt context. Reads preshareCtx under g_ctx_mutex. */ +int is_preshare_ctx_ready(void) { + pthread_mutex_lock(&g_ctx_mutex); + int ready = (preshareCtx != NULL); + pthread_mutex_unlock(&g_ctx_mutex); + return ready; +} + void handle(const int connfd) { while (1) { + /* Fail fast during lease recovery: avoid queuing FairPlay key- + * delivery requests to Apple's servers while the recovery worker + * is already performing a refresh cycle. The client receives an + * EOF/broken-pipe and should retry after a brief pause. */ + if (is_recovery_active()) { + fprintf(stderr, "[.] decrypt request refused: lease recovery in progress\n"); + return; + } + uint8_t adamSize; if (!readfull(connfd, &adamSize, sizeof(uint8_t))) return; @@ -692,6 +750,17 @@ void handle_m3u8(const int connfd) { char *ptr; unsigned long adamID = strtoul(adam, &ptr, 10); const char *m3u8; + + /* During lease recovery the decrypt context is being rebuilt. + * Return an empty line (same as a failed asset request) so the + * client can detect the condition and retry rather than waiting + * on a network call that will fail anyway. */ + if (is_recovery_active()) { + fprintf(stderr, "[.] m3u8 request refused: lease recovery in progress\n"); + writefull(connfd, "\n", 1); + continue; + } + if (offlineFlag) { m3u8 = get_m3u8_method_download(reqCtx, adamID); } else { @@ -1071,6 +1140,11 @@ int main(int argc, char *argv[]) { _ZN22SVPlaybackLeaseManager12requestLeaseERKb(leaseMgr, &autom); FHinstance = _ZN21SVFootHillSessionCtrl8instanceEv(); + /* Start the async recovery thread. Must be started after leaseMgr and + * FHinstance are initialised so that refresh_decrypt_ctx() is safe to call + * from the worker at any point after this. */ + start_recovery_thread(); + offlineFlag = offline_available(); if (offlineFlag) { fprintf(stderr, "[+] This account supports offline channel\n"); diff --git a/main.cpp b/main.cpp index fec64f4..c38235b 100644 --- a/main.cpp +++ b/main.cpp @@ -1,10 +1,242 @@ +/** + * main.cpp — C++ glue: lease/playback callbacks and recovery subsystem. + * + * Recovery state machine + * ───────────────────── + * + * ┌──────────────────────────────────────────────────────┐ + * │ Running │◄──────────────┐ + * └──────────────────────────────┬───────────────────────┘ │ + * │ Lease end / Playback error │ + * ▼ │ + * ┌──────────────────────────────────────────────────────┐ │ + * │ Scheduled │ │ + * │ (event queued; worker waking; backoff pending) │ │ + * └──────────────────────────────┬───────────────────────┘ │ + * │ backoff elapsed │ + * ▼ │ + * ┌──────────────────────────────────────────────────────┐ │ + * │ Refreshing │ │ + * │ (refresh_decrypt_ctx() in progress; requests │ │ + * │ gated at handle() / handle_m3u8()) │ │ + * └───────────────┬──────────────────────────────┬───────┘ │ + * │ preshareCtx non-null │ preshareCtx NULL │ + * ▼ ▼ │ + * ┌───────────────────────────┐ ┌───────────────────────────┐ │ + * │ Running │ │ Failed │ │ + * │ consec_fails = 0 │ │ consec_fails++ │ │ + * │ normal service resumes │ │ auto-schedules retry │──────────┘ + * └───────────────────────────┘ └───────────────────────────┘ + * + * Auto-retry on failure + * ───────────────────── + * When refresh_decrypt_ctx() succeeds but preshareCtx remains NULL (e.g. + * transient Apple server error, network interruption), no further Apple event + * arrives, so the worker would stall waiting on the condition variable. + * To avoid this, the worker self-schedules a retry via schedule_recovery() + * before looping back. The retry goes through the same queue and backoff + * path, ensuring exponential spacing even for self-triggered retries. + * + * Coalescing + * ────────── + * The queue is drained completely on each wake. A burst of events + * (3084 → 3084 → PLAYBACK_ERR) results in one refresh cycle, not three. + * + * RecoveryState visibility + * ──────────────────────── + * get_recovery_state() is exported as extern "C" and returns RecoveryState + * as a plain int so main.c (and a future HTTP status endpoint or Electron IPC + * handler) can expose the daemon's current condition without C++ knowledge. + * Values map directly to the RecoveryState enum below. + */ + +#include +#include #include #include #include +#include +#include +#include +#include /* sleep() */ +#include +extern "C" void refresh_decrypt_ctx(void); +extern "C" int is_preshare_ctx_ready(void); extern "C" void handle(int fd); -extern "C" uint8_t handle_cpp(int fd) { +// --------------------------------------------------------------------------- +// RecoveryState — the daemon's lifecycle state machine +// --------------------------------------------------------------------------- + +enum class RecoveryState : int { + Running = 0, // Normal operation; all requests served + Scheduled = 1, // Recovery event queued; worker waking; backoff pending + Refreshing = 2, // refresh_decrypt_ctx() in flight; requests gated + Failed = 3, // Last cycle failed; auto-retry queued +}; + +static std::atomic g_recovery_state{RecoveryState::Running}; + +// Returns the current RecoveryState as a plain int (C-visible). +// 0 = Running, 1 = Scheduled, 2 = Refreshing, 3 = Failed. +extern "C" int get_recovery_state(void) +{ + return static_cast(g_recovery_state.load()); +} + +// Convenience predicate used by handle() / handle_m3u8() to gate requests. +// Returns 1 when the daemon is not in its normal Running state. +extern "C" int is_recovery_active(void) +{ + return (g_recovery_state.load() != RecoveryState::Running) ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Recovery queue +// --------------------------------------------------------------------------- + +// Event codes pushed into the recovery queue. +static const int kPlaybackErrSentinel = -1; // playback error from pbErrCb +static const int kRetryInternal = -2; // self-scheduled retry on failure + +// Exponential backoff: 1 → 2 → 5 → 10 → 30 seconds. +// kBackoffMaxIdx is reused indefinitely so retries never stop. +static const int kBackoffSecs[] = {1, 2, 5, 10, 30}; +static const int kBackoffMaxIdx = 4; + +static std::mutex g_recovery_mtx; +static std::condition_variable g_recovery_cv; +static std::queue g_recovery_q; + +static void schedule_recovery(int code) +{ + { + std::lock_guard lk(g_recovery_mtx); + g_recovery_q.push(code); + } + g_recovery_cv.notify_one(); +} + +// --------------------------------------------------------------------------- +// Recovery worker — the single owner of retry timing and reacquisition +// --------------------------------------------------------------------------- + +static void recovery_worker() +{ + int consec_fails = 0; + + while (true) { + + // ── Wait for at least one event ───────────────────────────────────── + std::vector burst; + { + std::unique_lock lk(g_recovery_mtx); + g_recovery_cv.wait(lk, []{ + return !g_recovery_q.empty(); + }); + // Drain the entire queue (coalescing). + while (!g_recovery_q.empty()) { + burst.push_back(g_recovery_q.front()); + g_recovery_q.pop(); + } + } + + g_recovery_state.store(RecoveryState::Scheduled); + + // ── Log every code in the burst ───────────────────────────────────── + fprintf(stderr, "[recovery] STATE=Scheduled — %zu event(s) coalesced:\n", + burst.size()); + for (int c : burst) { + switch (c) { + case kPlaybackErrSentinel: + fprintf(stderr, "[recovery] PLAYBACK_ERROR\n"); + break; + case kRetryInternal: + fprintf(stderr, "[recovery] RETRY_INTERNAL " + "(self-scheduled after failed refresh)\n"); + break; + case 3084: + fprintf(stderr, "[recovery] LEASE_END code=3084 " + "(server revoke — simultaneous playback " + "or natural expiry)\n"); + break; + default: + fprintf(stderr, "[recovery] LEASE_END code=%d " + "(unknown — logging for future mapping)\n", c); + break; + } + } + + // ── Exponential backoff ───────────────────────────────────────────── + int idx = (consec_fails <= kBackoffMaxIdx) ? consec_fails + : kBackoffMaxIdx; + int delay = kBackoffSecs[idx]; + + if (consec_fails == 0) { + fprintf(stderr, + "[recovery] RECOVERY_SCHEDULED attempt=1 backoff=%ds\n", + delay); + } else { + fprintf(stderr, + "[recovery] RECOVERY_SCHEDULED attempt=%d backoff=%ds " + "(Apple may still be rejecting — retrying indefinitely)\n", + consec_fails + 1, delay); + } + sleep(delay); + + // ── Enter Refreshing state — gate incoming client requests ────────── + g_recovery_state.store(RecoveryState::Refreshing); + fprintf(stderr, "[recovery] STATE=Refreshing — calling refresh_decrypt_ctx()\n"); + + // Single-threaded by design: the worker is the only caller of + // refresh_decrypt_ctx(). No other code path calls it directly. + refresh_decrypt_ctx(); + + // ── Evaluate success ──────────────────────────────────────────────── + // is_preshare_ctx_ready() reads preshareCtx under g_ctx_mutex (main.c). + // A non-null context means Apple accepted the new lease and FairPlay + // key derivation succeeded — requests can resume. + if (is_preshare_ctx_ready()) { + consec_fails = 0; + g_recovery_state.store(RecoveryState::Running); + fprintf(stderr, + "[recovery] STATE=Running — REFRESH_SUCCESS, " + "decrypt context ready, HTTP servers resuming normal operation\n"); + } else { + consec_fails++; + g_recovery_state.store(RecoveryState::Failed); + + int next_idx = (consec_fails <= kBackoffMaxIdx) ? consec_fails + : kBackoffMaxIdx; + int next_delay = kBackoffSecs[next_idx]; + fprintf(stderr, + "[recovery] STATE=Failed — REFRESH_FAILED " + "ctx_ready=false consecutive_fails=%d retrying_in=%ds\n", + consec_fails, next_delay); + + // Self-schedule a retry so the daemon keeps trying even when + // Apple sends no further lease-end event (e.g. transient network + // failure during the refresh). The retry goes through the normal + // queue + backoff path, so exponential spacing is preserved. + schedule_recovery(kRetryInternal); + } + } +} + +// Called from main() in main.c, after leaseMgr and FHinstance are ready. +extern "C" void start_recovery_thread(void) +{ + std::thread(recovery_worker).detach(); + fprintf(stderr, "[+] recovery thread started\n"); +} + +// --------------------------------------------------------------------------- +// FairPlay decrypt exception shim +// --------------------------------------------------------------------------- + +extern "C" uint8_t handle_cpp(int fd) +{ try { handle(fd); return 1; @@ -14,15 +246,26 @@ extern "C" uint8_t handle_cpp(int fd) { } } -static void endLeaseCb(int const &c) { - fprintf(stderr, "[.] end lease code %d\n", c); - exit(1); +// --------------------------------------------------------------------------- +// SVPlaybackLeaseManager callbacks +// +// CONTRACT: return as fast as possible. Do NOT call any library function, +// perform I/O, or acquire any lock that the lease manager might also hold. +// --------------------------------------------------------------------------- + +static void endLeaseCb(int const &c) +{ + fprintf(stderr, "[.] LEASE_END code=%d — scheduling async recovery\n", c); + schedule_recovery(c); + // Returns immediately. recovery_worker handles reacquisition. } -static void pbErrCb(void *) { - fprintf(stderr, "[.] playback error\n"); - exit(1); +static void pbErrCb(void *) +{ + fprintf(stderr, "[.] PLAYBACK_ERROR — scheduling async recovery\n"); + schedule_recovery(kPlaybackErrSentinel); + // Returns immediately. recovery_worker handles context refresh. } extern "C" std::function endLeaseCallback(endLeaseCb); -extern "C" std::function pbErrCallback(pbErrCb); \ No newline at end of file +extern "C" std::function pbErrCallback(pbErrCb); \ No newline at end of file From 8f9f5cfb0e3facc6ebd15469257687b97c8b5038 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:40:23 +0530 Subject: [PATCH 2/2] Add DRM state tracking and wrapper drift check --- .github/workflows/build-for-x86_64.yml | 3 + Dockerfile.build | 36 +++ main.c | 27 ++- scripts/check-drift.py | 294 +++++++++++++++++++++++++ wrapper-rootless.c | 90 +++++--- wrapper.c | 7 +- 6 files changed, 416 insertions(+), 41 deletions(-) create mode 100644 Dockerfile.build create mode 100644 scripts/check-drift.py diff --git a/.github/workflows/build-for-x86_64.yml b/.github/workflows/build-for-x86_64.yml index 67dd2a8..f60c056 100644 --- a/.github/workflows/build-for-x86_64.yml +++ b/.github/workflows/build-for-x86_64.yml @@ -26,6 +26,9 @@ jobs: curl -fLO https://dl.google.com/android/repository/android-ndk-r23b-linux.zip unzip -qd . android-ndk-r23b-linux.zip + - name: Wrapper drift check + run: python3 scripts/check-drift.py + - name: Build run: | mkdir build diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..b0f8b03 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,36 @@ +FROM debian:13.2 + +ARG TARGET_ARCH=amd64 +ARG NDK_VERSION=23 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + unzip \ + git \ + ca-certificates \ + aria2 \ + && rm -rf /var/lib/apt/lists/* + +# NOTE: No system LLVM install needed — the Android NDK bundles its own +# clang at android-ndk-rb/toolchains/llvm/prebuilt/linux-x86_64/bin/ + +# Download Android NDK +RUN aria2c -x 16 -o ndk.zip \ + https://dl.google.com/android/repository/android-ndk-r${NDK_VERSION}b-linux.zip \ + && unzip -q -d /app ndk.zip \ + && rm ndk.zip + +# Copy source (no glob — explicit) +COPY cmdline.c cmdline.h cmdline.ggo ./ +COPY main.c main.cpp ./ +COPY wrapper.c wrapper-rootless.c ./ +COPY import.h ./ +COPY CMakeLists.txt ./ +COPY rootfs ./rootfs + +RUN mkdir -p build \ + && cmake -S /app -B /app/build -DTARGET_ARCH=${TARGET_ARCH} \ + && cmake --build /app/build -j$(nproc) diff --git a/main.c b/main.c index 38b746d..0699066 100644 --- a/main.c +++ b/main.c @@ -34,6 +34,21 @@ static char *g_storefront_id = NULL; static char *g_dev_token = NULL; static char *g_music_token = NULL; +/* Write a single-word state token to base_dir/drm-state. + * The Go engine reads this file via inotify to track wrapper lifecycle. + * States: STARTING LOGIN WAITING_2FA INITIALIZING_FAIRPLAY RUNNING + * RECOVERY FAILED STOPPED + */ +static void write_drm_state(const char *state) { + if (!args_info.base_dir_arg) return; + char path[512]; + snprintf(path, sizeof(path), "%s/drm-state", args_info.base_dir_arg); + FILE *fp = fopen(path, "w"); + if (!fp) return; + fprintf(fp, "%s\n", state); + fclose(fp); +} + /* Protects preshareCtx against concurrent access from the main decrypt * thread (handle/getKdContext) and the recovery worker (refresh_decrypt_ctx). * Using PTHREAD_MUTEX_INITIALIZER avoids the need for an explicit init call. */ @@ -206,9 +221,10 @@ static void credentialHandler(struct shared_ptr *credReqHandler, int passLen = strlen(amPassword); if (need2FA) { + write_drm_state("WAITING_2FA"); if (args_info.code_from_file_flag) { fprintf(stderr, "[!] Enter your 2FA code into rootfs/%s/2fa.txt\n", args_info.base_dir_arg); - fprintf(stderr, "[!] Example command: echo -n 114514 > rootfs/%s/2fa.txt\n", args_info.base_dir_arg); + fprintf(stderr, "[!] Example command: echo -n 123456 > rootfs/%s/2fa.txt\n", args_info.base_dir_arg); fprintf(stderr, "[!] Waiting for input...\n"); int count = 0; while (1) @@ -270,7 +286,7 @@ static inline void init() { setenv("all_proxy", args_info.proxy_arg, 1); } - static const char *resolvers[2] = {"223.5.5.5", "223.6.6.6"}; + static const char *resolvers[2] = {"1.1.1.1", "8.8.8.8"}; _resolv_set_nameservers_for_net(0, resolvers, 2, "."); // static char android_id[16]; @@ -1125,12 +1141,14 @@ int main(int argc, char *argv[]) { init(); reqCtx = init_ctx(); + write_drm_state("STARTING"); if (args_info.login_given) { amUsername = strtok(args_info.login_arg, ":"); amPassword = strtok(NULL, ":"); } if (args_info.login_given && !login(reqCtx)) { fprintf(stderr, "[!] login failed\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } _ZN22SVPlaybackLeaseManagerC2ERKNSt6__ndk18functionIFvRKiEEERKNS1_IFvRKNS0_10shared_ptrIN17storeservicescore19StoreErrorConditionEEEEEE( @@ -1144,6 +1162,7 @@ int main(int argc, char *argv[]) { * FHinstance are initialised so that refresh_decrypt_ctx() is safe to call * from the worker at any point after this. */ start_recovery_thread(); + write_drm_state("INITIALIZING_FAIRPLAY"); offlineFlag = offline_available(); if (offlineFlag) { @@ -1154,22 +1173,26 @@ int main(int argc, char *argv[]) { g_storefront_id = get_account_storefront_id(reqCtx); if (g_storefront_id == NULL) { fprintf(stderr, "[!] failed to get storefront ID\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } g_dev_token = get_dev_token(reqCtx); if (g_dev_token == NULL) { fprintf(stderr, "[!] failed to get dev token\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } g_music_token = get_music_user_token(get_guid(), g_dev_token, reqCtx); if (g_music_token == NULL) { fprintf(stderr, "[!] failed to get music token\n"); + write_drm_state("FAILED"); return EXIT_FAILURE; } fprintf(stderr, "[+] account info cached successfully\n"); write_storefront_id(); write_music_token(); + write_drm_state("RUNNING"); pthread_t m3u8_thread; pthread_create(&m3u8_thread, NULL, &new_socket_m3u8, NULL); diff --git a/scripts/check-drift.py b/scripts/check-drift.py new file mode 100644 index 0000000..f8a21dd --- /dev/null +++ b/scripts/check-drift.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Two-phase drift guard for wrapper.c / wrapper-rootless.c. + +Phase 1 — Skeleton comparison + Strip each file's implementation-specific regions, normalize whitespace, + then diff the skeletons. Any remaining difference is unexpected drift. + +Phase 2 — Allow-list verification + Verify that every region actually stripped matches a named entry in the + allow-list, and that each entry was matched the expected number of times. + A new region added to either file without updating the allow-list fails here + even if Phase 1 would have passed after a new strip rule was also added. + +Usage: + python3 scripts/check-drift.py (run from repo root) + +Exit codes: + 0 both phases pass + 1 unexpected drift or allow-list violation +""" + +import os +import re +import subprocess +import sys +import tempfile + + +# --------------------------------------------------------------------------- +# Allow-list +# +# Each entry names a region that is permitted to differ between the two files. +# 'file' which file the region lives in +# 'type' single_line | end | end_brace (see strip_regions) +# 'start' regex that identifies the first line of the region +# 'end' regex for the closing line (type "end" only) +# 'expect' how many times this region must appear (default 1) +# --------------------------------------------------------------------------- + +ALLOW_LIST = [ + # ── wrapper.c privileged-only regions ──────────────────────────────── + { + "name": "stdlib.h include", + "file": "wrapper.c", + "type": "single_line", + "start": re.compile(r"#include\s+"), + }, + { + "name": "CAP_SYS_ADMIN defines", + "file": "wrapper.c", + "type": "end", + "start": re.compile(r"#define CAP_SYS_ADMIN_IDX"), + "end": re.compile(r"#define CAP_SYS_ADMIN_BIT"), + }, + { + "name": "has_cap_sys_admin function", + "file": "wrapper.c", + "type": "end_brace", + "start": re.compile(r"^int has_cap_sys_admin\(\)"), + }, + { + "name": "conditional CLONE_NEWPID unshare", + "file": "wrapper.c", + "type": "end_brace", + "start": re.compile(r"if \(has_cap_sys_admin\(\)\)"), + }, + + # ── wrapper-rootless.c rootless-only regions ────────────────────────── + { + "name": "write_file comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: write"), + }, + { + "name": "write_file function", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"^static int write_file\("), + }, + { + "name": "setup_user_namespace comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: create"), + }, + { + "name": "setup_user_namespace function", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"^static int setup_user_namespace\(\)"), + }, + { + "name": "setup_user_namespace call-site comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: establish"), + }, + { + "name": "setup_user_namespace call", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"if \(setup_user_namespace\(\)"), + }, + { + "name": "unconditional CLONE_NEWPID comment", + "file": "wrapper-rootless.c", + "type": "single_line", + "start": re.compile(r"/\* rootless-only: user namespace"), + }, + { + "name": "unconditional CLONE_NEWPID unshare", + "file": "wrapper-rootless.c", + "type": "end_brace", + "start": re.compile(r"if \(unshare\(CLONE_NEWPID\)"), + }, +] + + +# --------------------------------------------------------------------------- +# Stripping +# --------------------------------------------------------------------------- + +def strip_end_brace(lines, start_idx): + """Return the index after the closing brace that matches the opening + brace on lines[start_idx].""" + depth, seen_open = 0, False + i = start_idx + while i < len(lines): + depth += lines[i].count("{") - lines[i].count("}") + i += 1 + if depth > 0: + seen_open = True + if seen_open and depth <= 0: + break + return i + + +def strip_regions(lines, rules): + """ + Remove every region matched by rules. + Returns (stripped_lines, hits) where hits is a dict mapping rule index + to the number of times that rule was matched. + """ + hits = {i: 0 for i in range(len(rules))} + result = [] + i = 0 + while i < len(lines): + line = lines[i] + matched_idx = next( + (idx for idx, r in enumerate(rules) if r["start"].search(line)), + None, + ) + + if matched_idx is None: + result.append(line) + i += 1 + continue + + hits[matched_idx] += 1 + rule = rules[matched_idx] + + if rule["type"] == "single_line": + i += 1 + continue + + if rule["type"] == "end_brace": + i = strip_end_brace(lines, i) + continue + + # type == "end": inclusive range + end_re = rule["end"] + i += 1 + while i < len(lines): + matched = end_re.search(lines[i]) + i += 1 + if matched: + break + + return result, hits + + +def normalize(lines): + out = [] + for line in lines: + s = line.rstrip() + if not out and "canonical implementation" in s: + continue + if s == "" and out and out[-1] == "": + continue + out.append(s) + return out + + +def skeleton(path, rules): + with open(path) as f: + raw = [l.rstrip("\n") for l in f] + stripped, hits = strip_regions(raw, rules) + return normalize(stripped), hits + + +# --------------------------------------------------------------------------- +# Phase 1 — skeleton comparison +# --------------------------------------------------------------------------- + +def phase1(canon_skel, rootless_skel): + with tempfile.NamedTemporaryFile("w", suffix=".canon", delete=False) as a: + a.write("\n".join(canon_skel) + "\n"); a_path = a.name + with tempfile.NamedTemporaryFile("w", suffix=".rootless", delete=False) as b: + b.write("\n".join(rootless_skel) + "\n"); b_path = b.name + try: + result = subprocess.run( + ["diff", "-u", + "--label", "wrapper.c (skeleton)", + "--label", "wrapper-rootless.c (skeleton)", + a_path, b_path], + capture_output=True, text=True, + ) + finally: + os.unlink(a_path) + os.unlink(b_path) + return result + + +# --------------------------------------------------------------------------- +# Phase 2 — allow-list verification +# --------------------------------------------------------------------------- + +def phase2(canon_hits, rootless_hits, canon_rules, rootless_rules): + failures = [] + + def check(hits, rules, label): + for idx, rule in enumerate(rules): + expected = rule.get("expect", 1) + actual = hits[idx] + if actual != expected: + failures.append( + f" [{label}] '{rule['name']}': " + f"expected {expected} match(es), got {actual}" + ) + + check(canon_hits, canon_rules, "wrapper.c") + check(rootless_hits, rootless_rules, "wrapper-rootless.c") + return failures + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + canon_rules = [r for r in ALLOW_LIST if r["file"] == "wrapper.c"] + rootless_rules = [r for r in ALLOW_LIST if r["file"] == "wrapper-rootless.c"] + + canon_skel, canon_hits = skeleton("wrapper.c", canon_rules) + rootless_skel, rootless_hits = skeleton("wrapper-rootless.c", rootless_rules) + + p1 = phase1(canon_skel, rootless_skel) + p2 = phase2(canon_hits, rootless_hits, canon_rules, rootless_rules) + + ok = True + + if p1.returncode != 0: + ok = False + print("PHASE 1 FAIL: unexpected skeleton drift.") + print(" Add a strip rule to ALLOW_LIST for any new approved difference.") + print() + print(p1.stdout) + + if p2: + ok = False + print("PHASE 2 FAIL: allow-list region count mismatch.") + print(" Update ALLOW_LIST if a region was intentionally added or removed.") + print() + for line in p2: + print(line) + print() + + if ok: + canon_count = sum(canon_hits.values()) + rootless_count = sum(rootless_hits.values()) + print( + f"OK: {canon_count} privileged-only region(s) in wrapper.c, " + f"{rootless_count} rootless-only region(s) in wrapper-rootless.c. " + f"No unexpected drift." + ) + return 0 + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wrapper-rootless.c b/wrapper-rootless.c index fa3564d..cee82b4 100644 --- a/wrapper-rootless.c +++ b/wrapper-rootless.c @@ -1,16 +1,17 @@ +/* canonical implementation: wrapper.c — this file may only differ where rootless operation requires it */ #define _GNU_SOURCE + #include +#include #include #include -#include #include #include #include #include -#include #include #include -#include +#include #include "cmdline.h" @@ -23,6 +24,7 @@ static void intHan(int signum) { } } +/* rootless-only: write a single line to a /proc file */ static int write_file(const char *path, const char *line) { int fd = open(path, O_WRONLY); if (fd < 0) return -1; @@ -32,13 +34,23 @@ static int write_file(const char *path, const char *line) { return (ret == len) ? 0 : -1; } -static int setup_unprivileged_namespaces() { +/* rootless-only: create user+mount namespace and map current uid/gid to root */ +static int setup_user_namespace() { uid_t uid = getuid(); gid_t gid = getgid(); char buf[128]; - if (unshare(CLONE_NEWUSER | CLONE_NEWNS | CLONE_NEWPID) == -1) { - perror("unshare"); + if (unshare(CLONE_NEWUSER | CLONE_NEWNS) == -1) { + if (errno == EPERM) { + fprintf(stderr, + "error: unprivileged user namespaces are not permitted on this system.\n" + " options:\n" + " 1. enable: sudo sysctl -w kernel.unprivileged_userns_clone=1\n" + " 2. use the privileged wrapper instead\n" + " 3. grant capabilities: sudo setcap cap_sys_admin+ep ./wrapper-rootless\n"); + } else { + perror("unshare"); + } return -1; } @@ -69,21 +81,11 @@ int main(int argc, char *argv[], char *envp[]) { return 1; } - if (setup_unprivileged_namespaces() != 0) { - return 1; - } - - child_proc = fork(); - if (child_proc == -1) { - perror("fork"); + /* rootless-only: establish user namespace before any privileged operations */ + if (setup_user_namespace() != 0) { return 1; } - if (child_proc > 0) { - wait(NULL); - return 0; - } - if (mkdir("./rootfs/dev", 0755) != 0 && errno != EEXIST) { perror("mkdir ./rootfs/dev failed"); return 1; @@ -101,41 +103,57 @@ int main(int argc, char *argv[], char *envp[]) { return 1; } - if (mkdir("./rootfs/proc", 0755) != 0 && errno != EEXIST) { - perror("mkdir ./rootfs/proc failed"); + if (chdir("./rootfs") != 0) { + perror("chdir"); + return 1; + } + if (chroot("./") != 0) { + perror("chroot"); return 1; } - if (mount("proc", "./rootfs/proc", "proc", 0, NULL) != 0) { - perror("mount proc failed"); + if (mkdir("/proc", 0755) != 0 && errno != EEXIST) { + perror("mkdir /proc failed"); return 1; } - // 5. 切换目录并 chroot - if (chdir("./rootfs") != 0) { - perror("chdir ./rootfs failed"); + chmod("/system/bin/linker64", 0755); + chmod("/system/bin/main", 0755); + + /* rootless-only: user namespace guarantees CLONE_NEWPID is always available */ + if (unshare(CLONE_NEWPID)) { + perror("unshare"); return 1; } - if (chroot(".") != 0) { - perror("chroot . failed"); + + child_proc = fork(); + if (child_proc == -1) { + perror("fork"); return 1; } - chmod("/system/bin/linker64", 0755); - chmod("/system/bin/main", 0755); + if (child_proc > 0) { + wait(NULL); + return 0; + } + + if (mount("proc", "/proc", "proc", 0, NULL) != 0) { + perror("mount proc failed"); + return 1; + } if (mkdir(args_info.base_dir_arg, 0777) != 0 && errno != EEXIST) { perror("mkdir base_dir_arg failed"); - } - - char db_path[512]; - snprintf(db_path, sizeof(db_path), "%s/mpl_db", args_info.base_dir_arg); - if (mkdir(db_path, 0777) != 0 && errno != EEXIST) { + } + + char db_dir[1024]; + snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", args_info.base_dir_arg); + if (mkdir(db_dir, 0777) != 0 && errno != EEXIST) { perror("mkdir mpl_db failed"); } execve("/system/bin/main", argv, envp); - + perror("execve"); return 1; -} \ No newline at end of file +} diff --git a/wrapper.c b/wrapper.c index 4e57f4f..7604bdd 100644 --- a/wrapper.c +++ b/wrapper.c @@ -1,3 +1,4 @@ +/* canonical implementation — any change here must be reviewed against wrapper-rootless.c */ #define _GNU_SOURCE #include @@ -129,7 +130,7 @@ int main(int argc, char *argv[], char *envp[]) { if (mkdir(args_info.base_dir_arg, 0777) != 0 && errno != EEXIST) { perror("mkdir base_dir_arg failed"); } - + char db_dir[1024]; snprintf(db_dir, sizeof(db_dir), "%s/mpl_db", args_info.base_dir_arg); if (mkdir(db_dir, 0777) != 0 && errno != EEXIST) { @@ -137,7 +138,7 @@ int main(int argc, char *argv[], char *envp[]) { } execve("/system/bin/main", argv, envp); - + perror("execve"); return 1; -} \ No newline at end of file +}