Showing Posts From
Go
一个二进制交付:把 Vue 3 流量后台嵌入 Go 代理服务
这是 Relay Observatory 代理项目实现系列的第四篇。完整代码放在 GitHub。 前面已经完成:Go HTTP/HTTPS 正向代理; SOCKS5 CONNECT、BIND 与 UDP ASSOCIATE; SQLite 用户认证与流量聚合。最后一步是给代理增加一个真正可用的管理后台,同时保持单文件部署。 最终交付形式: proxy-admin 可执行文件 ├── HTTP Proxy ├── SOCKS5 Proxy ├── Admin JSON API ├── Vue 3 静态资源 └── SQLite 迁移逻辑运行时额外产生: └── proxy_admin.db为什么选择“独立开发、嵌入交付” 前端仍然使用完整 Vue 工程: web/admin/ ├── src/ ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json └── vite.config.ts开发阶段保留:Vue 3 Composition API; TypeScript 类型检查; Vite 热更新; 独立 CSS 和组件; pnpm 依赖锁定。但生产部署不再要求单独运行 Nginx 或 Node: Vue Source ↓ pnpm build dist/ ↓ go:embed Go Binary这比直接在 Go 字符串里拼 HTML 更容易维护,也比部署两个服务简单。 Vite 输出到 Go embed 目录 vite.config.ts: import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { resolve } from "node:path";export default defineConfig({ plugins: [vue()], build: { outDir: resolve( import.meta.dirname, "../../internal/adminui/dist", ), emptyOutDir: true, }, server: { proxy: { "/api": "http://127.0.0.1:9090", }, }, });生产构建: cd web/admin pnpm install pnpm build开发服务器通过 Vite proxy 把 /api 请求转发给 Go,生产环境则由同一个 Go Server 提供页面和 API,因此前端代码始终使用相对 URL: fetch("/api/admin/overview", { credentials: "same-origin", });不需要在构建时写死域名。 使用 go:embed 打包前端 package adminuiimport "embed"//go:embed dist var assets embed.FSgo:embed 在编译时读取文件,所以 dist 必须在 go build 前生成。 构建顺序: cd web/admin pnpm build cd ../.. go build ./cmd/proxy-admin生成的 JS、CSS、字体和 index.html 会直接进入 Go 二进制。部署服务器不需要保留 web/admin 源码。 fs.Sub 去掉 dist 前缀 嵌入后的路径是: dist/index.html dist/assets/index-xxx.jsHTTP FileServer 希望根目录直接看到 index.html,因此使用: dist, err := fs.Sub(assets, "dist") if err != nil { panic(err) }fileServer := http.FileServer(http.FS(dist))此时浏览器请求: /assets/index-xxx.js会映射到嵌入文件: dist/assets/index-xxx.jsSPA 回退不能影响静态资源 单页应用未来可能增加: /dashboard /users这些路径在嵌入文件系统中不存在,但应该返回 index.html,让 Vue 接管路由。 func (h *spaHandler) ServeHTTP( w http.ResponseWriter, r *http.Request, ) { name := strings.TrimPrefix( path.Clean(r.URL.Path), "/", ) if name == "." || name == "" { name = "index.html" } if _, err := fs.Stat(h.files, name); err != nil { r = r.Clone(r.Context()) r.URL.Path = "/" } h.fileServer.ServeHTTP(w, r) }注意:API 路由必须在外层 ServeMux 中优先注册,并为未知 API 添加兜底,不能让 /api/... 回退到 HTML。 mux.HandleFunc("GET /api/admin/overview", overview) mux.Handle("/api/", http.NotFoundHandler()) mux.Handle("/", adminui.Handler())Go 1.22 的 method-aware pattern 能直接区分 GET、POST、PATCH 和 DELETE。 后台 API 的边界 管理后台提供: POST /api/admin/login DELETE /api/admin/session GET /api/admin/me GET /api/admin/overview GET /api/admin/users POST /api/admin/users PATCH /api/admin/users/{id} DELETE /api/admin/users/{id}用户管理包括:创建代理用户; 立即启用或停用; 重置 bcrypt 密码; 删除用户及级联流量; 防止当前管理员删除或停用自己。网络层每次建立新连接都会查询 SQLite,因此后台停用用户后,新 HTTP 和 SOCKS5 连接会立即认证失败。 已经建立的长连接不会被强制踢下线。如果需要该能力,就要增加在线连接注册表,并在用户停用时主动关闭对应连接。这是另一个明确的生命周期问题。 为什么管理会话不继续用 Basic Auth 代理协议使用 Basic 或 RFC 1929 是客户端兼容性要求。浏览器后台则使用随机 Session Cookie: random := make([]byte, 32) _, _ = rand.Read(random) token := base64.RawURLEncoding.EncodeToString(random)Cookie 属性: http.Cookie{ Name: "proxy_admin_session", Value: token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, }安全作用:属性 作用HttpOnly JavaScript 无法读取 Token,降低 XSS 后的凭据窃取风险SameSite=Strict 跨站请求默认不携带 CookieSecure HTTPS 下只通过加密连接发送随机 256 bit Token 无法通过用户名或时间推测会话保存在内存,服务重启后管理员需要重新登录。这通常比把管理 Session 也持久化更安全、更简单。 修改接口再做一次同源校验 SameSite 不是唯一防线。对 POST/PATCH/DELETE 再检查 Origin: func sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } parsed, err := url.Parse(origin) return err == nil && strings.EqualFold(parsed.Host, r.Host) }这不是完整的通用 CSRF 框架,但对同源 JSON 管理后台形成了清晰的额外边界。 如果管理后台暴露到公网,还应该:在反向代理层启用 HTTPS; 限制登录频率; 设置可信代理 Header; 增加审计日志; 根据部署拓扑决定是否强制 Secure Cookie。流量页面需要哪些数据 后台首页不是简单显示三个数字,而是回答四个问题:总共转发了多少数据; 最近 24 小时流量如何变化; 哪种代理协议占用最多; 哪个用户流量最高。API 一次返回: interface Overview { traffic: { uploaded_bytes: number; downloaded_bytes: number; requests: number; }; format: { uploaded: string; downloaded: string; total: string; }; active_users: number; protocols: ProtocolUsage[]; trend: TrendPoint[]; top_users: UserRank[]; }后端同时返回原始字节和格式化值:原始字节用于图表计算; 格式化值用于显示 KB、MB、GB、TB; 前端不需要猜测单位精度; API 仍保留机器可计算能力。不引入图表库也能画趋势 24 小时流量曲线数据量很小,可以直接生成 SVG polyline: function line(key: "uploaded_bytes" | "downloaded_bytes") { return points .map((point, index) => { const x = padding + index / Math.max(1, points.length - 1) * innerWidth; const y = height - padding - point[key] / maxValue * innerHeight; return `${x},${y}`; }) .join(" "); }模板: <polyline :points="line('downloaded_bytes')" class="download-line" /> <polyline :points="line('uploaded_bytes')" class="upload-line" />这减少了 ECharts 等大型依赖,也让空状态、颜色和响应式布局完全可控。 设计语言来自网络流量本身 后台没有套通用 Admin Template,而是围绕代理主题设计:深海蓝表示底层网络; 冷青表示上行; 琥珀表示下行; JetBrains Mono 显示字节和请求数; 双向流量轨迹作为主视觉; Vue 页面每 15 秒刷新聚合数据。动画只用在登录页信号线和流量轨迹,且尊重: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }技术后台的美观不等于堆渐变和卡片,而是让信息层级与系统模型一致。 端到端测试 后端: go test -race ./... go vet ./... go build ./cmd/proxy-admin前端: cd web/admin pnpm build浏览器测试覆盖:打开嵌入页面; 管理员登录; 等待流量总览 API; 切换用户管理; 检查浏览器异常; 截取登录、总览、用户页面。代理回归则真实使用 curl: curl -x http://alice:password@127.0.0.1:8080 \ https://example.comcurl --proxy socks5h://alice:password@127.0.0.1:1080 \ https://example.com最后查询 /stats 或后台,确认两种协议流量都进入 SQLite。 小结 Vue 嵌入 Go 的关键链路是: Vue/TypeScript ↓ Vite build dist 静态资源 ↓ go:embed Go FileServer + SPA fallback ↓ 单个可执行文件配合 SQLite、统一认证接口和 Session Cookie,一个代理程序就从协议实验升级成了可以实际管理和观测的单机服务,同时仍保持部署简单。
代理服务的数据层:SQLite 用户认证、流量累计与小时趋势
这是 Relay Observatory 代理项目实现系列的第三篇。完整代码放在 GitHub。 前两篇完成了 HTTP/HTTPS 正向代理 和 SOCKS5 三种命令。当用户、密码和流量都只存在内存时,进程一重启,账号和统计就全部丢失。 这一篇解决数据层问题:如何用 SQLite 持久化用户、认证、分协议流量总量和 24 小时趋势。 为什么单机代理适合 SQLite 这个场景具有典型的 SQLite 特征:单实例服务; 写入来自连接结束事件,频率有限; 后台查询以聚合为主; 希望只部署一个可执行文件和一个数据文件; 不需要多机共享写数据库。如果为了几十个用户和每秒少量统计写入部署 PostgreSQL,运维复杂度会远大于收益。 SQLite 的边界也很明确:它适合单机单写,不适合把数据库文件放在 NFS 上让多台代理同时写。 数据模型:总量和趋势分开 用户表: CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash BLOB NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')), enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP );设计点:用户名使用 COLLATE NOCASE,避免 Alice 和 alice 成为两个账号; 密码只保存 bcrypt hash; enabled 用于立即阻止新连接; 管理员和代理用户复用同一张表。流量总量表: CREATE TABLE traffic_totals ( user_id INTEGER NOT NULL, protocol TEXT NOT NULL, uploaded_bytes INTEGER NOT NULL DEFAULT 0, downloaded_bytes INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, protocol), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );小时趋势表: CREATE TABLE traffic_hourly ( user_id INTEGER NOT NULL, protocol TEXT NOT NULL, bucket TEXT NOT NULL, uploaded_bytes INTEGER NOT NULL DEFAULT 0, downloaded_bytes INTEGER NOT NULL DEFAULT 0, requests INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, protocol, bucket), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );为什么不只保留明细日志? 如果每个连接都插入明细,后台每次打开都要扫描大量历史记录做 GROUP BY。当前需求只关心累计值和小时曲线,直接维护聚合表更便宜。 如果未来需要审计单次连接,再增加 append-only 明细表,而不是让当前查询承担不需要的成本。 SQLite 连接参数 启动时执行: PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;作用:PRAGMA 作用journal_mode=WAL 读查询不必等待写事务完全结束foreign_keys=ON 删除用户时级联删除流量,避免孤儿数据busy_timeout=5000 遇到短暂写锁时等待,而不是立即报 database is locked项目使用 modernc.org/sqlite,它是纯 Go 驱动,不要求部署环境安装 CGO 工具链。 当前规模下设置单连接: db.SetMaxOpenConns(1)SQLite 只有一个写者。单连接能让行为更容易推导,也避免 :memory: 测试因为连接池创建出多个独立数据库。 当读压力明显增加时,可以使用多个连接,但要确保每个连接都正确设置连接级 PRAGMA。 用 bcrypt 存储密码 创建用户: hash, err := bcrypt.GenerateFromPassword( []byte(password), bcrypt.DefaultCost, )_, err = db.Exec(` INSERT INTO users(username, password_hash, role) VALUES(?, ?, ?) `, username, hash, role)认证时只查询启用用户: err := db.QueryRow(` SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE AND enabled = 1 `, username).Scan(&id, &name, &role, &hash)if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil { return Identity{}, false }这里故意把“用户不存在”和“密码错误”统一成认证失败,避免向外部泄露用户名是否存在。 统一 Authenticator,网络层不依赖 SQLite HTTP 和 SOCKS5 不应该导入 database/sql。它们只依赖接口: type Authenticator interface { Authenticate(username, password string) (Identity, bool) }type Identity struct { ID int64 Username string Role string }SQLite 数据库实现这个接口,测试中的内存凭据也实现同一个接口: HTTP Proxy ----\ SOCKS5 Proxy ----> Authenticator <---- SQLite Admin Login ----/这个边界带来两个直接收益:网络协议测试不需要真实数据库; 以后换 PostgreSQL、LDAP 或远程认证服务,不需要修改代理协议代码。每次流量写入同时维护两张表 流量写入必须保证总量和小时趋势一致,因此使用同一个事务: tx, err := db.Begin() if err != nil { return err } defer tx.Rollback()// 更新 traffic_totals // 更新 traffic_hourlyreturn tx.Commit()总量使用 UPSERT: INSERT INTO traffic_totals( user_id, protocol, uploaded_bytes, downloaded_bytes, requests, updated_at ) VALUES(?, ?, ?, ?, 1, ?) ON CONFLICT(user_id, protocol) DO UPDATE SET uploaded_bytes = uploaded_bytes + excluded.uploaded_bytes, downloaded_bytes = downloaded_bytes + excluded.downloaded_bytes, requests = requests + 1, updated_at = excluded.updated_at;这里的 excluded.uploaded_bytes 表示本次准备插入的值。 同一个用户第一次产生 HTTP 流量时创建行,后续请求只做原子累加,不需要先 SELECT 再 UPDATE,也避免读改写竞争。 小时 bucket 如何生成 将 UTC 时间截断到整点: bucket := time.Now(). UTC(). Truncate(time.Hour). Format(time.RFC3339)例如: 2026-08-24T16:37:42Z => 2026-08-24T16:00:00Z小时表主键为: (user_id, protocol, bucket)同一用户、同一协议、同一小时的流量会累计到同一行。后台查询最近 24 小时只需要扫描有限行数。 为什么要区分协议 项目记录: const ( ProtocolHTTP = "http" ProtocolHTTPS = "https_connect" ProtocolSOCKS = "socks_connect" ProtocolSOCKSBind = "socks_bind" ProtocolSOCKSUDP = "socks_udp" )总量仍然可以跨协议求和,但保留 protocol 维度后,后台能够回答:HTTP 与 SOCKS5 谁占用更多流量; UDP 中继是否突然增长; BIND 是否真的有人使用; 某个协议的请求数与字节数是否异常。如果一开始就只存用户总量,这些信息以后无法恢复。 后台聚合查询 总量: SELECT COALESCE(SUM(uploaded_bytes), 0), COALESCE(SUM(downloaded_bytes), 0), COALESCE(SUM(requests), 0) FROM traffic_totals;协议占比: SELECT protocol, SUM(uploaded_bytes), SUM(downloaded_bytes), SUM(requests) FROM traffic_totals GROUP BY protocol;最近 24 小时趋势: SELECT bucket, SUM(uploaded_bytes), SUM(downloaded_bytes), SUM(requests) FROM traffic_hourly WHERE bucket >= ? GROUP BY bucket ORDER BY bucket;用户排行则把 users 与 traffic_totals 左连接。使用 LEFT JOIN 是为了让还没有流量的新用户也能出现在管理列表中。 旧 .env 用户如何迁移 原始版本使用: PROXY_USERS=alice:strong-secret,bob:strong-password升级后启动程序会:解析旧用户列表; 查询 SQLite 是否已有同名用户; 不存在则 bcrypt 后插入; 已存在则跳过,不覆盖后台修改过的密码。这类迁移应该是幂等的。服务每次启动都执行也不会改变已有账户。 完成迁移后,PROXY_USERS 只承担首次导入,用户生命周期全部由后台管理。 同步写入的边界 当前实现在线程结束时同步写 SQLite,优点是简单、统计不易丢失。 但如果代理发展到每秒数千个短连接,单写者会成为瓶颈。届时可以改成: 代理连接结束 ↓ 写入有界 channel ↓ 后台协程按 100 条或 1 秒批量事务需要同时处理:channel 满时是阻塞还是丢统计; 进程退出前如何 flush; 写失败如何重试; 避免重复入账的幂等 ID。不要在当前负载还很低时提前引入这套复杂度。 小结 代理数据层的关键不是“把 map 换成 SQLite”,而是提前确定:密码只存 bcrypt hash; 网络层依赖认证接口,不依赖数据库; 总量与趋势在同一事务中更新; 保留协议维度; 用 UPSERT 原子累加; 旧配置迁移必须幂等。下一篇完成最后一层:把 Vue 3 管理后台编译后嵌入 Go 可执行文件。
拆解 SOCKS5:用 Go 实现 CONNECT、BIND 与 UDP ASSOCIATE
这是 Relay Observatory 代理项目实现系列的第二篇。完整代码放在 GitHub。 上一篇实现了 Go HTTP 正向代理。SOCKS5 与 HTTP 代理不同:它不解析 HTTP 文本,而是在 TCP 连接上交换紧凑的二进制报文,因此可以代理 SSH、数据库协议和其他 TCP 应用。 SOCKS5 是一段状态机 一条 SOCKS5 连接依次经过: TCP Accept ↓ 认证方式协商 ↓ 用户名密码认证 ↓ 读取命令与目标地址 ↓ CONNECT / BIND / UDP ASSOCIATE ↓ 数据转发与流量统计每一步都依赖前一步完成,不能把它当成普通 HTTP Handler。 第一步:协商认证方式 客户端首先发送: +-----+----------+----------+ | VER | NMETHODS | METHODS | +-----+----------+----------+ | 1 | 1 | 1~255 | +-----+----------+----------+例如: 05 01 02含义是:05:SOCKS5; 01:提供一种认证方式; 02:用户名密码认证。读取固定长度协议字段时,应使用 io.ReadFull: header := make([]byte, 2) if _, err := io.ReadFull(client, header); err != nil { return err }methods := make([]byte, int(header[1])) if _, err := io.ReadFull(client, methods); err != nil { return err }不能假设一次 Read 就会拿到完整报文。TCP 是字节流,一次写入可能被拆成多次读取,也可能与后续数据一起到达。 服务端选择 0x02: 05 02如果客户端没有提供支持的方法,则返回: 05 FFFF 表示“所有认证方式都不接受”。 RFC 1929 用户名密码认证 用户名密码认证不是 RFC 1928 主协议的一部分,而是 RFC 1929 扩展: +-----+------+----------+------+----------+ | VER | ULEN | UNAME | PLEN | PASSWD | +-----+------+----------+------+----------+ | 1 | 1 | 1~255 | 1 | 1~255 | +-----+------+----------+------+----------+这里的版本是 01,不是 SOCKS 版本 05。 认证成功返回: 01 00失败返回: 01 01生产实现不应保存明文密码。项目把用户存入 SQLite,并使用 bcrypt: identity, ok := authenticator.Authenticate( username, string(passwordBytes), )需要注意:RFC 1929 只定义了密码格式,没有提供传输加密。客户端到 SOCKS5 服务端之间如果是不可信公网,密码仍可能被窃听,应再套 VPN 或 TLS。 第二步:解析通用请求 认证成功后,三种命令共享同一报文结构: +-----+-----+-----+------+----------+----------+ | VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | +-----+-----+-----+------+----------+----------+ | 1 | 1 | 1 | 1 | Variable | 2 | +-----+-----+-----+------+----------+----------+CMD:值 命令01 CONNECT02 BIND03 UDP ASSOCIATEATYP:值 地址 长度01 IPv4 4 字节03 域名 1 字节长度 + 域名04 IPv6 16 字节端口固定为两字节大端序: port := binary.BigEndian.Uint16(portBytes)把地址解析抽成 endpoint 后,TCP 请求和 UDP 报文可以复用同一套编码器: type endpoint struct { Host string Port uint16 }func (e endpoint) Address() string { return net.JoinHostPort(e.Host, strconv.Itoa(int(e.Port))) }net.JoinHostPort 很重要,它会正确处理 IPv6 的方括号: 2001:db8::1 + 443 => [2001:db8::1]:443CONNECT:代理主动连接目标 CONNECT 与 HTTP CONNECT 的隧道阶段类似: upstream, err := dialer.DialContext( context.Background(), "tcp", target.Address(), )连接成功后回复: +-----+-----+-----+------+----------+----------+ | VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | +-----+-----+-----+------+----------+----------+其中 REP=00 表示成功,BND.ADDR/BND.PORT 是代理用于这条上游连接的本地地址。 之后启动两个 io.Copy: client --------上传--------> upstream client <-------下载--------- upstreamCONNECT 适用于绝大多数 TCP 场景,也是 curl 的 socks5h:// 主要使用的命令。 BIND:为什么必须返回两次 BIND 的连接方向相反。代理不主动连接目标,而是创建临时监听端口: 客户端 ----控制连接----> SOCKS5 代理 目标端 ----反向连接----> 代理临时端口因此协议需要两次响应。 第一次响应 代理调用: listener, err := net.ListenTCP( "tcp", &net.TCPAddr{IP: localIP, Port: 0}, )端口 0 表示让操作系统分配空闲端口。第一次响应把这个监听地址告诉客户端: REP=00, BND.ADDR=代理地址, BND.PORT=临时端口等待并校验目标 客户端在请求中提供的 DST.ADDR/DST.PORT 表示预期连接者。代理不能接受第一个随便连入的连接,否则临时端口可能被第三方抢占。 校验逻辑包括:请求端口非零时必须匹配来源端口; 请求 IP 非 0.0.0.0/:: 时必须匹配来源 IP; 请求是域名时解析全部 IP 后比较; 不匹配的连接关闭并继续等待; 超过 30 秒返回超时。第二次响应 目标通过校验后,代理再次返回成功,这次 BND.ADDR/BND.PORT 是目标的真实来源地址。 客户端只有收到第二次响应后,才能开始把 BIND 连接当成普通 TCP 隧道使用。 UDP ASSOCIATE:TCP 控制,UDP 传数据 UDP ASSOCIATE 最容易误解。它不是把 UDP 塞进 TCP,而是: TCP:客户端 -------- 会话生命周期 -------- SOCKS5 代理UDP:客户端 ---- SOCKS5 UDP 报文 ----> 代理 ---- 原始 UDP ----> 目标 UDP:客户端 <--- SOCKS5 UDP 报文 ----- 代理 <--- 原始 UDP ----- 目标客户端发送 UDP ASSOCIATE 后,代理创建 UDP socket,并通过 TCP 回复它的地址。 TCP 控制连接必须保持打开。一旦 TCP 断开,代理立即关闭对应 UDP socket: go func() { _, _ = io.Copy(io.Discard, controlConnection) _ = relaySocket.Close() }()这个 TCP 连接没有业务数据,只承担“租约”作用。 SOCKS5 UDP 报文格式 客户端不能把原始 UDP payload 直接发给代理,因为代理不知道目标地址。每个数据报都要带 Header: +------+------+------+----------+----------+----------+ | RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA | +------+------+------+----------+----------+----------+ | 2 | 1 | 1 | Variable | 2 | Variable | +------+------+------+----------+----------+----------+例如把 hello udp 发到 127.0.0.1:53: 00 00 | 00 | 01 | 7F 00 00 01 | 00 35 | hello udp RSV FRAG ATYP IP PORT DATA代理解析 Header,把 DATA 作为普通 UDP 发给目标。目标响应后,代理使用目标来源地址重新封装,再发回客户端。 为什么拒绝 FRAG != 0 FRAG 用于 UDP 分片。完整实现需要:按客户端与目标维护分片队列; 识别分片序号和结束位; 设置重组超时; 限制总大小与并发队列; 防止内存耗尽攻击。大多数 SOCKS5 客户端不会使用该能力。为了避免“看似支持、实际不安全”,实现中明确丢弃 FRAG != 0 的数据报。 这是协议实现中很重要的原则:不完整的复杂特性应该明确拒绝,而不是静默误处理。 UDP 中继的来源约束 公开 UDP 中继很容易变成反射攻击工具,因此至少要做三层限制:UDP 来源 IP 必须等于已认证 TCP 客户端 IP; 第一个合法 UDP 包锁定客户端真实端口; 目标响应必须来自客户端主动联系过的地址。contactedTargets[targetAddress.String()] = struct{}{}if _, allowed := contactedTargets[source.String()]; !allowed { continue }请求中的客户端地址经常是 0.0.0.0:0,这是因为客户端在发送第一个 UDP 包前可能还不知道 NAT 映射端口。因此“首次合法报文锁定”比盲目信任请求字段更实用。 三种命令的流量定义 项目采用:命令 上传 下载CONNECT 客户端到目标的 TCP 字节 目标到客户端的 TCP 字节BIND 客户端到反向连接者的字节 反向连接者到客户端的字节UDP ASSOCIATE 去除 SOCKS Header 后的 UDP payload 目标返回的 UDP payloadUDP 不统计 SOCKS5 封装头,便于衡量真正的业务数据。 测试不能只测 parser 协议 parser 单元测试只能证明字节解析正确。完整测试还应创建真实 socket: 测试客户端 -> SOCKS5 服务 -> 本地 TCP/UDP Echo Server覆盖:正确和错误密码; IPv4、IPv6、域名编码; CONNECT 双向数据; BIND 两次响应; UDP ASSOCIATE 双向回显; FRAG != 0 拒绝; 上传、下载和请求次数。再配合 go test -race,检查会话关闭、资源跟踪和统计写入是否存在数据竞争。 小结 SOCKS5 的难点不在 io.Copy,而在状态机和边界: 握手 -> 认证 -> 通用请求 -> 命令分派 -> 生命周期 -> 统计CONNECT 是主动拨号,BIND 是两阶段反向接入,UDP ASSOCIATE 则是 TCP 控制下的 UDP 中继。把地址编解码、命令处理和资源关闭拆开后,协议实现才会既可读又可测试。 下一篇进入数据层:用 SQLite 持久化代理用户、分协议流量与小时趋势。
从 TCP 到 HTTP:用 Go 实现带鉴权与流量统计的正向代理
这是 Relay Observatory 代理项目实现系列的第一篇。完整代码放在 GitHub。 很多 HTTP 代理教程只贴一个 ServeHTTP,却没有解释为什么 HTTPS 要使用 CONNECT、Hijack 返回的连接和缓冲区分别是什么,以及流量到底应该在哪一层统计。本文从连接模型开始,把这些问题串起来。 先建立正确的连接模型 正向代理同时持有两侧连接: 客户端 <------ client connection ------> 代理 代理 <------ upstream connection ----> 目标服务器对普通 HTTP,代理能够解析请求和响应: 客户端 -- HTTP Request --> 代理 -- HTTP Request --> 目标 客户端 <- HTTP Response -- 代理 <- HTTP Response -- 目标对 HTTPS,HTTP 内容在 TLS 内部已经加密。代理不能先读取 GET /,因为客户端在发送 HTTP 请求前必须与目标服务器完成 TLS 握手。 解决办法是客户端先向代理发送明文的 CONNECT: CONNECT example.com:443 HTTP/1.1 Proxy-Authorization: Basic ...代理连接 example.com:443,返回: HTTP/1.1 200 Connection Established此后代理只搬运 TLS 二进制数据,不解析内部 HTTP。 入口只做三件事 入口 Handler 最容易写乱。一个清楚的入口应该只负责:鉴权; 判断普通 HTTP 还是 CONNECT; 将流量统一入账。func (p *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { identity, ok := auth.AuthenticateBasicHeader( p.users, r.Header.Get("Proxy-Authorization"), ) if !ok { w.Header().Set("Proxy-Authenticate", `Basic realm="proxy"`) http.Error(w, "proxy authentication required", 407) return } protocol := traffic.ProtocolHTTP var uploaded, downloaded int64 if r.Method == http.MethodConnect { protocol = traffic.ProtocolHTTPS uploaded, downloaded = p.forwardTunnel(w, r) } else { uploaded, downloaded = p.forwardHTTP(w, r, identity.Username) } _ = p.traffic.Record(identity.ID, protocol, uploaded, downloaded) }http.MethodConnect 只是字符串常量 "CONNECT"。这里不是在判断网站是否以 https:// 开头,而是在判断客户端是否要求建立 TCP 隧道。 普通 HTTP:RoundTripper 完成一次往返 Go 的 http.RoundTripper 是一次 HTTP 请求的底层接口: type RoundTripper interface { RoundTrip(*http.Request) (*http.Response, error) }它完成: 代理 -- Request --> 目标 代理 <- Response -- 目标代理使用 RoundTripper 而不是高级的 http.Client,因为代理不应该替用户自动跟随重定向、保存 Cookie 或改变请求语义。 转发前需要整理请求: func prepareUpstreamRequest(r *http.Request) { if r.URL.Scheme == "" { r.URL.Scheme = "http" } if r.URL.Host == "" { r.URL.Host = r.Host } // 客户端请求使用 RequestURI;RoundTripper 要求它为空。 r.RequestURI = "" // 代理密码绝不能继续发给目标网站。 r.Header.Del("Proxy-Authorization") removeHopByHopHeaders(r.Header) }然后发送请求并把响应流式复制回客户端: response, err := transport.RoundTrip(r) if err != nil { http.Error(w, "upstream request failed", http.StatusBadGateway) return } defer response.Body.Close()copyHeaders(w.Header(), response.Header) w.WriteHeader(response.StatusCode)downloaded, err := io.Copy(w, response.Body)io.Copy 不会把整个文件读进内存。即使目标返回几 GB 文件,也只需要一个固定大小的缓冲区: 目标返回一块 -> 代理读取一块 -> 立即写给客户端Hop-by-Hop Header 为什么不能转发 Connection、Keep-Alive、Proxy-Authorization、Transfer-Encoding 等 Header 只描述当前一跳连接。 例如客户端与代理希望保持长连接,不代表代理与目标服务器必须使用同样的连接策略。因此这些 Header 必须在进入下一跳前删除。 还要处理 Connection 中动态声明的字段: for _, value := range header.Values("Connection") { for name := range strings.SplitSeq(value, ",") { header.Del(strings.TrimSpace(name)) } }只删除固定列表而忽略 Connection: Foo,会把本应仅对当前连接有效的 Foo 转发出去。 CONNECT:先连接目标,再接管客户端连接 第一步是代理主动连接目标: upstream, err := dialer.DialContext( r.Context(), "tcp", r.Host, )如果请求是: CONNECT example.com:443 HTTP/1.1那么 r.Host 是 example.com:443。DialContext 会完成 DNS 查询与 TCP 三次握手,并返回代理到目标服务器的 upstream 连接。 第二步是从 net/http 手中接管客户端连接: hijacker, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking is not supported", 500) return }client, buffered, err := hijacker.Hijack()这里有三个容易混淆的对象:对象 含义client 客户端到代理的底层 TCP 连接upstream 代理到目标服务器的 TCP 连接buffered client 前面的缓冲读取器,不是第三条连接net/http 可能提前从客户端读取了一部分数据放进缓冲区。上传方向必须先读 buffered,否则会漏掉已经离开内核 socket、但还没交给业务代码的数据。 两个 io.Copy 组成全双工隧道 上传和下载必须同时进行: go func() { uploaded, _ := io.Copy(upstream, buffered) results <- uploaded }()go func() { downloaded, _ := io.Copy(client, upstream) results <- downloaded }()如果按顺序执行: io.Copy(upstream, client) io.Copy(client, upstream)第一行通常要等客户端关闭连接才返回,第二行永远没有机会及时执行,下载方向就被阻塞了。 当一个方向结束时,不应该立刻关闭整条 TCP 连接,而应优先使用半关闭: if tcp, ok := connection.(*net.TCPConn); ok { _ = tcp.CloseWrite() }CloseWrite 发送 FIN,表示“我不会再发送”,但仍允许把另一个方向尚未完成的数据读完。 流量统计应该统计哪一层 普通 HTTP 可以统计请求体和响应体: uploaded = request body bytes downloaded = response body bytes请求体通过装饰器计数: type countingReadCloser struct { io.ReadCloser bytes int64 }func (r *countingReadCloser) Read(p []byte) (int, error) { n, err := r.ReadCloser.Read(p) r.bytes += int64(n) return n, err }HTTPS CONNECT 无法看到内部请求体,因此统计的是 TLS 隧道原始字节。它会包含:TLS 握手; 证书; TLS record 开销; 加密后的 HTTP Header 和 Body。所以 curl %{size_download} 与代理下载量不会完全相等。前者通常只统计响应 Body,后者统计整条加密隧道。出现约千分之几的差异是正常现象。 用接口隔离认证和统计 代理层不应该知道密码存储在 .env、SQLite 还是远程服务: type Authenticator interface { Authenticate(username, password string) (Identity, bool) }type Recorder interface { Record( userID int64, protocol string, uploaded int64, downloaded int64, ) error }这样网络层只负责协议,存储层可以从内存平滑替换为 SQLite,测试也能使用轻量内存实现。 小结 Go HTTP 正向代理的核心不是某个库,而是两套不同的数据路径: 普通 HTTP: Request -> RoundTripper -> Response -> io.CopyHTTPS: CONNECT -> Dial -> Hijack -> 双向 io.Copy一旦分清 client、upstream、buffered 和两个方向的数据流,鉴权、流量统计、持久化都只是围绕主路径增加的边界能力。 下一篇继续实现 SOCKS5 的 CONNECT、BIND 与 UDP ASSOCIATE。
Go 后端接入 Web3 钱包登录:nonce + personal_sign 验签
Go 后端接 Web3 钱包登录,标准流程只有四步:后端下发 nonce 前端钱包用 personal_sign 签 "Login\nNonce: xxx" 后端 recover 出地址,比对是不是同一个 通过后签发 JWT / session省心的做法是前端签名 + 后端验签,别在后端搞私钥。 生成 nonce 一次性、5 分钟过期、写 Redis: package authimport ( "crypto/rand" "encoding/hex" )func GenerateNonce() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) }Redis key:wallet:nonce:0xabc... → <nonce>,TTL 5 分钟。 前端签名 const message = `Login\nNonce: ${nonce}`; const signature = await ethereum.request({ method: "personal_sign", params: [message, address], });要点:用 personal_sign,不要自己算 hash 消息用明文发给 MetaMask,它会自动加 EIP-191 前缀Go 侧验签 package authimport ( "fmt" "strings" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" )func VerifySignature(address, message, sigHex string) bool { sigHex = strings.TrimPrefix(sigHex, "0x") sig := common.FromHex(sigHex) if len(sig) != 65 { return false } // 关键点 1:v 值处理(MetaMask 返回 27/28,crypto 库要 0/1) if sig[64] != 27 && sig[64] != 28 { return false } sig[64] -= 27 // 关键点 2:EIP-191 前缀 prefixed := fmt.Sprintf( "\x19Ethereum Signed Message:\n%d%s", len(message), message, ) hash := crypto.Keccak256Hash([]byte(prefixed)) pubKey, err := crypto.SigToPub(hash.Bytes(), sig) if err != nil { return false } recovered := crypto.PubkeyToAddress(*pubKey) return strings.EqualFold(recovered.Hex(), address) }登录 handler type LoginReq struct { Address string `json:"address"` Signature string `json:"signature"` }func Login(c *gin.Context) { var req LoginReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(400, gin.H{"error": "bad request"}) return } key := "wallet:nonce:" + strings.ToLower(req.Address) nonce, err := redisClient.Get(ctx, key).Result() if err != nil { c.JSON(401, gin.H{"error": "nonce expired"}) return } message := "Login\nNonce: " + nonce if !VerifySignature(req.Address, message, req.Signature) { c.JSON(401, gin.H{"error": "invalid signature"}) return } // 防重放:立刻删掉 nonce redisClient.Del(ctx, key) token, _ := GenerateJWT(req.Address) c.JSON(200, gin.H{"token": token}) }三个必踩的坑 1. 少了 EIP-191 前缀 MetaMask 的 personal_sign 会自动加: "\x19Ethereum Signed Message:\n" + len(msg) + msg后端验签必须手动加回这个前缀再算 keccak。少了直接验不过。 2. v 值 27/28 vs 0/1 MetaMask 返回的签名最后一个字节(v)是 27 或 28(EIP-155 前的格式)。go-ethereum 的 crypto.SigToPub 要求是 0 或 1。必须减 27。 3. 忘了防重放 nonce 验完不删,攻击者抓包重放就能永远登进来。验签成功后立刻 redis.Del。 建议直接上 SIWE Sign-In with Ethereum(EIP-4361)是现在的标准,消息格式包括域名、URI、Chain ID、Issued At 等,钱包会更友好地显示: example.com wants you to sign in with your Ethereum account: 0xabc...123URI: https://example.com Version: 1 Chain ID: 1 Nonce: 8a2c... Issued At: 2026-05-10T16:31:04ZGo 侧现成库: go get github.com/spruceid/siwe-goimport siwe "github.com/spruceid/siwe-go"msg, err := siwe.ParseMessage(rawMessage) if err != nil { ... }if _, err := msg.Verify(signature, &domain, &nonce, nil); err != nil { // 验签失败 }SIWE 帮你处理消息构造、时效、chain id、防重放,比手写靠谱。 前端推荐栈wagmi + viem:现在 EVM dApp 的事实标准 直接 useSignMessage,签名一行搞定 不用自己处理 window.ethereum一句话总结 Web3 登录 = nonce + personal_sign + Go recover 比地址。三个坑:加 EIP-191 前缀、v 值减 27、nonce 用完立刻删。新项目直接上 SIWE,别自己拼消息格式。
