Files
panel/internal/service/ws.go
T
耗子andClaude Opus 4.8 443516a2cf refactor!: 迁移至 samber/do 依赖注入与三层架构
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
  改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
  任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)

三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
  service/command/job 表现层只依赖用例,不再直接引用仓储

路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
  Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
  从 validate 标签生成

目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:54:22 +08:00

616 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os/exec"
"strings"
"time"
"github.com/coder/websocket"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/collect"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/samber/do/v2"
stdssh "golang.org/x/crypto/ssh"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/docker"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/ssh"
"github.com/acepanel/panel/v3/pkg/tools"
)
type WsService struct {
t *gotext.Locale
conf *config.Config
log *slog.Logger
api *api.API
sshRepo *biz.SSHUsecase
settingRepo *biz.SettingUsecase
certRepo *biz.CertUsecase
backupRepo *biz.BackupUsecase
taskRepo *biz.TaskUsecase
}
func NewWsService(i do.Injector) (*WsService, error) {
return &WsService{
t: do.MustInvoke[*gotext.Locale](i),
conf: do.MustInvoke[*config.Config](i),
log: do.MustInvoke[*slog.Logger](i),
api: api.NewAPI(app.Version, app.Locale),
sshRepo: do.MustInvoke[*biz.SSHUsecase](i),
settingRepo: do.MustInvoke[*biz.SettingUsecase](i),
certRepo: do.MustInvoke[*biz.CertUsecase](i),
backupRepo: do.MustInvoke[*biz.BackupUsecase](i),
taskRepo: do.MustInvoke[*biz.TaskUsecase](i),
}, nil
}
func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade exec ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
// 第一条消息是命令
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, cmd, err := ws.Read(ctx)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command: %v", err))
return
}
out, err := shell.ExecfWithPipe(ctx, string(cmd))
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to run command: %v", err))
return
}
go func() {
scanner := bufio.NewScanner(out)
for scanner.Scan() {
line := scanner.Text()
_ = ws.Write(ctx, websocket.MessageText, []byte(line))
}
if err = scanner.Err(); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command output: %v", err))
}
}()
s.readLoop(ctx, ws)
}
// Follow 文件或 systemd 服务实时跟踪
// path 给定时用 tail -F;service 给定时用 journalctl -f
func (s *WsService) Follow(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.FileFollow](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if req.Path == "" && req.Service == "" && req.Container == "" {
Error(w, http.StatusUnprocessableEntity, s.t.Get("path, service or container is required"))
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade follow ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
if req.Container != "" {
s.followContainer(ctx, ws, req.Container)
return
}
var cmd *exec.Cmd
if req.Service != "" {
cmd = exec.CommandContext(ctx, "journalctl", "--no-pager", "-n", "0", "-f", "-u", req.Service)
} else {
cmd = exec.CommandContext(ctx, "tail", "-n", "0", "-F", req.Path)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
if err := cmd.Start(); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
done := make(chan struct{})
go func() {
defer close(done)
defer cancel() // 进程输出结束时取消,促使下方读取循环退出
buf := make([]byte, 4096)
for {
n, rerr := stdout.Read(buf)
if n > 0 {
if werr := ws.Write(ctx, websocket.MessageBinary, buf[:n]); werr != nil {
return
}
}
if rerr != nil {
return
}
}
}()
// 连接结束时取消 ctx 杀掉进程,待输出读取完成后 Wait 回收,避免残留僵尸进程
defer func() {
cancel()
<-done
_ = cmd.Wait()
}()
for {
_, _, rerr := ws.Read(ctx)
if rerr != nil {
return
}
}
}
// PTY 通用 PTY 命令执行
// 前端发送第一条消息为要执行的命令,后端通过 PTY 执行并实时返回输出
func (s *WsService) PTY(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade pty ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// 要执行的命令
_, message, err := ws.Read(ctx)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read command: %v", err))
return
}
command := string(message)
if command == "" {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("command is empty"))
return
}
// PTY 执行命令
turn, err := shell.NewPTYTurn(ctx, ws, command)
if err != nil {
_ = ws.Write(ctx, websocket.MessageBinary, []byte("\r\n"+s.t.Get("Failed to start command: %v", err)+"\r\n"))
_ = ws.Close(websocket.StatusNormalClosure, "")
return
}
go func() {
defer turn.Close()
_ = turn.Handle(ctx)
}()
turn.Wait()
}
func (s *WsService) Session(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
info, err := s.sshRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade session ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
sshClient, err := ssh.NewSSHClient(info.Config)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
defer func(sshClient *stdssh.Client) { _ = sshClient.Close() }(sshClient)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
turn, err := ssh.NewTurn(ctx, ws, sshClient)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
go func() {
defer turn.Close() // Handle 退出后关闭 SSH 连接,以结束 Wait 阶段
_ = turn.Handle(ctx)
}()
turn.Wait()
}
// ContainerTerminal 容器终端
func (s *WsService) ContainerTerminal(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ContainerID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade container terminal ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
sock := s.getContainerSock()
// 通过 /bin/sh 启动,自动尝试切换到 bash,不存在则留在 sh
turn, err := docker.NewTurn(ctx, ws, req.ID, []string{"/bin/sh", "-c", "exec bash 2>/dev/null || exec sh"}, sock)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to start container terminal: %v", err))
return
}
go func() {
defer turn.Close()
_ = turn.Handle(ctx)
}()
turn.Wait()
}
// ContainerImagePull 镜像拉取
func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade image pull ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
_, message, err := ws.Read(ctx)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read params: %v", err))
return
}
var req request.ContainerImagePull
if err = json.Unmarshal(message, &req); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("invalid params: %v", err))
return
}
// 创建 Docker 客户端
apiClient, err := client.New(client.WithHost(s.getContainerSock()))
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to create docker client: %v", err))
return
}
defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient)
// 构建拉取选项
options := client.ImagePullOptions{}
if req.Auth {
authConfig := registry.AuthConfig{
Username: req.Username,
Password: req.Password,
}
encodedJSON, err := json.Marshal(authConfig)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to encode auth: %v", err))
return
}
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
}
// 拉取镜像
resp, err := apiClient.ImagePull(ctx, req.Name, options)
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to pull image: %v", err))
return
}
// 迭代进度
for msg, err := range resp.JSONMessages(ctx) {
if err != nil {
s.log.Warn("image pull error", slog.Any("err", err))
errorMsg, _ := json.Marshal(map[string]any{
"status": "error",
"error": err.Error(),
})
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
return
}
// 如果有错误,发送错误消息
if msg.Error != nil {
errorMsg, _ := json.Marshal(map[string]any{
"status": "error",
"error": msg.Error.Message,
})
_ = ws.Write(ctx, websocket.MessageText, errorMsg)
return
}
// 转发进度信息
progressMsg, _ := json.Marshal(msg)
if err = ws.Write(ctx, websocket.MessageText, progressMsg); err != nil {
s.log.Warn("write image pull progress error", slog.Any("err", err))
return
}
}
// 拉取完成
completeMsg, _ := json.Marshal(map[string]any{
"status": "complete",
"complete": true,
})
_ = ws.Write(ctx, websocket.MessageText, completeMsg)
_ = ws.Close(websocket.StatusNormalClosure, "")
}
// CertObtain 通过 WebSocket 签发证书并实时推送进度
func (s *WsService) CertObtain(w http.ResponseWriter, r *http.Request) {
s.handleCertWs(w, r, "obtain", func(ctx context.Context, id uint, cb func(string)) error {
_, err := s.certRepo.ObtainAutoWithProgressCallback(ctx, id, cb)
return err
})
}
// CertRenew 通过 WebSocket 续签证书并实时推送进度
func (s *WsService) CertRenew(w http.ResponseWriter, r *http.Request) {
s.handleCertWs(w, r, "renew", func(ctx context.Context, id uint, cb func(string)) error {
_, err := s.certRepo.RenewWithProgressCallback(ctx, id, cb)
return err
})
}
// PanelUpdate 通过 WebSocket 升级面板并实时推送进度
func (s *WsService) PanelUpdate(w http.ResponseWriter, r *http.Request) {
// 前置检查在建连前完成(此时 app.Status 仍为 Normal,握手可过状态中间件)
if offline, _ := s.settingRepo.GetBool(biz.SettingKeyOfflineMode); offline {
Error(w, http.StatusForbidden, s.t.Get("unable to update in offline mode"))
return
}
if s.taskRepo.HasRunningTask() {
Error(w, http.StatusInternalServerError, s.t.Get("background task is running, updating is prohibited, please try again later"))
return
}
channel, _ := s.settingRepo.Get(biz.SettingKeyChannel)
panel, err := s.api.LatestVersion(channel)
if err != nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get the latest version: %v", err))
return
}
download := collect.First(panel.Downloads)
if download == nil {
Error(w, http.StatusInternalServerError, s.t.Get("failed to get the latest version download link"))
return
}
url := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.URL)
checksum := fmt.Sprintf("https://%s%s", s.conf.App.DownloadEndpoint, download.Checksum)
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn("upgrade panel update ws error", slog.Any("err", err))
return
}
defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws)
// 写入用带超时的独立 context:与请求/WS 生命周期解耦,
// 用户关闭页面导致连接断开也不会中断升级(升级内部 shell 执行不带 ctx)
write := func(status, msg string) {
data, _ := json.Marshal(map[string]any{"status": status, "msg": msg})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = ws.Write(ctx, websocket.MessageText, data)
}
if err = s.backupRepo.UpdatePanel(panel.Version, url, checksum, func(msg string) { write("progress", msg) }); err != nil {
write("error", err.Error())
_ = ws.Close(websocket.StatusNormalClosure, "")
return
}
write("success", "success")
_ = ws.Close(websocket.StatusNormalClosure, "")
// 升级成功,由本入口负责重启面板(唯一一次重启)
tools.RestartPanel()
}
// handleCertWs 证书操作的公共 WebSocket 处理逻辑
func (s *WsService) handleCertWs(w http.ResponseWriter, r *http.Request, action string, fn func(ctx context.Context, id uint, cb func(string)) error) {
ws, err := s.upgrade(w, r)
if err != nil {
s.log.Warn(fmt.Sprintf("upgrade cert %s ws error", action), slog.Any("err", err))
return
}
defer func() { _ = ws.CloseNow() }()
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// 读取参数,10 秒超时防止连接后不发消息
readCtx, readCancel := context.WithTimeout(ctx, 10*time.Second)
_, message, err := ws.Read(readCtx)
readCancel()
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("failed to read params: %v", err))
return
}
var req struct {
ID uint `json:"id"`
}
if err = json.Unmarshal(message, &req); err != nil {
_ = ws.Close(websocket.StatusNormalClosure, s.t.Get("invalid params: %v", err))
return
}
progressCallback := func(msg string) {
if ctx.Err() != nil {
return
}
data, _ := json.Marshal(map[string]any{
"status": "progress",
"msg": msg,
})
if err = ws.Write(ctx, websocket.MessageText, data); err != nil {
s.log.Warn("write cert progress error", slog.Any("err", err), slog.String("action", action))
}
}
if err = fn(ctx, req.ID, progressCallback); err != nil {
errMsg, _ := json.Marshal(map[string]any{
"status": "error",
"msg": err.Error(),
})
_ = ws.Write(ctx, websocket.MessageText, errMsg)
_ = ws.Close(websocket.StatusNormalClosure, "")
return
}
completeMsg, _ := json.Marshal(map[string]any{
"status": "success",
"msg": "success",
"data": nil,
})
_ = ws.Write(ctx, websocket.MessageText, completeMsg)
_ = ws.Close(websocket.StatusNormalClosure, "")
}
// getContainerSock 获取容器 socket 路径
func (s *WsService) getContainerSock() string {
sock, _ := s.settingRepo.Get(biz.SettingKeyContainerSock)
if sock == "" {
sock = "/var/run/docker.sock"
}
if !strings.Contains(sock, "://") {
sock = fmt.Sprintf("unix://%s", sock)
}
return sock
}
// wsBinaryWriter 将写入转发为 WebSocket 二进制帧
type wsBinaryWriter struct {
ctx context.Context
ws *websocket.Conn
}
func (w *wsBinaryWriter) Write(p []byte) (int, error) {
if err := w.ws.Write(w.ctx, websocket.MessageBinary, p); err != nil {
return 0, err
}
return len(p), nil
}
// followContainer 通过 Docker SDK 实时跟踪容器日志并转发到 WebSocket
func (s *WsService) followContainer(ctx context.Context, ws *websocket.Conn, id string) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
apiClient, err := client.New(client.WithHost(s.getContainerSock()))
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient)
// 非 TTY 容器日志为多路复用流,需按 TTY 设置决定是否解复用
inspect, err := apiClient.ContainerInspect(ctx, id, client.ContainerInspectOptions{})
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
tty := inspect.Container.Config != nil && inspect.Container.Config.Tty
// Tail "0" 表示只推送新增日志,历史由 HTTP /file/tail 反向分页加载
reader, err := apiClient.ContainerLogs(ctx, id, client.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
Tail: "0",
})
if err != nil {
_ = ws.Close(websocket.StatusNormalClosure, err.Error())
return
}
done := make(chan struct{})
go func() {
defer close(done)
defer cancel() // 日志流结束(如容器停止)时取消,促使下方读取循环退出
_ = docker.CopyLogs(&wsBinaryWriter{ctx: ctx, ws: ws}, reader, tty)
}()
// 连接结束时取消 ctx 并关闭日志流,待拷贝协程退出后返回,避免泄漏
defer func() {
cancel()
_ = reader.Close()
<-done
}()
for {
if _, _, rerr := ws.Read(ctx); rerr != nil {
return
}
}
}
func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
opts := &websocket.AcceptOptions{
CompressionMode: websocket.CompressionContextTakeover,
}
// debug 模式下不校验 origin,方便 vite 代理调试
if s.conf.App.Debug {
opts.InsecureSkipVerify = true
}
return websocket.Accept(w, r, opts)
}
// readLoop 阻塞直到客户端关闭连接
func (s *WsService) readLoop(ctx context.Context, c *websocket.Conn) {
for {
if _, _, err := c.Read(ctx); err != nil {
_ = c.CloseNow()
break
}
}
}