diff --git a/internal/biz/container_image.go b/internal/biz/container_image.go index beb6167b..f84b1141 100644 --- a/internal/biz/container_image.go +++ b/internal/biz/container_image.go @@ -7,6 +7,7 @@ import ( type ContainerImageRepo interface { List() ([]types.ContainerImage, error) + Exist(name string) (bool, error) Pull(req *request.ContainerImagePull) error Remove(id string) error Prune() error diff --git a/internal/data/container_image.go b/internal/data/container_image.go index 5e9879e1..d453b030 100644 --- a/internal/data/container_image.go +++ b/internal/data/container_image.go @@ -8,6 +8,7 @@ import ( "strings" "time" + cerrdefs "github.com/containerd/errdefs" "github.com/moby/moby/api/types/registry" "github.com/moby/moby/client" @@ -58,6 +59,25 @@ func (r *containerImageRepo) List() ([]types.ContainerImage, error) { return images, nil } +// Exist 检查镜像是否存在 +func (r *containerImageRepo) Exist(name string) (bool, error) { + apiClient, err := getDockerClient("/var/run/docker.sock") + if err != nil { + return false, err + } + defer func(apiClient *client.Client) { _ = apiClient.Close() }(apiClient) + + _, err = apiClient.ImageInspect(context.Background(), name) + if err != nil { + if cerrdefs.IsNotFound(err) { + return false, nil + } + return false, err + } + + return true, nil +} + // Pull 拉取镜像 func (r *containerImageRepo) Pull(req *request.ContainerImagePull) error { apiClient, err := getDockerClient("/var/run/docker.sock") diff --git a/internal/route/http.go b/internal/route/http.go index 4beadf25..4c1afaa8 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -391,6 +391,7 @@ func (route *Http) Register(r *chi.Mux) { }) r.Route("/image", func(r chi.Router) { r.Get("/", route.containerImage.List) + r.Get("/exist", route.containerImage.Exist) r.Post("/", route.containerImage.Pull) r.Delete("/{id}", route.containerImage.Remove) r.Post("/prune", route.containerImage.Prune) diff --git a/internal/route/ws.go b/internal/route/ws.go index b8a8a14c..205dc791 100644 --- a/internal/route/ws.go +++ b/internal/route/ws.go @@ -20,5 +20,7 @@ func (route *Ws) Register(r *chi.Mux) { r.Route("/api/ws", func(r chi.Router) { r.Get("/ssh", route.ws.Session) r.Get("/exec", route.ws.Exec) + r.Get("/container/{id}", route.ws.ContainerTerminal) + r.Get("/container/image/pull", route.ws.ContainerImagePull) }) } diff --git a/internal/service/container_image.go b/internal/service/container_image.go index 88029453..b612695d 100644 --- a/internal/service/container_image.go +++ b/internal/service/container_image.go @@ -34,6 +34,22 @@ func (s *ContainerImageService) List(w http.ResponseWriter, r *http.Request) { }) } +func (s *ContainerImageService) Exist(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ContainerImagePull](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + exist, err := s.containerImageRepo.Exist(req.Name) + if err != nil { + Error(w, http.StatusInternalServerError, "%v", err) + return + } + + Success(w, exist) +} + func (s *ContainerImageService) Pull(w http.ResponseWriter, r *http.Request) { req, err := Bind[request.ContainerImagePull](r) if err != nil { diff --git a/internal/service/ws.go b/internal/service/ws.go index b4309877..7b3a69f0 100644 --- a/internal/service/ws.go +++ b/internal/service/ws.go @@ -3,16 +3,21 @@ package service import ( "bufio" "context" + "encoding/base64" + "encoding/json" "log/slog" "net/http" "github.com/coder/websocket" "github.com/leonelquinteros/gotext" + "github.com/moby/moby/api/types/registry" + "github.com/moby/moby/client" stdssh "golang.org/x/crypto/ssh" "github.com/acepanel/panel/internal/biz" "github.com/acepanel/panel/internal/http/request" "github.com/acepanel/panel/pkg/config" + "github.com/acepanel/panel/pkg/docker" "github.com/acepanel/panel/pkg/shell" "github.com/acepanel/panel/pkg/ssh" ) @@ -114,6 +119,42 @@ func (s *WsService) Exec(w http.ResponseWriter, r *http.Request) { s.readLoop(ctx, ws) } +// ContainerTerminal 容器终端 WebSocket 处理 +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("[Websocket] upgrade container terminal ws error", slog.Any("err", err)) + return + } + defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 默认使用 bash 作为 shell,如果不存在则回退到 sh + turn, err := docker.NewTurn(ctx, ws, req.ID, []string{"/bin/bash"}) + if err != nil { + turn, err = docker.NewTurn(ctx, ws, req.ID, []string{"/bin/sh"}) + 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() +} + func (s *WsService) upgrade(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) { opts := &websocket.AcceptOptions{ CompressionMode: websocket.CompressionContextTakeover, @@ -136,3 +177,95 @@ func (s *WsService) readLoop(ctx context.Context, c *websocket.Conn) { } } } + +// ContainerImagePull 镜像拉取 WebSocket 处理 +func (s *WsService) ContainerImagePull(w http.ResponseWriter, r *http.Request) { + ws, err := s.upgrade(w, r) + if err != nil { + s.log.Warn("[Websocket] upgrade image pull ws error", slog.Any("err", err)) + return + } + defer func(ws *websocket.Conn) { _ = ws.CloseNow() }(ws) + + ctx, cancel := context.WithCancel(context.Background()) + 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("unix:///var/run/docker.sock")) + 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("[Websocket] 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("[Websocket] 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, "") +} diff --git a/pkg/docker/turn.go b/pkg/docker/turn.go new file mode 100644 index 00000000..167f3805 --- /dev/null +++ b/pkg/docker/turn.go @@ -0,0 +1,126 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/coder/websocket" + "github.com/moby/moby/client" +) + +// MessageResize 终端大小调整消息 +type MessageResize struct { + Resize bool `json:"resize"` + Columns uint `json:"columns"` + Rows uint `json:"rows"` +} + +// Turn 容器终端转发器 +type Turn struct { + ctx context.Context + ws *websocket.Conn + client *client.Client + execID string + hijack client.ExecAttachResult + closeOnce bool +} + +// NewTurn 创建容器终端转发器 +func NewTurn(ctx context.Context, ws *websocket.Conn, containerID string, command []string) (*Turn, error) { + apiClient, err := client.New(client.WithHost("unix:///var/run/docker.sock")) + if err != nil { + return nil, fmt.Errorf("failed to create docker client: %w", err) + } + + // 创建 exec 实例 + execCreateResp, err := apiClient.ExecCreate(ctx, containerID, client.ExecCreateOptions{ + Cmd: command, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + TTY: true, + }) + if err != nil { + _ = apiClient.Close() + return nil, fmt.Errorf("failed to create exec instance: %w", err) + } + + // 附加到 exec 实例 + hijack, err := apiClient.ExecAttach(ctx, execCreateResp.ID, client.ExecAttachOptions{ + TTY: true, + }) + if err != nil { + _ = apiClient.Close() + return nil, fmt.Errorf("failed to attach to exec instance: %w", err) + } + + turn := &Turn{ + ctx: ctx, + ws: ws, + client: apiClient, + execID: execCreateResp.ID, + hijack: hijack, + } + + return turn, nil +} + +// Write 实现 io.Writer 接口,将容器输出写入 WebSocket +func (t *Turn) Write(p []byte) (n int, err error) { + if err = t.ws.Write(t.ctx, websocket.MessageText, p); err != nil { + return 0, err + } + return len(p), nil +} + +// Close 关闭连接 +func (t *Turn) Close() { + if t.closeOnce { + return + } + t.closeOnce = true + t.hijack.Close() + _ = t.client.Close() +} + +// Handle 处理 WebSocket 消息 +func (t *Turn) Handle(ctx context.Context) error { + var resize MessageResize + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + _, data, err := t.ws.Read(ctx) + if err != nil { + // 通常是客户端关闭连接 + return fmt.Errorf("failed to read ws message: %w", err) + } + + // 判断是否是 resize 消息 + if err = json.Unmarshal(data, &resize); err == nil { + if resize.Resize && resize.Columns > 0 && resize.Rows > 0 { + if _, err = t.client.ExecResize(ctx, t.execID, client.ExecResizeOptions{ + Height: resize.Rows, + Width: resize.Columns, + }); err != nil { + return fmt.Errorf("failed to resize terminal: %w", err) + } + } + continue + } + + if _, err = t.hijack.Conn.Write(data); err != nil { + return fmt.Errorf("failed to write to container stdin: %w", err) + } + } + } +} + +// Wait 等待容器输出并转发到 WebSocket +func (t *Turn) Wait() { + _, _ = io.Copy(t, t.hijack.Reader) +} diff --git a/web/src/api/panel/container/index.ts b/web/src/api/panel/container/index.ts index a7bcc9ae..96107ef3 100644 --- a/web/src/api/panel/container/index.ts +++ b/web/src/api/panel/container/index.ts @@ -55,6 +55,8 @@ export default { // 获取镜像列表 imageList: (page: number, limit: number): any => http.Get(`/container/image`, { params: { page, limit } }), + // 检查镜像是否存在 + imageExist: (name: string): any => http.Get(`/container/image/exist`, { params: { name } }), // 拉取镜像 imagePull: (config: any): any => http.Post(`/container/image`, config), // 删除镜像 diff --git a/web/src/api/ws/index.ts b/web/src/api/ws/index.ts index 158521ce..a2c0b448 100644 --- a/web/src/api/ws/index.ts +++ b/web/src/api/ws/index.ts @@ -20,5 +20,26 @@ export default { ws.onopen = () => resolve(ws) ws.onerror = (e) => reject(e) }) + }, + // 连接容器终端 + container: (id: string): Promise => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${base}/container/${id}`) + ws.onopen = () => resolve(ws) + ws.onerror = (e) => reject(e) + }) + }, + // 拉取镜像 + imagePull: (name: string, auth?: { username: string; password: string }): Promise => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${base}/container/image/pull`) + ws.onopen = () => { + ws.send( + JSON.stringify({ name, auth: !!auth, username: auth?.username, password: auth?.password }) + ) + resolve(ws) + } + ws.onerror = (e) => reject(e) + }) } } diff --git a/web/src/components/common/CommonEditor.vue b/web/src/components/common/CommonEditor.vue index f779100e..5e7cc375 100644 --- a/web/src/components/common/CommonEditor.vue +++ b/web/src/components/common/CommonEditor.vue @@ -2,6 +2,7 @@ import { useThemeStore } from '@/store' import { getMonaco } from '@/utils/monaco' import type * as Monaco from 'monaco-editor' +import { useThemeVars } from 'naive-ui' const value = defineModel('value', { type: String, required: true }) const props = defineProps({ @@ -27,6 +28,7 @@ const monacoRef = shallowRef() const loading = ref(true) const themeStore = useThemeStore() +const themeVars = useThemeVars() async function initEditor() { if (!containerRef.value) return @@ -37,7 +39,7 @@ async function initEditor() { editorRef.value = monaco.editor.create(containerRef.value, { value: value.value, language: props.lang, - theme: 'vs-dark', + theme: 'vs' + (themeStore.darkMode ? '-dark' : ''), readOnly: props.readOnly, automaticLayout: true, smoothScrolling: true, @@ -92,7 +94,7 @@ onBeforeUnmount(() => {