96d44569fd
- proto.py: 帧协议 [type][req_id][len][payload], req_id 多路复用 - client.py: TLS 长连 + AUTH + 断线自动重连(全标准库, OpenWrt 可跑) - proxy_http.py: 本地 HTTP 透明代理(明文 80 走通道, 失败回 502) - proxy_dns.py: DNS 分流(内网域 -> 学校 DNS, 公网域 -> 云通道递归) - server.py: 云端 AUTH + HTTPS 套壳(统一 UA, 剔 hop-by-hop)+ DNS 递归 - deploy/hook.sh: REDIRECT 规则(内网段放行, 认证 eportal 绝不走通道) - 30 个新单测(40 总)+ smoke_channel 端到端冒烟 PASS
130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
"""L2 本地 HTTP 透明代理(软路由侧, 监听 8080)。
|
|
|
|
iptables 把 LAN 出向 dport 80 的明文 HTTP REDIRECT 到这里:
|
|
1. recv 请求(头 + Content-Length body)
|
|
2. 原始字节打包走通道(云端负责 https 套壳)
|
|
3. 响应原样写回客户端后关闭连接(每个请求一条连接)
|
|
连接断/通道错误 -> 502。目标为内网段(学校)的流量被 hook 规则排除,
|
|
不会到本代理。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import threading
|
|
from typing import Any
|
|
|
|
from .client import ChannelClient, ChannelError
|
|
from .httpmsg import parse_request_head
|
|
|
|
LISTEN_BACKLOG = 128
|
|
MAX_HEAD = 64 * 1024
|
|
|
|
|
|
class HttpTransparentProxy:
|
|
def __init__(self, client: ChannelClient, listen_port: int) -> None:
|
|
if listen_port < 1 or listen_port > 65535:
|
|
raise ValueError(f"bad listen port: {listen_port}")
|
|
self.client = client
|
|
self.port = listen_port
|
|
self.served = 0
|
|
self.failed = 0
|
|
|
|
def serve(self) -> None:
|
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("0.0.0.0", self.port))
|
|
srv.listen(LISTEN_BACKLOG)
|
|
print(f"[http-proxy] listen 0.0.0.0:{self.port}", flush=True)
|
|
while True:
|
|
conn, _addr = srv.accept()
|
|
threading.Thread(
|
|
target=self._handle,
|
|
args=(conn,),
|
|
name="http-proxy-conn",
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _recv_request(self, conn: socket.socket) -> bytes | None:
|
|
buf = bytearray()
|
|
while len(buf) < MAX_HEAD:
|
|
chunk = conn.recv(8192)
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
parsed = parse_request_head(bytes(buf))
|
|
if parsed is None:
|
|
continue
|
|
_req, head_len = parsed
|
|
content_length = 0
|
|
for key, value in _req.headers:
|
|
if key.lower() == "content-length":
|
|
try:
|
|
content_length = int(value)
|
|
except ValueError:
|
|
content_length = 0
|
|
total = head_len + content_length
|
|
if len(buf) >= total:
|
|
return bytes(buf[:total])
|
|
if content_length > MAX_HEAD:
|
|
return None
|
|
return None
|
|
|
|
def _handle(self, conn: socket.socket) -> None:
|
|
try:
|
|
conn.settimeout(30)
|
|
raw = self._recv_request(conn)
|
|
if raw is None:
|
|
return
|
|
resp = self._channel_send(raw)
|
|
if resp is None:
|
|
self._write_502(conn)
|
|
self.failed += 1
|
|
return
|
|
conn.sendall(resp)
|
|
self.served += 1
|
|
except OSError:
|
|
self.failed += 1
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _channel_send(self, raw: bytes) -> bytes | None:
|
|
try:
|
|
return self.client.send_http_request(raw)
|
|
except ChannelError:
|
|
try:
|
|
self.client.ensure_connected()
|
|
return self.client.send_http_request(raw)
|
|
except ChannelError:
|
|
return None
|
|
|
|
def _write_502(self, conn: socket.socket) -> None:
|
|
body = b"<html><body><h1>502 Bad Gateway</h1>"
|
|
body += b"<p>channel unavailable</p></body></html>"
|
|
head = (
|
|
b"HTTP/1.1 502 Bad Gateway\r\n"
|
|
b"Content-Type: text/html; charset=utf-8\r\n"
|
|
+ f"Content-Length: {len(body)}\r\n".encode("ascii")
|
|
+ b"Connection: close\r\n\r\n"
|
|
)
|
|
try:
|
|
conn.sendall(head + body)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def run_http_proxy(cfg: dict[str, Any]) -> None:
|
|
cloud: dict[str, Any] = cfg["cloud"]
|
|
proxy_cfg: dict[str, Any] = cfg["http_proxy"]
|
|
client = ChannelClient(
|
|
host=str(cloud["host"]),
|
|
port=int(cloud["port"]),
|
|
token=str(cloud["token"]),
|
|
ca_path=str(cloud.get("ca_path", "")),
|
|
heartbeat=float(cfg.get("heartbeat", 30)),
|
|
)
|
|
client.connect()
|
|
HttpTransparentProxy(client, int(proxy_cfg["listen_port"])).serve()
|