这是 Relay Observatory 代理项目实现系列的第四篇。完整代码放在 GitHub。
前面已经完成:
最后一步是给代理增加一个真正可用的管理后台,同时保持单文件部署。
最终交付形式:
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 adminui
import "embed"
//go:embed dist
var assets embed.FS
go: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.js
HTTP 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.js
SPA 回退不能影响静态资源
单页应用未来可能增加:
/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 | 跨站请求默认不携带 Cookie |
Secure | 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;
- 增加审计日志;
- 根据部署拓扑决定是否强制
SecureCookie。
流量页面需要哪些数据
后台首页不是简单显示三个数字,而是回答四个问题:
- 总共转发了多少数据;
- 最近 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.com
curl --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,一个代理程序就从协议实验升级成了可以实际管理和观测的单机服务,同时仍保持部署简单。
