Files
vpn-loopback-firewall/channel/proxy_http.py
T
lou 96d44569fd L2/L3 通道: HTTP/DNS 套壳转发(本地分流代理 + 云端转发器)
- 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
2026-09-09 14:55:02 +08:00

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()