dd/app/Services/Freenode/Codec.php

190 lines
9.9 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'] ?? ''));
$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));
}
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;
}
}