🐛 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:
Abner
2026-07-22 18:14:32 +08:00
parent 71ca8538f7
commit 5a65dd3d01
7 changed files with 161 additions and 22 deletions

View File

@@ -39,6 +39,12 @@ ACCESS_KEY=
# Simyo 代理客户端令牌
# ⚠️ 启用 Simyo 代理时必填server.js 不再提供默认回退
SIMYO_CLIENT_TOKEN=
# 可选:固定 X-Device-IDUUID官方 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 中配置。

View File

@@ -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"

View File

@@ -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 代理一致,走 webapisimyoapi 为旧路径)
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

View File

@@ -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
};

View File

@@ -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'
}
};

View 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);
});
});

View File

@@ -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');
// 测试包含会话令牌的请求头