Compare commits

...

4 Commits

Author SHA1 Message Date
MengMengCode 5207082cd1 release: v1.1.4 2026-06-08 15:44:58 +08:00
MengMengCode 608b50f18a 修复了一些功能 2026-06-08 15:44:35 +08:00
MengMengCode b58a6b1030 release: v1.1.3 2026-06-08 14:41:51 +08:00
MengMengCode 366f889a8c 优化安装脚本执行逻辑 2026-06-08 14:40:06 +08:00
11 changed files with 182 additions and 30 deletions
+1
View File
@@ -58,6 +58,7 @@ backend/tmp/
*.swp *.swp
*.swo *.swo
*~ *~
*.claude/
# OS # OS
.DS_Store .DS_Store
+5
View File
@@ -75,8 +75,10 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if subUser, _ := claims["sub_user"].(string); subUser != "" { if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64) tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat) tokenVersion := int(tokenVersionFloat)
foundSubUser := false
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser { if config.AppConfig.SubUsers[i].Username == subUser {
foundSubUser = true
stored := config.AppConfig.SubUsers[i].TokenVersion stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly. // If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely. // This also rejects legacy tokens that lack token_version entirely.
@@ -86,6 +88,9 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
break break
} }
} }
if !foundSubUser {
return nil, false
}
} }
return claims, ok return claims, ok
+30 -6
View File
@@ -18,7 +18,10 @@ import (
type webVNCTicket struct { type webVNCTicket struct {
ContainerName string ContainerName string
ContainerUUID string ContainerUUID string
Username string
SubUser bool SubUser bool
ClientIP string
UserAgent string
ExpiresAt time.Time ExpiresAt time.Time
} }
@@ -58,13 +61,17 @@ func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
return return
} }
username, isSubUser := vncRequesterIdentity(r)
ticket := randomHex(32) ticket := randomHex(32)
webVNCTickets.Lock() webVNCTickets.Lock()
cleanupExpiredWebVNCTicketsLocked(time.Now()) cleanupExpiredWebVNCTicketsLocked(time.Now())
webVNCTickets.items[ticket] = webVNCTicket{ webVNCTickets.items[ticket] = webVNCTicket{
ContainerName: c.Name, ContainerName: c.Name,
ContainerUUID: c.UUID, ContainerUUID: c.UUID,
SubUser: isSubUserRequest(r), Username: username,
SubUser: isSubUser,
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
ExpiresAt: time.Now().Add(60 * time.Second), ExpiresAt: time.Now().Add(60 * time.Second),
} }
webVNCTickets.Unlock() webVNCTickets.Unlock()
@@ -89,7 +96,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
item, ok := consumeWebVNCTicket(ticket, containerName) item, ok := consumeWebVNCTicket(ticket, containerName, r)
if !ok { if !ok {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized) http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return return
@@ -137,7 +144,7 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
} }
defer ws.Close() 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) done := make(chan string, 2)
var writeMu sync.Mutex var writeMu sync.Mutex
@@ -147,7 +154,21 @@ func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
reason := <-done reason := <-done
_ = vncConn.Close() _ = vncConn.Close()
_ = ws.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 { func webVNCTicketFromRequest(r *http.Request) string {
@@ -175,7 +196,7 @@ func webVNCResponseProtocol(r *http.Request) string {
return "" return ""
} }
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) { func consumeWebVNCTicket(ticket, containerName string, r *http.Request) (webVNCTicket, bool) {
now := time.Now() now := time.Now()
webVNCTickets.Lock() webVNCTickets.Lock()
defer webVNCTickets.Unlock() defer webVNCTickets.Unlock()
@@ -185,7 +206,10 @@ func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
return webVNCTicket{}, false return webVNCTicket{}, false
} }
delete(webVNCTickets.items, ticket) 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) { func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
+24
View File
@@ -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) 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 { func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
uidBase, gidBase, err := unprivilegedIDMap() uidBase, gidBase, err := unprivilegedIDMap()
if err != nil { if err != nil {
@@ -1000,6 +1021,9 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
} }
rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs") rootfsPath := filepath.Join(m.LxcPath, lxcName, "rootfs")
marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted") marker := filepath.Join(rootfsPath, ".clicd-unprivileged-shifted")
if err := m.ensureUnprivilegedLXCPathAccess(lxcName); err != nil {
return err
}
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
return nil return nil
} }
-1
View File
@@ -1 +0,0 @@
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.2" Version = "1.1.4"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+46 -2
View File
@@ -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 connect = async () => {
const target = screenRef.current const target = screenRef.current
if (!target) return if (!target) return
@@ -47,7 +87,10 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} }
try { 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.scaleViewport = true
rfb.resizeSession = false rfb.resizeSession = false
rfb.focusOnClick = true rfb.focusOnClick = true
@@ -76,7 +119,8 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setStatus('error') setStatus('error')
setErrorMsg('WebVNC 初始化失败') const message = err instanceof Error && err.message ? `${err.message}` : ''
setErrorMsg(`WebVNC 初始化失败${message}`)
} }
} }
+1 -1
View File
@@ -106,7 +106,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.2</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.4</p>
</div> </div>
</div> </div>
) )
+1 -2
View File
@@ -456,10 +456,9 @@ export const getWebSSHUrl = (containerName: string) => {
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}` 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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName }) const params = new URLSearchParams({ container: containerName })
if (ticket) params.set('ticket', ticket)
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}` return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
} }
+72 -16
View File
@@ -354,8 +354,14 @@ remove_clicd_quota_records() {
} }
remove_clicd_tmp_files() { remove_clicd_tmp_files() {
current_dir="$(pwd -P 2>/dev/null || pwd)"
for path in /tmp/clicd-* /tmp/clicd.*; do for path in /tmp/clicd-* /tmp/clicd.*; do
[ -e "$path" ] || [ -L "$path" ] || continue [ -e "$path" ] || [ -L "$path" ] || continue
abs_path="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")"
if [ "$abs_path" = "$current_dir" ]; then
log "跳过当前安装目录 $path,避免中断后续安装步骤。"
continue
fi
rm -rf "$path" rm -rf "$path"
log "已删除 $path" log "已删除 $path"
done done
@@ -674,17 +680,43 @@ EOF
sysctl --system >/dev/null 2>&1 || true sysctl --system >/dev/null 2>&1 || true
} }
systemd_unit_exists() {
unit="$1"
systemctl list-unit-files "$unit" >/dev/null 2>&1 || [ -e "/etc/systemd/system/$unit" ] || [ -e "/usr/lib/systemd/system/$unit" ] || [ -e "/lib/systemd/system/$unit" ]
}
systemd_enable_now_if_exists() {
unit="$1"
if systemd_unit_exists "$unit"; then
systemctl enable --now "$unit" >/dev/null 2>&1 || warn "服务 $unit 启动失败,将继续安装并在运行时降级处理。"
return
fi
log "未检测到 systemd 单元 $unit,跳过。"
}
systemd_existing_units() {
for unit in "$@"; do
if systemd_unit_exists "$unit"; then
printf ' %s' "$unit"
fi
done
}
setup_runtime_services() { setup_runtime_services() {
log "正在配置 LXC 和 KVM 服务..." log "正在配置 LXC 和 KVM 服务..."
if is_systemd; then if is_systemd; then
systemctl enable --now lxcfs >/dev/null 2>&1 || true systemd_enable_now_if_exists lxcfs.service
systemctl enable --now lxc-net >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc-net.service
systemctl enable --now lxc >/dev/null 2>&1 || true systemd_enable_now_if_exists lxc.service
systemctl enable --now libvirtd >/dev/null 2>&1 || true if systemd_unit_exists libvirtd.service; then
systemctl enable --now virtqemud >/dev/null 2>&1 || true systemd_enable_now_if_exists libvirtd.service
systemctl enable --now virtqemud.socket >/dev/null 2>&1 || true log "检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。"
systemctl enable --now virtlogd.socket >/dev/null 2>&1 || true else
systemd_enable_now_if_exists virtqemud.service
systemd_enable_now_if_exists virtqemud.socket
fi
systemd_enable_now_if_exists virtlogd.socket
return return
fi fi
@@ -752,17 +784,36 @@ setup_subids() {
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid 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() { try_enable_project_quota() {
root_src="$(findmnt -no SOURCE / 2>/dev/null || true)" root_src="$(findmnt -no SOURCE / 2>/dev/null || true)"
root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)" root_fs="$(findmnt -no FSTYPE / 2>/dev/null || true)"
if [ "$root_fs" != "ext4" ] || [ -z "$root_src" ] || [ ! -b "$root_src" ]; then case "$root_fs" in
warn "根文件系统 ${root_fs:-unknown} 不适合自动启用 project quota,将使用兼容模式。" ext4)
;;
xfs|btrfs|zfs|overlay|unknown|"")
log "根文件系统 ${root_fs:-unknown} 不需要/不适合自动启用 ext4 project quotaCLICD 将使用兼容磁盘限制模式。"
return
;;
*)
log "根文件系统 ${root_fs:-unknown} 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。"
return
;;
esac
if [ -z "$root_src" ] || [ ! -b "$root_src" ]; then
log "根分区来源 ${root_src:-unknown} 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
if ! has_cmd tune2fs; then if ! has_cmd tune2fs; then
warn "未找到 tune2fs,跳过 project quota 检查,将使用兼容模式。" log "未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。"
return return
fi fi
@@ -771,7 +822,7 @@ try_enable_project_quota() {
return return
fi fi
warn "ext4 project quota 未启用,磁盘限制将回退到 loopback 镜像模式。" log "ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。"
} }
download_release_if_needed() { download_release_if_needed() {
@@ -821,19 +872,23 @@ install_binary() {
} }
install_systemd_service() { install_systemd_service() {
cat > /etc/systemd/system/clicd.service << 'EOF' libvirt_after="$(systemd_existing_units libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket)"
libvirt_wants="$(systemd_existing_units libvirtd.service virtqemud.socket virtlogd.socket)"
lxc_after="$(systemd_existing_units lxc.service lxcfs.service lxc-net.service)"
cat > /etc/systemd/system/clicd.service << EOF
[Unit] [Unit]
Description=CLICD - LXC/KVM Container Manager Description=CLICD - LXC/KVM Container Manager
After=network-online.target lxc.service lxcfs.service libvirtd.service virtqemud.service virtqemud.socket virtlogd.socket After=network-online.target${lxc_after}${libvirt_after}
Wants=network-online.target libvirtd.service virtqemud.socket virtlogd.socket Wants=network-online.target${libvirt_wants}
StartLimitIntervalSec=60
StartLimitBurst=10
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/clicd server ExecStart=/usr/local/bin/clicd server
Restart=always Restart=always
RestartSec=5 RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=10
LimitNOFILE=1048576 LimitNOFILE=1048576
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -918,6 +973,7 @@ run_step "配置内核网络参数" configure_kernel_networking
run_step "配置运行时服务" setup_runtime_services run_step "配置运行时服务" setup_runtime_services
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
run_step "配置 UID/GID 映射" setup_subids 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 "检查 project quota" try_enable_project_quota
run_step "下载发行版包" download_release_if_needed run_step "下载发行版包" download_release_if_needed
run_step "安装 CLICD 二进制" install_binary run_step "安装 CLICD 二进制" install_binary