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

+72 -41
View File
@@ -1,6 +1,7 @@
package k8sclient
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/base64"
@@ -8,9 +9,11 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/gorilla/websocket"
"gopkg.in/yaml.v3"
)
@@ -45,8 +48,11 @@ type KubeConfig struct {
// Client wraps an HTTP client for K8s API calls
type Client struct {
BaseURL string
HTTPClient *http.Client
BaseURL string
HTTPClient *http.Client
KubeconfigYAML string // raw kubeconfig for kubectl subprocess
TLSConfig *tls.Config
BearerToken string
}
// NewClient creates a K8s API client from kubeconfig YAML
@@ -98,7 +104,8 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
tlsCfg.InsecureSkipVerify = true
}
// Find user certs
// Find user creds (client certs and/or bearer token)
var bearerToken string
for _, u := range kcfg.Users {
if u.Name == userName {
if u.User.ClientCertificateData != "" && u.User.ClientKeyData != "" {
@@ -110,6 +117,9 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
}
tlsCfg.Certificates = []tls.Certificate{cert}
}
if u.User.Token != "" {
bearerToken = u.User.Token
}
break
}
}
@@ -122,8 +132,11 @@ func NewClient(kubeconfigYAML string) (*Client, error) {
}
return &Client{
BaseURL: strings.TrimRight(server, "/"),
HTTPClient: httpClient,
BaseURL: strings.TrimRight(server, "/"),
HTTPClient: httpClient,
KubeconfigYAML: kubeconfigYAML,
TLSConfig: tlsCfg,
BearerToken: bearerToken,
}, nil
}
@@ -573,7 +586,7 @@ func (c *Client) RestartDeployment(namespace, name string) error {
return c.patch(path, body, nil)
}
// --- Exec (non-interactive command) ---
// --- Exec (K8s WebSocket channel protocol) ---
// ExecResult holds the output of a pod exec command
type ExecResult struct {
@@ -581,52 +594,70 @@ type ExecResult struct {
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) {
cmdStr := ""
for i, c := range command {
if i > 0 {
cmdStr += "&command="
}
cmdStr += c
// Build exec WebSocket URL
execURL := c.BaseURL
if strings.HasPrefix(execURL, "https://") {
execURL = "wss://" + strings.TrimPrefix(execURL, "https://")
} else {
execURL = "ws://" + strings.TrimPrefix(execURL, "http://")
}
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s&stdout=true&stderr=true",
namespace, podName, cmdStr)
params := url.Values{}
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 != "" {
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 {
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.
// Fall back to SPDY upgrade attempt.
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "SPDY/3.1")
defer conn.Close()
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("exec %s: %w", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
var stdout, stderr bytes.Buffer
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break // connection closed = command finished
}
if len(msg) == 0 {
continue
}
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{
Stdout: string(body),
Stderr: "",
Stdout: stdout.String(),
Stderr: stderr.String(),
}, nil
}