sub2clash_singbox.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887
  1. import sys
  2. import requests
  3. import base64
  4. import json
  5. import yaml
  6. try:
  7. from ruamel.yaml import YAML
  8. except ImportError:
  9. try:
  10. import ruamel.yaml
  11. YAML = ruamel.yaml.YAML
  12. except Exception as e:
  13. print('Error: ruamel.yaml not installed. Please install it with: pip install ruamel.yaml')
  14. raise e
  15. from urllib.parse import urlparse, parse_qs, unquote
  16. from collections import OrderedDict
  17. import re
  18. def is_valid_ws_path(path):
  19. # All '%' must be followed by exactly two hex digits
  20. # Regex: '%' not followed by two hex digits is invalid
  21. invalid = re.search(r'%($|[^0-9A-Fa-f]{0,2}|[0-9A-Fa-f]($|[^0-9A-Fa-f]))', path)
  22. return not invalid
  23. # ---------- UTILITIES ----------
  24. def download_subscription(sub_url):
  25. resp = requests.get(sub_url)
  26. resp.raise_for_status()
  27. text = resp.text.strip()
  28. # meta: some sub files are base64 encoded!
  29. try:
  30. if all(ord(c) < 128 for c in text) and not text.startswith(
  31. ('vmess://', 'vless://', 'trojan://', 'ss://', 'socks://', 'hy2://', 'hysteria2://', 'tuic://',
  32. 'wg://')):
  33. # Try decode base64 whole (often for SSR/SS)
  34. dec = base64.b64decode(text).decode('utf-8', errors='ignore')
  35. if dec.count('\n') > text.count('\n'):
  36. text = dec
  37. except Exception:
  38. pass
  39. return [line.strip() for line in text.splitlines() if line.strip()]
  40. # --- PROTOCOL PARSERS ---
  41. def parse_vmess(uri):
  42. # vmess://<base64json>
  43. payload = uri[8:]
  44. try:
  45. raw = base64.b64decode(payload + '=' * ((4 - len(payload) % 4) % 4)).decode('utf-8')
  46. data = json.loads(raw)
  47. cipher = data.get('cipher')
  48. # Fix: always set a valid cipher (Meta: chacha20-poly1305 or auto is safest)
  49. if not cipher or cipher.lower() not in ["auto", "chacha20-poly1305", "aes-128-gcm", "none"]:
  50. cipher = 'auto'
  51. net = data.get('net') or data.get('network', 'tcp')
  52. ws_opts = None
  53. tls_opts = None
  54. # Handle WebSocket transport
  55. if net in ['ws', 'wss']:
  56. path = data.get('path', '/')
  57. host = None
  58. try:
  59. addh = data.get('add', None)
  60. host = data.get('host', None) or data.get('Host', None)
  61. if host:
  62. if isinstance(host, list):
  63. host = host[0]
  64. else:
  65. if addh:
  66. host = addh
  67. except Exception:
  68. host = None
  69. ws_opts = {'type': 'ws', 'path': path if path else '/', 'host': host if host else None}
  70. # Handle TLS
  71. tls_enabled = (data.get('tls', 'none') == 'tls')
  72. if tls_enabled:
  73. sni = data.get('sni') or data.get('host') or data.get('add')
  74. tls_opts = {'server_name': sni} if sni else {}
  75. proxy = {
  76. 'type': 'vmess',
  77. 'server': data.get('add'),
  78. 'port': int(data.get('port', 0)),
  79. 'uuid': data.get('id'),
  80. 'alterId': data.get('aid', '0'),
  81. 'cipher': cipher,
  82. 'network': net,
  83. 'transport': ws_opts,
  84. 'tls': tls_enabled,
  85. 'tls_opts': tls_opts,
  86. 'name': data.get('ps') or f"vmess_{data.get('add')}",
  87. }
  88. return proxy
  89. except Exception:
  90. return None
  91. def parse_vless(uri):
  92. # vless://[uuid]@[host]:[port]?params#remark
  93. try:
  94. url = urlparse(uri)
  95. user = url.username
  96. server = url.hostname
  97. port = url.port
  98. params = parse_qs(url.query)
  99. tag = unquote(url.fragment) if url.fragment else f"vless_{server}"
  100. if not port or not user or not server:
  101. return None
  102. encryption = params.get('encryption', [None])[0]
  103. if encryption in (None, '', 'none'):
  104. encryption = None
  105. net = params.get('type', ['tcp'])[0]
  106. path = params.get('path', ['/'])[0]
  107. host = params.get('host', [server])[0]
  108. sni = params.get('sni', [None])[0]
  109. # Transport options
  110. ws_opts = None
  111. if net in ['ws', 'wss']:
  112. ws_opts = {'type': 'ws', 'path': path, 'host': host or server}
  113. # TLS options
  114. security = params.get('security', ['none'])[0]
  115. tls_enabled = security in ['tls', 'reality']
  116. tls_opts = None
  117. if tls_enabled:
  118. tls_opts = {}
  119. if sni:
  120. tls_opts['server_name'] = sni
  121. if security == 'reality':
  122. # Reality specific parameters
  123. public_key = params.get('pbk', [None])[0]
  124. short_id = params.get('sid', [None])[0]
  125. fp = params.get('fp', [None])[0]
  126. if public_key:
  127. tls_opts['reality'] = {
  128. 'public_key': public_key,
  129. 'short_id': short_id or '',
  130. 'fingerprint': fp or 'chrome'
  131. }
  132. return {
  133. 'type': 'vless',
  134. 'server': server,
  135. 'port': int(port),
  136. 'uuid': user,
  137. 'encryption': encryption,
  138. 'flow': params.get('flow', [''])[0],
  139. 'network': net,
  140. 'transport': ws_opts,
  141. 'tls': tls_enabled,
  142. 'tls_opts': tls_opts,
  143. 'name': tag,
  144. }
  145. except Exception:
  146. return None
  147. def parse_trojan(uri):
  148. # trojan://password@host:port?params#remark
  149. try:
  150. url = urlparse(uri)
  151. server = url.hostname
  152. port = url.port
  153. password = url.username
  154. tag = unquote(url.fragment) if url.fragment else f"trojan_{server}"
  155. if not port or not password or not server:
  156. return None
  157. params = parse_qs(url.query)
  158. sni = params.get('sni', [None])[0]
  159. # TLS options for Hysteria2
  160. tls_opts = None
  161. if sni:
  162. tls_opts = {'server_name': sni}
  163. return {
  164. 'type': 'trojan',
  165. 'server': server,
  166. 'port': int(port),
  167. 'password': password,
  168. 'tls_opts': tls_opts,
  169. 'name': tag
  170. }
  171. except Exception:
  172. return None
  173. def parse_ss(uri):
  174. # ss://[method:pass@host:port] or ss://base64#remark
  175. try:
  176. rest = uri[5:]
  177. if '@' in rest:
  178. if '#' in rest:
  179. rest, tag = rest.split('#', 1)
  180. tag = unquote(tag)
  181. else:
  182. tag = None
  183. auth, host_port = rest.split('@', 1)
  184. method, password = auth.split(':', 1)
  185. host, port = host_port.split(':', 1)
  186. if not port:
  187. return None
  188. else:
  189. if '#' in rest:
  190. main, tag = rest.split('#', 1)
  191. tag = unquote(tag)
  192. else:
  193. main = rest
  194. tag = None
  195. raw = base64.b64decode(main.split('?')[0] + '===').decode('utf-8')
  196. userinfo, host_port = raw.rsplit('@', 1)
  197. method, password = userinfo.split(':', 1)
  198. host, port = host_port.split(':', 1)
  199. if not port:
  200. return None
  201. return {
  202. 'type': 'ss',
  203. 'server': host,
  204. 'port': int(port),
  205. 'method': method,
  206. 'password': password,
  207. 'name': tag or f"ss_{host}"
  208. }
  209. except Exception:
  210. return None
  211. def parse_socks(uri):
  212. # socks://[username:password@]host:port
  213. try:
  214. url = urlparse(uri)
  215. username = url.username or ''
  216. password = url.password or ''
  217. server = url.hostname
  218. port = url.port
  219. tag = unquote(url.fragment) if url.fragment else f"socks_{server}"
  220. if not port or not server:
  221. return None
  222. return {
  223. 'type': 'socks',
  224. 'server': server,
  225. 'port': int(port),
  226. 'username': username,
  227. 'password': password,
  228. 'name': tag
  229. }
  230. except Exception:
  231. return None
  232. def parse_hysteria2(uri):
  233. # hy2://password@host:port?params#remark or hysteria2://password@host:port?params#remark
  234. try:
  235. if uri.startswith('hy2://'):
  236. uri = 'hysteria2://' + uri[6:]
  237. url = urlparse(uri)
  238. server = url.hostname
  239. port = url.port
  240. password = url.username
  241. tag = unquote(url.fragment) if url.fragment else f"hysteria2_{server}"
  242. if not port or not password or not server:
  243. return None
  244. params = parse_qs(url.query)
  245. sni = params.get('sni', [None])[0]
  246. # TLS options for Hysteria2
  247. tls_opts = None
  248. if sni:
  249. tls_opts = {'server_name': sni}
  250. return {
  251. 'type': 'hysteria2',
  252. 'server': server,
  253. 'port': int(port),
  254. 'password': password,
  255. 'tls_opts': tls_opts,
  256. 'name': tag
  257. }
  258. except Exception:
  259. return None
  260. def parse_tuic(uri):
  261. # tuic://uuid:password@host:port?params#remark
  262. try:
  263. url = urlparse(uri)
  264. server = url.hostname
  265. port = url.port
  266. user_info = url.username
  267. tag = unquote(url.fragment) if url.fragment else f"tuic_{server}"
  268. if not port or not user_info or not server:
  269. return None
  270. # Parse uuid:password
  271. if ':' in user_info:
  272. uuid, password = user_info.split(':', 1)
  273. else:
  274. uuid = user_info
  275. password = ''
  276. params = parse_qs(url.query)
  277. sni = params.get('sni', [None])[0]
  278. # TLS options for TUIC
  279. tls_opts = None
  280. if sni:
  281. tls_opts = {'server_name': sni}
  282. return {
  283. 'type': 'tuic',
  284. 'server': server,
  285. 'port': int(port),
  286. 'uuid': uuid,
  287. 'password': password,
  288. 'tls_opts': tls_opts,
  289. 'name': tag
  290. }
  291. except Exception:
  292. return None
  293. def parse_wireguard(uri):
  294. # wg://... (basic support)
  295. try:
  296. # This is a simplified parser for WireGuard
  297. # Real WireGuard configs are usually much more complex
  298. url = urlparse(uri)
  299. server = url.hostname
  300. port = url.port or 51820
  301. tag = unquote(url.fragment) if url.fragment else f"wireguard_{server}"
  302. if not server:
  303. return None
  304. params = parse_qs(url.query)
  305. private_key = params.get('privatekey', [None])[0]
  306. public_key = params.get('publickey', [None])[0]
  307. if not private_key or not public_key:
  308. return None
  309. return {
  310. 'type': 'wireguard',
  311. 'server': server,
  312. 'port': int(port),
  313. 'private_key': private_key,
  314. 'public_key': public_key,
  315. 'name': tag
  316. }
  317. except Exception:
  318. return None
  319. def parse_proxy_line(line):
  320. if line.startswith('vmess://'):
  321. return parse_vmess(line)
  322. elif line.startswith('vless://'):
  323. return parse_vless(line)
  324. elif line.startswith('trojan://'):
  325. return parse_trojan(line)
  326. elif line.startswith('ss://'):
  327. return parse_ss(line)
  328. elif line.startswith('socks://'):
  329. return parse_socks(line)
  330. elif line.startswith(('hy2://', 'hysteria2://')):
  331. return parse_hysteria2(line)
  332. elif line.startswith('tuic://'):
  333. return parse_tuic(line)
  334. elif line.startswith('wg://'):
  335. return parse_wireguard(line)
  336. elif line.startswith(('reality://', 'anytls://')):
  337. print(f'[!] WARNING: New or future protocol detected in link: {line[:32]}...')
  338. return None # Not yet implemented -- print warning
  339. else:
  340. if line.strip():
  341. print(f'[!] WARNING: Unknown v2ray/vless/protocol line skipped: {line[:48]}...')
  342. return None
  343. def validate_proxy(p):
  344. # Block injection if domain is well-known public web service or parameters are missing
  345. if not p:
  346. return False
  347. if not p.get('server') or not p.get('port') or not p.get('uuid', ''):
  348. return False
  349. # Block known public domains (e.g. speedtest.net, npmjs.com, google.com, etc.)
  350. public_domains = {
  351. 'www.speedtest.net', 'speedtest.net', 'npmjs.com', 'google.com', 'github.com', 'cloudflare.com',
  352. 'facebook.com', 'twitter.com', 'spotify.com', 'youtube.com', 'apple.com', 'microsoft.com', 'instagram.com'
  353. }
  354. server_l = p['server'].lower()
  355. if any(domain in server_l for domain in public_domains):
  356. return False
  357. # Optionally block .ir endpoints (for Iran direct/dns leaks)
  358. if server_l.endswith('.ir'):
  359. return False
  360. # You may add extra filters here if needed
  361. return True
  362. # --- CONFIG PARSING/RENDER ---
  363. def read_yaml_file(yaml_path):
  364. with open(yaml_path, 'r', encoding='utf-8') as f:
  365. yaml_ = YAML()
  366. content = yaml_.load(f)
  367. return content
  368. def write_yaml_file(yaml_obj, yaml_path):
  369. with open(yaml_path, 'w', encoding='utf-8') as f:
  370. yaml_ = YAML()
  371. yaml_.default_flow_style = False
  372. yaml_.dump(yaml_obj, f)
  373. def read_json_file(path):
  374. with open(path, 'r', encoding='utf-8') as f:
  375. return json.load(f)
  376. def write_json_file(obj, path):
  377. with open(path, 'w', encoding='utf-8') as f:
  378. json.dump(obj, f, indent=2, ensure_ascii=False)
  379. # ---- INJECTION LOGIC ----
  380. def ascii_name(name):
  381. # Remove all characters except ASCII letters, numbers, dash, underscore, and spaces
  382. n = re.sub(r'[^A-Za-z0-9 \-_]', '', name)
  383. return n.strip()
  384. def update_clash_proxies(clash_cfg, proxies):
  385. yaml_proxies = []
  386. name_registry = {}
  387. for p in proxies:
  388. clash_proxy = proxy_to_clash(p)
  389. if clash_proxy:
  390. orig_name = ascii_name(clash_proxy['name'])
  391. # Ensure unique names for Clash
  392. name = orig_name
  393. i = 2
  394. while name in name_registry:
  395. name = f"{orig_name} #{i}"
  396. i += 1
  397. clash_proxy['name'] = name
  398. name_registry[name] = True
  399. yaml_proxies.append(clash_proxy)
  400. proxy_names = [p['name'] for p in yaml_proxies]
  401. # ... (rest unchanged)
  402. for p in yaml_proxies:
  403. p['name'] = p['name']
  404. # ... (rest unchanged)
  405. clash_cfg['proxies'] = yaml_proxies
  406. clash_cfg['proxy-groups'] = []
  407. auto_group = {
  408. "name": "AUTO",
  409. "type": "url-test",
  410. "url": "https://www.gstatic.com/generate_204",
  411. "interval": 300,
  412. "tolerance": 50,
  413. "proxies": proxy_names
  414. }
  415. proxy_group = {
  416. "name": "PROXY",
  417. "type": "select",
  418. "proxies": ["AUTO"] + proxy_names
  419. }
  420. clash_cfg['proxy-groups'] = [auto_group, proxy_group]
  421. # Update rules to ensure consistent formatting (fix the spacing issues)
  422. if 'rules' in clash_cfg:
  423. new_rules = []
  424. for rule in clash_cfg['rules']:
  425. # Ensure proper formatting without extra spaces
  426. if isinstance(rule, str):
  427. # Split and rejoin to fix spacing
  428. parts = [part.strip() for part in rule.split(',')]
  429. new_rules.append(','.join(parts))
  430. else:
  431. new_rules.append(rule)
  432. clash_cfg['rules'] = new_rules
  433. return clash_cfg
  434. def proxy_to_clash(proxy):
  435. # Map internal proxy to Clash Meta format
  436. if proxy['type'] == 'vmess':
  437. clash_proxy = {
  438. 'name': proxy['name'],
  439. 'type': 'vmess',
  440. 'server': proxy['server'],
  441. 'port': proxy['port'],
  442. 'uuid': proxy['uuid'],
  443. 'alterId': int(proxy.get('alterId', '0')),
  444. 'cipher': proxy.get('cipher', 'auto'),
  445. 'network': proxy.get('network', 'tcp'),
  446. }
  447. if proxy.get('tls'):
  448. clash_proxy['tls'] = True
  449. if proxy.get('tls_opts', {}).get('server_name'):
  450. clash_proxy['servername'] = proxy['tls_opts']['server_name']
  451. # WebSocket options
  452. if proxy.get('transport'):
  453. clash_proxy['ws-opts'] = {
  454. 'path': proxy['transport'].get('path', '/'),
  455. 'headers': {}
  456. }
  457. if proxy['transport'].get('host'):
  458. clash_proxy['ws-opts']['headers']['Host'] = proxy['transport']['host']
  459. return clash_proxy
  460. elif proxy['type'] == 'vless':
  461. clash_proxy = {
  462. 'name': proxy['name'],
  463. 'type': 'vless',
  464. 'server': proxy['server'],
  465. 'port': proxy['port'],
  466. 'uuid': proxy['uuid'],
  467. 'network': proxy.get('network', 'tcp'),
  468. }
  469. if proxy.get('flow'):
  470. clash_proxy['flow'] = proxy['flow']
  471. if proxy.get('encryption'):
  472. clash_proxy['encryption'] = proxy['encryption']
  473. if proxy.get('tls'):
  474. clash_proxy['tls'] = True
  475. if proxy.get('tls_opts', {}).get('server_name'):
  476. clash_proxy['servername'] = proxy['tls_opts']['server_name']
  477. # Reality support
  478. if proxy.get('tls_opts', {}).get('reality'):
  479. reality = proxy['tls_opts']['reality']
  480. clash_proxy['reality-opts'] = {
  481. 'public-key': reality['public_key'],
  482. 'short-id': reality.get('short_id', '')
  483. }
  484. if reality.get('fingerprint'):
  485. clash_proxy['client-fingerprint'] = reality['fingerprint']
  486. # WebSocket options
  487. if proxy.get('transport'):
  488. clash_proxy['ws-opts'] = {
  489. 'path': proxy['transport'].get('path', '/'),
  490. 'headers': {}
  491. }
  492. if proxy['transport'].get('host'):
  493. clash_proxy['ws-opts']['headers']['Host'] = proxy['transport']['host']
  494. return clash_proxy
  495. elif proxy['type'] == 'trojan':
  496. clash_proxy = {
  497. 'name': proxy['name'],
  498. 'type': 'trojan',
  499. 'server': proxy['server'],
  500. 'port': proxy['port'],
  501. 'password': proxy['password'],
  502. }
  503. if proxy.get('tls_opts', {}).get('server_name'):
  504. clash_proxy['servername'] = proxy['tls_opts']['server_name']
  505. return clash_proxy
  506. elif proxy['type'] == 'ss':
  507. return {
  508. 'name': proxy['name'],
  509. 'type': 'ss',
  510. 'server': proxy['server'],
  511. 'port': proxy['port'],
  512. 'cipher': proxy['method'],
  513. 'password': proxy['password']
  514. }
  515. elif proxy['type'] == 'socks':
  516. clash_proxy = {
  517. 'name': proxy['name'],
  518. 'type': 'socks5',
  519. 'server': proxy['server'],
  520. 'port': proxy['port'],
  521. }
  522. if proxy['username']:
  523. clash_proxy['username'] = proxy['username']
  524. if proxy['password']:
  525. clash_proxy['password'] = proxy['password']
  526. return clash_proxy
  527. elif proxy['type'] == 'hysteria2':
  528. clash_proxy = {
  529. 'name': proxy['name'],
  530. 'type': 'hysteria2',
  531. 'server': proxy['server'],
  532. 'port': proxy['port'],
  533. 'password': proxy['password'],
  534. }
  535. if proxy.get('tls_opts', {}).get('server_name'):
  536. clash_proxy['sni'] = proxy['tls_opts']['server_name']
  537. return clash_proxy
  538. else:
  539. # Unsupported protocol for Clash
  540. return None
  541. def update_singbox_outbounds(sj, proxies):
  542. new_outbounds = []
  543. tagset = set()
  544. for p in proxies:
  545. sbo = proxy_to_singbox(p)
  546. if not sbo:
  547. continue
  548. if sbo['tag'] in tagset:
  549. continue
  550. new_outbounds.append(sbo)
  551. tagset.add(sbo['tag'])
  552. # Find existing system outbounds to preserve
  553. system_outbounds = []
  554. for o in sj.get('outbounds', []):
  555. if o.get('type') in ['direct', 'block']:
  556. system_outbounds.append(o)
  557. elif o.get('type') in ['selector', 'urltest']:
  558. # Keep the system selector/urltest outbounds but update their proxy lists
  559. system_outbounds.append(o)
  560. # Replace all outbounds but keep system ones and add new ones
  561. sj['outbounds'] = system_outbounds + new_outbounds
  562. # Update selector and urltest outbounds with new proxy tags
  563. proxy_tags = list(tagset)
  564. for o in sj['outbounds']:
  565. if o['type'] == 'selector' and o.get('tag') == 'proxy':
  566. # Replace any placeholder with auto + all proxies
  567. o['outbounds'] = ['auto'] + proxy_tags
  568. o['default'] = 'auto'
  569. elif o['type'] == 'urltest' and o.get('tag') == 'auto':
  570. # Replace any placeholder with all proxies
  571. o['outbounds'] = proxy_tags
  572. return sj
  573. def proxy_to_singbox(proxy):
  574. tag = ascii_name(proxy['name'])
  575. net = proxy.get('network', 'tcp')
  576. # If grpc network, change to tcp and add transport block if possible
  577. if net == 'grpc':
  578. net = 'tcp'
  579. transport = {'type': 'grpc'}
  580. if 'serviceName' in proxy:
  581. transport['serviceName'] = proxy['serviceName']
  582. proxy['transport'] = transport
  583. if net not in ('tcp', 'ws', 'wss', 'grpc'):
  584. print(f"[!] Skipping proxy with unsupported network type for sing-box: {net} ({proxy['name']})")
  585. return None
  586. # Now do ws path validation on path fields
  587. if net in ('ws', 'wss') and proxy.get('transport') and proxy['transport'].get('path'):
  588. path = str(proxy['transport'].get('path'))
  589. if not is_valid_ws_path(path):
  590. print(f"[FATAL] Skipping proxy with invalid WebSocket path: {path} ({proxy['name']})")
  591. return None
  592. if proxy['type'] == 'vmess':
  593. ws = net in ('ws', 'wss')
  594. out = OrderedDict([
  595. ('type', 'vmess'),
  596. ('tag', tag),
  597. ('server', proxy['server']),
  598. ('server_port', proxy['port']),
  599. ('uuid', proxy['uuid']),
  600. ('alter_id', int(proxy.get('alterId', '0'))),
  601. ('network', 'tcp' if ws else net)
  602. ])
  603. # TLS configuration
  604. if proxy.get('tls'):
  605. tls_config = {}
  606. if proxy.get('tls_opts', {}).get('server_name'):
  607. tls_config['server_name'] = proxy['tls_opts']['server_name']
  608. out['tls'] = tls_config
  609. # Transport configuration
  610. if ws and proxy.get('transport'):
  611. transport = {}
  612. if proxy['transport'].get('type'):
  613. transport['type'] = proxy['transport']['type']
  614. if proxy['transport'].get('path'):
  615. transport['path'] = proxy['transport']['path']
  616. if transport:
  617. out['transport'] = transport
  618. return out
  619. elif proxy['type'] == 'vless':
  620. ws = net in ('ws', 'wss')
  621. out = OrderedDict([
  622. ('type', 'vless'),
  623. ('tag', tag),
  624. ('server', proxy['server']),
  625. ('server_port', proxy['port']),
  626. ('uuid', proxy['uuid']),
  627. ('network', 'tcp' if ws else net)
  628. ])
  629. if proxy.get('flow'):
  630. out['flow'] = proxy['flow']
  631. if proxy.get('encryption'):
  632. out['encryption'] = proxy['encryption']
  633. # TLS configuration
  634. if proxy.get('tls'):
  635. tls_config = {}
  636. if proxy.get('tls_opts', {}).get('server_name'):
  637. tls_config['server_name'] = proxy['tls_opts']['server_name']
  638. if proxy.get('tls_opts', {}).get('reality'):
  639. reality = proxy['tls_opts']['reality']
  640. tls_config['reality'] = {
  641. 'enabled': True,
  642. 'public_key': reality['public_key'],
  643. 'short_id': reality.get('short_id', '')
  644. }
  645. out['tls'] = tls_config
  646. # Transport configuration
  647. if ws and proxy.get('transport'):
  648. transport = {}
  649. if proxy['transport'].get('type'):
  650. transport['type'] = proxy['transport']['type']
  651. if proxy['transport'].get('path'):
  652. transport['path'] = proxy['transport']['path']
  653. if transport:
  654. out['transport'] = transport
  655. return out
  656. elif proxy['type'] == 'trojan':
  657. out = {
  658. 'type': 'trojan',
  659. 'tag': tag,
  660. 'server': proxy['server'],
  661. 'server_port': proxy['port'],
  662. 'password': proxy['password'],
  663. }
  664. # TLS configuration
  665. if proxy.get('tls_opts', {}).get('server_name'):
  666. out['tls'] = {
  667. 'server_name': proxy['tls_opts']['server_name']
  668. }
  669. return out
  670. elif proxy['type'] == 'ss':
  671. return {
  672. 'type': 'shadowsocks',
  673. 'tag': tag,
  674. 'server': proxy['server'],
  675. 'server_port': proxy['port'],
  676. 'method': proxy['method'],
  677. 'password': proxy['password'],
  678. }
  679. elif proxy['type'] == 'socks':
  680. out = {
  681. 'type': 'socks',
  682. 'tag': tag,
  683. 'server': proxy['server'],
  684. 'server_port': proxy['port'],
  685. 'version': '5',
  686. }
  687. if proxy['username']:
  688. out['username'] = proxy['username']
  689. if proxy['password']:
  690. out['password'] = proxy['password']
  691. return out
  692. elif proxy['type'] == 'hysteria2':
  693. out = {
  694. 'type': 'hysteria2',
  695. 'tag': tag,
  696. 'server': proxy['server'],
  697. 'server_port': proxy['port'],
  698. 'password': proxy['password'],
  699. }
  700. # TLS configuration
  701. if proxy.get('tls_opts', {}).get('server_name'):
  702. out['tls'] = {
  703. 'server_name': proxy['tls_opts']['server_name']
  704. }
  705. return out
  706. elif proxy['type'] == 'tuic':
  707. out = {
  708. 'type': 'tuic',
  709. 'tag': tag,
  710. 'server': proxy['server'],
  711. 'server_port': proxy['port'],
  712. 'uuid': proxy['uuid'],
  713. 'password': proxy['password'],
  714. }
  715. # TLS configuration
  716. if proxy.get('tls_opts', {}).get('server_name'):
  717. out['tls'] = {
  718. 'server_name': proxy['tls_opts']['server_name']
  719. }
  720. return out
  721. else:
  722. # Unsupported protocol for sing-box or skip
  723. return None
  724. # ------ MAIN ENTRYPOINT ------
  725. if __name__ == '__main__':
  726. if len(sys.argv) != 6:
  727. print(
  728. "Usage: python sub2clash_singbox.py <sub_url> <clash_template.yaml> <singbox_template.json> <output_clash.yaml> <output_singbox.json>")
  729. sys.exit(1)
  730. (url, clash_tmpl, singbox_tmpl, out_clash, out_sb) = sys.argv[1:]
  731. print(f"[+] Download: {url}")
  732. lines = download_subscription(url)
  733. print(f"[+] {len(lines)} lines found in sub...")
  734. proxies = []
  735. for line in lines:
  736. px = parse_proxy_line(line)
  737. if validate_proxy(px):
  738. proxies.append(px)
  739. # NEW: Warn and halt if no valid proxies!
  740. if not proxies:
  741. print('[FATAL] No valid proxies remain after filtering subscription. Check your sub or filtering policy!')
  742. sys.exit(2)
  743. print(f"[+] Parsed proxies: {len(proxies)}")
  744. # Show protocol distribution
  745. protocol_count = {}
  746. for proxy in proxies:
  747. protocol = proxy['type']
  748. protocol_count[protocol] = protocol_count.get(protocol, 0) + 1
  749. print(f"[+] Protocol distribution: {protocol_count}")
  750. # --- Handle Clash Meta YAML ---
  751. print(f"[~] Processing Clash...")
  752. clash_cfg = read_yaml_file(clash_tmpl)
  753. clash_cfg = update_clash_proxies(clash_cfg, proxies)
  754. write_yaml_file(clash_cfg, out_clash)
  755. print(f"[✓] Output Clash config: {out_clash}")
  756. # --- Handle Singbox JSON ---
  757. print(f"[~] Processing Singbox...")
  758. singbox_cfg = read_json_file(singbox_tmpl)
  759. singbox_cfg = update_singbox_outbounds(singbox_cfg, proxies)
  760. write_json_file(singbox_cfg, out_sb)
  761. print(f"[✓] Output Singbox config: {out_sb}")
  762. print("Done!")