落雨宸的时光机
1151 字
6 分钟
sub-store 实现 Mihomo 和 Surge 共用配置
2026-08-11
...

Surge 配置文件特征#

启用自动更新(Managed Profile)#

在头部加入以下行:

#!MANAGED-CONFIG http://test.com/surge.conf interval=43200 strict=true

其中 interval 单位为 seconds

https://www.v2ex.com/t/1063404 https://manual.nssurge.com/profile/managed-profile.html

配置文件结构#

[General]
internet-test-url = http://www.baidu.com
proxy-test-url = http://www.google.com/generate_204
skip-proxy = 127.0.0.1, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, 17.0.0.0/8, localhost, *.local, *.crashlytics.com, seed-sequoia.siri.apple.com, sequoia.apple.com
always-real-ip = *.srv.nintendo.net
http-listen = 0.0.0.0
socks5-listen = 0.0.0.0
...

[Proxy Group]

[Rule]

[Host]

[MITM]

...

重要部分如 [Proxy Group], [Rule] 可以与 Mihomo 配置一一对应,为自动转换创造了条件。

sub-store 脚本用法#

语言为 Node.js,有三个魔法参数:

$content, $files, $options

它们会根据脚本执行顺序一直往下传递。

// { content,content,files, options } will be passed to the next operator // content is the final content of the file // context is shared by later operators in this run. // Use context.process to control later actions by customName. // This control does not cross with Response Transformer context.process. // context.process = { type: ‘disable’, customNames: [‘branch-b’] } // type: ‘disable’ skips listed customName; type: ‘enable’ only runs listed customName.

若在 URL 参数中附上 ?$options=A=B,则 $options?.A === "B"

而 URL 参数会反映在 $options._req.query。这里采用 URL 参数 ?<key>=<value> 来传递参数,然后使用脚本把相关参数映射到 $options.<key>

脚本示例#

  1. 预处理与注入节点
//////////////////// PRE-PROCESS ////////////////////
if ($options) {
  const param = Object.fromEntries(
    Object.entries($options._req.query ?? {})
      .filter(([key, value]) => key !== 'token')
  );
  Object.assign($options, param);
}

//////////////////// INJECT NODES ////////////////////
{
  const collection = await produceArtifact({
    type: 'collection',
    name: '<Your-Collection-Name>',
    platform: 'ClashMeta',
    produceType: 'internal',
    ignoreFailedRemoteSub: false,
  });
  
  let yaml = ProxyUtils.yaml.safeLoad($content ?? '') || {};
  
  const have = new Set(yaml.proxies?.map(p => p.name));
  const extra = (collection || []).filter(p => !have.has(p.name));
  yaml.proxies = [...(extra ?? []), ...(yaml.proxies ?? [])];
  
  $content = ProxyUtils.yaml.safeDump(yaml);
}
  1. Mihomo 转 Surge
// ═════════════ mihomo YAML → Surge/SurgeMac 动态转换(规则集 URL 替换版) ═════════════
if(!["Surge", "SurgeMac"].includes($options?.target)) return;

const BASE_URL = '<Your-Base-URL-Of-Substore>';

const REFRESH_INTERVAL = 21600;

const RULE_REPLACE = {
  'Apple':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Apple/Apple.list',
  'Spotify':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Spotify/Spotify.list',
  'TikTok':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/TikTok/TikTok.list',
  'Netflix':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Netflix/Netflix.list',
  'YouTube':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/YouTube/YouTube.list',
  'GlobalMedia':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/GlobalMedia/GlobalMedia.list',
  'Nintendo':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Nintendo/Nintendo.list',
  'PlayStation':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/PlayStation/PlayStation.list',
  'Epic':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Epic/Epic.list',
  'Xbox':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Xbox/Xbox.list',
  'OpenAI':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/OpenAI/OpenAI.list',
  'Microsoft':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Microsoft/Microsoft.list',
  'Proxy':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/Proxy/Proxy.list',
  'China':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/China/China.list',
  'ChinaMedia':'https://raw.githubusercontent.com/blackmatrix7/ios_rule_script/master/rule/Surge/ChinaMedia/ChinaMedia.list',
  // LAN 不用外部规则集 → Surge 内置(见 replaceUrl 特殊分支)
};

const MANUAL_RULE = `
PROCESS-NAME,mihomo,DIRECT

PROCESS-NAME,/Library/Application Support/Microsoft/MAU2.0/Microsoft AutoUpdate.app/Contents/MacOS/Microsoft Update Assistant.app/Contents/MacOS/Microsoft Update Assistant,REJECT
`.split('\n').map(s => s.trim()).filter(Boolean);

const MANUAL_GENERAL = `
read-etc-hosts = true
http-api-web-dashboard = false
use-default-policy-if-wifi-not-primary = false
skip-proxy = 127.0.0.1, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 17.0.0.0/8, localhost, *.local, *.crashlytics.com, seed-sequoia.siri.apple.com, sequoia.apple.com
http-listen = 0.0.0.0
socks5-listen = 0.0.0.0
`.split('\n').map(s => s.trim()).filter(Boolean);

let yaml;
try { yaml = ProxyUtils.yaml.safeLoad($content ?? $files[0]); }
catch (e) { throw new Error('无法解析 YAML: ' + e.message); }
if (!yaml || (!yaml.proxies && !yaml.rules)) { throw new Error('No Nodes'); }

// ---------- 1. 节点 ----------
const isMac = $options?.target === 'SurgeMac';
let plist = yaml.proxies || [];
if (isMac) plist = plist.map(p => ({ ...p, _mihomoExternal: true }));
const nodeText = ProxyUtils.produce(plist, $options?.target || 'Surge', 'Conf',
  isMac ? { mihomoExternal: true } : {});
const nodeLines = nodeText.split('\n').filter(l => l.trim());

// ---------- 2. [General] ----------
const g = [...MANUAL_GENERAL];
if (yaml.dns && typeof yaml.dns === 'object') {
  const plainIPs = [], encList = [];
  const pushServer = (s) => {
    let t = String(s).trim();
    if (t.includes('#')) t = t.split('#')[0].trim();
    if (/^(https?:|h3:|quic:|tls:)/i.test(t)) encList.push(t);
    else if (/^\d+\.\d+\.\d+\.\d+/.test(t)) plainIPs.push(t);
    else if (t) plainIPs.push(t);
  };
  if (Array.isArray(yaml.dns['nameserver'])) yaml.dns['nameserver'].forEach(pushServer);
  if (encList.length) g.push(`encrypted-dns-server = ${encList.join(', ')}`);
  if (plainIPs.length) g.push(`dns-server = ${plainIPs.slice(0, 2).join(', ')}`);
  else g.push('dns-server = 223.5.5.5, 119.29.29.29');
  if (Array.isArray(yaml.dns['fake-ip-filter'])) {
    const fips = [];
    for (const item of yaml.dns['fake-ip-filter']) {
      const s = String(item).trim();
      if (!s || s === '*') continue;
      if (s.startsWith('+')) { const d = s.slice(1).replace(/^\.+/, ''); fips.push(d, `*.${d}`); }
      else if (s.startsWith('*')) { fips.push(s); }
      else if (s.includes('.')) { fips.push(s, `*.${s}`); }
      else { fips.push(s, `*.${s}`); }
    }
    const seen = new Set(), dedup = [];
    for (const f of fips) if (!seen.has(f)) { seen.add(f); dedup.push(f); }
    if (dedup.length) g.push(`always-real-ip = ${dedup.join(', ')}`);
  }
}
g.push(`ipv6 = ${yaml.ipv6 ? 'true' : 'false'}`);
g.push('lazy = true');
const g0 = (yaml['proxy-groups'] || []).find(x => x && x.type !== 'select' && x.url);
if (g0 && g0.url) g.push(`proxy-test-url = ${g0.url}`);
if (yaml.udp === false) g.push('no-udp = true');

// ---------- 3. [Proxy Group] ----------
const G = { 'select':'select','url-test':'url-test','fallback':'fallback','load-balance':'load-balance' };
const groups = (yaml['proxy-groups'] || [])
  .map(gg => {
    if (!gg || !gg.name) return null;
    const members = (gg.proxies || []).filter(m => typeof m === 'string').join(', ');
    const t = G[gg.type] || 'url-test';
    const parts = [`${gg.name} = ${t}`];
    if (gg['include-all'] || gg['include-all-providers']) parts.push('include-all-proxies=true');
    if (typeof gg.filter === 'string' && gg.filter) parts.push(`policy-regex-filter="${gg.filter}"`);
    if (t === 'url-test' || t === 'fallback') {
      if (gg.url) parts.push(`url=${gg.url}`);
      if (gg.interval != null) parts.push(`interval=${gg.interval}`);
      if (gg.timeout) parts.push(`timeout=${Math.round(Number(gg.timeout) / 1000) || gg.timeout}`);
      if (gg['max-failed-times'] != null) parts.push(`max-retry=${gg['max-failed-times']}`);
    }
    if (members) parts.push(members);
    if (t === 'select' && !/\bDIRECT\b/.test(members)) parts.push('DIRECT');
    return parts.join(', ');
  })
  .filter(Boolean);

// ---------- 4. RULE-SET URL 替换映射 ----------
// key: mihomo rule-provider 名, 值: 双兼容 Surge 规则集 URL
// LAN: 用 Surge 内置规则集(RULE-SET,LAN 即可,不含 URL)
const BUILTIN = new Set(['LAN', 'SYSTEM']);
const replaceUrl = (name, orig) => {
  if (BUILTIN.has(name)) return null;          // 内置 → 输出 RULE-SET,LAN
  return RULE_REPLACE[name] || orig;
};

// ---------- 5. [Rule] 规则映射: RULE-SET -> 替换 URL ----------
const RC = {
  'DOMAIN':'DOMAIN','DOMAIN-SUFFIX':'DOMAIN-SUFFIX','DOMAIN-KEYWORD':'DOMAIN-KEYWORD',
  'IP-CIDR':'IP-CIDR','IP-CIDR6':'IP-CIDR6','GEOIP':'GEOIP','DST-PORT':'DST-PORT',
  'SRC-IP-CIDR':'SRC-IP','SRC-PORT':'SRC-PORT','PROCESS-NAME':'PROCESS-NAME',
  'USER-AGENT':'USER-AGENT','PROTOCOL':'PROTOCOL','RULE-SET':'RULE-SET',
};
const provUrl = {};
for (const [name, p] of Object.entries(yaml['rule-providers'] || {})) {
  if (typeof p === 'string') provUrl[name] = p;
  else if (p && p.url) provUrl[name] = p.url;
}
const rules = (yaml.rules || [])
  .map(r => {
    if (typeof r !== 'string') return null;
    const a = r.split(',').map(s => s.trim()).filter(s => s);
    if (!a.length) return null;
    const head = a[0];
    if (head === 'MATCH') { const p = a[1] || ''; return p ? `FINAL,${p}` : null; }
    if (head === 'RULE-SET') {
      const name = a[1] || '', policy = a[2] || 'DIRECT';
      const orig = provUrl[name];
      const url = replaceUrl(name, orig);
      // url 为 null 且名字是内置集 → 输出无 URL 的 RULE-SET,LAN
      if (!url && BUILTIN.has(name)) return `RULE-SET,${name},${policy}`;
      return url ? `RULE-SET,${url},${policy}` : null;
    }
    const to = RC[head];
    return to ? `${to},${a.slice(1).join(',')}` : null;
  })
  .filter(Boolean);

// ---------- 6. 组装 ----------
const hasFinal = rules.some(r => r.startsWith('FINAL'));
const _reqUrl = $options?._req?.url || '';
let managedUrl = BASE_URL + _reqUrl;

$content = [
  managedUrl ? `#!MANAGED-CONFIG ${managedUrl} interval=${REFRESH_INTERVAL}` : `#!MANAGED-CONFIG interval=${REFRESH_INTERVAL} strict=true`,
  '[General]', ...g, '',
  '[Proxy]', ...nodeLines, '',
  ...(groups.length ? ['[Proxy Group]', ...groups] : []), '',
  '[Rule]', ...MANUAL_RULE, ...rules, ...(hasFinal ? [] : ['FINAL,DIRECT']), '',
].join('\n');

sub-store 实现 Mihomo 和 Surge 共用配置
https://blog.lzc256.com/posts/sharing-mihomo-and-surge-config-in-sub-store/
作者
落雨宸
发布于
2026-08-11
许可协议
CC BY-NC-SA 4.0


Loading Comment Component...