'hysteria2', 'socks5' => 'socks'][$type] ?? $type; if (!in_array($type, ['vmess', 'vless', 'trojan', 'ss', 'socks', 'http', 'https', 'hysteria2', 'hysteria', 'tuic', 'anytls'], true)) { throw new InvalidArgumentException('unsupported_protocol'); } if ($type === 'vmess') { $config = json_decode(self::decode64(explode('#', $m[2], 2)[0]), true, 512, JSON_THROW_ON_ERROR); if (!is_array($config)) { throw new InvalidArgumentException('invalid_vmess'); } self::endpoint((string) ($config['add'] ?? ''), (int) ($config['port'] ?? 0)); self::uuid((string) ($config['id'] ?? '')); $config['port'] = (int) $config['port']; $config['aid'] = (int) ($config['aid'] ?? 0); $name = (string) ($config['ps'] ?? 'VMess'); $config['ps'] = ''; return self::node($type, $config, $name); } [$connection, $name] = array_pad(explode('#', $m[2], 2), 2, ''); if (preg_match('/[\x00-\x20\x7F]/', $connection)) { throw new InvalidArgumentException('invalid_uri_characters'); } // Old SS encodes the complete authority; SIP002 encodes only userinfo. if ($type === 'ss' && !str_contains($connection, '@')) { $connection = self::decode64($connection); } $url = parse_url('proxy://'.$connection); if ($url === false) { throw new InvalidArgumentException('invalid_authority'); } $host = trim((string) ($url['host'] ?? ''), '[]'); $port = (int) ($url['port'] ?? 0); self::endpoint($host, $port); $query = []; foreach (explode('&', $url['query'] ?? '') as $part) { if ($part === '') continue; [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); $query[rawurldecode($key)] = rawurldecode($value); } $user = rawurldecode($url['user'] ?? ''); $password = rawurldecode($url['pass'] ?? ''); if ($type === 'ss') { if (!isset($url['pass'])) { [$user, $password] = array_pad(explode(':', self::decode64($user), 2), 2, ''); } if ($user === '' || $password === '') throw new InvalidArgumentException('invalid_ss_auth'); } if (in_array($type, ['vless', 'tuic'], true)) self::uuid($user); if (in_array($type, ['trojan', 'anytls', 'hysteria2'], true) && $user === '') { throw new InvalidArgumentException('missing_auth'); } // Rebuild only authority. Keep query bytes intact: do not decode the whole URI. $authority = ($url['user'] ?? '').(isset($url['pass']) ? ':'.$url['pass'] : ''); $authority = ($authority !== '' ? $authority.'@' : '').(str_contains($host, ':') ? '['.$host.']' : $host).':'.$port; $connection = $authority.($url['path'] ?? '').(isset($url['query']) ? '?'.$url['query'] : ''); return self::node($type, ['host' => $host, 'port' => $port, 'user' => $user, 'password' => $password, 'query' => $query, 'connection' => $connection], rawurldecode($name)); } private static function node(string $type, array $data, string $name): array { $identity = $data; // Identity is semantic and independent of name, source and URI query ordering. unset($identity['connection']); $sort = function (array $a) use (&$sort): array { if (!array_is_list($a)) ksort($a); foreach ($a as &$v) if (is_array($v)) $v = $sort($v); return $a; }; return ['id' => hash('sha256', json_encode([$type, $sort($identity)], JSON_THROW_ON_ERROR)), 'protocol' => $type, 'data' => $data, 'name' => $name ?: strtoupper($type), 'sources' => []]; } public static function endpoint(string $host, int $port): void { if ($host === '' || preg_match('/[\x00-\x20\x7F\/\[\]@?#]/', $host) || $port < 1 || $port > 65535) { throw new InvalidArgumentException('invalid_endpoint'); } } private static function uuid(string $uuid): void { if (!preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i', $uuid)) throw new InvalidArgumentException('invalid_uuid'); } public static function uri(array $node, string $name): string { if ($node['protocol'] === 'vmess') { $config = $node['data']; $config['ps'] = $name; return 'vmess://'.base64_encode(json_encode($config, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); } if ($node['protocol'] === 'ss') { $d = $node['data']; $auth = str_starts_with($d['user'], '2022-') ? rawurlencode($d['user']).':'.rawurlencode($d['password']) : rtrim(strtr(base64_encode($d['user'].':'.$d['password']), '+/', '-_'), '='); $query = $d['query'] ? '?'.http_build_query($d['query'], '', '&', PHP_QUERY_RFC3986) : ''; return 'ss://'.$auth.'@'.(str_contains($d['host'], ':') ? '['.$d['host'].']' : $d['host']).':'.$d['port'].$query.'#'.rawurlencode($name); } return $node['protocol'].'://'.$node['data']['connection'].'#'.rawurlencode($name); } public static function singbox(array $node, string $name): array { $type = $node['protocol']; $d = $node['data']; if ($type === 'vmess') { $transport = strtolower($d['net'] ?? 'tcp'); $q = ['type' => $transport, 'path' => $d['path'] ?? '', 'host' => $d['host'] ?? '', 'serviceName' => $d['path'] ?? '', 'sni' => $d['sni'] ?? $d['host'] ?? '', 'security' => $d['tls'] ?? '', 'alpn' => $d['alpn'] ?? '', 'fp' => $d['fp'] ?? '']; if (($d['type'] ?? '') === 'http' && $transport === 'tcp') throw new InvalidArgumentException('unsupported_tcp_http'); $out = ['type' => 'vmess', 'tag' => $name, 'server' => $d['add'], 'server_port' => (int) $d['port'], 'uuid' => $d['id'], 'security' => $d['scy'] ?? 'auto', 'alter_id' => (int) ($d['aid'] ?? 0)]; } else { $q = $d['query']; $out = ['type' => ['ss' => 'shadowsocks', 'https' => 'http'][$type] ?? $type, 'tag' => $name, 'server' => $d['host'], 'server_port' => $d['port']]; switch ($type) { case 'vless': $out['uuid'] = $d['user']; if (!empty($q['flow'])) $out['flow'] = $q['flow']; if (!empty($q['packetEncoding']) && $q['packetEncoding'] !== 'none') $out['packet_encoding'] = $q['packetEncoding']; break; case 'trojan': case 'anytls': case 'hysteria2': $out['password'] = $d['user']; break; case 'ss': $out['method'] = $d['user']; $out['password'] = $d['password']; if (!empty($q['plugin'])) { [$plugin, $options] = array_pad(explode(';', $q['plugin'], 2), 2, ''); $out['plugin'] = $plugin; $out['plugin_opts'] = $options; } break; case 'socks': case 'http': case 'https': if ($d['user'] !== '') $out['username'] = $d['user']; if ($d['password'] !== '') $out['password'] = $d['password']; break; case 'tuic': $out['uuid'] = $d['user']; $out['password'] = $d['password']; foreach (['congestion_control', 'udp_relay_mode'] as $k) if (isset($q[$k])) $out[$k] = $q[$k]; break; default: throw new InvalidArgumentException('unsupported_singbox_protocol'); } } $net = strtolower($q['type'] ?? 'tcp'); if (in_array($type, ['vless', 'vmess', 'trojan'], true)) { if (!in_array($net, ['tcp', 'raw', 'none', '', 'ws', 'grpc', 'http', 'httpupgrade'], true) || !empty($q['headerType']) && $q['headerType'] !== 'none') { throw new InvalidArgumentException('unsupported_transport'); } if (in_array($net, ['ws', 'http', 'httpupgrade'], true)) { $out['transport'] = ['type' => $net, 'path' => $q['path'] ?? '/']; if (!empty($q['host'])) { if ($net === 'http') $out['transport']['host'] = explode(',', $q['host']); else $out['transport']['headers'] = ['Host' => $q['host']]; } } elseif ($net === 'grpc') { $out['transport'] = ['type' => 'grpc', 'service_name' => $q['serviceName'] ?? '']; } } if (in_array($type, ['trojan', 'anytls', 'hysteria2', 'tuic', 'https'], true) || in_array($q['security'] ?? '', ['tls', 'reality'], true)) { $tls = ['enabled' => true]; if (!empty($q['sni'] ?? $q['peer'] ?? '')) $tls['server_name'] = $q['sni'] ?? $q['peer']; if (!empty($q['alpn'])) $tls['alpn'] = explode(',', $q['alpn']); if (isset($q['insecure']) || isset($q['allowInsecure'])) $tls['insecure'] = in_array(strtolower($q['insecure'] ?? $q['allowInsecure']), ['1', 'true', 'yes'], true); if (!empty($q['fp'])) $tls['utls'] = ['enabled' => true, 'fingerprint' => $q['fp']]; if (($q['security'] ?? '') === 'reality') { if (empty($q['pbk'])) throw new InvalidArgumentException('missing_reality_key'); $tls['reality'] = ['enabled' => true, 'public_key' => $q['pbk'], 'short_id' => $q['sid'] ?? '']; } $out['tls'] = $tls; } if ($type === 'hysteria2' && !empty($q['obfs'])) $out['obfs'] = ['type' => $q['obfs'], 'password' => $q['obfs-password'] ?? '']; return $out; } }