♻️ refactor(simyo): 统一客户端版本与 User-Agent 配置

抽取 client-identity 作为版本、设备型号与 User-Agent 的单一事实来源;
本地与 Netlify 代理强制使用配置的 User-Agent,不再透传浏览器 UA。
This commit is contained in:
Abner
2026-07-22 18:18:58 +08:00
parent 5a65dd3d01
commit 2bbe2f9bc4
8 changed files with 121 additions and 24 deletions

View File

@@ -41,9 +41,12 @@ ACCESS_KEY=
SIMYO_CLIENT_TOKEN=
# 可选:固定 X-Device-IDUUID官方 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

View File

@@ -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 代理仅允许指定站点跨域访问

View File

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

View File

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

View File

@@ -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';
/** 官方客户端 TokenX-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
});

View File

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

View File

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

View File

@@ -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',