Files
LorenEteval_Furious/Furious/Backends/SocksURI.py
T
2026-08-17 08:52:20 +08:00

337 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Parse compatible SOCKS share links and emit one canonical form."""
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import quote, unquote_to_bytes, urlsplit
import base64
import binascii
import ipaddress
import re
__all__ = [
'SOCKS_URI_SCHEMES',
'SocksURIData',
'SocksURIError',
'parseSocksURI',
'serializeSocksURI',
]
# Xray's outbound speaks SOCKS5. The remaining schemes are import aliases
# retained for existing Furious links and v2rayN interoperability; canonical
# export normalizes every accepted alias to ``socks://``.
SOCKS_URI_SCHEMES = ('socks', 'socks5', 'socks5h', 'socks4')
_BASE64_PATTERN = re.compile(r'^[A-Za-z0-9+/_-]+={0,2}$')
_HEX_DIGITS = frozenset('0123456789abcdefABCDEF')
class SocksURIError(ValueError):
"""Report a malformed or unsupported SOCKS share link."""
@dataclass(frozen=True)
class SocksURIData:
"""Store the meaningful fields of one SOCKS share link."""
host: str
port: int
username: str = ''
password: str = ''
tag: str = ''
def _validatePercentEncoding(value: str, label: str):
"""Reject incomplete percent escapes before decoding *value*."""
index = 0
while index < len(value):
if value[index] != '%':
index += 1
continue
if (
index + 2 >= len(value)
or value[index + 1] not in _HEX_DIGITS
or value[index + 2] not in _HEX_DIGITS
):
raise SocksURIError(f'{label} contains invalid percent encoding')
index += 3
def _percentDecode(value: str, label: str) -> str:
"""Decode one RFC3986 component as strict UTF-8 without ``+`` semantics."""
_validatePercentEncoding(value, label)
try:
return unquote_to_bytes(value).decode('utf-8')
except UnicodeDecodeError as ex:
raise SocksURIError(f'{label} is not valid UTF-8') from ex
def _decodeBase64(value: str, label: str) -> str:
"""Decode standard or URL-safe Base64 with strict alphabet validation."""
value = _percentDecode(value, label)
if not value or not _BASE64_PATTERN.fullmatch(value):
raise SocksURIError(f'{label} is not valid Base64')
unpadded = value.rstrip('=')
suppliedPadding = len(value) - len(unpadded)
requiredPadding = -len(unpadded) % 4
if (
'=' in unpadded
or len(unpadded) % 4 == 1
or (suppliedPadding and suppliedPadding != requiredPadding)
):
raise SocksURIError(f'{label} has invalid Base64 padding')
try:
decoded = base64.b64decode(
(unpadded + '=' * requiredPadding).encode('ascii'),
altchars=b'-_',
validate=True,
)
return decoded.decode('utf-8')
except (ValueError, binascii.Error, UnicodeDecodeError) as ex:
raise SocksURIError(f'{label} is not valid Base64 UTF-8') from ex
def _normalizeHost(host: str) -> str:
"""Validate and normalize a hostname or IP address for storage."""
host = str(host).strip()
if host.startswith('[') and host.endswith(']'):
host = host[1:-1]
if not host:
raise SocksURIError('server host cannot be empty')
if any(
character.isspace() or ord(character) < 32 or character in '/?#@%'
for character in host
):
raise SocksURIError('server host contains invalid characters')
try:
return ipaddress.ip_address(host).compressed
except ValueError:
if ':' in host:
raise SocksURIError('server host is malformed')
try:
normalized = host.encode('idna').decode('ascii')
except UnicodeError as ex:
raise SocksURIError('server host is malformed') from ex
if not normalized or len(normalized) > 253:
raise SocksURIError('server host is malformed')
labels = normalized.rstrip('.').split('.')
if any(
not label
or len(label) > 63
or label.startswith('-')
or label.endswith('-')
or not all(character.isalnum() or character == '-' for character in label)
for label in labels
):
raise SocksURIError('server host is malformed')
return normalized
def _parsePort(value) -> int:
"""Return a validated TCP port number."""
try:
port = int(value)
except (TypeError, ValueError) as ex:
raise SocksURIError('server port must be an integer') from ex
if not 1 <= port <= 65535:
raise SocksURIError('server port must be between 1 and 65535')
return port
def _parseEndpoint(value: str) -> tuple[str, int]:
"""Parse a URI authority endpoint, including bracketed IPv6."""
try:
result = urlsplit(f'//{value}')
host = result.hostname or ''
port = result.port
except ValueError as ex:
# v2rayN's legacy parser split the port at the final colon, so old
# whole-payload links may contain an unbracketed IPv6 literal. Keep
# that compatibility isolated here; canonical export adds brackets.
host, separator, port = value.rpartition(':')
if separator and ':' in host:
return _normalizeHost(host), _parsePort(port)
raise SocksURIError('server endpoint is malformed') from ex
if not host or port is None:
raise SocksURIError('server endpoint must contain a host and port')
if result.username is not None or result.password is not None or result.path:
raise SocksURIError('server endpoint is malformed')
return _normalizeHost(host), _parsePort(port)
def _parseUserinfo(value: str) -> tuple[str, str]:
"""Parse direct RFC userinfo or v2rayN-compatible Base64 userinfo."""
if ':' in value:
username, password = value.split(':', 1)
return (
_percentDecode(username, 'username'),
_percentDecode(password, 'password'),
)
decoded = _percentDecode(value, 'userinfo')
if ':' in decoded:
# Accept producers that percent-encode the complete userinfo value,
# including its credential separator. Canonical export always keeps
# the separator literal so encoded colons inside either field remain
# unambiguous.
username, password = decoded.split(':', 1)
return username, password
decoded = _decodeBase64(value, 'userinfo')
if ':' not in decoded:
raise SocksURIError('userinfo is missing the credential separator')
username, password = decoded.split(':', 1)
return username, password
def _parseLegacyURI(result) -> SocksURIData:
"""Parse the historical ``base64(user:pass@host:port)`` form."""
if result.query:
raise SocksURIError('legacy SOCKS URI cannot contain a query')
payload = f'{result.netloc}{result.path}'
decoded = _decodeBase64(payload, 'legacy payload')
userinfo, separator, endpoint = decoded.rpartition('@')
if not separator:
raise SocksURIError('legacy payload has no server separator')
if ':' not in userinfo:
raise SocksURIError('legacy userinfo has no credential separator')
username, password = userinfo.split(':', 1)
host, port = _parseEndpoint(endpoint)
tag = _percentDecode(result.fragment, 'tag')
return SocksURIData(host, port, username, password, tag)
def parseSocksURI(uri: str) -> SocksURIData:
"""Parse standard and v2rayN-compatible SOCKS share-link forms."""
if not isinstance(uri, str) or not uri.strip():
raise SocksURIError('SOCKS URI cannot be empty')
try:
result = urlsplit(uri.strip())
except ValueError as ex:
raise SocksURIError('SOCKS URI is malformed') from ex
if result.scheme.casefold() not in SOCKS_URI_SCHEMES:
raise SocksURIError('URI scheme must identify SOCKS')
if result.query:
raise SocksURIError('SOCKS URI cannot contain a query')
if '@' not in result.netloc:
try:
host, port = _parseEndpoint(result.netloc)
except SocksURIError as endpointError:
try:
return _parseLegacyURI(result)
except SocksURIError:
raise endpointError
if result.path not in ('', '/'):
raise SocksURIError('SOCKS URI path must be empty or /')
return SocksURIData(
host,
port,
tag=_percentDecode(result.fragment, 'tag'),
)
if result.path not in ('', '/'):
raise SocksURIError('SOCKS URI path must be empty or /')
userinfo, separator, endpoint = result.netloc.rpartition('@')
if not separator or not userinfo:
raise SocksURIError('SOCKS URI userinfo is malformed')
username, password = _parseUserinfo(userinfo)
host, port = _parseEndpoint(endpoint)
tag = _percentDecode(result.fragment, 'tag')
return SocksURIData(host, port, username, password, tag)
def _formatHost(host: str) -> str:
"""Return a canonical URI host, bracketing IPv6 literals."""
normalized = _normalizeHost(host)
try:
address = ipaddress.ip_address(normalized)
except ValueError:
return normalized
return f'[{address.compressed}]' if address.version == 6 else address.compressed
def serializeSocksURI(value: SocksURIData) -> str:
"""Serialize a SOCKS profile using unambiguous RFC3986 userinfo."""
if not isinstance(value, SocksURIData):
raise TypeError('value must be a SocksURIData instance')
port = _parsePort(value.port)
endpoint = f'{_formatHost(value.host)}:{port}'
fragment = quote(value.tag, safe='')
authority = endpoint
if value.username or value.password:
authority = (
f'{quote(value.username, safe="")}:'
f'{quote(value.password, safe="")}@{endpoint}'
)
return f'socks://{authority}{"#" + fragment if fragment else ""}'