200 lines
10 KiB
PHP
200 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Freenode;
|
|
|
|
use InvalidArgumentException;
|
|
|
|
/** URI input is split before percent decoding; the original connection string is retained. */
|
|
final class Codec
|
|
{
|
|
public static function decode64(string $text): string
|
|
{
|
|
$text = preg_replace('/\s+/', '', $text);
|
|
$text = strtr($text, '-_', '+/');
|
|
$result = base64_decode(str_pad($text, (int) (ceil(strlen($text) / 4) * 4), '='), true);
|
|
if ($result === false) {
|
|
throw new InvalidArgumentException('invalid_base64');
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public static function parse(string $line): array
|
|
{
|
|
$line = trim(preg_replace('/^\xEF\xBB\xBF/', '', trim($line)));
|
|
if (!preg_match('~^([a-zA-Z0-9]+)://(.+)$~s', $line, $m)) {
|
|
throw new InvalidArgumentException('invalid_scheme');
|
|
}
|
|
$type = strtolower($m[1]);
|
|
$type = ['hy2' => '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;
|
|
}
|
|
}
|