// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// A proxy a client sends its connections through.
///
/// `http` and `https` are the `CONNECT` method of RFC 9110 §9.3.6 — an `https` proxy speaks TLS
/// to the proxy itself — and `socks5` is RFC 1928, with the host sent as a name so the proxy
/// resolves it. The user and password, when present, authenticate to the proxy: `Basic` for
/// `CONNECT`, username and password for SOCKS5.
pub(all) struct Proxy {
scheme : String
host : String
port : Int32
user : String?
password : String?
}
///|
/// A proxy from a URL: `http://user:pass@host:1080`, `socks5://host:1080`.
pub fn Proxy::parse(text : StringView) -> Proxy raise Refused {
let url = @moonurl.Url::decode(text) catch { _ => raise Url(text.to_owned()) }
let scheme = url.scheme.unwrap_or("")
guard scheme is ("http" | "https" | "socks5" | "socks5h") else {
raise Url("\{text}: not a proxy scheme")
}
let port = match url.port {
Some(port) => port
None =>
match scheme {
"http" => 80
"https" => 443
_ => 1080
}
}
guard url.host is Some(host) && host != "" else {
raise Url("\{text}: no host")
}
let (user, password) = match url.userinfo {
Some(info) =>
match info.split_once(":") {
Some((user, password)) =>
(Some(user.to_owned()), Some(password.to_owned()))
None => (Some(info), None)
}
None => (None, None)
}
{ scheme, host, port, user, password, }
}
///|
/// Whether the proxy takes an `http` request in absolute form instead of a tunnel.
fn Proxy::absolute(self : Proxy) -> Bool {
self.scheme is ("http" | "https")
}
///|
/// The `Proxy-Authorization` value, when the proxy wants one.
fn Proxy::authorization(self : Proxy) -> String? {
match (self.user, self.password) {
(Some(user), Some(password)) =>
Some("Basic " + @base64.encode(@utf8.encode("\{user}:\{password}")))
_ => None
}
}
///|
/// Open the tunnel to `host:port` through an HTTP proxy (`CONNECT`).
async fn Proxy::tunnel(
self : Proxy,
conn : @tcp.Conn,
host : String,
port : Int32,
) -> Unit {
let authority = if host.contains(":") {
"[\{host}]:\{port}"
} else {
"\{host}:\{port}"
}
let head = StringBuilder()
head.write_string("CONNECT \{authority} HTTP/1.1\r\n")
head.write_string("Host: \{authority}\r\n")
if self.authorization() is Some(value) {
head.write_string("Proxy-Authorization: \{value}\r\n")
}
head.write_string("\r\n")
conn.write(@utf8.encode(head.to_string()))
let held = Buffer()
for ;; {
if held.to_bytes().find(b"\r\n\r\n") is Some(_) {
break
}
guard held.length() < 65536 else {
raise Proxy("\{authority}: CONNECT head too long")
}
match conn.read_some(max_len=chunk) {
Some(data) => held.write_bytes(data)
None => raise Proxy("\{authority}: closed during CONNECT")
}
}
let answer = @utf8.decode_lossy(held.to_bytes()[:])
let status = match answer.split_once("\r\n") {
Some((line, _)) =>
match line.split_once(" ") {
Some((_, rest)) =>
match rest.split_once(" ") {
Some((code, _)) => @string.parse_int(code) catch { _ => 0 }
None => 0
}
None => 0
}
None => 0
}
guard status is (200..<300) else {
raise Proxy("\{authority}: CONNECT answered \{status}")
}
}
///|
/// RFC 1928: greet, authenticate if asked, then ask for `host:port`.
async fn Proxy::socks5(
self : Proxy,
conn : @tcp.Conn,
host : String,
port : Int32,
) -> Unit {
let hello = Buffer()
hello.write_byte(b'\x05')
if self.user is Some(_) {
hello.write_byte(b'\x02')
hello.write_byte(b'\x00')
hello.write_byte(b'\x02')
} else {
hello.write_byte(b'\x01')
hello.write_byte(b'\x00')
}
conn.write(hello.to_bytes())
let answer = conn.read_exactly(2)
guard answer[0] == b'\x05' else { raise Proxy("not a SOCKS5 proxy") }
match answer[1].to_int() {
0 => ()
2 =>
match (self.user, self.password) {
(Some(user), Some(password)) => {
let auth = Buffer()
auth.write_byte(b'\x01')
auth.write_byte(user.length().to_byte())
auth.write_bytes(@utf8.encode(user))
auth.write_byte(password.length().to_byte())
auth.write_bytes(@utf8.encode(password))
conn.write(auth.to_bytes())
let reply = conn.read_exactly(2)
guard reply[1].to_int() == 0 else {
raise Proxy("SOCKS5 authentication refused")
}
}
_ => raise Proxy("SOCKS5 wants a username and password")
}
code => raise Proxy("SOCKS5 wants method \{code}, which is not offered")
}
let name = @utf8.encode(host)
guard name.length() <= 255 else {
raise Proxy("host name too long for SOCKS5")
}
let ask = Buffer()
ask.write_byte(b'\x05')
ask.write_byte(b'\x01')
ask.write_byte(b'\x00')
ask.write_byte(b'\x03')
ask.write_byte(name.length().to_byte())
ask.write_bytes(name)
ask.write_byte((port >> 8).to_byte())
ask.write_byte((port & 0xff).to_byte())
conn.write(ask.to_bytes())
let reply = conn.read_exactly(4)
guard reply[0] == b'\x05' else {
raise Proxy("SOCKS5 reply is not version 5")
}
guard reply[1].to_int() == 0 else {
raise Proxy("SOCKS5 refused: \{socks_reason(reply[1].to_int())}")
}
match reply[3].to_int() {
1 => ignore(conn.read_exactly(4 + 2))
4 => ignore(conn.read_exactly(16 + 2))
3 => {
let length = conn.read_exactly(1)
ignore(conn.read_exactly(length[0].to_int() + 2))
}
_ => raise Proxy("SOCKS5 reply has an unknown address type")
}
}
///|
fn socks_reason(code : Int32) -> String {
match code {
1 => "general failure"
2 => "not allowed by the rules"
3 => "network unreachable"
4 => "host unreachable"
5 => "connection refused"
6 => "time out"
7 => "command not supported"
8 => "address type not supported"
_ => "error \{code}"
}
}