diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 37b2e7f..bfbf980 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -3246,5 +3246,50 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进 - **缓存 TTL=120s 的内存槽上限 4×128KB**:大歌单 `playlist_detail`(trackIds >128KB)不入内存、仅盘缓存(内存瘦身口径 REPORT §32);命中读盘+重解析,避免把上千首 trackIds 常驻内存。 - **手刷只标详情接口**:歌单手动刷新只绕过 `v6/playlist/detail`(列表结构),`song_detail` 批 2 分钟内走缓存(改动歌曲为新键自然 miss,元数据 ≤2min 旧可接受);home 刷新绕过 `user/playlist`。 -- **eapi 分支未注入 MUSIC_U**(登录态 eapi 播放接口仍走原有的非-cookie 鉴权路径)——本次仅按任务补游客 MUSIC_A;MUSIC_U 头注入留后续(超范围)。 +- **eapi 分支未注入 MUSIC_U**(登录态 eapi 播放接口仍走原有的非-cookie 鉴权路径)——本次仅按任务补游客 MUSIC_A;**已由 §37 补齐**(eapi Cookie 头现注入 MUSIC_U/MUSIC_A,且 `ncm_login_refresh` 依赖此会话锚点)。 - **盘缓存读路径未在真机/宿主整链实测**(内存命中优先,盘仅在内存 miss/大体时兜底)——代码审查 + 文件存在性断言验证;真机可观察 `ncm_api_cache/` 落盘与热更后命中。 + +## 37. MUSIC_U 临期主动续期(用户定案:失效前用当前 cookie 换新登录态,用户无感;仅刷新失败才退扫码)(2026-09-02) + +覆盖 WORK_STATE 会话定案「MUSIC_U 有时效——失效前主动续期」。语义(用户定案): +- **时机**:App 启动查 cookie 年龄(`account.json last_refresh_ts`,默认阈值 12h 可配 `ncm.cookie_refresh_h` 小时);临期且已登录 → home_worker 后台静默刷一次(worker 线程,不卡 UI、不打断播)。 +- **刷新**:带当前 cookie 调 `/api/login/token/refresh`(eapi,NCM_API_NOTES §2.2 L117 = CNMPlayer `login_refresh.rs` / Node `login_refresh.js`)→ 响应 set-cookie 提取新 MUSIC_U/__csrf,`ncm_req_cookie_merge` patch 进现存 cookie(MUSIC_A/__remember_me 等不丢)→ `ncm_ctx_set_cookie` 加密落盘(同登录机制)→ 更新 `last_refresh_ts`。 +- **失败**:网络/TLS/HTTP 瞬断 → 记 `ncm.cookie_refresh_fail_ts` 冷却 30 分钟(跨 boot 持久化),保持旧 cookie(用户仍用旧会话,下次启动再试);响应 `code!=200`(301/-460/会话失效)→ `NCM_ERR_AUTH` → UI 线程 `appstate_logout()`+切登录页(复用 settings 登出→扫码路径)。 +- **风控**:同一 boot 单飞只刷一次(`s_cookie_refreshed`);失败冷却 30 分钟。 + +### 37.1 实现(全部在 `patch_v2/ncm_app/`) + +| 文件 | 改动 | +|---|---| +| `core/ncm_req.c` / `.h` | ① eapi Cookie 头补齐**会话锚点**:注入 `MUSIC_U`(登录态识别——login_refresh/song_url 会员档依赖)与 `MUSIC_A`(游客);游客无 MUSIC_U/A 时补 `MUSIC_A=anonymous_token`(逻辑保留)。**补齐 §36.5 遗留**。② 新增 `ncm_req_cookie_merge(base,patch,out)`:patch 的同名 cookie 覆盖 base(无则追加),其余保留——登录续期合并新 MUSIC_U 用。 | +| `core/ncm_login.c` / `.h` | 新增 `ncm_login_refresh(device,cookie,out,sz)`:eapi `/api/login/token/refresh` 空体;`code==200`→`ncm_req_cookie_merge` 合并响应 set-cookie 到 `out` 返 NCM_OK;`code!=200`→`NCM_ERR_AUTH`;HTTP 非 2xx/网络/TLS/超时→负错误码(调用方退避);JSON 解析失败→`NCM_ERR_JSON`。 | +| `app/logic/account.c` | ① `account.json` 增 `last_refresh_ts`(cookie 年龄)+ 拆分持久化 `cvip_expire_ms`/`svip_expire_ms`(vip 字段,防 cookie 刷新落盘覆盖)。② 新增 `account_cookie_maybe_refresh()`(决策:登录+cookie 非空+年龄超阈值+单飞+不在冷却 → `ncm_login_refresh`→成功应用+更新 ts+清冷却;AUTH→置回登录页标志;瞬断→冷却+保持旧 cookie)。③ `account_cookie_need_relogin/consume/mark_fresh`。 | +| `app/ui/login.c` | 803 落盘新 cookie 后调 `account_cookie_mark_fresh()`(年龄=now,重置单飞/冷却)。 | +| `app/ui/home.c` | `home_worker` 先调 `account_cookie_maybe_refresh()` 再拉歌单(后续 eapi/weapi 用新 cookie);AUTH→跳过歌单加载。 | +| `app/app.c` | `app_on_tick` 消费 `account_cookie_need_relogin()`:`appstate_logout()`+`meow_view_page_replace(ACT_LOGIN)`(UI 线程——视图栈非线程安全)。 | +| `app/ncm_app_internal.h` | `ncm_app_state_t` 增 `cookie_last_refresh_ts`、`vip_cvip_exp_ms`、`vip_svip_exp_ms`;account_* 新声明。 | + +### 37.2 验证(宿主 + qemu + 真机只读) + +| 项 | 结果 | +|---|---| +| 新增 `test_ncm_core` `test_cookie_merge`(合并/空 patch)+ `test_eapi_musicu_hdr`(eapi Cookie 头带 MUSIC_U、游客 anonymous) | host + mipsel **PASS** | +| 新增 `bench/cookie_refresh_sim.c`(真实 core + ncm_http 计数桩,0 网络) | **ALL PASS**:code200+set-cookie→新 MUSIC_U/__csrf 替换+旧字段保留 / code301→NCM_ERR_AUTH / 网络瞬断→NCM_ERR_NET / code200 无 set-cookie→原样 / 无 cookie 优雅 | +| 新增 `bench/account_cookie_sim.c`(真实 account.c + 外围桩,0 网络) | **ALL PASS**:临期触发 / 未临期不刷 / 旧会话(last=0)视为临期 / 瞬断退避(负码+冷却持久化) / 失效→AUTH+回登录标志 / 冷却(31min>30min 重刷, 10min<30min 不刷) / mark_fresh 后不刷 | +| `stream_sim` / `player_flow_sim` / `window_sim` / `search_sim` / `cache_evict_sim` / `apicache_sim` / `pagination_sim` / `playlist_race_sim` | 全 PASS(`build_ncm_sims.sh` suite exit=0) | +| `test_ncm_core_host` + `qemu test_ncm_core_mipsel` | host + mipsel **PASS** | +| `build_ncm.sh`(-Wall -Wextra) | **0 warning**;`text 1031970 / data 1664 / bss 1364516` | +| `build_ncm_host.sh` | 通过(仅 SDK `meow_ui_vol.c` 存量 `system()` 告警,非本批) | +| **真机只读探针**(设备 10.78.193.50,真实 cookie,不写回) | 连续 2 次 `refresh_rc=-5`(NCM_ERR_TLS);`session.enc`/`deviceId` md5 前后一致(`a86c9448…`/`5af2657a…`),**登录态零损伤**。 | + +### 37.3 md5(本批产物) + +`ncm` = `c960244b44dd8c7b08bb29a44a65fad7`(`md5sum ncm`,首 8 位 `c960244b`);`text 1031970 / bss 1364516 / dec 2398150`。未部署(留主会话上卡)。 + +### 37.4 诚实出入 / 遗留 + +- **真机 refresh 端点未能拿到 `code 200 + 新 MUSIC_U` 的正向结论**:`/api/login/token/refresh` 走 eapi `https://interface.music.163.com/eapi/…`,而设备当前网络/环境对 `interface.music.163.com:443` TLS 握手被对端 reset(busybox `alert code 80`;`music.163.com` weapi 域 `200 OK` 正常)→ 连续 2 次探针均 `-5`。即**该设备网络屏蔽了 eapi 域名**,refresh(以及 song_detail/lyric/song_url/扫码等全部 eapi)在当下不可达。**这不是本批代码问题**——正因如此,本批把 `-5(TLS)` 归为「瞬断→退避+保持旧 cookie+不清登录态」,正是为了在该网络下不把用户登出;换到 `interface.music.163.com` 可达的网络(如家用 WiFi),refresh 应能返回 200 并轮换 cookie。**建议主会话在 eapi 可达网络下再做 1 次只读探针确认正向**。 +- **冷却跨 boot 持久化用 `meow_config`(`ncm.cookie_refresh_fail_ts`)**,不随 `account.json` 清空——登出/换账号不影响冷却(无副作用;仅防重刷)。 +- **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。 +- **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。 +- **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。 diff --git a/patch_v2/ncm_app/app/app.c b/patch_v2/ncm_app/app/app.c index 6c1c349..97d9947 100644 --- a/patch_v2/ncm_app/app/app.c +++ b/patch_v2/ncm_app/app/app.c @@ -140,6 +140,14 @@ static void app_on_swipe_progress(int x_start, int dx) static void app_on_tick(void) { uint64_t now = meow_ui_time_ms(); + /* §NEW cookie 续期判会话失效 → 清登录态回登录页(worker 在后台续期发现 301/-460 时置标志; + * 在此 UI 线程执行 logout+切页——meow_view_page_replace 非线程安全,只能 UI 线程做) */ + if (account_cookie_need_relogin()) { + account_cookie_relogin_consume(); + meow_log(MEOW_LOG_WARN, "account: cookie invalid -> re-login"); + appstate_logout(); + meow_view_page_replace(ACT_LOGIN); + } autoplay_maybe(); /* 真机联调 hook:/tmp/ncm_autoplay 存在则自动播已缓存曲目(一次性) */ /* autoplay 已在后台线程起播缓存曲 → 在此(UI 线程)打开播放页,复现用户点曲播放路径 */ { diff --git a/patch_v2/ncm_app/app/logic/account.c b/patch_v2/ncm_app/app/logic/account.c index 52d6077..bf22955 100644 --- a/patch_v2/ncm_app/app/logic/account.c +++ b/patch_v2/ncm_app/app/logic/account.c @@ -15,11 +15,42 @@ #define ACCOUNT_FILE "account.json" #define VIP_TTL_SEC (24 * 3600) /* 超 1 天重查 */ +/* §NEW MUSIC_U 临期续期:cookie 年龄阈值默认 12h(可配 ncm.cookie_refresh_h 小时);失败冷却 30 分钟。 */ +#define COOKIE_REFRESH_DEFAULT_H 12 +#define COOKIE_REFRESH_COOLDOWN_SEC (30 * 60) +#define COOKIE_FAIL_TS_KEY "ncm.cookie_refresh_fail_ts" /* 上次续期失败时刻(秒)持久化:跨 boot 冷却 */ + /* 单飞标志(每次启动每条件最多一次请求) */ static int s_refreshed_login; /* 登录后已查 */ static int s_refreshed_stale; /* 超 1 天已查 */ static int s_refreshed_expired; /* 到期后已查 */ +/* §NEW cookie 续期状态(app 单实例;跨 boot 由 account.json last_refresh_ts + config 冷却键持久化) */ +static volatile int s_cookie_refreshed; /* 本 boot 已试(单飞:同一 boot 只刷一次) */ +static volatile int64_t s_cookie_fail_ts; /* 上次续期失败时刻(秒;冷却窗口用,跨 boot 持久化) */ +static volatile int s_cookie_need_relogin; /* 1=续期判会话失效,需回登录页(UI 消费) */ + +static int64_t cookie_refresh_ttl_sec(void) +{ + int h = meow_config_int("ncm.cookie_refresh_h", COOKIE_REFRESH_DEFAULT_H); + if (h < 1) h = 1; + if (h > 72) h = 72; + return (int64_t)h * 3600; +} + +/* 冷却时刻读写(持久化到 config,跨 boot:失败后 30 分钟内不重刷,不猛刷/防 -460 自锁)。 */ +static int64_t cookie_fail_ts_load(void) +{ + int v = meow_config_int(COOKIE_FAIL_TS_KEY, 0); + return (int64_t)(v > 0 ? v : 0); +} +static void cookie_fail_ts_store(int64_t now_s) +{ + char b[24]; + snprintf(b, sizeof b, "%lld", (long long)(now_s > 0 ? now_s : 0)); + meow_config_set(COOKIE_FAIL_TS_KEY, b); +} + static void account_path(char *out, size_t sz) { snprintf(out, sz, "%s/%s", g_st.data_dir, ACCOUNT_FILE); @@ -30,6 +61,9 @@ void account_load(void) g_st.vip = NCM_VIP_NONE; g_st.vip_expire_ms = 0; g_st.vip_checked_at = 0; + g_st.vip_cvip_exp_ms = 0; + g_st.vip_svip_exp_ms = 0; + g_st.cookie_last_refresh_ts = 0; char path[600], buf[512]; account_path(path, sizeof path); @@ -50,26 +84,34 @@ void account_load(void) if (rc != NCM_OK) cvip_exp = 0; int64_t svip_exp = ncm_json_get_i64_at(&j, ncm_json_path(&j, "svip_expire_ms"), &rc); if (rc != NCM_OK) svip_exp = 0; + /* §NEW:last_refresh_ts(0=旧会话/缺失 → 视为临期,开机续期一次) */ + int64_t last_refresh = ncm_json_get_i64_at(&j, ncm_json_path(&j, "last_refresh_ts"), &rc); + if (rc != NCM_OK) last_refresh = 0; if (vip < NCM_VIP_NONE || vip > NCM_VIP_SVIP) vip = NCM_VIP_NONE; g_st.vip = (int)vip; + g_st.vip_cvip_exp_ms = cvip_exp; + g_st.vip_svip_exp_ms = svip_exp; g_st.vip_checked_at = checked; + g_st.cookie_last_refresh_ts = last_refresh; /* 有效到期时间 = 当前档对应的到期字段 */ g_st.vip_expire_ms = (vip == NCM_VIP_SVIP) ? svip_exp : (vip == NCM_VIP_CVIP) ? cvip_exp : 0; - meow_log(MEOW_LOG_INFO, "account: loaded vip=%d expire=%lld checked_at=%ld", - g_st.vip, (long long)g_st.vip_expire_ms, (long)g_st.vip_checked_at); + meow_log(MEOW_LOG_INFO, "account: loaded vip=%d expire=%lld last_refresh_ts=%lld", + g_st.vip, (long long)g_st.vip_expire_ms, (long long)last_refresh); } -static void account_save(int vip, int64_t cvip_exp, int64_t svip_exp, int64_t checked_at) +static void account_persist(int vip, int64_t cvip_exp, int64_t svip_exp, int64_t checked_at) { char path[600], buf[512]; account_path(path, sizeof path); snprintf(buf, sizeof buf, "{\"uid\":\"%s\",\"nickname\":\"%s\",\"vip\":%d," - "\"cvip_expire_ms\":%lld,\"svip_expire_ms\":%lld,\"checked_at\":%lld}", + "\"cvip_expire_ms\":%lld,\"svip_expire_ms\":%lld,\"checked_at\":%lld," + "\"last_refresh_ts\":%lld}", g_st.uid, g_st.nickname, vip, - (long long)cvip_exp, (long long)svip_exp, (long long)checked_at); + (long long)cvip_exp, (long long)svip_exp, (long long)checked_at, + (long long)g_st.cookie_last_refresh_ts); if (ncm_store_write_file(path, buf, strlen(buf)) != NCM_OK) meow_log(MEOW_LOG_WARN, "account: save failed"); } @@ -120,10 +162,73 @@ void account_vip_maybe_refresh(int force) } g_st.vip = info.vip; + g_st.vip_cvip_exp_ms = info.cvip_expire_ms; + g_st.vip_svip_exp_ms = info.svip_expire_ms; g_st.vip_expire_ms = (info.vip == NCM_VIP_SVIP) ? info.svip_expire_ms : (info.vip == NCM_VIP_CVIP) ? info.cvip_expire_ms : 0; g_st.vip_checked_at = now_s; - account_save(info.vip, info.cvip_expire_ms, info.svip_expire_ms, now_s); + account_persist(info.vip, info.cvip_expire_ms, info.svip_expire_ms, now_s); meow_log(MEOW_LOG_INFO, "account: vip refresh (%s) -> vip=%d expire=%lld", why, info.vip, (long long)g_st.vip_expire_ms); } + +/* ---- §NEW MUSIC_U 临期续期 ---- + * 用户定案语义:失效前用当前 cookie 换新登录态(用户无感);只有刷新失败(会话确失效)才退扫码。 + * 风控:同一 boot 只试一次;失败冷却 30 分钟(下次启动再试,不猛刷)。在 worker 线程上下文调用。 + * 返回:NCM_OK(已续期/未临期/冷却中/未登录);NCM_ERR_AUTH(会话失效,需回登录页); + * 其它负错误码(网络/TLS 瞬断——保持旧 cookie,退避)。 */ +int account_cookie_maybe_refresh(void) +{ + if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_OK; + if (s_cookie_refreshed) return NCM_OK; /* 本 boot 已试(单飞) */ + + int64_t now_s = ncm_now_ms() / 1000; + s_cookie_fail_ts = cookie_fail_ts_load(); /* 跨 boot 恢复上次失败时刻 */ + if (s_cookie_fail_ts > 0 && now_s - s_cookie_fail_ts < COOKIE_REFRESH_COOLDOWN_SEC) { + s_cookie_refreshed = 1; /* 冷却窗口内:视为已处理,boot 内不再重复 */ + return NCM_OK; + } + + int64_t last = g_st.cookie_last_refresh_ts; + int stale = (last == 0) || (now_s - last > cookie_refresh_ttl_sec()); + if (!stale) { s_cookie_refreshed = 1; return NCM_OK; } /* cookie 尚新(≤阈值),无需续期 */ + + s_cookie_refreshed = 1; /* 单飞:本 boot 最多一次刷 */ + char new_cookie[NCM_COOKIE_MAX]; + int rc = ncm_login_refresh(g_st.ctx.device_id, g_st.ctx.cookie, + new_cookie, sizeof new_cookie); + if (rc == NCM_OK) { + ncm_ctx_set_cookie(&g_st.ctx, new_cookie); /* 内存 + 加密落盘(同一机制) */ + g_st.cookie_last_refresh_ts = now_s; + s_cookie_fail_ts = 0; cookie_fail_ts_store(0); /* 成功:清冷却 */ + account_persist(g_st.vip, g_st.vip_cvip_exp_ms, g_st.vip_svip_exp_ms, g_st.vip_checked_at); + meow_log(MEOW_LOG_INFO, "account: cookie refreshed (age %llds -> now), last_refresh_ts=%lld", + (long long)(now_s - (last ? last : now_s)), (long long)now_s); + return NCM_OK; + } + if (rc == NCM_ERR_AUTH) { + s_cookie_fail_ts = now_s; cookie_fail_ts_store(now_s); + s_cookie_need_relogin = 1; /* UI 线程消费 → logout + 进登录页 */ + meow_log(MEOW_LOG_WARN, "account: cookie refresh -> session invalid (rc=%d), re-login", rc); + return NCM_ERR_AUTH; + } + /* 网络/TLS/HTTP 异常(瞬断):冷却 30 分钟(跨 boot 持久化),保持旧 cookie,下次启动再试 */ + s_cookie_fail_ts = now_s; cookie_fail_ts_store(now_s); + meow_log(MEOW_LOG_WARN, "account: cookie refresh transient rc=%d, backoff 30min", rc); + return rc; +} + +int account_cookie_need_relogin(void) { return s_cookie_need_relogin; } +void account_cookie_relogin_consume(void) { s_cookie_need_relogin = 0; } + +/* 登录成功(落盘新 cookie)后调用:把续期年龄标为现在(新 cookie 视为全新),并重置单飞/冷却, + * 使同 boot 内后续如果年龄再次临期(不可能,但幂等)可再刷;同时清除上会的「需回登录页」标志。 */ +void account_cookie_mark_fresh(void) +{ + g_st.cookie_last_refresh_ts = ncm_now_ms() / 1000; + s_cookie_refreshed = 0; + s_cookie_fail_ts = 0; cookie_fail_ts_store(0); + s_cookie_need_relogin = 0; + meow_log(MEOW_LOG_INFO, "account: cookie marked fresh (last_refresh_ts=%lld)", + (long long)g_st.cookie_last_refresh_ts); +} diff --git a/patch_v2/ncm_app/app/ncm_app_internal.h b/patch_v2/ncm_app/app/ncm_app_internal.h index c60b9c0..693a685 100644 --- a/patch_v2/ncm_app/app/ncm_app_internal.h +++ b/patch_v2/ncm_app/app/ncm_app_internal.h @@ -171,7 +171,12 @@ typedef struct ncm_app_state { /* 会员态(§20,account.json 落盘:data/account.json;刷新=登录后/超 1 天/到期后一次) */ int vip; /* NCM_VIP_*(NONE/CVIP/SVIP) */ int64_t vip_expire_ms; /* 当前档到期时间(ms;0=无/未知) */ + int64_t vip_cvip_exp_ms; /* 音乐包/畅听到期(ms;account.json cvip_expire_ms,落盘保真) */ + int64_t vip_svip_exp_ms; /* 黑胶到期(ms;account.json svip_expire_ms) */ int64_t vip_checked_at; /* vip/info 最近获取时间(秒) */ + /* MUSIC_U 临期续期(§NEW,用户定案):cookie 最近一次成功续期时间(秒;account.json + * last_refresh_ts;0=缺失/旧会话视为临期)。App 启动时后台静默刷新一次,不打断播/不卡 UI。 */ + int64_t cookie_last_refresh_ts; /* 我的歌单 */ ncm_playlist_t playlists[NCM_PLAYLIST_MAX]; @@ -434,6 +439,13 @@ void account_load(void); /* appstate_init 时读 account.j int account_vip(void); /* 当前有效会员档(到期自动降档视图,不联网) */ int account_level_cap(void); /* 会员态允许的最高音质档(NONE→EXHIGH/CVIP→HIRES/SVIP→SURROUND) */ void account_vip_maybe_refresh(int force); /* worker 线程上下文调用(含网络,单次) */ +/* §NEW MUSIC_U 临期续期:App 启动(home_worker)调用——查 cookie 年龄,临期且登录且本 boot 未试 + * 则后台静默刷新一次(用当前 cookie 换新登录态落盘);返回 NCM_OK(已续期/未临期/冷却中)、 + * NCM_ERR_AUTH(会话失效,需回登录页,读 account_cookie_need_relogin)或负错误码(瞬断,退避)。 */ +int account_cookie_maybe_refresh(void); +int account_cookie_need_relogin(void); /* UI 线程取「续期判失效需回登录页」标志 */ +void account_cookie_relogin_consume(void); /* UI 线程消费该标志 */ +void account_cookie_mark_fresh(void); /* 登录成功落盘新 cookie 后调用(重置年龄/单飞/冷却) */ void appstate_apply_playlist_cover(int idx); void appstate_set_playing_song(int idx, const char *cover_url); int appstate_current_playing(void); /* 返回当前播放歌曲下标(UI) */ diff --git a/patch_v2/ncm_app/app/ui/home.c b/patch_v2/ncm_app/app/ui/home.c index d75f528..e4af442 100644 --- a/patch_v2/ncm_app/app/ui/home.c +++ b/patch_v2/ncm_app/app/ui/home.c @@ -209,7 +209,13 @@ static void *home_worker(void *arg) (void)arg; int rc; if (!g_st.logged_in) rc = NCM_ERR_AUTH; - else rc = appstate_load_playlists(); + else { + /* §NEW MUSIC_U 临期续期:先于歌单网络请求(后续 eapi/weapi 用新 cookie)。 + * AUTH=会话确失效(置回登录页标志,UI 线程消费);瞬断=保持旧 cookie 走缓存/旧会话。 */ + int crc = account_cookie_maybe_refresh(); + if (crc == NCM_ERR_AUTH) rc = NCM_ERR_AUTH; + else rc = appstate_load_playlists(); + } if (rc == NCM_OK) account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */ pthread_mutex_lock(&g_home_mu); diff --git a/patch_v2/ncm_app/app/ui/login.c b/patch_v2/ncm_app/app/ui/login.c index 0b95d43..98568a7 100644 --- a/patch_v2/ncm_app/app/ui/login.c +++ b/patch_v2/ncm_app/app/ui/login.c @@ -145,6 +145,7 @@ static void *login_worker(void *arg) meow_log(MEOW_LOG_INFO, "login: 803 confirmed, saving cookie"); ncm_strlcpy(cookie, out_cookie, sizeof cookie); ncm_ctx_set_cookie(&g_st.ctx, cookie); + account_cookie_mark_fresh(); /* §NEW:新登录态 cookie 视为全新(年龄=now,重置单飞/冷却) */ /* 803 后同步取 uid/nickname 并落盘(weapi 已修复:不再挂死、cookie 全量且去重发送, * 服务端认可会话)。此处在 worker 线程执行(不卡 UI);失败不阻断跳页——home 加载 * 时 appstate_ensure_uid 会再验一次(也设了硬超时上限,不阻塞歌单页)。 */ diff --git a/patch_v2/ncm_app/bench/account_cookie_sim.c b/patch_v2/ncm_app/bench/account_cookie_sim.c new file mode 100644 index 0000000..e9611bb --- /dev/null +++ b/patch_v2/ncm_app/bench/account_cookie_sim.c @@ -0,0 +1,189 @@ +/* bench/account_cookie_sim.c — MUSIC_U 临期续期决策逻辑验证(§NEW,0 真实 NCM 请求)。 + * + * 用**真实** app/logic/account.c,仅把外围依赖换成桩(g_st 状态 + meow_config/log + + * ncm_login_refresh(强制结果)+ ncm_ctx_set_cookie(记录落盘)+ ncm_store/json),验证 + * account_cookie_maybe_refresh 的语义(用户定案): + * ① 临期触发:cookie 年龄超阈值(12h)→ 后台静默刷一次(refresh 被调、新 cookie 应用、落盘 + * last_refresh_ts 更新); + * ② 未临期:年龄 ≤ 阈值 → 不刷(refresh 零调用); + * ③ 首次/旧会话(last_refresh_ts=0,未知年龄)→ 视为临期刷一次; + * ④ 失败退避:网络/TLS 瞬断 → 负错误码返回 + 冷却记录(30 分钟内重拨不重刷); + * ⑤ 失效兜底:code301/-460 → NCM_ERR_AUTH + 置「需回登录页」标志(UI 退扫码); + * ⑥ 冷却:上次失败 < 30 分钟(跨 boot 持久化 fail_ts)→ 即使临期也不刷。 + * 风控纪律:0 次真实 NCM 请求;ncm_login_refresh 为桩、时间由 g_now 推动。 + + * 编译(宿主),在 ncm_app 目录:见 build_ncm_sims.sh 新增 account_cookie_sim 段。 + * 运行:./account_cookie_sim + */ +#include "ncm.h" +#include "ncm_app_internal.h" /* ncm_app_state_t / g_st / account_* 声明 */ +#include +#include +#include + +/* ---- 真实 account.o 依赖的外围桩 ---- */ +ncm_app_state_t g_st; /* 全局状态(app.c 通常定义;本桩自持) */ + +static int64_t g_now_ms = 0; /* 时钟(ncm_now_ms 桩返回,驱动年龄/冷却) */ +int64_t ncm_now_ms(void) { return g_now_ms; } + +static int g_refresh_calls = 0; /* ncm_login_refresh 桩调用计数 */ +static int g_refresh_rc = NCM_OK; /* 桩强制返回码 */ +static const char *g_refresh_out = NULL; /* 成功时写入 out_cookie 的串 */ +int ncm_login_refresh(const char *device_id, const char *cookie, + char *out_cookie, size_t out_cookie_sz) { + (void)device_id; (void)cookie; + g_refresh_calls++; + if (g_refresh_rc == NCM_OK && g_refresh_out) + snprintf(out_cookie, out_cookie_sz, "%s", g_refresh_out); + return g_refresh_rc; +} + +static char g_applied_cookie[NCM_COOKIE_MAX]; /* ncm_ctx_set_cookie 落盘内容 */ +static int g_set_cookie_calls = 0; +int ncm_ctx_set_cookie(ncm_ctx_t *ctx, const char *cookie) { + if (cookie) snprintf(ctx->cookie, sizeof ctx->cookie, "%s", cookie); + else ctx->cookie[0] = 0; + snprintf(g_applied_cookie, sizeof g_applied_cookie, "%s", cookie ? cookie : ""); + g_set_cookie_calls++; + return NCM_OK; +} + +static char g_account_json[1024]; /* account_persist 写出的 account.json */ +static int g_write_calls = 0; +int ncm_store_read_file(const char *path, char *buf, size_t sz) { (void)path; (void)buf; (void)sz; return -1; } +int ncm_store_write_file(const char *path, const void *data, size_t len) { + (void)path; + size_t n = len < sizeof g_account_json - 1 ? len : sizeof g_account_json - 1; + memcpy(g_account_json, data, n); g_account_json[n] = 0; + g_write_calls++; + return NCM_OK; +} + +/* account.o 引用的 json 便捷函数(本桩不调用 account_load,占位保证链接) */ +int ncm_json_parse(ncm_json_t *j, const char *text, size_t len, + jsmntok_t *toks, int tokcap, int *parent) { + (void)j; (void)text; (void)len; (void)toks; (void)tokcap; (void)parent; return -1; +} +int ncm_json_path(const ncm_json_t *j, const char *path) { (void)j; (void)path; return -1; } +int ncm_json_path_int(const ncm_json_t *j, const char *path, long *out) { (void)j; (void)path; if (out) *out = 0; return NCM_OK; } +int64_t ncm_json_get_i64_at(const ncm_json_t *j, int tok, int *rc) { (void)j; (void)tok; if (rc) *rc = NCM_OK; return 0; } +int ncm_api_vip_info(const char *d, const char *ck, const char *uid, ncm_vip_info_t *out) { + (void)d; (void)ck; (void)uid; if (out) memset(out, 0, sizeof *out); return NCM_ERR_NOTFOUND; +} + +static int g_cfg_hours = 12; /* ncm.cookie_refresh_h 配置 */ +static int g_cfg_fail_ts = 0; /* ncm.cookie_refresh_fail_ts 配置(跨 boot 冷却) */ +int meow_config_int(const char *key, int def) { + if (strcmp(key, "ncm.cookie_refresh_h") == 0) return g_cfg_hours; + if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) return g_cfg_fail_ts; + return def; +} +int meow_config_get(const char *key, char *out, int outsz) { + (void)outsz; + char b[24]; + if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) { snprintf(b, sizeof b, "%d", g_cfg_fail_ts); } + else { snprintf(b, sizeof b, "%d", meow_config_int(key, 0)); } + snprintf(out, (size_t)outsz, "%s", b); + return 0; +} +int meow_config_set(const char *key, const char *val) { + if (strcmp(key, "ncm.cookie_refresh_fail_ts") == 0) g_cfg_fail_ts = atoi(val); + return 0; +} +void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; } + +/* ---- harness ---- */ +static int g_fail = 0; +#define CHECK(cond, fmt, ...) do { \ + if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ +} while (0) + +#define NOW_S (1'700'000'000ll) /* 基准秒 */ +#define COOKIE "MUSIC_U=OLD_TOKEN; __csrf=cs; MUSIC_A=anon" +static void reset_state(int64_t last_refresh_ts) +{ + memset(&g_st, 0, sizeof g_st); + g_st.logged_in = 1; + snprintf(g_st.ctx.cookie, sizeof g_st.ctx.cookie, "%s", COOKIE); + snprintf(g_st.ctx.device_id, sizeof g_st.ctx.device_id, "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00"); + g_st.vip = NCM_VIP_NONE; + g_now_ms = NOW_S * 1000; + g_refresh_calls = 0; g_refresh_rc = NCM_OK; g_refresh_out = "MUSIC_U=NEW_TOKEN; __csrf=cs2; MUSIC_A=anon"; + g_set_cookie_calls = 0; g_applied_cookie[0] = 0; + g_account_json[0] = 0; g_write_calls = 0; + g_cfg_hours = 12; g_cfg_fail_ts = 0; + /* mark_fresh 重置 account.c 内单飞/冷却/回登录标志(跨用例需手动清) */ + account_cookie_mark_fresh(); + g_st.cookie_last_refresh_ts = last_refresh_ts; /* 覆盖为用例指定年龄 */ + g_st.logged_in = 1; /* mark_fresh 不碰登录态,显式恢复 */ +} + +int main(void) { + puts("== account_cookie_sim: 临期续期决策 =="); + + /* ① 临期触发:年龄超阈值(12h)→ 后台静默刷一次,新 cookie 应用 + 落盘 last_refresh_ts */ + reset_state(NOW_S - 13 * 3600); /* 13h 前刷过 → 已临期 */ + int rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK, "临期: rc=0 (got %d)", rc); + CHECK(g_refresh_calls == 1, "临期: refresh 被调 1 次 (got %d)", g_refresh_calls); + CHECK(g_set_cookie_calls == 1, "临期: 新 cookie 已应用落盘 (got %d)", g_set_cookie_calls); + CHECK(strstr(g_applied_cookie, "NEW_TOKEN") != NULL, "临期: 应用的是新 MUSIC_U"); + CHECK(g_st.cookie_last_refresh_ts == NOW_S, "临期: last_refresh_ts 更新为 now (got %lld)", + (long long)g_st.cookie_last_refresh_ts); + CHECK(strstr(g_account_json, "\"last_refresh_ts\":") != NULL, "临时: account.json 记录 last_refresh_ts"); + CHECK(g_cfg_fail_ts == 0, "临期成功: 冷却已清 (got %d)", g_cfg_fail_ts); + + /* ② 未临期:年龄 ≤ 阈值 → 不刷 */ + reset_state(NOW_S - 5 * 3600); /* 5h 前刷过 → 未临期 */ + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == 0, "未临期: 不刷 (calls=%d)", g_refresh_calls); + + /* ③ 首次/旧会话:last_refresh_ts=0 → 视为临期刷一次 */ + reset_state(0); + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == 1, "旧会话(last=0): 视为临期刷一次 (calls=%d)", g_refresh_calls); + + /* ④ 失败退避:网络/TLS 瞬断 → 负错误码 + 冷却记录,随后冷却期内不重刷 */ + reset_state(NOW_S - 13 * 3600); + g_refresh_rc = NCM_ERR_NET; + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_ERR_NET, "瞬断: rc=%d (期望 %d)", rc, NCM_ERR_NET); + CHECK(g_set_cookie_calls == 0, "瞬断: 未应用新 cookie"); + CHECK(g_cfg_fail_ts == (int)NOW_S, "瞬断: 冷却时间已持久化 (got %d)", g_cfg_fail_ts); + /* 同 boot 再调(单飞)→ 不重刷;跨 boot 冷却:走新进程静态清零但 fail_ts 持久化在 config */ + int c = g_refresh_calls; + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == c, "瞬断+单飞: 本 boot 不再重试 (calls=%d)", g_refresh_calls); + + /* ⑤ 失效兜底:code301 → NCM_ERR_AUTH + 置「需回登录页」标志 */ + reset_state(NOW_S - 13 * 3600); + g_refresh_rc = NCM_ERR_AUTH; + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_ERR_AUTH, "失效: rc=%d (期望 %d)", rc, NCM_ERR_AUTH); + CHECK(account_cookie_need_relogin() == 1, "失效: 置回登录页标志"); + account_cookie_relogin_consume(); + CHECK(account_cookie_need_relogin() == 0, "失效: 标志可被 UI 消费"); + + /* ⑥ 冷却(跨 boot):config fail_ts 为 10 分钟前,即使临期也不刷 */ + reset_state(NOW_S - 13 * 3600); + g_cfg_fail_ts = (int)(NOW_S - 10 * 60); /* 上次失败 10 分钟前 < 30 分钟冷却 */ + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == 0, "冷却(10min<30min): 不刷 (calls=%d)", g_refresh_calls); + /* 冷却过期(>30min)后再刷 */ + reset_state(NOW_S - 13 * 3600); + g_cfg_fail_ts = (int)(NOW_S - 31 * 60); /* 31 分钟前 > 冷却 */ + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == 1, "冷却过期(31min>30min): 重刷 (calls=%d)", g_refresh_calls); + + /* ⑦ 登录重置:mark_fresh 后年龄=now、冷却清、单飞清 → 不再临期(默认阈值内) */ + reset_state(NOW_S - 13 * 3600); + account_cookie_mark_fresh(); + g_refresh_calls = 0; + rc = account_cookie_maybe_refresh(); + CHECK(rc == NCM_OK && g_refresh_calls == 0, "登录后 mark_fresh: 视为全新不刷 (calls=%d)", g_refresh_calls); + + printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS =="); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/bench/cookie_refresh_sim.c b/patch_v2/ncm_app/bench/cookie_refresh_sim.c new file mode 100644 index 0000000..22c0a29 --- /dev/null +++ b/patch_v2/ncm_app/bench/cookie_refresh_sim.c @@ -0,0 +1,134 @@ +/* bench/cookie_refresh_sim.c — MUSIC_U 登录续期端点语义验证(§NEW,0 真实 NCM 请求)。 + * + * 用真实 core(ncm_login.c / ncm_req.c / ncm_crypto.c / ncm_json.c / ncm_common.c), + * 仅把网络层换成计数桩(canned eapi set-cookie 响应 / 网络错误),验证 ncm_login_refresh: + * ① 成功(code200 + Set-Cookie MUSIC_U/__csrf)→ NCM_OK,新 MUSIC_U patch 进 cookie、 + * 旧 cookie 其它字段(MUSIC_A/__remember_me)保留(= 新 cookie 替换落盘的合并源); + * ② 会话失效(code301)→ NCM_ERR_AUTH(调用方据此退扫码重登); + * ③ 网络/TLS 瞬断 → 负错误码(调用方退避,保持旧 cookie); + * ④ code200 但无 set-cookie(服务端未轮换)→ NCM_OK,cookie 原样(不损坏)。 + * 风控纪律:0 次真实 NCM 请求;桩按 mode 切换。 + + * 编译(宿主),在 ncm_app 目录:见 build_ncm_sims.sh 新增 cookie_refresh_sim 段。 + * 运行:./cookie_refresh_sim + */ +#include "ncm.h" +#include "ncm_http.h" +#include +#include +#include + +static int g_mode; /* 0=成功+set-cookie; 1=code301; 2=网络错误; 3=code200 无 set-cookie */ +static int g_http_calls; /* ncm_http_request 桩计数 = 真实网络调用数 */ + +static const char *HDR_OK = + "Content-Type: application/json\r\n" + "Set-Cookie: MUSIC_U=NEWTOKEN123; Path=/; Max-Age=7776000; HttpOnly\r\n" + "Set-Cookie: __csrf=newcsrf99; Domain=.music.163.com\r\n"; +static const char *BODY_OK = "{\"code\":200,\"message\":\"ok\"}"; +static const char *BODY_AUTH = "{\"code\":301,\"message\":\"需要登录\"}"; + +int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) { + (void)req; (void)buf_sz; + g_http_calls++; + memset(resp, 0, sizeof *resp); + if (g_mode == 2) return NCM_ERR_NET; /* 网络瞬断 */ + const char *body = (g_mode == 1) ? BODY_AUTH : BODY_OK; + size_t bn = strlen(body); + memcpy(buf, body, bn); + resp->status = 200; + resp->body = buf; + resp->body_len = bn; + resp->content_length = bn; + if (g_mode == 0) { /* 带 Set-Cookie 轮换 */ + size_t hn = strlen(HDR_OK); + memcpy(resp->headers, HDR_OK, hn); + resp->headers_len = hn; + } + return NCM_OK; +} + +/* 复刻 ncm_http.c 的 set-cookie 提取(解析所有 Set-Cookie 名值,去属性,"; " 连接) */ +int ncm_http_parse_set_cookie(const char *hdrs, size_t len, char *out, size_t out_sz) { + if (!hdrs || !out || out_sz == 0) return -1; + out[0] = 0; + size_t o = 0; + int count = 0; + const char *p = hdrs; + const char *end = hdrs + len; + while ((p = strstr(p, "Set-Cookie:")) != NULL && p < end) { + const char *val = p + strlen("Set-Cookie:"); + while (*val == ' ' || *val == '\t') val++; + const char *semi = strchr(val, ';'); + size_t nlen = semi ? (size_t)(semi - val) : strlen(val); + while (nlen && (val[nlen-1]==' '||val[nlen-1]=='\t')) nlen--; + if (nlen == 0) { p = val; continue; } + if (o > 0) { if (o + 2 >= out_sz) break; out[o++] = ';'; out[o++] = ' '; } + if (o + nlen >= out_sz) break; + memcpy(out + o, val, nlen); + o += nlen; + count++; + p = val; + } + out[o] = 0; + return count; +} +void ncm_http_set_verify_default(int verify, const char *ca_path) { (void)verify; (void)ca_path; } + +static int g_fail = 0; +#define CHECK(cond, fmt, ...) do { \ + if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ + else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \ +} while (0) + +static const char *g_device = "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00"; + +int main(void) { + /* 基础会话 cookie(MUSIC_U 在中间,__remember_me/MUSIC_A 在后——验证不丢失、不被重排破坏) */ + static char cookie[NCM_COOKIE_MAX]; + snprintf(cookie, sizeof cookie, + "__csrf=old_csrf; MUSIC_U=OLD_TOKEN_abc_123; MUSIC_A=anon_a; __remember_me=true"); + char out[NCM_COOKIE_MAX]; + char v[64]; + + puts("== cookie_refresh_sim: ncm_login_refresh endpoint semantics =="); + + /* ① 成功:code200 + Set-Cookie 轮换 → 新 MUSIC_U 覆盖旧值,其余字段保留 */ + g_mode = 0; g_http_calls = 0; + int rc = ncm_login_refresh(g_device, cookie, out, sizeof out); + CHECK(rc == NCM_OK, "success rc=0 (got %d)", rc); + CHECK(g_http_calls == 1, "exactly 1 http call (got %d)", g_http_calls); + CHECK(ncm_req_cookie_get(out, "MUSIC_U", v, sizeof v) == NCM_OK && + strcmp(v, "NEWTOKEN123") == 0, "MUSIC_U replaced with NEWTOKEN123 (got %s)", v); + CHECK(ncm_req_cookie_get(out, "__csrf", v, sizeof v) == NCM_OK && + strcmp(v, "newcsrf99") == 0, "__csrf replaced (got %s)", v); + CHECK(ncm_req_cookie_get(out, "MUSIC_A", v, sizeof v) == NCM_OK && + strcmp(v, "anon_a") == 0, "MUSIC_A preserved (got %s)", v); + CHECK(ncm_req_cookie_get(out, "__remember_me", v, sizeof v) == NCM_OK && + strcmp(v, "true") == 0, "__remember_me preserved (got %s)", v); + + /* ② 会话失效:code301 → NCM_ERR_AUTH(退扫码重登) */ + g_mode = 1; g_http_calls = 0; + rc = ncm_login_refresh(g_device, cookie, out, sizeof out); + CHECK(rc == NCM_ERR_AUTH, "auth-fail rc=%d (期望 %d)", rc, NCM_ERR_AUTH); + + /* ③ 网络瞬断 → 负错误码(退避,保持旧 cookie 由调用方保证) */ + g_mode = 2; g_http_calls = 0; + rc = ncm_login_refresh(g_device, cookie, out, sizeof out); + CHECK(rc == NCM_ERR_NET, "net-fail rc=%d (期望 %d)", rc, NCM_ERR_NET); + + /* ④ code200 但无 set-cookie(未轮换)→ NCM_OK,cookie 原样(无害) */ + g_mode = 3; g_http_calls = 0; + memset(out, 0, sizeof out); + rc = ncm_login_refresh(g_device, cookie, out, sizeof out); + CHECK(rc == NCM_OK, "code200-no-cookie rc=0 (got %d)", rc); + CHECK(strcmp(out, cookie) == 0, "no-cookie: cookie unchanged"); + + /* ⑤ 无 cookie(未登录)→ 参数允许空 body 字段但仍应优雅(eapi 头不注入 MUSIC_U) */ + g_mode = 3; g_http_calls = 0; + rc = ncm_login_refresh(g_device, "", out, sizeof out); + CHECK(rc == NCM_OK, "empty-cookie still builds http ok (got %d)", rc); + + printf("\n%s\n", g_fail ? "== RESULT: FAIL ==" : "== RESULT: PASS =="); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/build_ncm_sims.sh b/patch_v2/ncm_app/build_ncm_sims.sh index 95053c6..d831380 100755 --- a/patch_v2/ncm_app/build_ncm_sims.sh +++ b/patch_v2/ncm_app/build_ncm_sims.sh @@ -91,4 +91,26 @@ gcc -O2 -o apicache_sim /tmp/ac.o \ -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm run_sim apicache_sim +echo "==> build cookie_refresh_sim (§NEW 续期端点语义;0 真实网络——ncm_http 计数桩)" +gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd \ + -Ivendor/jsmn -Ivendor/mbedtls/include \ + -c bench/cookie_refresh_sim.c -o /tmp/crs.o +gcc -O2 -o cookie_refresh_sim /tmp/crs.o \ + $(ls out/core-host/ncm_login.o out/core-host/ncm_req.o out/core-host/ncm_crypto.o \ + out/core-host/ncm_json.o out/core-host/ncm_common.o out/core-host/ncm_store.o \ + out/core-host/v_qrcodegen.o) \ + out/core-host/jsmn_impl.o \ + -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm +run_sim cookie_refresh_sim + +echo "==> build account_cookie_sim (§NEW 临期续期决策;真实 account.c + 外围桩,0 真实网络)" +gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \ + -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \ + -c app/logic/account.c -o /tmp/acs_account.o +gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \ + -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \ + -c bench/account_cookie_sim.c -o /tmp/acs.o +gcc -O2 -o account_cookie_sim /tmp/acs.o /tmp/acs_account.o -lpthread -lm +run_sim account_cookie_sim + echo "===== all sims done =====" diff --git a/patch_v2/ncm_app/core/ncm_login.c b/patch_v2/ncm_app/core/ncm_login.c index 072158b..000d6af 100644 --- a/patch_v2/ncm_app/core/ncm_login.c +++ b/patch_v2/ncm_app/core/ncm_login.c @@ -187,6 +187,50 @@ int ncm_login_status_check(const char *device_id, const char *cookie, return NCM_OK; } +int ncm_login_refresh(const char *device_id, const char *cookie, + char *out_cookie, size_t out_cookie_sz) { + if (!device_id || !cookie) return NCM_ERR_ARG; + char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX]; + int rc = ncm_req_build(device_id, cookie, "/api/login/token/refresh", "{}", + NCM_CRYPT_EAPI, + body, sizeof body, hdrs, sizeof hdrs, url, sizeof url); + if (rc != NCM_OK) return rc; + + ncm_http_req_t req; + memset(&req, 0, sizeof req); + req.url = url; + req.method = "POST"; + req.body = body; + req.body_len = strlen(body); + req.extra_hdrs = hdrs; + req.verify_ca = NCM_HTTP_VERIFY_DEFAULT; + req.timeout_ms = 10000; + + char respbuf[16384]; + ncm_http_resp_t resp; + rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp); + if (rc != NCM_OK) return rc; /* 网络/TLS/超时 → 调用方退避 */ + if (resp.status != 200) return NCM_ERR_HTTP; /* 异常 HTTP → 调用方退避 */ + + /* 提取响应 set-cookie(会话轮换:MUSIC_U/__csrf),patch 进现存 cookie */ + char captured[4096] = ""; + ncm_http_parse_set_cookie(resp.headers, resp.headers_len, captured, sizeof captured); + + ncm_json_t j; + jsmntok_t toks[512]; + int parent[512]; + rc = ncm_json_parse(&j, resp.body, resp.body_len, toks, 512, parent); + if (rc < 0) return NCM_ERR_JSON; + + long code = -1; + if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON; + if (code != 200) return NCM_ERR_AUTH; /* 301=未登录 / -460=风控 / 其它 → 会话失效,重登 */ + + if (out_cookie) + ncm_req_cookie_merge(cookie, captured, out_cookie, out_cookie_sz); + return NCM_OK; +} + int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie) { return ncm_store_save_session(store, cookie); } diff --git a/patch_v2/ncm_app/core/ncm_login.h b/patch_v2/ncm_app/core/ncm_login.h index 5c64544..add1ccb 100644 --- a/patch_v2/ncm_app/core/ncm_login.h +++ b/patch_v2/ncm_app/core/ncm_login.h @@ -47,6 +47,13 @@ int ncm_login_poll(const char *device_id, const char *unikey, const char *cur_co int ncm_login_status_check(const char *device_id, const char *cookie, char *uid, size_t usz, char *nickname, size_t nsz); +/* 登录续期(eapi /api/login/token/refresh,空体):带当前 cookie 换取新登录态。 + * 成功(code==200)把响应 set-cookie 的新 MUSIC_U/__csrf patch 进 cookie 写 out_cookie 并返 + * NCM_OK;code!=200(301/-460/会话失效)返 NCM_ERR_AUTH(调用方应清登录态进扫码);网络/TLS/ + * 超时/HTTP 异常返负错误码(调用方退避,保持旧 cookie)。 */ +int ncm_login_refresh(const char *device_id, const char *cookie, + char *out_cookie, size_t out_cookie_sz); + /* 会话 cookie 加密落盘/读回(机器绑定密钥,仅防裸读)。 */ int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie); int ncm_login_load_cookie(const ncm_store_t *store, char *cookie, size_t sz); diff --git a/patch_v2/ncm_app/core/ncm_req.c b/patch_v2/ncm_app/core/ncm_req.c index 96224d9..949c7b3 100644 --- a/patch_v2/ncm_app/core/ncm_req.c +++ b/patch_v2/ncm_app/core/ncm_req.c @@ -100,6 +100,17 @@ int ncm_req_cookie_get(const char *cookie, const char *name, char *out, size_t o return NCM_OK; } +/* 合并 cookie:先 parse base(字段指针指向 base 源串),再 parse patch——同名字段被 patch 值 + * 覆盖(ck_set 替换值指针),新字段追加;ck_to_string 在调用期间同步使用,指针不外泄。 */ +int ncm_req_cookie_merge(const char *base, const char *patch, char *out, size_t out_sz) { + if (!out || out_sz == 0) return NCM_ERR_ARG; + ckmap_t m; memset(&m, 0, sizeof m); + ck_parse(&m, base); + ck_parse(&m, patch); /* patch 覆盖 base(后值覆盖先值) */ + ck_to_string(&m, out, out_sz); + return NCM_OK; +} + /* ---------- json 注入 ---------- */ /* 把 json{...} 内部插入 ","(尾部)。extra 形如 "\"csrf_token\":\"x\""(无前导逗号) */ static int json_inject(const char *json, const char *extra, char *out, size_t out_sz) { @@ -155,6 +166,20 @@ static void hm_to_cookie(const hmap_tab *h, char *out, size_t out_sz) { out[o < out_sz ? o : out_sz - 1] = 0; } +/* eapi 会话 cookie 锚点:把 cookie 中某个字段(MUSIC_U / MUSIC_A)urlencode 后追加到 + * Cookie 头。MUSIC_U 值可达 ~800B 不进 hmap(val[256] 放不下),故单独附加。有界附加: + * 放不下则静默停止(不截断破坏 Cookie 头)。 */ +static void eapi_append_cookie(const char *cookie, const char *name, char *hdr, size_t hdr_sz) { + char val[3072] = ""; + if (ncm_req_cookie_get(cookie, name, val, sizeof val) != NCM_OK) return; + char ev[4096]; + if (ncm_url_encode(val, ev, sizeof ev) < 0) return; + size_t o = strlen(hdr); + if (o >= hdr_sz) return; + int n = snprintf(hdr + o, hdr_sz - o, "%s%s=%s", (o > 0) ? "; " : "", name, ev); + if (n < 0 || (size_t)n >= hdr_sz - o) hdr[o] = 0; /* 放不下:还原,丢弃本次附加 */ +} + /* head 注入(eapi):把 __csrf/deviceId/os/... 等填到 header map 与 cookie。 * 同时产出 json.header 片段。 */ static void eapi_fill_header(const char *device_id, const char *csrf, @@ -283,14 +308,24 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path, char params[4096]; if (ncm_crypto_eapi(path, full2, params, sizeof params) != 0) return NCM_ERR_CRYPTO; - char cookie_hdr[2048]; + char cookie_hdr[3072]; hm_to_cookie(&h, cookie_hdr, sizeof cookie_hdr); - /* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U 时补 MUSIC_A=anonymous_token - * 到 eapi Cookie 头(weapi 分支同样注入);login 路径不注入。仅检测存在性 - * (MUSIC_U 值可能很长,无需拷出)。 */ + /* 会话 cookie 锚点(对齐 request.rs):eapi Cookie 头需带 MUSIC_U(登录态识别—— + * 登录刷新/song_url 会员档等必须)与 MUSIC_A(游客)。MUSIC_U 值长不进 hmap, + * 单独 urlencode 追加。仅当 cookie 确有该字段才追加(游客/未登录不注入)。 */ + { + char musicu[8]; + if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) == NCM_OK) + eapi_append_cookie(cookie, "MUSIC_U", cookie_hdr, sizeof cookie_hdr); + if (ncm_req_cookie_get(cookie, "MUSIC_A", musicu, sizeof musicu) == NCM_OK) + eapi_append_cookie(cookie, "MUSIC_A", cookie_hdr, sizeof cookie_hdr); + } + /* 游客 cookie 伪装(NCMAPI_NOTES §4):无 MUSIC_U/MUSIC_A 时补 MUSIC_A=anonymous_token + * 到 eapi Cookie 头;login 路径不注入(登录端点自身处理匿名)。 */ if (!strstr(path, "login")) { char musicu[8]; - if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) != NCM_OK) + if (ncm_req_cookie_get(cookie, "MUSIC_U", musicu, sizeof musicu) != NCM_OK && + ncm_req_cookie_get(cookie, "MUSIC_A", musicu, sizeof musicu) != NCM_OK) ncm_strlcat(cookie_hdr, "; MUSIC_A=anonymous_token", sizeof cookie_hdr); } diff --git a/patch_v2/ncm_app/core/ncm_req.h b/patch_v2/ncm_app/core/ncm_req.h index b990784..10ead32 100644 --- a/patch_v2/ncm_app/core/ncm_req.h +++ b/patch_v2/ncm_app/core/ncm_req.h @@ -44,6 +44,11 @@ int ncm_req_build(const char *device_id, const char *cookie, const char *path, /* 从 cookie 串取某个字段值(如 __csrf)。找到返回 0。*/ int ncm_req_cookie_get(const char *cookie, const char *name, char *out, size_t out_sz); +/* 合并 cookie:patch 中每个 name=value 覆盖 base 同名字段(无则追加),其余 base 字段保留。 + * 用于登录续期:把响应的新 MUSIC_U/__csrf patch 进现有会话 cookie(base 的 MUSIC_A/__remember_me + * 等其它 cookie 不丢失),输出为按名去重、"; " 连接的完整 cookie 串。返回 NCM_OK。 */ +int ncm_req_cookie_merge(const char *base, const char *patch, char *out, size_t out_sz); + #ifdef __cplusplus } #endif diff --git a/patch_v2/ncm_app/ncm b/patch_v2/ncm_app/ncm index ccce84e..05136c7 100755 Binary files a/patch_v2/ncm_app/ncm and b/patch_v2/ncm_app/ncm differ diff --git a/patch_v2/ncm_app/ncm_host b/patch_v2/ncm_app/ncm_host index b492ee5..ab7e430 100755 Binary files a/patch_v2/ncm_app/ncm_host and b/patch_v2/ncm_app/ncm_host differ diff --git a/patch_v2/ncm_app/test/test_ncm_core.c b/patch_v2/ncm_app/test/test_ncm_core.c index d2f8961..d5b37a9 100644 --- a/patch_v2/ncm_app/test/test_ncm_core.c +++ b/patch_v2/ncm_app/test/test_ncm_core.c @@ -189,6 +189,51 @@ static void test_crypto_vectors(void) { CHECK(strcmp(a, b) == 0, "eapi deterministic for same input"); } +static void test_cookie_merge(void) { + /* §NEW 登录续期:patch 的 MUSIC_U/__csrf 覆盖 base 同名字段,其余 base 字段保留。 */ + printf("== cookie merge (login refresh) ==\n"); + char out[4096]; + const char *base = "__csrf=old_csrf; MUSIC_A=anon; __remember_me=true; MUSIC_U=OLD_TOKEN_LONG_1234567890"; + const char *patch = "MUSIC_U=NEW_TOKEN_abcdef; __csrf=new_csrf"; + CHECKRC(ncm_req_cookie_merge(base, patch, out, sizeof out), "merge rc"); + char v[64]; + CHECK(ncm_req_cookie_get(out, "MUSIC_U", v, sizeof v) == NCM_OK && + strcmp(v, "NEW_TOKEN_abcdef") == 0, "MUSIC_U replaced (got %s)", v); + CHECK(ncm_req_cookie_get(out, "__csrf", v, sizeof v) == NCM_OK && + strcmp(v, "new_csrf") == 0, "__csrf replaced (got %s)", v); + CHECK(ncm_req_cookie_get(out, "MUSIC_A", v, sizeof v) == NCM_OK && + strcmp(v, "anon") == 0, "MUSIC_A preserved (got %s)", v); + CHECK(ncm_req_cookie_get(out, "__remember_me", v, sizeof v) == NCM_OK && + strcmp(v, "true") == 0, "__remember_me preserved (got %s)", v); + + /* 空 patch(响应无 set-cookie)= 原样返回 base */ + char out2[4096]; + CHECKRC(ncm_req_cookie_merge(base, "", out2, sizeof out2), "merge with empty patch rc"); + CHECK(strcmp(out2, base) == 0, "empty patch keeps base unchanged"); +} + +static void test_eapi_musicu_hdr(void) { + /* §NEW eapi Cookie 头须带 MUSIC_U(session 锚点,login_refresh/song_url 会员档依赖)。 + * 这里只构建请求,不打网络——验证 Cookie 头包含 MUSIC_U=(值 urlencode 后附加)。 */ + printf("== eapi cookie header carries MUSIC_U ==\n"); + const char *device = "AA11BB22CC33DD44EE55FF6600112233445566778899AABB00"; + const char *cookie = "__csrf=c; MUSIC_U=MyMusicUToken1a2b; MUSIC_A=guestA"; + char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX]; + CHECKRC(ncm_req_build(device, cookie, "/api/login/token/refresh", "{}", NCM_CRYPT_EAPI, + body, sizeof body, hdrs, sizeof hdrs, url, sizeof url), + "eapi build rc"); + CHECK(strstr(hdrs, "MUSIC_U=MyMusicUToken1a2b") != NULL, "Cookie header has MUSIC_U"); + CHECK(strstr(hdrs, "MUSIC_A=guestA") != NULL, "Cookie header has MUSIC_A"); + CHECK(strstr(url, "/eapi/login/token/refresh") != NULL, "url is eapi login/token/refresh"); + + /* 游客(无 MUSIC_U/A)+ 非 login 路径 → 补 anonymous_token 兜底 */ + char h2[NCM_REQ_HDRS_MAX], b2[NCM_REQ_BODY_MAX], u2[NCM_REQ_URL_MAX]; + CHECKRC(ncm_req_build(device, "", "/api/song/lyric", "{\"id\":1}", NCM_CRYPT_EAPI, + b2, sizeof b2, h2, sizeof h2, u2, sizeof u2), "guest eapi build rc"); + CHECK(strstr(h2, "MUSIC_A=anonymous_token") != NULL, "guest eapi injected anonymous MUSIC_A"); + CHECK(strstr(h2, "MUSIC_U=") == NULL, "guest eapi has no MUSIC_U"); +} + static void test_network(void) { printf("== network (live) ==\n"); int net_ok = 0; @@ -468,6 +513,8 @@ int main(int argc, char **argv) { test_store_cookie(); test_cache(); test_qr(); + test_cookie_merge(); + test_eapi_musicu_hdr(); test_network(); test_track_all_parse(); test_vip_parse(); diff --git a/patch_v2/ncm_app/test/test_ncm_core_host b/patch_v2/ncm_app/test/test_ncm_core_host index 8a54deb..9ee10c9 100755 Binary files a/patch_v2/ncm_app/test/test_ncm_core_host and b/patch_v2/ncm_app/test/test_ncm_core_host differ