From 2bbe2f9bc47cbcff3ec3b26e3583d5e01e2dc53e Mon Sep 17 00:00:00 2001 From: Abner <22141172+Silentely@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:18:58 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(simyo):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E4=B8=8E=20User-Agent=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取 client-identity 作为版本、设备型号与 User-Agent 的单一事实来源; 本地与 Netlify 代理强制使用配置的 User-Agent,不再透传浏览器 UA。 --- env.example | 5 ++- netlify.toml | 8 ++-- server.js | 14 +++++-- src/simyo/js/modules/api-config.js | 17 +++++---- src/simyo/js/modules/client-identity.js | 51 +++++++++++++++++++++++++ src/simyo/simyo_proxy_server.js | 18 ++++++--- tests/simyo/api-config.test.js | 31 +++++++++++++-- tests/test_simyo_esim.html | 1 + 8 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 src/simyo/js/modules/client-identity.js diff --git a/env.example b/env.example index c791102..a4ff786 100644 --- a/env.example +++ b/env.example @@ -41,9 +41,12 @@ ACCESS_KEY= SIMYO_CLIENT_TOKEN= # 可选:固定 X-Device-ID(UUID,官方 App 每个请求必带;未设则进程内自动生成) # SIMYO_DEVICE_ID= -# 可选:覆盖默认客户端版本 / UA(默认对齐 iOS 4.28.0 抓包) +# 可选:覆盖默认客户端身份(默认对齐 iOS 4.28.0 / iOS 27.0 / iPhone16,1 抓包) +# 与 src/simyo/js/modules/client-identity.js 保持同步 # SIMYO_CLIENT_VERSION=4.28.0 # SIMYO_CLIENT_PLATFORM=ios +# SIMYO_IOS_VERSION=27.0 +# SIMYO_DEVICE_MODEL=iPhone16,1 # SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1) # Giffgaff OAuth token exchange 回调 URI diff --git a/netlify.toml b/netlify.toml index 6b1f673..de27f7d 100644 --- a/netlify.toml +++ b/netlify.toml @@ -35,12 +35,14 @@ # API代理重定向(用于解决CORS问题) # 通用 Simyo API 代理 (webapi - 新版API) +# User-Agent 覆盖为官方 iOS 4.28.0 抓包值(浏览器无法可靠设置 UA) +# 与 src/simyo/js/modules/client-identity.js 保持同步 [[redirects]] from = "/api/simyo/*" to = "https://appapi.simyo.nl/webapi/api/v1/:splat" status = 200 force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl"} + headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} # v2 API 代理(用于特定端点如可用验证方式查询) [[redirects]] @@ -48,7 +50,7 @@ to = "https://appapi.simyo.nl/webapi/api/v2/esim.availableValidationMethods" status = 200 force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl"} + headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} # 保留旧版 simyoapi 路径的兼容性(如果需要) [[redirects]] @@ -56,7 +58,7 @@ to = "https://appapi.simyo.nl/simyoapi/api/v1/:splat" status = 200 force = true - headers = {X-Forwarded-Host = "appapi.simyo.nl"} + headers = {X-Forwarded-Host = "appapi.simyo.nl", User-Agent = "MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)"} [[headers]] # 限制 Simyo 代理仅允许指定站点跨域访问 diff --git a/server.js b/server.js index 557fb6a..0d67fef 100644 --- a/server.js +++ b/server.js @@ -29,10 +29,14 @@ const { parseOrigins, isAllowedOrigin: _isAllowedOrigin, resolveCorsOrigin: _res const origins = parseOrigins(process.env.ALLOWED_ORIGIN); const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins); const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins); +// 与 src/simyo/js/modules/client-identity.js 保持同步(官方 iOS 4.28.0 抓包) const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios'; const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0'; -// 与官方 iOS 4.28.0 抓包一致(版本号后两个空格) -const DEFAULT_SIMYO_USER_AGENT = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)'; +const DEFAULT_SIMYO_IOS_VERSION = '27.0'; +const DEFAULT_SIMYO_DEVICE_MODEL = 'iPhone16,1'; +// 版本号与括号之间为两个空格 +const DEFAULT_SIMYO_USER_AGENT = + `MijnSimyoFT/${DEFAULT_SIMYO_CLIENT_VERSION} (iOS ${DEFAULT_SIMYO_IOS_VERSION}; ${DEFAULT_SIMYO_DEVICE_MODEL})`; const crypto = require('crypto'); function getDefaultSimyoDeviceId() { if (process.env.SIMYO_DEVICE_ID) return process.env.SIMYO_DEVICE_ID; @@ -208,7 +212,8 @@ app.use('/api/simyo/*', (req, res) => { }); } - // 代理请求(X-Device-ID 为 Simyo 4.28+ 必填) + // 代理请求:强制官方 App 身份头(浏览器 UA 不可靠,禁止透传) + // X-Device-ID 为 Simyo 4.28+ 必填,优先使用前端持久化 ID const axios = require('axios'); const config = { method: req.method.toLowerCase(), @@ -216,7 +221,8 @@ app.use('/api/simyo/*', (req, res) => { headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'User-Agent': req.headers['user-agent'] || process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT, + 'Accept-Encoding': 'gzip', + 'User-Agent': process.env.SIMYO_USER_AGENT || DEFAULT_SIMYO_USER_AGENT, 'X-Client-Token': simyoClientToken, 'X-Client-Platform': process.env.SIMYO_CLIENT_PLATFORM || DEFAULT_SIMYO_CLIENT_PLATFORM, 'X-Client-Version': process.env.SIMYO_CLIENT_VERSION || DEFAULT_SIMYO_CLIENT_VERSION, diff --git a/src/simyo/js/modules/api-config.js b/src/simyo/js/modules/api-config.js index b66cda5..b6938e1 100644 --- a/src/simyo/js/modules/api-config.js +++ b/src/simyo/js/modules/api-config.js @@ -2,25 +2,28 @@ * Simyo API配置模块 * 定义所有API端点和请求配置 * - * 请求头字段对齐官方 Mijn Simyo iOS 4.28.0 抓包: + * 请求头字段对齐官方 Mijn Simyo iOS 抓包: * X-Client-Token / X-Client-Platform / X-Client-Version / X-Device-ID / User-Agent + * 版本与 UA 见 client-identity.js(单一事实来源) */ import { isNetlifyEnvironment } from './utils.js'; import { t } from '../../../js/modules/i18n.js'; +import { simyoClientIdentity } from './client-identity.js'; /** localStorage 键:持久化设备 ID,模拟 iOS identifierForVendor */ const DEVICE_ID_STORAGE_KEY = 'simyo_device_id'; /** - * Simyo客户端配置(与官方 App 抓包一致) + * Simyo客户端配置(与官方 App 抓包一致,源自 client-identity) */ export const simyoConfig = { - clientToken: 'e77b7e2f43db41bb95b17a2a11581a38', - clientPlatform: 'ios', - clientVersion: '4.28.0', - // 官方 UA 在版本号后有两个空格 - userAgent: 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)' + clientToken: simyoClientIdentity.clientToken, + clientPlatform: simyoClientIdentity.clientPlatform, + clientVersion: simyoClientIdentity.clientVersion, + iosVersion: simyoClientIdentity.iosVersion, + deviceModel: simyoClientIdentity.deviceModel, + userAgent: simyoClientIdentity.userAgent }; /** diff --git a/src/simyo/js/modules/client-identity.js b/src/simyo/js/modules/client-identity.js new file mode 100644 index 0000000..ff43897 --- /dev/null +++ b/src/simyo/js/modules/client-identity.js @@ -0,0 +1,51 @@ +/** + * Simyo 官方 App 客户端身份(单一事实来源) + * + * 对齐 Mijn Simyo iOS 抓包(2026-07-22 HAR): + * - App 版本: 4.28.0 + * - API User-Agent: MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1) + * (版本号与括号之间为两个空格) + * - 平台: ios + * - 设备型号: iPhone16,1 + * - 系统版本: 27.0 + * + * 修改此处后请同步: + * - server.js 中的 DEFAULT_SIMYO_* 常量 + * - src/simyo/simyo_proxy_server.js 中的 SIMYO_CONFIG.headers + * - netlify.toml 中 /api/simyo 代理的 User-Agent 覆盖 + * - env.example 注释示例 + */ + +/** 官方 App 版本号(X-Client-Version) */ +export const SIMYO_CLIENT_VERSION = '4.28.0'; + +/** 官方平台(X-Client-Platform) */ +export const SIMYO_CLIENT_PLATFORM = 'ios'; + +/** 官方客户端 Token(X-Client-Token) */ +export const SIMYO_CLIENT_TOKEN = 'e77b7e2f43db41bb95b17a2a11581a38'; + +/** 抓包中的 iOS 系统版本 */ +export const SIMYO_IOS_VERSION = '27.0'; + +/** 抓包中的设备型号(identifier) */ +export const SIMYO_DEVICE_MODEL = 'iPhone16,1'; + +/** + * appapi 请求使用的 User-Agent + * 注意:版本号后必须保留两个空格,与官方 Flutter 客户端一致 + */ +export const SIMYO_USER_AGENT = + `MijnSimyoFT/${SIMYO_CLIENT_VERSION} (iOS ${SIMYO_IOS_VERSION}; ${SIMYO_DEVICE_MODEL})`; + +/** + * 聚合配置,便于 createHeaders / 测试一次性读取 + */ +export const simyoClientIdentity = Object.freeze({ + clientToken: SIMYO_CLIENT_TOKEN, + clientPlatform: SIMYO_CLIENT_PLATFORM, + clientVersion: SIMYO_CLIENT_VERSION, + iosVersion: SIMYO_IOS_VERSION, + deviceModel: SIMYO_DEVICE_MODEL, + userAgent: SIMYO_USER_AGENT +}); diff --git a/src/simyo/simyo_proxy_server.js b/src/simyo/simyo_proxy_server.js index 58d167e..aac17a3 100644 --- a/src/simyo/simyo_proxy_server.js +++ b/src/simyo/simyo_proxy_server.js @@ -33,21 +33,27 @@ function isAllowedTarget(urlStr) { } } -// Simyo API配置(对齐官方 iOS 4.28.0 抓包) +// Simyo API配置(与 client-identity.js 同步:iOS 4.28.0 / iOS 27.0 / iPhone16,1) const crypto = require('crypto'); +const SIMYO_CLIENT_VERSION = process.env.SIMYO_CLIENT_VERSION || '4.28.0'; +const SIMYO_IOS_VERSION = process.env.SIMYO_IOS_VERSION || '27.0'; +const SIMYO_DEVICE_MODEL = process.env.SIMYO_DEVICE_MODEL || 'iPhone16,1'; +const SIMYO_USER_AGENT = + process.env.SIMYO_USER_AGENT || + `MijnSimyoFT/${SIMYO_CLIENT_VERSION} (iOS ${SIMYO_IOS_VERSION}; ${SIMYO_DEVICE_MODEL})`; const SIMYO_DEVICE_ID = process.env.SIMYO_DEVICE_ID || crypto.randomUUID().toUpperCase(); const SIMYO_CONFIG = { baseUrl: 'https://appapi.simyo.nl/webapi/api/v1', headers: { - 'X-Client-Token': 'e77b7e2f43db41bb95b17a2a11581a38', - 'X-Client-Platform': 'ios', - 'X-Client-Version': '4.28.0', + 'X-Client-Token': process.env.SIMYO_CLIENT_TOKEN || 'e77b7e2f43db41bb95b17a2a11581a38', + 'X-Client-Platform': process.env.SIMYO_CLIENT_PLATFORM || 'ios', + 'X-Client-Version': SIMYO_CLIENT_VERSION, 'X-Device-ID': SIMYO_DEVICE_ID, - 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)', + 'User-Agent': SIMYO_USER_AGENT, 'Content-Type': 'application/json', Accept: 'application/json', Connection: 'keep-alive', - 'Accept-Encoding': 'gzip, deflate, br' + 'Accept-Encoding': 'gzip' } }; diff --git a/tests/simyo/api-config.test.js b/tests/simyo/api-config.test.js index ac5426f..5184176 100644 --- a/tests/simyo/api-config.test.js +++ b/tests/simyo/api-config.test.js @@ -1,7 +1,7 @@ 'use strict'; /** - * Simyo api-config:请求头与设备 ID(对齐官方 App 抓包) + * Simyo api-config / client-identity:请求头与设备身份(对齐官方 App 抓包) */ import { @@ -9,8 +9,31 @@ import { getOrCreateDeviceId, simyoConfig } from '../../src/simyo/js/modules/api-config.js'; +import { + SIMYO_CLIENT_PLATFORM, + SIMYO_CLIENT_VERSION, + SIMYO_DEVICE_MODEL, + SIMYO_IOS_VERSION, + SIMYO_USER_AGENT, + simyoClientIdentity +} from '../../src/simyo/js/modules/client-identity.js'; const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/; +/** 官方抓包 UA:版本号后恰好两个空格 */ +const OFFICIAL_UA = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)'; + +describe('Simyo client-identity', () => { + it('版本 / 设备 / UA 应与 4.28.0 抓包完全一致', () => { + expect(SIMYO_CLIENT_VERSION).toBe('4.28.0'); + expect(SIMYO_CLIENT_PLATFORM).toBe('ios'); + expect(SIMYO_IOS_VERSION).toBe('27.0'); + expect(SIMYO_DEVICE_MODEL).toBe('iPhone16,1'); + expect(SIMYO_USER_AGENT).toBe(OFFICIAL_UA); + // 版本号与括号之间必须是两个空格(不是一个) + expect(SIMYO_USER_AGENT).toMatch(/^MijnSimyoFT\/4\.28\.0 {2}\(iOS 27\.0; iPhone16,1\)$/); + expect(simyoClientIdentity.userAgent).toBe(OFFICIAL_UA); + }); +}); describe('Simyo api-config headers', () => { beforeEach(() => { @@ -29,9 +52,10 @@ describe('Simyo api-config headers', () => { it('createHeaders 必须包含官方抓包中的核心头', () => { const headers = createHeaders(false); expect(headers['X-Client-Token']).toBe(simyoConfig.clientToken); - expect(headers['X-Client-Platform']).toBe('ios'); - expect(headers['X-Client-Version']).toBe('4.28.0'); + expect(headers['X-Client-Platform']).toBe(SIMYO_CLIENT_PLATFORM); + expect(headers['X-Client-Version']).toBe(SIMYO_CLIENT_VERSION); expect(headers['X-Device-ID']).toMatch(UUID_RE); + expect(headers['User-Agent']).toBe(OFFICIAL_UA); expect(headers['User-Agent']).toBe(simyoConfig.userAgent); expect(headers['Content-Type']).toBe('application/json'); expect(headers.Accept).toBe('application/json'); @@ -42,5 +66,6 @@ describe('Simyo api-config headers', () => { const headers = createHeaders(true, 'sess-token-1'); expect(headers['X-Session-Token']).toBe('sess-token-1'); expect(headers['X-Device-ID']).toMatch(UUID_RE); + expect(headers['User-Agent']).toBe(OFFICIAL_UA); }); }); diff --git a/tests/test_simyo_esim.html b/tests/test_simyo_esim.html index 533eeb2..372fba9 100644 --- a/tests/test_simyo_esim.html +++ b/tests/test_simyo_esim.html @@ -540,6 +540,7 @@ 'X-Client-Platform': 'ios', 'X-Client-Version': '4.28.0', 'X-Device-ID': 'E766D17B-BDF5-43B8-8807-35E43BA129E2', + // 官方抓包:版本号后两个空格 + iOS 27.0 + iPhone16,1 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)', 'Content-Type': 'application/json', 'Accept': 'application/json',