feat(k8s): M3 Pod文件上传 + ExecInPod WebSocket协议重写

M3: Pod 文件上传
- 后端: POST .../pods/:pod/files/upload (multipart, base64分块exec写入)
- 前端: PodFileBrowser 增加上传按钮 + FormData API
- 路由: 新增 Kuboard (:clusterCode) + 旧 (:id) 两套路由

重大修复: ExecInPod 从 raw HTTP SPDY 改为 WebSocket channel.k8s.io
- 旧方案: HTTP POST + SPDY upgrade → 无法解析响应(协议头缺失 + 帧解析复杂)
- 新方案: gorilla/websocket Dialer + channel.k8s.io 子协议 → 和 ExecTerminal 一致
- 影响: 文件浏览/下载/上传全部恢复正常

修复: BusyBox ls 解析
- BusyBox 不支持 --time-style 参数,默认输出 Mon DD HH:MM 格式
- 解析器改为自动检测 GNU vs BusyBox 日期格式
This commit is contained in:
attacker committed 2026-06-23 02:21:29 +08:00
1 parent 69df073d42
commit 9ab7572b8e
11 files changed
+389 -126

No files matched your search

+2 -1
View File
@@ -86,7 +86,8 @@ RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li # 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li
# bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送 # bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送
# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载 # kubectl/maven/nodejs/helm → 见「发布配置→工具配置」Tab,首次使用自动下载
# Pod文件浏览/exec 通过 WebSocket channel 协议直连 K8s API,无需 kubectl
RUN apk --no-cache add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx RUN apk --no-cache add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx
# Maven 阿里云镜像加速(maven 懒加载后生效) # Maven 阿里云镜像加速(maven 懒加载后生效)
+2 -2
View File
@@ -26,7 +26,7 @@
| Deployment 历史版本/回滚 | ✅ revisions/rollback | ✅ | ✅ | | Deployment 历史版本/回滚 | ✅ revisions/rollback | ✅ | ✅ |
| Pod 日志(实时流) | ✅ GetPodLogs | ✅ PodLogsViewer fetch stream | ✅ | | Pod 日志(实时流) | ✅ GetPodLogs | ✅ PodLogsViewer fetch stream | ✅ |
| Pod 终端 exec | ✅ ExecTerminal(WS) | ✅ PodExecTerminal bash/sh/ash | ✅ | | Pod 终端 exec | ✅ ExecTerminal(WS) | ✅ PodExecTerminal bash/sh/ash | ✅ |
| Pod 文件浏览 + 下载 | ✅ files/list + files/download | ✅ PodFileBrowser | ✅ | | Pod 文件浏览 + 下载 + 上传 | ✅ files/list + files/download + files/upload | ✅ PodFileBrowser | ✅ |
| YAML 查看/编辑 | ✅ GetYAML + UpdateYAML | ✅ YamlViewer | ✅ | | YAML 查看/编辑 | ✅ GetYAML + UpdateYAML | ✅ YamlViewer | ✅ |
| ReplicaSet 列表 | ✅ | ✅ | ✅ | | ReplicaSet 列表 | ✅ | ✅ | ✅ |
| Service/Ingress 列表 | ✅ | ✅ | ✅ | | Service/Ingress 列表 | ✅ | ✅ | ✅ |
@@ -41,7 +41,7 @@
| 功能 | 优先级 | 说明 | | 功能 | 优先级 | 说明 |
|------|:------:|------| |------|:------:|------|
| Pod 文件上传 | P2 | 只有下载,缺上传 | | Pod 文件上传 | ✅ | P2 | ✅ 已完成 M3 |
| CI/CD 集成菜单项 | P3 | 菜单有按钮但未接线 | | CI/CD 集成菜单项 | P3 | 菜单有按钮但未接线 |
| Deployment 暂停更新 | P3 | 菜单无此项 | | Deployment 暂停更新 | P3 | 菜单无此项 |
+8 -5
View File
@@ -38,16 +38,19 @@
--- ---
### M3: Pod 文件上传 [pending] ### M3: Pod 文件上传 [done]
**目标**: PodFileBrowser 增加上传能力 **目标**: PodFileBrowser 增加上传能力
**改动**: **改动**:
- 后端新增 `POST .../pods/:pod/files/upload` — 用 exec tar 解包写入 - 后端新增 `POST .../pods/:pod/files/upload` — multipart 表单接收,base64 分块 exec 写入
- 前端 PodFileBrowser 增加上传按钮 + 拖拽上传 - 前端 PodFileBrowser 增加上传按钮 + FormData 上传
- **重大修复**: `ExecInPod` 从 raw HTTP SPDY(不工作)改为 WebSocket channel.k8s.io 协议(和 ExecTerminal 一致)
- **修复**: BusyBox `ls` 不支持 `--time-style`,解析器改为兼容 BusyBox 默认日期格式
**验收标准**: **验收标准**:
- [ ] 可上传文件到 Pod 容器内 - [x] 可上传文件到 Pod 容器内
- [ ] 上传后文件列表刷新可见 - [x] 上传后文件列表刷新可见
- [x] verify 字段返回正确文件大小
--- ---
+7 -1
View File
@@ -84,7 +84,13 @@ curl -s "http://127.0.0.1:8080/api/v1/kubernetes-clusters/1/pods" \
# 上传文件 # 上传文件
echo "test content" > /tmp/test-upload.txt echo "test content" > /tmp/test-upload.txt
curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/1/namespace/devops/pods/aiops-xxx/files/upload?path=/tmp&container=aiops" \ curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/1/namespace/devops/pods/aiops-xxx/files/upload?path=/tmp&container=aiops" \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer ***" \
-F "file=@/tmp/test-upload.txt" -F "file=@/tmp/test-upload.txt"
# 期望: 200 + 文件出现在列表中 # 期望: 200 + 文件出现在列表中
``` ```
### 通过标准
- [x] 上传 API 返回 200 + verify 字段匹配文件大小
- [x] 文件列表 (ls) 能看到上传的文件
- [x] ExecInPod 使用 WebSocket channel.k8s.io 协议(不再用 SPDY raw HTTP)
- [x] BusyBox ls 解析兼容(Mon DD HH:MM 格式)
+2 -1
View File
@@ -78,7 +78,8 @@ RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li # 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li
# bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送 # bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送
# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载 # kubectl/maven/nodejs/helm → 见「发布配置→工具配置」Tab,首次使用自动下载
# Pod文件浏览/exec 通过 WebSocket channel 协议直连 K8s API,无需 kubectl
RUN --mount=type=cache,target=/var/cache/apk \ RUN --mount=type=cache,target=/var/cache/apk \
apk add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx apk add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx
+219 -68
View File
@@ -1,11 +1,13 @@
package handlers package handlers
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url"
"path" "path"
"sort" "sort"
"strconv" "strconv"
@@ -16,6 +18,7 @@ import (
"ezops/pkg/kubernetesclient" "ezops/pkg/kubernetesclient"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -916,7 +919,22 @@ func (h *KubernetesHandler) GetPodLogs(c *gin.Context) {
// WebSocket Exec Terminal (交互式 bash) // WebSocket Exec Terminal (交互式 bash)
// ============================================================================ // ============================================================================
// ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s SPDY exec // ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s WebSocket channel 协议
//
// 直接通过 K8s API 的 exec 端点(WebSocket channel.k8s.io v4 协议)建立
// 交互式终端,无需在容器中安装 kubectl。
//
// 数据流:
//
// Frontend WS (JSON) ←→ Backend ←→ K8s API WS (channel frames)
//
// K8s channel 协议: 每条消息 [1字节 channel ID] + [payload]
//
// 0 = stdin (client→server)
// 1 = stdout (server→client)
// 2 = stderr (server→client)
// 3 = error (server→client, JSON)
// 4 = resize (client→server, JSON {"Height","Width"})
func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { func (h *KubernetesHandler) ExecTerminal(c *gin.Context) {
clusterID, _ := strconv.Atoi(c.Param("id")) clusterID, _ := strconv.Atoi(c.Param("id"))
namespace := c.Param("namespace") namespace := c.Param("namespace")
@@ -927,102 +945,128 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) {
command = "sh" command = "sh"
} }
client, _, err := h.getK8sClient(clusterID) client, cluster, err := h.getK8sClient(clusterID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
return return
} }
// Build exec URL with SPDY upgrade // Build exec WebSocket URL — https→wss / http→ws
execPath := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?command=%s&stdin=true&stdout=true&stderr=true&tty=true", execURL := client.BaseURL
namespace, podName, command) if strings.HasPrefix(execURL, "https://") {
execURL = "wss://" + strings.TrimPrefix(execURL, "https://")
} else {
execURL = "ws://" + strings.TrimPrefix(execURL, "http://")
}
params := url.Values{}
params.Set("command", command)
params.Set("stdin", "true")
params.Set("stdout", "true")
params.Set("stderr", "true")
params.Set("tty", "true")
if container != "" { if container != "" {
execPath += "&container=" + container params.Set("container", container)
} }
execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s",
url.PathEscape(namespace), url.PathEscape(podName), params.Encode())
// Open SPDY connection to K8s API // Dial K8s API server via WebSocket with channel protocol subprotocol
k8sReq, err := http.NewRequest("POST", client.BaseURL+execPath, nil) k8sDialer := &websocket.Dialer{
TLSClientConfig: client.TLSConfig,
Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"},
HandshakeTimeout: 10 * time.Second,
}
k8sHeader := http.Header{}
if client.BearerToken != "" {
k8sHeader.Set("Authorization", "Bearer "+client.BearerToken)
}
k8sConn, _, err := k8sDialer.Dial(execURL, k8sHeader)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "build request: " + err.Error()}) log.Printf("[K8sExec] dial K8s exec WS failed: %v", err)
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s exec 连接失败: " + err.Error()})
return return
} }
k8sReq.Header.Set("Connection", "Upgrade") defer k8sConn.Close()
k8sReq.Header.Set("Upgrade", "SPDY/3.1")
k8sReq.Header.Set("X-Stream-Protocol-Version", "v4.channel.k8s.io")
k8sReq.Header.Set("X-Stream-Protocol-Version", "v3.channel.k8s.io")
// Use raw TCP dial for SPDY upgrade // Upgrade frontend connection to WebSocket
dialer := client.HTTPClient.Transport.(*http.Transport).DialContext frontendConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
conn, err := dialer(c.Request.Context(), "tcp", strings.TrimPrefix(strings.TrimPrefix(client.BaseURL, "https://"), "http://"))
if err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "dial K8s: " + err.Error()})
return
}
defer conn.Close()
if err := k8sReq.Write(conn); err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "write request: " + err.Error()})
return
}
// Read the 101 Switching Protocols response
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil || n == 0 {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "read upgrade response: " + fmt.Sprint(err)})
return
}
respStr := string(buf[:n])
if !strings.Contains(respStr, "101") {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "upgrade failed: " + respStr[:min(len(respStr), 200)]})
return
}
// Upgrade Gin to WebSocket
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
return return
} }
defer wsConn.Close() defer frontendConn.Close()
// Bidirectional proxy: WS ↔ SPDY
done := make(chan struct{}, 2) done := make(chan struct{}, 2)
// WS → SPDY (stdin) // Frontend WS → K8s WS (stdin + resize)
go func() { go func() {
defer func() { done <- struct{}{} }() defer func() { done <- struct{}{} }()
for { for {
_, msg, err := wsConn.ReadMessage() msgType, msg, err := frontendConn.ReadMessage()
if err != nil { if err != nil {
return return
} }
// SPDY stdin frame: channel 0 _ = msgType
frame := make([]byte, len(msg)+8)
frame[0] = 0 // channel 0 (stdin) // Parse frontend JSON protocol: {status, data} or {status, cols, rows}
copy(frame[8:], msg) var wsMsg WSMessage
if _, err := conn.Write(frame); err != nil { if err := json.Unmarshal(msg, &wsMsg); err != nil {
return continue
}
if wsMsg.Status == 0 && wsMsg.Data != "" {
// stdin: prepend channel 0 byte
frame := make([]byte, 1+len(wsMsg.Data))
frame[0] = 0 // channel 0 = stdin
copy(frame[1:], wsMsg.Data)
if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
} else if wsMsg.Status == 1 && wsMsg.Cols > 0 {
// resize: channel 4 + JSON
resizeJSON, _ := json.Marshal(struct {
Height uint16 `json:"Height"`
Width uint16 `json:"Width"`
}{Height: uint16(wsMsg.Rows), Width: uint16(wsMsg.Cols)})
frame := make([]byte, 1+len(resizeJSON))
frame[0] = 4 // channel 4 = resize
copy(frame[1:], resizeJSON)
if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
} }
} }
}() }()
// SPDY → WS (stdout/stderr) // K8s WS → Frontend WS (stdout/stderr/error)
go func() { go func() {
defer func() { done <- struct{}{} }() defer func() { done <- struct{}{} }()
buf := make([]byte, 4096)
for { for {
n, err := conn.Read(buf) msgType, msg, err := k8sConn.ReadMessage()
if err != nil { if err != nil {
// K8s connection closed — notify frontend
frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[33m[K8s 连接已关闭]\x1b[0m\r\n"})
return return
} }
if n > 8 { _ = msgType
// Skip SPDY header (8 bytes), send data if len(msg) == 0 {
wsConn.WriteMessage(1, buf[8:n]) // 1 = TextMessage continue
}
channel := msg[0]
payload := msg[1:]
switch channel {
case 1, 2: // stdout / stderr → forward to frontend as terminal output
frontendConn.WriteJSON(WSMessage{Status: 0, Data: string(payload)})
case 3: // error → forward as error message
errMsg := string(payload)
frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[31m" + errMsg + "\x1b[0m\r\n"})
} }
} }
}() }()
<-done <-done
_ = cluster
} }
// ============================================================================ // ============================================================================
@@ -2623,8 +2667,8 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
if req.Path == "" { req.Path = "/" } if req.Path == "" { req.Path = "/" }
client, _, err := h.getK8sClient(clusterID) client, _, err := h.getK8sClient(clusterID)
if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return }
// ls -la 配合自定义 time-style 获取清晰可解析的字段 // ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format
cmd := []string{"ls", "-la", "--time-style=+%Y-%m-%dT%H:%M:%S", req.Path} cmd := []string{"ls", "-la", req.Path}
result, err := client.ExecInPod(ns, podName, req.Container, cmd) result, err := client.ExecInPod(ns, podName, req.Container, cmd)
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return } if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return }
@@ -2636,7 +2680,7 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
IsDir bool `json:"is_dir"` IsDir bool `json:"is_dir"`
} }
files := []FileInfo{} files := []FileInfo{}
output := cleanExecOutput(result.Stdout) output := result.Stdout // WebSocket output is already clean text
for _, line := range strings.Split(output, "\n") { for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "total ") { continue } if line == "" || strings.HasPrefix(line, "total ") { continue }
@@ -2645,13 +2689,20 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) {
mode := fields[0] mode := fields[0]
if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue } if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue }
size := fields[4] size := fields[4]
modTime := fields[5] // BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time)
// 文件名可能含空格:定位到 modTime 之后的内容作为 name // GNU ls --time-style: "2026-06-23T02:17:00" (1 field)
name := "" // Detect format: if fields[5] contains ":" it's time-only (BusyBox style)
if idx := strings.Index(line, modTime); idx >= 0 { var name, modTime string
name = strings.TrimSpace(line[idx+len(modTime):]) if strings.Contains(fields[5], ":") || strings.Contains(fields[5], "T") {
// GNU style: single timestamp field
modTime = fields[5]
name = strings.Join(fields[6:], " ")
} else {
// BusyBox style: "Mon DD HH:MM" = 3 fields starting at index 5
modTime = strings.Join(fields[5:8], " ")
name = strings.Join(fields[8:], " ")
} }
if name == "" { name = strings.Join(fields[6:], " ") } if name == "" || name == "." || name == ".." { continue }
files = append(files, FileInfo{ files = append(files, FileInfo{
Name: name, Name: name,
Size: size, Size: size,
@@ -2679,9 +2730,109 @@ func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) {
cmd := []string{"cat", filePath} cmd := []string{"cat", filePath}
result, err := client.ExecInPod(ns, podName, container, cmd) result, err := client.ExecInPod(ns, podName, container, cmd)
if err != nil { c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return } if err != nil { c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return }
content := cleanExecOutput(result.Stdout) content := result.Stdout
fileName := path.Base(filePath) fileName := path.Base(filePath)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Type", "application/octet-stream") c.Header("Content-Type", "application/octet-stream")
c.String(200, content) c.String(200, content)
} }
// UploadPodFileNS 上传文件到 Pod 容器内指定目录(通过 exec base64 分块写入)
func (h *KubernetesHandler) UploadPodFileNS(c *gin.Context) {
h.uploadPodFileWithNS(c, c.Param("namespace"))
}
func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) {
clusterID, _ := strconv.Atoi(c.Param("id"))
if clusterID == 0 {
clusterID = h.resolveClusterCode(c.Param("clusterCode"))
}
podName := c.Param("podName")
container := c.PostForm("container")
targetDir := c.PostForm("path")
if targetDir == "" {
targetDir = "/"
}
// 读取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(400, models.Response{Code: 400, Message: "文件上传失败: " + err.Error()})
return
}
defer file.Close()
// 限制文件大小 10MB
const maxUploadSize = 10 << 20 // 10MB
if header.Size > maxUploadSize {
c.JSON(400, models.Response{Code: 400, Message: fmt.Sprintf("文件过大: %d bytes, 上限 %d bytes", header.Size, maxUploadSize)})
return
}
content, err := io.ReadAll(file)
if err != nil {
c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()})
return
}
client, _, err := h.getK8sClient(clusterID)
if err != nil {
c.JSON(500, models.Response{Code: 500, Message: err.Error()})
return
}
// 目标完整路径
targetFile := strings.TrimRight(targetDir, "/") + "/" + header.Filename
// 确保目标目录存在
_, _ = client.ExecInPod(ns, podName, container, []string{"mkdir", "-p", targetDir})
// base64 分块写入:每块 3000 字节 base64 (~2250 bytes 二进制),保证 URL 长度安全
encoded := base64.StdEncoding.EncodeToString(content)
const chunkSize = 3000
first := true
for i := 0; i < len(encoded); i += chunkSize {
end := i + chunkSize
if end > len(encoded) {
end = len(encoded)
}
chunk := encoded[i:end]
var cmd []string
if first {
// 首块:创建/覆盖文件
cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d > '%s'", chunk, targetFile)}
first = false
} else {
// 后续块:追加
cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d >> '%s'", chunk, targetFile)}
}
result, err := client.ExecInPod(ns, podName, container, cmd)
if err != nil {
stderr := ""
if result != nil {
stderr = result.Stderr
}
c.JSON(500, models.Response{Code: 500, Message: fmt.Sprintf("写入文件失败 (chunk %d): %s\n%s", i/chunkSize, err.Error(), stderr)})
return
}
}
// 验证文件大小
verifyResult, verifyErr := client.ExecInPod(ns, podName, container, []string{"stat", "-c", "%s", targetFile})
writtenSize := ""
if verifyErr == nil && verifyResult != nil {
writtenSize = strings.TrimSpace(verifyResult.Stdout)
}
log.Printf("[UploadPodFile] %s/%s → %s (%d bytes, %d chunks)", ns, podName, targetFile, len(content), (len(encoded)+chunkSize-1)/chunkSize)
c.JSON(200, models.Response{
Code: 200,
Message: "上传成功",
Data: gin.H{
"path": targetFile,
"name": header.Filename,
"size": len(content),
"verify": writtenSize,
},
})
}
+2
View File
@@ -317,6 +317,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// Pod 文件浏览 / 下载 / 删除 // Logan.Li // Pod 文件浏览 / 下载 / 删除 // Logan.Li
clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS)
clusters.GET("/:id/namespaces/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) clusters.GET("/:id/namespaces/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS)
clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/upload", k8sHandler.UploadPodFileNS)
clusters.DELETE("/:id/namespaces/:namespace/pods/:podName", k8sHandler.DeletePodNS) clusters.DELETE("/:id/namespaces/:namespace/pods/:podName", k8sHandler.DeletePodNS)
// Namespace // Logan.Li // Namespace // Logan.Li
clusters.GET("/:id/namespaces", k8sHandler.ListNamespaces) clusters.GET("/:id/namespaces", k8sHandler.ListNamespaces)
@@ -369,6 +370,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
// 🆕 Pod 文件浏览 / 下载 / 删除 // Logan.Li // 🆕 Pod 文件浏览 / 下载 / 删除 // Logan.Li
kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS)
kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS)
kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/upload", k8sHandler.UploadPodFileNS)
kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pods/:podName", k8sHandler.DeletePodNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pods/:podName", k8sHandler.DeletePodNS)
// ConfigMap / Secret // ConfigMap / Secret
+72 -41
View File
@@ -1,6 +1,7 @@
package k8sclient package k8sclient
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
@@ -8,9 +9,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
"github.com/gorilla/websocket"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -45,8 +48,11 @@ type KubeConfig struct {
// Client wraps an HTTP client for K8s API calls // Client wraps an HTTP client for K8s API calls
type Client struct { type Client struct {
BaseURL string BaseURL string
HTTPClient *http.Client HTTPClient *http.Client
KubeconfigYAML string // raw kubeconfig for kubectl subprocess
TLSConfig *tls.Config
BearerToken string
} }
// NewClient creates a K8s API client from kubeconfig YAML // NewClient creates a K8s API client from kubeconfig YAML
@@ -98,7 +104,8 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
tlsCfg.InsecureSkipVerify = true tlsCfg.InsecureSkipVerify = true
} }
// Find user certs // Find user creds (client certs and/or bearer token)
var bearerToken string
for _, u := range kcfg.Users { for _, u := range kcfg.Users {
if u.Name == userName { if u.Name == userName {
if u.User.ClientCertificateData != "" && u.User.ClientKeyData != "" { if u.User.ClientCertificateData != "" && u.User.ClientKeyData != "" {
@@ -110,6 +117,9 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
} }
tlsCfg.Certificates = []tls.Certificate{cert} tlsCfg.Certificates = []tls.Certificate{cert}
} }
if u.User.Token != "" {
bearerToken = u.User.Token
}
break break
} }
} }
@@ -122,8 +132,11 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
} }
return &Client{ return &Client{
BaseURL: strings.TrimRight(server, "/"), BaseURL: strings.TrimRight(server, "/"),
HTTPClient: httpClient, HTTPClient: httpClient,
KubeconfigYAML: kubeconfigYAML,
TLSConfig: tlsCfg,
BearerToken: bearerToken,
}, nil }, nil
} }
@@ -573,7 +586,7 @@ func (c *Client) RestartDeployment(namespace, name string) error {
return c.patch(path, body, nil) return c.patch(path, body, nil)
} }
// --- Exec (non-interactive command) --- // --- Exec (K8s WebSocket channel protocol) ---
// ExecResult holds the output of a pod exec command // ExecResult holds the output of a pod exec command
type ExecResult struct { type ExecResult struct {
@@ -581,52 +594,70 @@ type ExecResult struct {
Stderr string `json:"stderr"` Stderr string `json:"stderr"`
} }
// ExecInPod runs a command in a pod container and returns stdout/stderr // ExecInPod runs a command in a pod container and returns stdout/stderr.
// Uses K8s WebSocket channel protocol (channel.k8s.io) — no kubectl needed.
func (c *Client) ExecInPod(namespace, podName, container string, command []string) (*ExecResult, error) { func (c *Client) ExecInPod(namespace, podName, container string, command []string) (*ExecResult, error) {
cmdStr := "" // Build exec WebSocket URL
for i, c := range command { execURL := c.BaseURL
if i > 0 { if strings.HasPrefix(execURL, "https://") {
cmdStr += "&command=" execURL = "wss://" + strings.TrimPrefix(execURL, "https://")
} } else {
cmdStr += c execURL = "ws://" + strings.TrimPrefix(execURL, "http://")
} }
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s&stdout=true&stderr=true", params := url.Values{}
namespace, podName, cmdStr) for _, cmd := range command {
params.Add("command", cmd)
}
params.Set("stdin", "false")
params.Set("stdout", "true")
params.Set("stderr", "true")
params.Set("tty", "false")
if container != "" { if container != "" {
path += "&container=" + container params.Set("container", container)
}
execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s",
url.PathEscape(namespace), url.PathEscape(podName), params.Encode())
dialer := websocket.Dialer{
TLSClientConfig: c.TLSConfig,
Subprotocols: []string{"channel.k8s.io"},
HandshakeTimeout: 10 * time.Second,
}
header := http.Header{}
if c.BearerToken != "" {
header.Set("Authorization", "Bearer "+c.BearerToken)
} }
req, err := http.NewRequest("POST", c.BaseURL+path, nil) conn, _, err := dialer.Dial(execURL, header)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("K8s exec WS dial: %w", err)
} }
// SPDY is the default for exec, but modern K8s also supports WebSocket. defer conn.Close()
// Fall back to SPDY upgrade attempt.
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "SPDY/3.1")
resp, err := c.HTTPClient.Do(req) var stdout, stderr bytes.Buffer
if err != nil { for {
return nil, fmt.Errorf("exec %s: %w", path, err) _, msg, err := conn.ReadMessage()
} if err != nil {
defer resp.Body.Close() break // connection closed = command finished
}
body, err := io.ReadAll(resp.Body) if len(msg) == 0 {
if err != nil { continue
return nil, err }
channel := msg[0]
payload := msg[1:]
switch channel {
case 1:
stdout.Write(payload)
case 2:
stderr.Write(payload)
case 3:
stderr.Write(payload)
}
} }
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("exec error %d: %s", resp.StatusCode, string(body[:min(len(body), 500)]))
}
// The exec response is multiplexed SPDY frames. For simple commands,
// the output comes in channel 1 (stdout) and channel 2 (stderr).
// Since we're not doing full SPDY parsing, we return the raw output
// and let the caller handle it.
return &ExecResult{ return &ExecResult{
Stdout: string(body), Stdout: stdout.String(),
Stderr: "", Stderr: stderr.String(),
}, nil }, nil
} }
@@ -107,19 +107,17 @@ export function PodExecTerminal({
term.clear(); term.clear();
term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`); term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`);
const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined); const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell);
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
setConnState('connected'); setConnState('connected');
term.reset(); term.reset();
// 发送初始 resize + 启动 shell // 发送初始 resize(command/shell 已在 WS URL 参数中指定)
try { try {
const { cols, rows } = term; const { cols, rows } = term;
ws.send(JSON.stringify({ status: 1, cols, rows })); ws.send(JSON.stringify({ status: 1, cols, rows }));
// 启动 shell 进程:标准 remote exec 协议 (status:0 data)
ws.send(JSON.stringify({ status: 0, data: `${shell}\r` }));
} catch { /* noop */ } } catch { /* noop */ }
setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100); setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100);
}; };
@@ -15,7 +15,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage, Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage,
File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp, File, Download, Upload, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive, RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
AlertCircle, FileType, Binary, AlertCircle, FileType, Binary,
} from 'lucide-react'; } from 'lucide-react';
@@ -156,6 +156,10 @@ export function PodFileBrowser({
// 预览 // 预览
const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null); const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null);
// 上传
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 选中的条目(用于高亮) // 选中的条目(用于高亮)
const [selected, setSelected] = useState<string>(''); const [selected, setSelected] = useState<string>('');
@@ -287,6 +291,26 @@ export function PodFileBrowser({
toast({ title: '已开始下载', description: path }); toast({ title: '已开始下载', description: path });
}, [clusterCode, namespace, podName, container, toast]); }, [clusterCode, namespace, podName, container, toast]);
// 上传文件
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
toast({ title: '上传成功', description: `${file.name} → ${currentPath}/${file.name}` });
}
// 刷新文件列表
loadPath(currentPath);
} catch (e: any) {
toast({ title: '上传失败', description: e?.message || String(e), variant: 'destructive' });
} finally {
setUploading(false);
// 清空 input 允许重复上传同一文件
if (fileInputRef.current) fileInputRef.current.value = '';
}
}, [clusterCode, namespace, podName, container, currentPath, loadPath, toast]);
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]); const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
return ( return (
@@ -331,6 +355,23 @@ export function PodFileBrowser({
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新"> <Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} /> <RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
title="上传文件"
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={(e) => handleUpload(e.target.files)}
/>
<button <button
onClick={() => setFullscreen(!fullscreen)} onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]" className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
+31 -2
View File
@@ -249,12 +249,13 @@ export const kubernetesApi = {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container }); return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container });
}, },
// 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>) // 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>)
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token= // ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&command=&token=
buildPodExecWsUrl: ( buildPodExecWsUrl: (
clusterId: number | string, clusterId: number | string,
namespace: string, namespace: string,
podName: string, podName: string,
container?: string container?: string,
command?: string
) => { ) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`; const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
@@ -262,6 +263,7 @@ export const kubernetesApi = {
const token = useUserStore.getState().token || ''; const token = useUserStore.getState().token || '';
const params = new URLSearchParams(); const params = new URLSearchParams();
if (container) params.set('container', container); if (container) params.set('container', container);
if (command) params.set('command', command);
if (token) params.set('token', token); if (token) params.set('token', token);
const qs = params.toString(); const qs = params.toString();
return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`; return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
@@ -309,6 +311,33 @@ export const kubernetesApi = {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.text(); return resp.text();
}, },
// 上传文件到 Pod 容器内指定目录
uploadPodFile: async (
clusterCode: string,
namespace: string,
podName: string,
targetPath: string,
file: File,
container?: string,
) => {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetPath);
if (container) formData.append('container', container);
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
const resp = await fetch(
`${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/upload`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
},
);
const json = await resp.json();
if (!resp.ok || json.code !== 200) throw new Error(json.message || `HTTP ${resp.status}`);
return json.data;
},
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => { scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => {
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas }); return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas });
}, },