From 608b50f18a0af7b4d97df8254b5e3dc80e5c2a4d Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:44:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/auth.go | 5 +++ backend/internal/api/vnc.go | 36 +++++++++++++++--- backend/internal/lxc/lxc.go | 24 ++++++++++++ frontend/src/components/WebVNCViewer.tsx | 48 +++++++++++++++++++++++- frontend/src/services/api.ts | 3 +- install.sh | 7 ++++ 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 35ca805..a8c25d7 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -75,8 +75,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) { if subUser, _ := claims["sub_user"].(string); subUser != "" { tokenVersionFloat, hasVersion := claims["token_version"].(float64) tokenVersion := int(tokenVersionFloat) + foundSubUser := false for i := range config.AppConfig.SubUsers { if config.AppConfig.SubUsers[i].Username == subUser { + foundSubUser = true stored := config.AppConfig.SubUsers[i].TokenVersion // If stored version > 0, require token_version to match exactly. // This also rejects legacy tokens that lack token_version entirely. @@ -86,6 +88,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) { break } } + if !foundSubUser { + return nil, false + } } return claims, ok diff --git a/backend/internal/api/vnc.go b/backend/internal/api/vnc.go index 0409522..ebbbc11 100644 --- a/backend/internal/api/vnc.go +++ b/backend/internal/api/vnc.go @@ -18,7 +18,10 @@ import ( type webVNCTicket struct { ContainerName string ContainerUUID string + Username string SubUser bool + ClientIP string + UserAgent string ExpiresAt time.Time } @@ -58,13 +61,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) { return } + username, isSubUser := vncRequesterIdentity(r) ticket := randomHex(32) webVNCTickets.Lock() cleanupExpiredWebVNCTicketsLocked(time.Now()) webVNCTickets.items[ticket] = webVNCTicket{ ContainerName: c.Name, ContainerUUID: c.UUID, - SubUser: isSubUserRequest(r), + Username: username, + SubUser: isSubUser, + ClientIP: clientIP(r), + UserAgent: r.UserAgent(), ExpiresAt: time.Now().Add(60 * time.Second), } webVNCTickets.Unlock() @@ -89,7 +96,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { return } - item, ok := consumeWebVNCTicket(ticket, containerName) + item, ok := consumeWebVNCTicket(ticket, containerName, r) if !ok { http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) return @@ -137,7 +144,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { } defer ws.Close() - log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort) + log.Printf("WebVNC connected for container %s as %s (sub_user=%t) -> 127.0.0.1:%d", containerName, item.Username, item.SubUser, vncPort) done := make(chan string, 2) var writeMu sync.Mutex @@ -147,7 +154,21 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) { reason := <-done _ = vncConn.Close() _ = ws.Close() - log.Printf("WebVNC disconnected for container %s: %s", containerName, reason) + log.Printf("WebVNC disconnected for container %s as %s: %s", containerName, item.Username, reason) +} + +func vncRequesterIdentity(r *http.Request) (string, bool) { + claims, ok := claimsFromRequest(r) + if !ok { + return "api-key", false + } + if subUser, ok := claims["sub_user"].(string); ok && subUser != "" { + return subUser, true + } + if username, ok := claims["username"].(string); ok && username != "" { + return username, false + } + return "unknown", false } func webVNCTicketFromRequest(r *http.Request) string { @@ -175,7 +196,7 @@ func webVNCResponseProtocol(r *http.Request) string { return "" } -func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { +func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) { now := time.Now() webVNCTickets.Lock() defer webVNCTickets.Unlock() @@ -185,7 +206,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { return webVNCTicket{}, false } delete(webVNCTickets.items, ticket) - return item, item.ContainerName == containerName && now.Before(item.ExpiresAt) + return item, item.ContainerName == containerName && + item.ClientIP == clientIP(r) && + item.UserAgent == r.UserAgent() && + now.Before(item.ExpiresAt) } func cleanupExpiredWebVNCTicketsLocked(now time.Time) { diff --git a/backend/internal/lxc/lxc.go b/backend/internal/lxc/lxc.go index c0fbf87..2fa7af7 100644 --- a/backend/internal/lxc/lxc.go +++ b/backend/internal/lxc/lxc.go @@ -993,6 +993,27 @@ func parseSubIDRange(path, user string) (int, error) { return 0, fmt.Errorf("%s must contain a %s subordinate id range with at least 65536 ids", path, user) } +func (m *Manager) ensureUnprivilegedLXCPathAccess(lxcName string) error { + // Unprivileged container root maps to a subordinate host UID, so it needs + // execute permission on the LXC parent and container directories to reach + // rootfs. Some distributions create /var/lib/lxc as 750/700, which causes + // lxc-start to abort with "Could not access /var/lib/lxc". + for _, path := range []string{m.LxcPath, filepath.Join(m.LxcPath, lxcName)} { + info, err := os.Stat(path) + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0001 != 0 { + continue + } + if err := os.Chmod(path, mode|0001); err != nil { + return fmt.Errorf("failed to fix LXC path permissions for %s: %v", path, err) + } + } + return nil +} + func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { uidBase, gidBase, err := unprivilegedIDMap() if err != nil { @@ -1000,6 +1021,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error { } rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") + if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil { + return err + } if _, err := os.Stat(marker); err == nil { return nil } diff --git a/frontend/src/components/WebVNCViewer.tsx b/frontend/src/components/WebVNCViewer.tsx index 3fd8690..2ac948f 100644 --- a/frontend/src/components/WebVNCViewer.tsx +++ b/frontend/src/components/WebVNCViewer.tsx @@ -21,6 +21,46 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro } } + const ensureResizeObserver = () => { + if ('ResizeObserver' in window) return + + class FallbackResizeObserver { + private target: Element | null = null + private timer = 0 + private lastWidth = -1 + private lastHeight = -1 + + constructor(private callback: ResizeObserverCallback) {} + + observe = (target: Element) => { + this.target = target + this.check() + this.timer = window.setInterval(this.check, 250) + window.addEventListener('resize', this.check) + } + + unobserve = () => this.disconnect() + + disconnect = () => { + if (this.timer) window.clearInterval(this.timer) + this.timer = 0 + window.removeEventListener('resize', this.check) + this.target = null + } + + private check = () => { + if (!this.target) return + const contentRect = this.target.getBoundingClientRect() + if (contentRect.width === this.lastWidth && contentRect.height === this.lastHeight) return + this.lastWidth = contentRect.width + this.lastHeight = contentRect.height + this.callback([{ target: this.target, contentRect } as ResizeObserverEntry], this as unknown as ResizeObserver) + } + } + + ;(window as unknown as { ResizeObserver: typeof ResizeObserver }).ResizeObserver = FallbackResizeObserver as unknown as typeof ResizeObserver + } + const connect = async () => { const target = screenRef.current if (!target) return @@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro } try { - const rfb = new RFB(target, getWebVNCUrl(containerName, ticket)) + ensureResizeObserver() + const rfb = new RFB(target, getWebVNCUrl(containerName), { + wsProtocols: ['binary', `clicd-vnc-ticket.${ticket}`], + }) rfb.scaleViewport = true rfb.resizeSession = false rfb.focusOnClick = true @@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro } catch (err) { console.error(err) setStatus('error') - setErrorMsg('WebVNC 初始化失败') + const message = err instanceof Error && err.message ? `:${err.message}` : '' + setErrorMsg(`WebVNC 初始化失败${message}`) } } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9acfe92..7ef6b2c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -456,10 +456,9 @@ export const getWebSSHUrl = (containerName: string) => { return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` } -export const getWebVNCUrl = (containerName: string, ticket?: string) => { +export const getWebVNCUrl = (containerName: string) => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const params = new URLSearchParams({ container: containerName }) - if (ticket) params.set('ticket', ticket) return `${protocol}//${window.location.host}/api/vnc?${params.toString()}` } diff --git a/install.sh b/install.sh index 6a7a9b1..2d15677 100644 --- a/install.sh +++ b/install.sh @@ -784,6 +784,12 @@ setup_subids() { grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid } +configure_lxc_storage_access() { + log "Configuring LXC storage directory permissions..." + mkdir -p /var/lib/lxc + chmod 755 /var/lib/lxc +} + try_enable_project_quota() { root_src="$(findmnt -no SOURCE / 2>/dev/null || true)" root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)" @@ -967,6 +973,7 @@ run_step "配置内核网络参数" configure_kernel_networking run_step "配置运行时服务" setup_runtime_services run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network run_step "配置 UID/GID 映射" setup_subids +run_step "Configure LXC storage permissions" configure_lxc_storage_access run_step "检查 project quota" try_enable_project_quota run_step "下载发行版包" download_release_if_needed run_step "安装 CLICD 二进制" install_binary