diff --git a/env.example b/env.example index bf021d6..c791102 100644 --- a/env.example +++ b/env.example @@ -39,6 +39,12 @@ ACCESS_KEY= # Simyo 代理客户端令牌 # ⚠️ 启用 Simyo 代理时必填;server.js 不再提供默认回退 SIMYO_CLIENT_TOKEN= +# 可选:固定 X-Device-ID(UUID,官方 App 每个请求必带;未设则进程内自动生成) +# SIMYO_DEVICE_ID= +# 可选:覆盖默认客户端版本 / UA(默认对齐 iOS 4.28.0 抓包) +# SIMYO_CLIENT_VERSION=4.28.0 +# SIMYO_CLIENT_PLATFORM=ios +# SIMYO_USER_AGENT=MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1) # Giffgaff OAuth token exchange 回调 URI # 注意:该变量只影响服务端 token exchange;前端授权跳转 URI 仍在 src/giffgaff/js/modules/api-config.js 中配置。 diff --git a/netlify.toml b/netlify.toml index ad6789a..6b1f673 100644 --- a/netlify.toml +++ b/netlify.toml @@ -63,7 +63,7 @@ for = "/api/simyo/*" [headers.values] Access-Control-Allow-Origin = "https://esim.cosr.eu.org" - Access-Control-Allow-Headers = "Content-Type, Authorization" + Access-Control-Allow-Headers = "Content-Type, Authorization, Accept, X-Client-Token, X-Client-Platform, X-Client-Version, X-Device-ID, X-Session-Token" Access-Control-Allow-Methods = "GET, POST, OPTIONS" Vary = "Origin" diff --git a/server.js b/server.js index 90502a6..557fb6a 100644 --- a/server.js +++ b/server.js @@ -31,7 +31,17 @@ const isAllowedOrigin = (origin) => _isAllowedOrigin(origin, origins); const getCorsOrigin = (origin) => _resolveCorsOrigin(origin, origins); const DEFAULT_SIMYO_CLIENT_PLATFORM = 'ios'; const DEFAULT_SIMYO_CLIENT_VERSION = '4.28.0'; -const DEFAULT_SIMYO_USER_AGENT = 'MijnSimyoFT/4.28.0 (iOS 26.3; iPhone16,1)'; +// 与官方 iOS 4.28.0 抓包一致(版本号后两个空格) +const DEFAULT_SIMYO_USER_AGENT = 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)'; +const crypto = require('crypto'); +function getDefaultSimyoDeviceId() { + if (process.env.SIMYO_DEVICE_ID) return process.env.SIMYO_DEVICE_ID; + // 进程内稳定 ID,避免每次请求换设备身份 + if (!global.__simyoDeviceId) { + global.__simyoDeviceId = crypto.randomUUID().toUpperCase(); + } + return global.__simyoDeviceId; +} // 启动时环境检查 if (!INTERNAL_FUNCTION_KEY) { @@ -176,13 +186,14 @@ app.use('/api/simyo/*', (req, res) => { const [pathPart, queryPart] = req.originalUrl.replace(/^\/api\/simyo/, '').split('?'); const proxyPath = pathPart || '/'; const queryString = queryPart ? `?${queryPart}` : ''; - const targetUrl = `https://appapi.simyo.nl/simyoapi/api/v1${proxyPath}${queryString}`; + // 与官方 App / Netlify 代理一致,走 webapi(simyoapi 为旧路径) + const targetUrl = `https://appapi.simyo.nl/webapi/api/v1${proxyPath}${queryString}`; Logger.log(`[Simyo Proxy] ${req.method} ${req.path} -> ${targetUrl}`); // 设置CORS头(仅允许指定域) res.header('Access-Control-Allow-Origin', getCorsOrigin(req.headers.origin)); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-Token, X-Client-Platform, X-Client-Version, X-Session-Token'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, X-Client-Token, X-Client-Platform, X-Client-Version, X-Device-ID, X-Session-Token'); res.header('Vary', 'Origin'); if (req.method === 'OPTIONS') { @@ -197,17 +208,19 @@ app.use('/api/simyo/*', (req, res) => { }); } - // 代理请求 + // 代理请求(X-Device-ID 为 Simyo 4.28+ 必填) const axios = require('axios'); const config = { method: req.method.toLowerCase(), url: targetUrl, headers: { 'Content-Type': 'application/json', + Accept: 'application/json', 'User-Agent': req.headers['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, + 'X-Device-ID': req.headers['x-device-id'] || process.env.SIMYO_DEVICE_ID || getDefaultSimyoDeviceId(), ...(req.headers['x-session-token'] ? { 'X-Session-Token': req.headers['x-session-token'] } : {}) }, timeout: 30000 diff --git a/src/simyo/js/modules/api-config.js b/src/simyo/js/modules/api-config.js index 1a93948..b66cda5 100644 --- a/src/simyo/js/modules/api-config.js +++ b/src/simyo/js/modules/api-config.js @@ -1,21 +1,86 @@ /** * Simyo API配置模块 * 定义所有API端点和请求配置 + * + * 请求头字段对齐官方 Mijn Simyo iOS 4.28.0 抓包: + * X-Client-Token / X-Client-Platform / X-Client-Version / X-Device-ID / User-Agent */ import { isNetlifyEnvironment } from './utils.js'; import { t } from '../../../js/modules/i18n.js'; +/** localStorage 键:持久化设备 ID,模拟 iOS identifierForVendor */ +const DEVICE_ID_STORAGE_KEY = 'simyo_device_id'; + /** - * Simyo客户端配置 + * Simyo客户端配置(与官方 App 抓包一致) */ export const simyoConfig = { - clientToken: "e77b7e2f43db41bb95b17a2a11581a38", - clientPlatform: "ios", - clientVersion: "4.28.0", - userAgent: "MijnSimyoFT/4.28.0 (iOS 26.3; iPhone16,1)" + clientToken: 'e77b7e2f43db41bb95b17a2a11581a38', + clientPlatform: 'ios', + clientVersion: '4.28.0', + // 官方 UA 在版本号后有两个空格 + userAgent: 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)' }; +/** + * 生成 UUID v4(大写,对齐 iOS X-Device-ID 形态) + * @returns {string} + */ +function generateUuidV4() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID().toUpperCase(); + } + // 无 crypto.randomUUID 时的回退(仍保证 RFC4122 形态) + const bytes = new Uint8Array(16); + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < 16; i += 1) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return ( + hex.slice(0, 8) + + '-' + + hex.slice(8, 12) + + '-' + + hex.slice(12, 16) + + '-' + + hex.slice(16, 20) + + '-' + + hex.slice(20) + ).toUpperCase(); +} + +/** + * 获取或创建稳定的 X-Device-ID + * 官方 App 每个请求都携带 UUID 形态的设备 ID;缺失会返回 400 missing X-Device-ID + * @returns {string} + */ +export function getOrCreateDeviceId() { + try { + if (typeof localStorage !== 'undefined') { + const existing = localStorage.getItem(DEVICE_ID_STORAGE_KEY); + if (existing && /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(existing)) { + return existing.toUpperCase(); + } + const created = generateUuidV4(); + localStorage.setItem(DEVICE_ID_STORAGE_KEY, created); + return created; + } + } catch (_) { + // 隐私模式 / 存储不可用时降级为会话内 ID + } + if (!getOrCreateDeviceId._sessionId) { + getOrCreateDeviceId._sessionId = generateUuidV4(); + } + return getOrCreateDeviceId._sessionId; +} + /** * API端点配置 */ @@ -27,30 +92,30 @@ export function getApiEndpoints() { return { // 认证相关 login: isNetlify - ? "/api/simyo/sessions" + ? '/api/simyo/sessions' : `${localBase}/api/simyo/sessions`, // eSIM相关 getEsim: isNetlify - ? "/api/simyo/esim/get-by-customer" + ? '/api/simyo/esim/get-by-customer' : `${localBase}/api/simyo/esim/get-by-customer`, // 设备更换相关 applyNewEsim: isNetlify - ? "/api/simyo/settings/simcard" + ? '/api/simyo/settings/simcard' : `${localBase}/api/simyo/settings/simcard`, verifyCode: isNetlify - ? "/api/simyo/esim/verify-code" + ? '/api/simyo/esim/verify-code' : `${localBase}/api/simyo/esim/verify-code`, // 新增:查询可用的验证方式 (v2 API) availableValidationMethods: isNetlify - ? "/api/simyo/esim.availableValidationMethods" + ? '/api/simyo/esim.availableValidationMethods' : `${localBase}/api/simyo/esim.availableValidationMethods`, // 安装确认 confirmInstall: isNetlify - ? "/api/simyo/esim/reorder-profile-installed" + ? '/api/simyo/esim/reorder-profile-installed' : `${localBase}/api/simyo/esim/reorder-profile-installed` }; } @@ -60,12 +125,14 @@ export function getApiEndpoints() { * @param {boolean} includeSession - 是否包含session token * @param {string} sessionToken - session token值 */ -export function createHeaders(includeSession = false, sessionToken = "") { +export function createHeaders(includeSession = false, sessionToken = '') { const headers = { 'Content-Type': 'application/json', + Accept: 'application/json', 'X-Client-Token': simyoConfig.clientToken, 'X-Client-Platform': simyoConfig.clientPlatform, 'X-Client-Version': simyoConfig.clientVersion, + 'X-Device-ID': getOrCreateDeviceId(), 'User-Agent': simyoConfig.userAgent }; diff --git a/src/simyo/simyo_proxy_server.js b/src/simyo/simyo_proxy_server.js index dd6058f..58d167e 100644 --- a/src/simyo/simyo_proxy_server.js +++ b/src/simyo/simyo_proxy_server.js @@ -33,16 +33,20 @@ function isAllowedTarget(urlStr) { } } -// Simyo API配置 +// Simyo API配置(对齐官方 iOS 4.28.0 抓包) +const crypto = require('crypto'); +const SIMYO_DEVICE_ID = process.env.SIMYO_DEVICE_ID || crypto.randomUUID().toUpperCase(); const SIMYO_CONFIG = { - baseUrl: 'https://appapi.simyo.nl/simyoapi/api/v1', + baseUrl: 'https://appapi.simyo.nl/webapi/api/v1', headers: { 'X-Client-Token': 'e77b7e2f43db41bb95b17a2a11581a38', 'X-Client-Platform': 'ios', 'X-Client-Version': '4.28.0', - 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 26.3; iPhone16,1)', + 'X-Device-ID': SIMYO_DEVICE_ID, + 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)', 'Content-Type': 'application/json', - 'Connection': 'keep-alive', + Accept: 'application/json', + Connection: 'keep-alive', 'Accept-Encoding': 'gzip, deflate, br' } }; diff --git a/tests/simyo/api-config.test.js b/tests/simyo/api-config.test.js new file mode 100644 index 0000000..ac5426f --- /dev/null +++ b/tests/simyo/api-config.test.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Simyo api-config:请求头与设备 ID(对齐官方 App 抓包) + */ + +import { + createHeaders, + getOrCreateDeviceId, + simyoConfig +} from '../../src/simyo/js/modules/api-config.js'; + +const UUID_RE = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/; + +describe('Simyo api-config headers', () => { + beforeEach(() => { + localStorage.clear(); + delete getOrCreateDeviceId._sessionId; + }); + + it('应生成并持久化大写 UUID 形态的 X-Device-ID', () => { + const id1 = getOrCreateDeviceId(); + const id2 = getOrCreateDeviceId(); + expect(id1).toMatch(UUID_RE); + expect(id2).toBe(id1); + expect(localStorage.getItem('simyo_device_id')).toBe(id1); + }); + + 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-Device-ID']).toMatch(UUID_RE); + expect(headers['User-Agent']).toBe(simyoConfig.userAgent); + expect(headers['Content-Type']).toBe('application/json'); + expect(headers.Accept).toBe('application/json'); + expect(headers['X-Session-Token']).toBeUndefined(); + }); + + it('includeSession 时应附带 X-Session-Token', () => { + const headers = createHeaders(true, 'sess-token-1'); + expect(headers['X-Session-Token']).toBe('sess-token-1'); + expect(headers['X-Device-ID']).toMatch(UUID_RE); + }); +}); diff --git a/tests/test_simyo_esim.html b/tests/test_simyo_esim.html index 214cd4a..533eeb2 100644 --- a/tests/test_simyo_esim.html +++ b/tests/test_simyo_esim.html @@ -539,8 +539,10 @@ 'X-Client-Token': 'e77b7e2f43db41bb95b17a2a11581a38', 'X-Client-Platform': 'ios', 'X-Client-Version': '4.28.0', - 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 26.3; iPhone16,1)', + 'X-Device-ID': 'E766D17B-BDF5-43B8-8807-35E43BA129E2', + 'User-Agent': 'MijnSimyoFT/4.28.0 (iOS 27.0; iPhone16,1)', 'Content-Type': 'application/json', + 'Accept': 'application/json', 'Connection': 'keep-alive', 'Accept-Encoding': 'gzip, deflate, br' }; @@ -581,6 +583,7 @@ testFramework.assertTrue('X-Client-Token' in headers1, 'Should include client token'); testFramework.assertTrue('X-Client-Platform' in headers1, 'Should include platform'); testFramework.assertTrue('X-Client-Version' in headers1, 'Should include version'); + testFramework.assertTrue('X-Device-ID' in headers1, 'Should include device id'); testFramework.assertFalse('X-Session-Token' in headers1, 'Should not include session token'); // 测试包含会话令牌的请求头