mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
🐛 fix(simyo): 补齐请求头 X-Device-ID
Simyo 4.28+ 要求每个请求携带 UUID 形态的 X-Device-ID, 缺失会返回 400 missing X-Device-ID。前端生成并持久化设备 ID, 本地与代理同步转发该请求头,并将上游 API 路径切换为 webapi。
This commit is contained in:
@@ -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 中配置。
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
21
server.js
21
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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
46
tests/simyo/api-config.test.js
Normal file
46
tests/simyo/api-config.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
// 测试包含会话令牌的请求头
|
||||
|
||||
Reference in New Issue
Block a user