功能:新增云管API与Web控制台并完成现场接入

This commit is contained in:
songyiming committed 2026-11-09 17:54:52 +08:00
1 parent 18a9ce995d
commit 3fb7b4da8c
79 files changed
+17367 -3

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
VDI_MODE=production
VDI_LISTEN=127.0.0.1:8080
DATABASE_URL=postgres://vdi:replace-me@127.0.0.1:5432/vdi?sslmode=require
VDI_JWT_SECRET=replace-with-at-least-32-random-characters
VDI_ADMIN_USERNAME=admin
VDI_ADMIN_PASSWORD=replace-with-strong-initial-password
# PVE_URL is the HTTPS origin. CA verification is always enabled.
# PVE_URL=https://pve.example.internal:8006
# PVE_TOKEN_ID=vdi@pve!console
# PVE_TOKEN_SECRET=replace-me
# PVE_CA_FILE=/run/secrets/pve-ca.pem
-1
View File
@@ -1 +0,0 @@
# Keep the monorepo directory structure in Git.
+13
View File
@@ -0,0 +1,13 @@
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY platform/go.mod platform/go.sum ./
RUN go mod download
COPY platform/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /vdi-api ./cmd/api-gateway
FROM alpine:3.23
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 vdi
COPY --from=build /vdi-api /usr/local/bin/vdi-api
USER vdi
EXPOSE 8080
ENTRYPOINT ["vdi-api"]
+14
View File
@@ -0,0 +1,14 @@
# VDI 云端服务
Go API 网关和第一阶段业务模块。运行、接口、真实 PVE 接入与当前限制见[开发说明](../docs/cloud-platform-development.md)。
```text
cmd/api-gateway/ 服务入口与优雅退出
internal/config/ 环境配置校验
internal/model/ 对外数据类型
internal/store/ PostgreSQL 初始化、账号与业务存储
internal/server/ 认证、资源/桌面、终端、授权、任务与审计
pkg/proxmox/ 真实 PVE API 适配与异步任务跟踪
```
配置参考 `.env.example`,变量由运行环境注入;程序不会自动读取 `.env`。不要提交访问凭据。
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/server"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
startup, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
db, err := store.Open(startup, cfg.DatabaseURL)
if err != nil {
log.Fatal("database initialization failed: ", err)
}
defer db.Close()
if err = store.Bootstrap(startup, db, cfg.Mode, cfg.AdminUsername, cfg.AdminPassword); err != nil {
log.Fatal("bootstrap failed: ", err)
}
var pve *proxmox.Client
if cfg.PVEURL != "" {
pve, err = proxmox.New(cfg.PVEURL, cfg.PVETokenID, cfg.PVETokenSecret, cfg.PVECAFile)
if err != nil {
log.Fatal(err)
}
}
s := server.New(cfg, db, pve)
go s.RunWorker(ctx)
if pve != nil {
go s.RunSync(ctx)
}
httpServer := &http.Server{Addr: cfg.Address, Handler: s.Router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
log.Printf("VDI API listening on %s (mode=%s)", cfg.Address, cfg.Mode)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Print(err)
stop()
}
}()
<-ctx.Done()
shutdown, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = httpServer.Shutdown(shutdown)
}
+47
View File
@@ -0,0 +1,47 @@
module vdi-cloud/platform
go 1.25.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/jackc/pgx/v5 v5.7.6
github.com/jmoiron/sqlx v1.4.0
golang.org/x/crypto v0.43.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+109
View File
@@ -0,0 +1,109 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"fmt"
"os"
"strings"
)
type Config struct {
Mode, Address, DatabaseURL, JWTSecret, AdminUsername, AdminPassword string
PVEURL, PVETokenID, PVETokenSecret, PVECAFile string
}
func Load() (Config, error) {
c := Config{Mode: env("VDI_MODE", "production"), Address: env("VDI_LISTEN", "127.0.0.1:8080"), DatabaseURL: os.Getenv("DATABASE_URL"), JWTSecret: os.Getenv("VDI_JWT_SECRET"), AdminUsername: os.Getenv("VDI_ADMIN_USERNAME"), AdminPassword: os.Getenv("VDI_ADMIN_PASSWORD"), PVEURL: os.Getenv("PVE_URL"), PVETokenID: os.Getenv("PVE_TOKEN_ID"), PVETokenSecret: os.Getenv("PVE_TOKEN_SECRET"), PVECAFile: os.Getenv("PVE_CA_FILE")}
if c.Mode != "demo" && c.Mode != "production" {
return c, fmt.Errorf("VDI_MODE must be demo or production")
}
if c.DatabaseURL == "" {
return c, fmt.Errorf("DATABASE_URL is required")
}
if len(c.JWTSecret) < 32 {
return c, fmt.Errorf("VDI_JWT_SECRET must contain at least 32 characters")
}
if c.Mode == "production" && strings.TrimSpace(c.AdminUsername) == "" {
return c, fmt.Errorf("production requires VDI_ADMIN_USERNAME")
}
if c.PVEURL != "" && (c.PVETokenID == "" || c.PVETokenSecret == "") {
return c, fmt.Errorf("PVE_TOKEN_ID and PVE_TOKEN_SECRET are required with PVE_URL")
}
return c, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+91
View File
@@ -0,0 +1,91 @@
package model
import "time"
type Administrator struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
DisplayName string `json:"displayName" db:"display_name"`
Role string `json:"role" db:"role"`
PasswordHash string `json:"-" db:"password_hash"`
}
type Desktop struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
OS string `json:"os" db:"os"`
Node string `json:"node" db:"node"`
VMID int `json:"vmid" db:"vmid"`
Pool string `json:"pool" db:"pool"`
PowerState string `json:"powerState" db:"power_state"`
ServiceState string `json:"serviceState" db:"service_state"`
Provider string `json:"provider" db:"provider"`
Endpoint string `json:"endpoint" db:"endpoint"`
App string `json:"app" db:"app"`
Width int `json:"width" db:"width"`
Height int `json:"height" db:"height"`
FPS int `json:"fps" db:"fps"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
type Terminal struct {
ID string `json:"id" db:"id"`
SN string `json:"sn" db:"sn"`
Name string `json:"name" db:"name"`
Group string `json:"group" db:"group_name"`
Status string `json:"status" db:"status"`
AgentVersion string `json:"agentVersion" db:"agent_version"`
LastSeen *time.Time `json:"lastSeen" db:"last_seen"`
SessionDesktopID *string `json:"sessionDesktopId" db:"session_desktop_id"`
SessionState string `json:"sessionState" db:"session_state"`
ConfigVersion int `json:"configVersion" db:"config_version"`
FPS *float64 `json:"fps" db:"fps"`
RTT *float64 `json:"rtt" db:"rtt"`
}
type User struct {
ID string `json:"id" db:"id"`
Username string `json:"username" db:"username"`
DisplayName string `json:"displayName" db:"display_name"`
Enabled bool `json:"enabled" db:"enabled"`
TerminalID *string `json:"terminalId" db:"terminal_id"`
DesktopIDs []string `json:"desktopIds"`
}
type Resource struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Status string `json:"status" db:"status"`
CPU float64 `json:"cpu" db:"cpu"`
Memory float64 `json:"memory" db:"memory"`
Storage float64 `json:"storage" db:"storage"`
Provider string `json:"provider" db:"provider"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
type Task struct {
ID string `json:"id" db:"id"`
Kind string `json:"kind" db:"kind"`
Action string `json:"action" db:"action"`
TargetID string `json:"targetId" db:"target_id"`
TargetName string `json:"targetName" db:"target_name"`
Actor string `json:"actor" db:"actor"`
Status string `json:"status" db:"status"`
Message string `json:"message" db:"message"`
ProviderRef string `json:"providerRef" db:"provider_ref"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
IdempotencyKey string `json:"-" db:"idempotency_key"`
}
type Audit struct {
ID string `json:"id" db:"id"`
Actor string `json:"actor" db:"actor"`
Action string `json:"action" db:"action"`
Target string `json:"target" db:"target"`
Result string `json:"result" db:"result"`
RequestID string `json:"requestId" db:"request_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
type DeviceDiagnostics struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
BootID string `json:"bootId" binding:"required,max=80"`
UptimeSeconds float64 `json:"uptimeSeconds" binding:"min=0"`
KioskState string `json:"kioskState" binding:"required,max=80"`
LogTail string `json:"logTail" binding:"max=16384"`
}
+553
View File
@@ -0,0 +1,553 @@
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"log"
"net"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
func (s *Server) overview(c *gin.Context) {
var counts struct {
Terminals int `db:"terminals"`
Online int `db:"online"`
Desktops int `db:"desktops"`
Running int `db:"running"`
Sessions int `db:"sessions"`
Failed int `db:"failed"`
}
err := s.DB.GetContext(c.Request.Context(), &counts, `SELECT (SELECT count(*) FROM terminals) terminals,(SELECT count(*) FROM terminals WHERE last_seen>now()-interval '90 seconds') online,(SELECT count(*) FROM desktops) desktops,(SELECT count(*) FROM desktops WHERE power_state='running') running,(SELECT count(*) FROM terminals WHERE session_state='connected' AND session_desktop_id IS NOT NULL AND last_seen>now()-interval '90 seconds') sessions,(SELECT count(*) FROM tasks WHERE status IN ('failed','timeout') AND created_at>now()-interval '24 hours') failed`)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"terminalCount": counts.Terminals, "onlineTerminals": counts.Online, "desktopCount": counts.Desktops, "runningDesktops": counts.Running, "activeSessions": counts.Sessions, "failedTasks": counts.Failed, "mode": s.Config.Mode})
}
func (s *Server) settings(c *gin.Context) {
provider := "unconfigured"
if s.Config.Mode == "demo" {
provider = "demo"
}
if s.PVE != nil {
provider = "proxmox"
}
c.JSON(200, gin.H{"mode": s.Config.Mode, "computeProvider": provider, "pveConfigured": s.PVE != nil, "terminalTransport": "https-poll", "capabilities": []string{"auth", "resources", "desktops", "terminals", "bindings", "tasks", "audit"}})
}
func (s *Server) resources(c *gin.Context) {
items := []model.Resource{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM resources ORDER BY name"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktops(c *gin.Context) {
items := []model.Desktop{}
q := "%" + c.Query("keyword") + "%"
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT * FROM desktops WHERE (name ILIKE $1 OR vmid::text ILIKE $1) AND ($2='' OR power_state=$2) ORDER BY name`, q, c.Query("status"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) desktop(c *gin.Context) {
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, d)
}
func (s *Server) terminals(c *gin.Context) {
items := []model.Terminal{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT id,sn,name,group_name,CASE WHEN last_seen>now()-interval '90 seconds' THEN 'online' WHEN status='unregistered' THEN 'unregistered' ELSE 'offline' END status,agent_version,last_seen,CASE WHEN last_seen>now()-interval '90 seconds' THEN session_desktop_id ELSE NULL END session_desktop_id,session_state,config_version,fps,rtt FROM terminals WHERE (name ILIKE $1 OR sn ILIKE $1) ORDER BY name`, "%"+c.Query("keyword")+"%")
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) users(c *gin.Context) {
items, err := store.Users(c.Request.Context(), s.DB)
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) createUser(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required,min=2,max=80"`
DisplayName string `json:"displayName" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
in.Username = strings.TrimSpace(in.Username)
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.Username == "" || in.DisplayName == "" {
s.fail(c, 400, "invalid_request", "账号和姓名不能为空")
return
}
id := store.ID("user_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO users(id,username,display_name) VALUES($1,$2,$3)", id, in.Username, in.DisplayName)
if err != nil {
if conflict(err) {
s.fail(c, 409, "conflict", "该账号已存在")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "create_user", id, "success")
c.JSON(201, model.User{ID: id, Username: in.Username, DisplayName: in.DisplayName, Enabled: true, DesktopIDs: []string{}})
}
func conflict(err error) bool {
var e *pgconn.PgError
return errors.As(err, &e) && (e.Code == "23505" || e.Code == "23503")
}
func (s *Server) bindings(c *gin.Context) {
var in struct {
TerminalID *string `json:"terminalId"`
DesktopIDs []string `json:"desktopIds" binding:"max=100"`
Enabled *bool `json:"enabled"`
}
if !s.decode(c, &in) {
return
}
if in.TerminalID != nil && *in.TerminalID == "" {
in.TerminalID = nil
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var user model.User
if err = tx.GetContext(ctx, &user, "SELECT * FROM users WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if in.Enabled != nil {
user.Enabled = *in.Enabled
}
_, err = tx.ExecContext(ctx, "UPDATE users SET terminal_id=$1,enabled=$2 WHERE id=$3", in.TerminalID, user.Enabled, user.ID)
if err != nil {
if conflict(err) {
s.fail(c, 409, "binding_conflict", "终端不存在或已关联其他用户")
} else {
s.dbError(c, err)
}
return
}
if _, err = tx.ExecContext(ctx, "DELETE FROM bindings WHERE user_id=$1", user.ID); err != nil {
s.dbError(c, err)
return
}
seen := map[string]bool{}
for _, id := range in.DesktopIDs {
if seen[id] {
continue
}
seen[id] = true
if _, err = tx.ExecContext(ctx, "INSERT INTO bindings VALUES($1,$2)", user.ID, id); err != nil {
if conflict(err) {
s.fail(c, 400, "invalid_desktop", "所选桌面不存在")
} else {
s.dbError(c, err)
}
return
}
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,'update_bindings',$3,'success',$4,now())", store.ID("audit_"), s.admin(c).Username, user.ID, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
user.TerminalID = in.TerminalID
user.DesktopIDs = in.DesktopIDs
if user.DesktopIDs == nil {
user.DesktopIDs = []string{}
}
c.JSON(200, user)
}
func (s *Server) tasks(c *gin.Context) {
items := []model.Task{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) task(c *gin.Context) {
var t model.Task
if err := s.DB.GetContext(c.Request.Context(), &t, "SELECT * FROM tasks WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, t)
}
func (s *Server) audits(c *gin.Context) {
items := []model.Audit{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 1000"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) discoverVMs(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "尚未配置 Proxmox 接入,演示环境不发现真实 VM")
return
}
items, err := s.PVE.Resources(c.Request.Context())
if err != nil {
s.fail(c, 502, "pve_unavailable", "Proxmox 查询失败,请检查连接、证书和访问权限")
return
}
vms := []gin.H{}
for _, r := range items {
if r.Type == "qemu" && r.Template == 0 {
vms = append(vms, gin.H{"vmid": r.VMID, "node": r.Node, "name": r.Name, "status": r.Status})
}
}
list(c, vms)
}
func (s *Server) syncResources(c *gin.Context) {
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "请通过服务端环境变量配置 Proxmox 接入")
return
}
s.submitTask(c, "resource", "sync", "cluster", "Proxmox 集群")
}
func (s *Server) importDesktop(c *gin.Context) {
var in struct {
Name string `json:"name" binding:"required,max=80"`
OS string `json:"os" binding:"required,oneof=Windows Ubuntu 麒麟"`
Node string `json:"node" binding:"required,max=100"`
VMID int `json:"vmid" binding:"min=100"`
Endpoint string `json:"endpoint" binding:"max=253"`
Width int `json:"width" binding:"omitempty,min=640,max=7680"`
Height int `json:"height" binding:"omitempty,min=480,max=4320"`
FPS int `json:"fps" binding:"omitempty,min=10,max=120"`
}
if !s.decode(c, &in) {
return
}
if s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "真实 VM 纳管需要先配置 Proxmox")
return
}
if in.Endpoint != "" && net.ParseIP(in.Endpoint) == nil {
for _, part := range strings.Split(in.Endpoint, ".") {
if part == "" || strings.ContainsAny(part, "/:@ \\") {
s.fail(c, 400, "invalid_endpoint", "连接地址应为 IP 或主机名")
return
}
}
}
power, err := s.PVE.Power(c.Request.Context(), in.Node, in.VMID)
if err != nil {
s.fail(c, 502, "pve_unavailable", "无法验证所选 VM,请检查节点和 VMID")
return
}
id := store.ID("desk_")
if in.Width == 0 {
in.Width = 2560
}
if in.Height == 0 {
in.Height = 1440
}
if in.FPS == 0 {
in.FPS = 30
}
_, err = s.DB.ExecContext(c.Request.Context(), `INSERT INTO desktops(id,name,os,node,vmid,power_state,provider,endpoint,width,height,fps) VALUES($1,$2,$3,$4,$5,$6,'proxmox',$7,$8,$9,$10)`, id, in.Name, in.OS, in.Node, in.VMID, power, in.Endpoint, in.Width, in.Height, in.FPS)
if err != nil {
if conflict(err) {
s.fail(c, 409, "already_managed", "该 VM 已纳管")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "import_desktop", id, "success")
c.Params = append(c.Params, gin.Param{Key: "id", Value: id})
s.desktop(c)
}
func (s *Server) desktopAction(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=start shutdown reboot"`
}
if !s.decode(c, &in) {
return
}
var d model.Desktop
if err := s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if d.Provider == "demo" && s.Config.Mode != "demo" {
s.fail(c, 409, "demo_disabled", "正式模式不执行演示桌面任务")
return
}
if d.Provider == "proxmox" && s.PVE == nil {
s.fail(c, 503, "pve_unconfigured", "Proxmox 尚未配置")
return
}
s.submitTask(c, "desktop", in.Action, d.ID, d.Name)
}
func (s *Server) terminalCommand(c *gin.Context) {
var in struct {
Action string `json:"action" binding:"required,oneof=reboot reset-session collect-logs"`
}
if !s.decode(c, &in) {
return
}
var name string
if err := s.DB.GetContext(c.Request.Context(), &name, "SELECT name FROM terminals WHERE id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.submitTask(c, "terminal", in.Action, c.Param("id"), name)
}
func (s *Server) submitTask(c *gin.Context, kind, action, targetID, targetName string) {
key := c.GetHeader("Idempotency-Key")
if key == "" || len(key) > 100 {
s.fail(c, 400, "idempotency_required", "请提供不超过 100 字符的 Idempotency-Key")
return
}
actor := s.admin(c).Username
ctx := c.Request.Context()
var existing model.Task
err := s.DB.GetContext(ctx, &existing, "SELECT * FROM tasks WHERE actor=$1 AND idempotency_key=$2", actor, key)
if err == nil {
if existing.Kind != kind || existing.Action != action || existing.TargetID != targetID {
s.fail(c, 409, "idempotency_conflict", "相同请求标识不能用于不同操作")
return
}
c.JSON(202, existing)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.dbError(c, err)
return
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var task model.Task
err = tx.GetContext(ctx, &task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,'queued','已提交,等待执行回执',$7) RETURNING *`, store.ID("task_"), kind, action, targetID, targetName, actor, key)
if err != nil {
if conflict(err) {
s.fail(c, 409, "task_conflict", "已有相同请求或该对象有任务执行中,请刷新任务列表")
} else {
s.dbError(c, err)
}
return
}
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,'submitted',$5,now())", store.ID("audit_"), actor, action, targetName, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(202, task)
}
// Complete persists the final result and its audit entry together.
func (s *Server) complete(ctx context.Context, t model.Task, status, message string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, "UPDATE tasks SET status=$1,message=$2,updated_at=now() WHERE id=$3 AND status IN ('running','queued')", status, message, t.ID)
if err != nil {
return err
}
n, _ := result.RowsAffected()
if n == 0 {
return nil
}
// Session release is scoped to a session ID by the device event endpoint.
// A delayed command acknowledgement must not clear a newer connection.
_, err = tx.ExecContext(ctx, "INSERT INTO audit_logs VALUES($1,$2,$3,$4,$5,$6,now())", store.ID("audit_"), t.Actor, t.Action, t.TargetName, status, t.ID)
if err != nil {
return err
}
return tx.Commit()
}
func ratio(a, b float64) float64 {
if b <= 0 {
return 0
}
return a / b
}
func (s *Server) sync(ctx context.Context) error {
items, err := s.PVE.Resources(ctx)
if err != nil {
return err
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, "DELETE FROM resources WHERE provider='proxmox'"); err != nil {
return err
}
for _, r := range items {
if r.Type == "node" {
if _, err = tx.ExecContext(ctx, `INSERT INTO resources VALUES($1,$1,$2,$3,$4,$5,'proxmox',now()) ON CONFLICT(id) DO UPDATE SET status=$2,cpu=$3,memory=$4,storage=$5,provider='proxmox',updated_at=now()`, r.Node, r.Status, r.CPU, ratio(r.Memory, r.MaxMemory), ratio(r.Disk, r.MaxDisk)); err != nil {
return err
}
}
if r.Type == "qemu" {
if _, err = tx.ExecContext(ctx, "UPDATE desktops SET power_state=$1,updated_at=now() WHERE provider='proxmox' AND node=$2 AND vmid=$3", r.Status, r.Node, r.VMID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *Server) RunWorker(ctx context.Context) {
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Unknown outcomes are never automatically retried after a worker restart.
var expired []model.Task
if s.DB.SelectContext(ctx, &expired, "SELECT * FROM tasks WHERE status IN ('queued','running') AND created_at<now()-interval '2 minutes'") == nil {
for _, t := range expired {
_ = s.complete(ctx, t, "timeout", "结果未确认,请核对设备或 PVE 实际状态;不会自动重放")
}
}
var t model.Task
err := s.DB.GetContext(ctx, &t, `UPDATE tasks SET status='running',updated_at=now(),message='正在执行,等待最终回执' WHERE id=(SELECT id FROM tasks WHERE status='queued' AND kind!='terminal' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)
if err != nil {
continue
}
s.execute(ctx, t)
}
}
}
// Resource reads are independent of the command queue and never change VM power.
func (s *Server) RunSync(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
readCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
if err := s.sync(readCtx); err != nil {
log.Printf("PVE resource sync failed: %v", err)
}
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Server) execute(parent context.Context, t model.Task) {
ctx, cancel := context.WithTimeout(parent, 90*time.Second)
defer cancel()
var err error
uncertain := false
message := "已收到执行结果"
if t.Kind == "resource" {
err = s.sync(ctx)
} else {
var d model.Desktop
err = s.DB.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1", t.TargetID)
if err == nil && d.Provider == "demo" {
if s.Config.Mode != "demo" {
err = errors.New("demo disabled")
} else {
select {
case <-ctx.Done():
err = ctx.Err()
case <-time.After(500 * time.Millisecond):
}
if err == nil {
power := "running"
if t.Action == "shutdown" {
power = "stopped"
}
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
message = "演示计算任务已完成;未检测真实连接服务"
}
}
} else if err == nil {
if s.PVE == nil {
err = errors.New("PVE unconfigured")
} else {
var upid string
upid, err = s.PVE.Action(ctx, d.Node, d.VMID, t.Action)
uncertain = true
var httpError *proxmox.HTTPError
if errors.As(err, &httpError) && httpError.Status >= 400 && httpError.Status < 500 {
uncertain = false
}
if err == nil {
_, err = s.DB.ExecContext(ctx, "UPDATE tasks SET provider_ref=$1 WHERE id=$2", upid, t.ID)
}
if err == nil {
err = s.PVE.Wait(ctx, d.Node, upid, time.Second)
}
if err == nil {
var power string
power, err = s.PVE.Power(ctx, d.Node, d.VMID)
if err == nil {
expected := "running"
if t.Action == "shutdown" {
expected = "stopped"
}
if power != expected {
err = fmt.Errorf("power result not confirmed")
} else {
_, err = s.DB.ExecContext(ctx, "UPDATE desktops SET power_state=$1,service_state='unknown',updated_at=now() WHERE id=$2", power, d.ID)
}
}
}
}
}
}
status := "success"
if err != nil {
log.Printf("task execution error taskId=%s error=%v", t.ID, err)
status = "failed"
message = "执行失败,请检查服务日志及 PVE 任务;未自动重试"
if (uncertain && !errors.Is(err, proxmox.ErrTaskFailed)) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
message = "结果未确认,请核对实际状态;未自动重试"
}
}
finalCtx, finalCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer finalCancel()
_ = s.complete(finalCtx, t, status, message)
}
+214
View File
@@ -0,0 +1,214 @@
package server
import (
"context"
"database/sql"
"errors"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"log"
"net/http"
"strconv"
"strings"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type Server struct {
Config config.Config
DB *sqlx.DB
PVE *proxmox.Client
Router *gin.Engine
}
type claims struct {
AdminID string `json:"adminId"`
jwt.RegisteredClaims
}
func New(cfg config.Config, db *sqlx.DB, pve *proxmox.Client) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{Config: cfg, DB: db, PVE: pve}
r := gin.New()
r.Use(gin.Recovery(), func(c *gin.Context) {
c.Set("requestId", store.ID("req_"))
c.Header("X-Request-ID", c.GetString("requestId"))
c.Header("Cache-Control", "no-store")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
c.Next()
})
s.Router = r
r.GET("/healthz", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if db.PingContext(ctx) != nil {
s.fail(c, 503, "dependency_unavailable", "数据库暂不可用")
return
}
c.JSON(200, gin.H{"status": "ok", "mode": cfg.Mode})
})
r.POST("/api/v1/auth/login", s.login)
r.POST("/api/v1/terminal/register", s.register)
t := r.Group("/api/v1/terminal", s.deviceAuth)
t.POST("/heartbeat", s.heartbeat)
t.GET("/config", s.deviceConfig)
t.GET("/desktops", s.deviceDesktops)
t.GET("/commands", s.deviceCommands)
t.POST("/commands/:id/ack", s.deviceAck)
t.POST("/connect-ticket", s.createTicket)
t.POST("/connect-ticket/consume", s.consumeTicket)
t.POST("/session", s.deviceSession)
a := r.Group("/api/v1", s.adminAuth)
a.GET("/auth/me", func(c *gin.Context) { c.JSON(200, c.MustGet("admin")) })
a.POST("/auth/logout", s.logout)
a.GET("/overview", s.overview)
a.GET("/settings", s.settings)
a.GET("/resources", s.resources)
a.GET("/resources/vms", s.discoverVMs)
a.POST("/resources/sync", s.allow("super_admin"), s.syncResources)
a.GET("/desktops", s.desktops)
a.POST("/desktops/import", s.allow("super_admin"), s.importDesktop)
a.GET("/desktops/:id", s.desktop)
a.POST("/desktops/:id/actions", s.allow("super_admin", "operator"), s.desktopAction)
a.GET("/terminals", s.terminals)
a.POST("/terminals", s.allow("super_admin"), s.enroll)
a.POST("/terminals/:id/commands", s.allow("super_admin", "operator"), s.terminalCommand)
a.GET("/users", s.users)
a.POST("/users", s.allow("super_admin"), s.createUser)
a.PUT("/users/:id/bindings", s.allow("super_admin"), s.bindings)
a.GET("/tasks", s.tasks)
a.GET("/tasks/:id", s.task)
a.GET("/tasks/:id/diagnostics", s.allow("super_admin", "operator"), s.taskDiagnostics)
a.GET("/audit-logs", s.audits)
r.NoRoute(func(c *gin.Context) { s.fail(c, 404, "not_found", "接口不存在") })
return s
}
func (s *Server) fail(c *gin.Context, status int, code, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message, "requestId": c.GetString("requestId")})
}
func (s *Server) dbError(c *gin.Context, err error) {
log.Printf("database request failed requestId=%s error=%v", c.GetString("requestId"), err)
if errors.Is(err, sql.ErrNoRows) {
s.fail(c, 404, "not_found", "记录不存在")
} else {
s.fail(c, 500, "database_error", "操作未完成,请根据请求 ID 检查服务日志")
}
}
func (s *Server) decode(c *gin.Context, v any) bool {
if err := c.ShouldBindJSON(v); err != nil {
s.fail(c, 400, "invalid_request", "请检查必填字段和数据格式")
return false
}
return true
}
func (s *Server) admin(c *gin.Context) model.Administrator {
return c.MustGet("admin").(model.Administrator)
}
func (s *Server) log(c *gin.Context, action, target, result string) {
actor := "device"
if a, ok := c.Get("admin"); ok {
actor = a.(model.Administrator).Username
}
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,$5,$6)", store.ID("audit_"), actor, action, target, result, c.GetString("requestId"))
if err != nil {
c.Error(err)
}
}
func (s *Server) allow(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
for _, role := range roles {
if role == s.admin(c).Role {
c.Next()
return
}
}
s.log(c, c.Request.Method+" "+c.FullPath(), c.Param("id"), "denied")
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
}
}
func (s *Server) adminAuth(c *gin.Context) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
cl := &claims{}
token, err := jwt.ParseWithClaims(raw, cl, func(t *jwt.Token) (any, error) { return []byte(s.Config.JWTSecret), nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("vdi-platform"), jwt.WithAudience("vdi-console"), jwt.WithExpirationRequired())
if err != nil || !token.Valid {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
var a model.Administrator
err = s.DB.GetContext(c.Request.Context(), &a, `SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE x.id=$1 AND a.id=$2 AND x.expires_at>now()`, cl.ID, cl.AdminID)
if err != nil {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
return
}
c.Set("admin", a)
c.Set("sessionId", cl.ID)
c.Next()
}
func (s *Server) login(c *gin.Context) {
var input struct {
Username string `json:"username" binding:"required,max=80"`
Password string `json:"password" binding:"required,max=200"`
}
if !s.decode(c, &input) {
return
}
var a model.Administrator
err := s.DB.GetContext(c.Request.Context(), &a, "SELECT * FROM administrators WHERE username=$1", input.Username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(input.Password)) != nil {
s.fail(c, 401, "invalid_credentials", "用户名或密码错误")
return
}
now := time.Now()
id := store.ID("sess_")
expires := now.Add(time.Hour)
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{AdminID: a.ID, RegisteredClaims: jwt.RegisteredClaims{Issuer: "vdi-platform", Audience: jwt.ClaimStrings{"vdi-console"}, ID: id, Subject: a.ID, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expires)}}).SignedString([]byte(s.Config.JWTSecret))
if err != nil {
s.dbError(c, err)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO admin_sessions VALUES($1,$2,$3)", id, a.ID, expires); err != nil {
s.dbError(c, err)
return
}
c.Set("admin", a)
s.log(c, "login", a.Username, "success")
c.JSON(200, gin.H{"accessToken": token, "expiresIn": 3600, "user": a})
}
func (s *Server) logout(c *gin.Context) {
if _, err := s.DB.ExecContext(c.Request.Context(), "DELETE FROM admin_sessions WHERE id=$1", c.GetString("sessionId")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "logout", s.admin(c).Username, "success")
c.Status(204)
}
func list[T any](c *gin.Context, items []T) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
total := len(items)
start := (page - 1) * size
if start < 0 || start > total {
start = total
}
end := start + size
if end > total {
end = total
}
if items == nil {
items = []T{}
}
c.JSON(200, gin.H{"items": items[start:end], "page": page, "pageSize": size, "total": total, "requestId": c.GetString("requestId")})
}
+375
View File
@@ -0,0 +1,375 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/jmoiron/sqlx"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"vdi-cloud/platform/internal/config"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
"vdi-cloud/platform/pkg/proxmox"
)
type fixture struct {
server *Server
db *sqlx.DB
t *testing.T
}
func newFixture(t *testing.T) *fixture {
t.Helper()
url := os.Getenv("VDI_TEST_DATABASE_URL")
if url == "" {
t.Skip("set VDI_TEST_DATABASE_URL to a dedicated empty test database")
}
ctx := context.Background()
db, err := store.Open(ctx, url)
if err != nil {
t.Fatal(err)
}
if _, err = db.Exec(`TRUNCATE connection_tickets,audit_logs,tasks,bindings,users,terminals,desktops,resources,admin_sessions,administrators CASCADE`); err != nil {
t.Fatal(err)
}
if err = store.Bootstrap(ctx, db, "demo", "", ""); err != nil {
t.Fatal(err)
}
s := New(config.Config{Mode: "demo", JWTSecret: strings.Repeat("test-", 10)}, db, nil)
ctx, cancel := context.WithCancel(ctx)
go s.RunWorker(ctx)
t.Cleanup(func() { cancel(); time.Sleep(30 * time.Millisecond); db.Close() })
return &fixture{s, db, t}
}
func (f *fixture) request(method, path, token string, body any, key string) (int, map[string]any) {
f.t.Helper()
var encoded []byte
if body != nil {
encoded, _ = json.Marshal(body)
}
r := httptest.NewRequest(method, "/api/v1"+path, bytes.NewReader(encoded))
r.Header.Set("Content-Type", "application/json")
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if key != "" {
r.Header.Set("Idempotency-Key", key)
}
w := httptest.NewRecorder()
f.server.Router.ServeHTTP(w, r)
out := map[string]any{}
if w.Body.Len() > 0 {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
f.t.Fatal(err)
}
}
return w.Code, out
}
func (f *fixture) login(user string) string {
f.t.Helper()
status, out := f.request("POST", "/auth/login", "", map[string]any{"username": user, "password": "Demo@2026"}, "")
if status != 200 {
f.t.Fatalf("login failed %d %v", status, out)
}
return out["accessToken"].(string)
}
func expect(t *testing.T, want, got int, out any) {
t.Helper()
if want != got {
t.Fatalf("want status %d got %d: %v", want, got, out)
}
}
func TestRBACLogoutAndDurableTask(t *testing.T) {
f := newFixture(t)
viewer := f.login("viewer")
operator := f.login("operator")
admin := f.login("admin")
status, out := f.request("GET", "/desktops", viewer, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", viewer, map[string]any{"action": "start"}, "viewer-1")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", operator, map[string]any{"desktopIds": []string{}}, "")
expect(t, 403, status, out)
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, out)
id := out["id"].(string)
status, again := f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "start"}, "start-1")
expect(t, 202, status, again)
if again["id"] != id {
t.Fatal("duplicate task created")
}
status, out = f.request("POST", "/desktops/desk_demo_kylin/actions", operator, map[string]any{"action": "shutdown"}, "start-1")
expect(t, 409, status, out)
deadline := time.Now().Add(5 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "success" {
break
}
if time.Now().After(deadline) {
t.Fatal("task never completed", out)
}
time.Sleep(50 * time.Millisecond)
}
var power, service string
if err := f.db.QueryRow("SELECT power_state,service_state FROM desktops WHERE id='desk_demo_kylin'").Scan(&power, &service); err != nil {
t.Fatal(err)
}
if power != "running" || service != "unknown" {
t.Fatal("power and service state confused")
}
status, out = f.request("POST", "/auth/logout", admin, nil, "")
expect(t, 204, status, out)
status, out = f.request("GET", "/auth/me", admin, nil, "")
expect(t, 401, status, out)
var count int
f.db.Get(&count, "SELECT count(*) FROM tasks WHERE id=$1", id)
if count != 1 {
t.Fatal("task not persisted")
}
}
func TestDeviceIdentityBindingTicketAndAcknowledgement(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
enroll := func(sn string) (string, string) {
status, out := f.request("POST", "/terminals", admin, map[string]any{"sn": sn, "name": sn, "group": "测试"}, "")
expect(t, 201, status, out)
secret := out["activationSecret"].(string)
id := out["id"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 200, status, out)
token := out["accessToken"].(string)
status, out = f.request("POST", "/terminal/register", "", map[string]any{"sn": sn, "activationSecret": secret, "agentVersion": "test"}, "")
expect(t, 401, status, out)
return id, token
}
id, device := enroll("TEST-DEVICE-001")
otherID, other := enroll("TEST-DEVICE-002")
status, out := f.request("GET", "/users", device, nil, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{"desk_demo_windows", "desk_demo_ubuntu"}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/terminal/desktops", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 2 {
t.Fatal("missing allowed desktops")
}
f.db.Exec("UPDATE desktops SET endpoint='192.0.2.100' WHERE id='desk_demo_windows'")
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 201, status, out)
ticket := out["ticket"].(string)
status, out = f.request("POST", "/terminal/connect-ticket/consume", other, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 200, status, out)
if out["credentialMode"] != "existing-pairing" {
t.Fatal("must declare existing Sunshine pairing")
}
sessionID := out["sessionId"].(string)
status, out = f.request("GET", "/overview", admin, nil, "")
expect(t, 200, status, out)
if out["activeSessions"] != float64(0) {
t.Fatal("reservation counted as active stream")
}
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/overview", admin, nil, "")
if out["activeSessions"] != float64(1) {
t.Fatal("stream-ready event missing")
}
status, out = f.request("POST", "/terminal/session", other, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "ended"}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/session", device, map[string]any{"sessionId": sessionID, "state": "connected"}, "")
expect(t, 409, status, out)
status, out = f.request("POST", "/terminal/connect-ticket/consume", device, map[string]any{"ticket": ticket}, "")
expect(t, 401, status, out)
status, out = f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": id, "desktopIds": []string{}, "enabled": true}, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/connect-ticket", device, map[string]any{"desktopId": "desk_demo_windows"}, "")
expect(t, 403, status, out)
status, out = f.request("POST", fmt.Sprintf("/terminals/%s/commands", id), admin, map[string]any{"action": "collect-logs"}, "cmd-1")
expect(t, 202, status, out)
taskID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", other, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 0 {
t.Fatal("command leaked to other terminal")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", other, map[string]any{"status": "success"}, "")
expect(t, 404, status, out)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
if len(out["items"].([]any)) != 1 {
t.Fatal("device did not receive command")
}
status, out = f.request("POST", "/terminal/commands/"+taskID+"/ack", device, map[string]any{"status": "failed", "message": "private device log"}, "")
expect(t, 200, status, out)
status, out = f.request("GET", "/tasks/"+taskID, admin, nil, "")
expect(t, 200, status, out)
if out["status"] != "failed" {
t.Fatal("must wait for device ack")
}
if strings.Contains(out["message"].(string), "private") {
t.Fatal("raw device detail leaked")
}
status, out = f.request("POST", "/terminals/"+id+"/commands", admin, map[string]any{"action": "collect-logs"}, "diagnostics-2")
expect(t, 202, status, out)
diagnosticID := out["id"].(string)
status, out = f.request("GET", "/terminal/commands", device, nil, "")
expect(t, 200, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success"}, "")
expect(t, 400, status, out)
status, out = f.request("POST", "/terminal/commands/"+diagnosticID+"/ack", device, map[string]any{"status": "success", "diagnostics": map[string]any{"agentVersion": "qt-test", "bootId": "test-boot", "kioskState": "DESKTOP_LIST", "uptimeSeconds": 100, "logTail": "private diagnostic"}}, "")
expect(t, 200, status, out)
viewer := f.login("viewer")
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", viewer, nil, "")
expect(t, 403, status, out)
status, out = f.request("GET", "/tasks/"+diagnosticID+"/diagnostics", admin, nil, "")
expect(t, 200, status, out)
if out["logTail"] != "private diagnostic" {
t.Fatal("diagnostic not retained")
}
_ = otherID
}
func TestUnknownTerminalOutcomeAndBindingRollback(t *testing.T) {
f := newFixture(t)
admin := f.login("admin")
status, out := f.request("PUT", "/users/user_demo_wang/bindings", admin, map[string]any{"terminalId": "term_demo_001", "desktopIds": []string{"missing"}}, "")
expect(t, 400, status, out)
var n int
f.db.Get(&n, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'")
if n != 2 {
t.Fatal("failed binding update lost original authorization")
}
status, out = f.request("POST", "/terminals/term_demo_001/commands", admin, map[string]any{"action": "reboot"}, "offline-1")
expect(t, 202, status, out)
id := out["id"].(string)
f.db.Exec("UPDATE tasks SET created_at=now()-interval '3 minutes' WHERE id=$1", id)
deadline := time.Now().Add(3 * time.Second)
for {
status, out = f.request("GET", "/tasks/"+id, admin, nil, "")
expect(t, 200, status, out)
if out["status"] == "timeout" {
break
}
if time.Now().After(deadline) {
t.Fatal("offline task was not timed out", out)
}
time.Sleep(50 * time.Millisecond)
}
status, out = f.request(http.MethodPost, "/resources/sync", admin, nil, "sync-1")
expect(t, 503, status, out)
}
func TestRestartPreservesRevokedBindingsAndRejectsModeChange(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("DELETE FROM bindings WHERE user_id='user_demo_wang'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(context.Background(), f.db, "demo", "", ""); err != nil {
t.Fatal(err)
}
var count int
if err := f.db.Get(&count, "SELECT count(*) FROM bindings WHERE user_id='user_demo_wang'"); err != nil || count != 0 {
t.Fatalf("restart restored revoked bindings: count=%d err=%v", count, err)
}
if err := store.Bootstrap(context.Background(), f.db, "production", "prod-admin", "long-password-2026"); err == nil {
t.Fatal("demo database accepted production bootstrap")
}
}
func TestInitialPasswordPolicyDoesNotRejectExistingAccountOnRestart(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE platform_metadata SET mode='production'"); err != nil {
t.Fatal(err)
}
defer f.db.Exec("UPDATE platform_metadata SET mode='demo'")
ctx := context.Background()
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err == nil {
t.Fatal("weak initial password accepted")
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "strong-initial-password"); err != nil {
t.Fatal(err)
}
var before, after string
if err := f.db.Get(&before, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if err := store.Bootstrap(ctx, f.db, "production", "new-admin", "short"); err != nil {
t.Fatal("existing account blocked startup", err)
}
if err := f.db.Get(&after, "SELECT password_hash FROM administrators WHERE username='new-admin'"); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatal("restart changed the account password")
}
}
func TestLateCommandAcknowledgementPreservesNewSession(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE terminals SET session_id='new-session',session_state='connected',session_desktop_id='desk_demo_windows' WHERE id='term_demo_001'"); err != nil {
t.Fatal(err)
}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES('task-late','terminal','reset-session','term_demo_001','test','admin','running','','late') RETURNING *`); err != nil {
t.Fatal(err)
}
if err := f.server.complete(context.Background(), task, "success", "ack"); err != nil {
t.Fatal(err)
}
var session string
if err := f.db.Get(&session, "SELECT session_id FROM terminals WHERE id='term_demo_001'"); err != nil || session != "new-session" {
t.Fatalf("new session lost: %q %v", session, err)
}
}
func TestPVEConfirmedFailureAndUncertainSubmission(t *testing.T) {
f := newFixture(t)
if _, err := f.db.Exec("UPDATE desktops SET provider='proxmox' WHERE id='desk_demo_kylin'"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
submission int
exit string
want string
}{{"submission unavailable", 503, "", "timeout"}, {"permission denied", 403, "", "failed"}, {"explicit PVE task failure", 200, "ERROR", "failed"}} {
t.Run(tc.name, func(t *testing.T) {
pve := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
w.WriteHeader(tc.submission)
fmt.Fprint(w, `{"data":"UPID:test"}`)
return
}
fmt.Fprintf(w, `{"data":{"status":"stopped","exitstatus":%q}}`, tc.exit)
}))
defer pve.Close()
f.server.PVE = &proxmox.Client{BaseURL: pve.URL, HTTP: pve.Client()}
var task model.Task
if err := f.db.Get(&task, `INSERT INTO tasks(id,kind,action,target_id,target_name,actor,status,message,idempotency_key) VALUES($1,'desktop','start','desk_demo_kylin','test VM','admin','running','test',$1) RETURNING *`, store.ID("task_")); err != nil {
t.Fatal(err)
}
f.server.execute(context.Background(), task)
var status string
if err := f.db.Get(&status, "SELECT status FROM tasks WHERE id=$1", task.ID); err != nil || status != tc.want {
t.Fatalf("want %s, got %s err=%v", tc.want, status, err)
}
})
}
}
+291
View File
@@ -0,0 +1,291 @@
package server
import (
"crypto/subtle"
"encoding/json"
"github.com/gin-gonic/gin"
"strings"
"time"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) enroll(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,min=3,max=100"`
Name string `json:"name" binding:"required,max=80"`
Group string `json:"group" binding:"max=80"`
}
if !s.decode(c, &in) {
return
}
in.SN = strings.TrimSpace(in.SN)
in.Name = strings.TrimSpace(in.Name)
if in.SN == "" || in.Name == "" {
s.fail(c, 400, "invalid_request", "SN 和终端名称不能为空")
return
}
id, secret := store.ID("term_"), store.ID("activate_")
_, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO terminals(id,sn,name,group_name,bootstrap_hash) VALUES($1,$2,$3,$4,$5)", id, in.SN, in.Name, in.Group, store.Hash(secret))
if err != nil {
if conflict(err) {
s.fail(c, 409, "sn_exists", "该 SN 已登记")
} else {
s.dbError(c, err)
}
return
}
s.log(c, "enroll_terminal", id, "success")
c.JSON(201, gin.H{"id": id, "activationSecret": secret})
}
func (s *Server) register(c *gin.Context) {
var in struct {
SN string `json:"sn" binding:"required,max=100"`
ActivationSecret string `json:"activationSecret" binding:"required,max=200"`
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
}
if !s.decode(c, &in) {
return
}
token := store.ID("device_")
var id string
err := s.DB.GetContext(c.Request.Context(), &id, `UPDATE terminals SET token_hash=$1,bootstrap_hash='',status='online',agent_version=$2,last_seen=now() WHERE sn=$3 AND bootstrap_hash=$4 AND bootstrap_hash!='' RETURNING id`, store.Hash(token), in.AgentVersion, in.SN, store.Hash(in.ActivationSecret))
if err != nil {
s.fail(c, 401, "invalid_activation", "激活信息无效或已使用,请联系管理员")
return
}
c.JSON(200, gin.H{"terminalId": id, "accessToken": token, "heartbeatInterval": 30})
}
func (s *Server) deviceAuth(c *gin.Context) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var id string
if token == "" || s.DB.GetContext(c.Request.Context(), &id, "SELECT id FROM terminals WHERE token_hash=$1", store.Hash(token)) != nil {
s.fail(c, 401, "device_unauthorized", "终端身份无效")
return
}
c.Set("terminalId", id)
c.Next()
}
func (s *Server) heartbeat(c *gin.Context) {
var in struct {
AgentVersion string `json:"agentVersion" binding:"required,max=80"`
FPS *float64 `json:"fps" binding:"omitempty,min=0,max=1000"`
RTT *float64 `json:"rtt" binding:"omitempty,min=0,max=60000"`
}
if !s.decode(c, &in) {
return
}
_, err := s.DB.ExecContext(c.Request.Context(), "UPDATE terminals SET last_seen=now(),status='online',agent_version=$1,fps=$2,rtt=$3 WHERE id=$4", in.AgentVersion, in.FPS, in.RTT, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true, "serverTime": time.Now().UTC()})
}
func (s *Server) deviceConfig(c *gin.Context) {
var version int
if err := s.DB.GetContext(c.Request.Context(), &version, "SELECT config_version FROM terminals WHERE id=$1", c.GetString("terminalId")); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"configVersion": version, "identityMode": "device", "commandTransport": "https-poll", "heartbeatInterval": 30})
}
func (s *Server) deviceDesktops(c *gin.Context) {
items := []model.Desktop{}
err := s.DB.SelectContext(c.Request.Context(), &items, `SELECT d.* FROM desktops d JOIN bindings b ON b.desktop_id=d.id JOIN users u ON u.id=b.user_id WHERE u.terminal_id=$1 AND u.enabled=true ORDER BY d.name`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceCommands(c *gin.Context) {
items := []model.Task{}
err := s.DB.SelectContext(c.Request.Context(), &items, `UPDATE tasks SET status='running',updated_at=now(),message='终端已领取,等待执行回执' WHERE id IN (SELECT id FROM tasks WHERE kind='terminal' AND target_id=$1 AND status='queued' AND created_at>now()-interval '2 minutes' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 10) RETURNING *`, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func (s *Server) deviceAck(c *gin.Context) {
var in struct {
Status string `json:"status" binding:"required,oneof=success failed"`
Message string `json:"message" binding:"max=300"`
Diagnostics *model.DeviceDiagnostics `json:"diagnostics"`
}
if !s.decode(c, &in) {
return
}
var task model.Task
err := s.DB.GetContext(c.Request.Context(), &task, "SELECT * FROM tasks WHERE id=$1 AND kind='terminal' AND target_id=$2", c.Param("id"), c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if task.Status == in.Status {
c.JSON(200, gin.H{"accepted": true})
return
}
if task.Status != "running" {
s.fail(c, 409, "invalid_task_state", "该命令尚未领取或已经结束")
return
}
if task.Action == "collect-logs" && in.Status == "success" {
if in.Diagnostics == nil {
s.fail(c, 400, "diagnostics_required", "收集日志成功需要上传诊断结果")
return
}
data, marshalErr := json.Marshal(in.Diagnostics)
if marshalErr != nil {
s.dbError(c, marshalErr)
return
}
if _, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO task_diagnostics(task_id,content) VALUES($1,$2) ON CONFLICT(task_id) DO NOTHING", task.ID, string(data)); err != nil {
s.dbError(c, err)
return
}
}
// Agent text can contain sensitive device details. Keep the public audit message fixed.
if err = s.complete(c.Request.Context(), task, in.Status, "已收到终端执行回执"); err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) createTicket(c *gin.Context) {
var in struct {
DesktopID string `json:"desktopId" binding:"required,max=100"`
}
if !s.decode(c, &in) {
return
}
var allowed bool
err := s.DB.GetContext(c.Request.Context(), &allowed, `SELECT EXISTS(SELECT 1 FROM bindings b JOIN users u ON u.id=b.user_id WHERE b.desktop_id=$1 AND u.terminal_id=$2 AND u.enabled=true)`, in.DesktopID, c.GetString("terminalId"))
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "该终端没有桌面使用权限")
return
}
var d model.Desktop
if err = s.DB.GetContext(c.Request.Context(), &d, "SELECT * FROM desktops WHERE id=$1", in.DesktopID); err != nil {
s.dbError(c, err)
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面未开机或尚未配置连接地址")
return
}
token := store.ID("ticket_")
expires := time.Now().Add(45 * time.Second)
_, err = s.DB.ExecContext(c.Request.Context(), "INSERT INTO connection_tickets(token_hash,terminal_id,desktop_id,expires_at) VALUES($1,$2,$3,$4)", store.Hash(token), c.GetString("terminalId"), in.DesktopID, expires)
if err != nil {
s.dbError(c, err)
return
}
c.JSON(201, gin.H{"ticket": token, "desktopId": in.DesktopID, "expiresAt": expires, "credentialMode": "existing-pairing"})
}
func (s *Server) consumeTicket(c *gin.Context) {
var in struct {
Ticket string `json:"ticket" binding:"required,max=200"`
}
if !s.decode(c, &in) {
return
}
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return
}
defer tx.Rollback()
var row struct {
TokenHash string `db:"token_hash"`
TerminalID string `db:"terminal_id"`
DesktopID string `db:"desktop_id"`
}
err = tx.GetContext(ctx, &row, `SELECT token_hash,terminal_id,desktop_id FROM connection_tickets WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, store.Hash(in.Ticket))
if err != nil || subtle.ConstantTimeCompare([]byte(row.TerminalID), []byte(c.GetString("terminalId"))) != 1 {
s.fail(c, 401, "invalid_ticket", "票据已失效或不属于当前终端")
return
}
var d model.Desktop
if err = tx.GetContext(ctx, &d, "SELECT * FROM desktops WHERE id=$1 FOR UPDATE", row.DesktopID); err != nil {
s.dbError(c, err)
return
}
var allowed bool
err = tx.GetContext(ctx, &allowed, `SELECT EXISTS(SELECT 1 FROM users u JOIN bindings b ON b.user_id=u.id WHERE u.terminal_id=$1 AND u.enabled AND b.desktop_id=$2)`, row.TerminalID, d.ID)
if err != nil {
s.dbError(c, err)
return
}
if !allowed {
s.fail(c, 403, "desktop_forbidden", "桌面授权已撤销")
return
}
if d.PowerState != "running" || d.Endpoint == "" {
s.fail(c, 409, "desktop_not_ready", "桌面当前不可连接")
return
}
var busy bool
if err = tx.GetContext(ctx, &busy, `SELECT EXISTS(SELECT 1 FROM terminals WHERE session_desktop_id=$1 AND id!=$2 AND last_seen>now()-interval '90 seconds')`, d.ID, row.TerminalID); err != nil {
s.dbError(c, err)
return
}
if busy {
s.fail(c, 409, "desktop_busy", "桌面已被其他终端占用")
return
}
sessionID := store.ID("session_")
_, err = tx.ExecContext(ctx, "UPDATE connection_tickets SET consumed_at=now() WHERE token_hash=$1", row.TokenHash)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE terminals SET session_desktop_id=$1,last_seen=now(),session_id=$3,session_state='connecting' WHERE id=$2", d.ID, row.TerminalID, sessionID)
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, gin.H{"desktopId": d.ID, "sessionId": sessionID, "protocol": "moonlight", "endpoint": d.Endpoint, "app": d.App, "width": d.Width, "height": d.Height, "fps": d.FPS, "credentialMode": "existing-pairing"})
}
func (s *Server) deviceSession(c *gin.Context) {
var in struct {
SessionID string `json:"sessionId" binding:"required,max=100"`
State string `json:"state" binding:"required,oneof=connected ended"`
}
if !s.decode(c, &in) {
return
}
query := "UPDATE terminals SET session_state='connected',last_seen=now() WHERE id=$1 AND session_id=$2 AND session_desktop_id IS NOT NULL"
if in.State == "ended" {
query = "UPDATE terminals SET session_state='idle',session_id='',session_desktop_id=NULL WHERE id=$1 AND session_id=$2"
}
result, err := s.DB.ExecContext(c.Request.Context(), query, c.GetString("terminalId"), in.SessionID)
if err != nil {
s.dbError(c, err)
return
}
n, _ := result.RowsAffected()
if n == 0 {
s.fail(c, 409, "stale_session", "连接预约已结束或已被新会话替代")
return
}
c.JSON(200, gin.H{"accepted": true})
}
func (s *Server) taskDiagnostics(c *gin.Context) {
var data []byte
if err := s.DB.GetContext(c.Request.Context(), &data, "SELECT content FROM task_diagnostics WHERE task_id=$1", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
s.log(c, "read_diagnostics", c.Param("id"), "success")
c.Data(200, "application/json", data)
}
+59
View File
@@ -0,0 +1,59 @@
CREATE TABLE IF NOT EXISTS platform_metadata (
singleton boolean PRIMARY KEY DEFAULT true CHECK(singleton), mode text NOT NULL CHECK(mode IN ('demo','production'))
);
CREATE TABLE IF NOT EXISTS administrators (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('super_admin','operator','viewer')), password_hash text NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_sessions (
id text PRIMARY KEY, admin_id text NOT NULL REFERENCES administrators(id), expires_at timestamptz NOT NULL
);
CREATE TABLE IF NOT EXISTS desktops (
id text PRIMARY KEY, name text NOT NULL, os text NOT NULL, node text NOT NULL, vmid integer NOT NULL,
pool text NOT NULL DEFAULT '', power_state text NOT NULL, service_state text NOT NULL DEFAULT 'unknown',
provider text NOT NULL CHECK (provider IN ('demo','proxmox')), endpoint text NOT NULL DEFAULT '', app text NOT NULL DEFAULT 'Desktop',
width integer NOT NULL DEFAULT 2560, height integer NOT NULL DEFAULT 1440, fps integer NOT NULL DEFAULT 30,
updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider,node,vmid)
);
CREATE TABLE IF NOT EXISTS terminals (
id text PRIMARY KEY, sn text NOT NULL UNIQUE, name text NOT NULL, group_name text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'unregistered', agent_version text NOT NULL DEFAULT '', last_seen timestamptz,
session_desktop_id text REFERENCES desktops(id), config_version integer NOT NULL DEFAULT 1,
bootstrap_hash text NOT NULL, token_hash text UNIQUE, fps double precision, rtt double precision
);
CREATE TABLE IF NOT EXISTS users (
id text PRIMARY KEY, username text NOT NULL UNIQUE, display_name text NOT NULL,
enabled boolean NOT NULL DEFAULT true, terminal_id text UNIQUE REFERENCES terminals(id)
);
CREATE TABLE IF NOT EXISTS bindings (
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
desktop_id text NOT NULL REFERENCES desktops(id), PRIMARY KEY(user_id,desktop_id)
);
CREATE TABLE IF NOT EXISTS resources (
id text PRIMARY KEY, name text NOT NULL, status text NOT NULL, cpu double precision NOT NULL,
memory double precision NOT NULL, storage double precision NOT NULL, provider text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tasks (
id text PRIMARY KEY, kind text NOT NULL, action text NOT NULL, target_id text NOT NULL, target_name text NOT NULL,
actor text NOT NULL, status text NOT NULL, message text NOT NULL DEFAULT '', provider_ref text NOT NULL DEFAULT '',
idempotency_key text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(actor,idempotency_key)
);
CREATE UNIQUE INDEX IF NOT EXISTS tasks_active_target ON tasks(kind,target_id) WHERE status IN ('queued','running');
CREATE TABLE IF NOT EXISTS audit_logs (
id text PRIMARY KEY, actor text NOT NULL, action text NOT NULL, target text NOT NULL, result text NOT NULL,
request_id text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS connection_tickets (
token_hash text PRIMARY KEY, terminal_id text NOT NULL REFERENCES terminals(id), desktop_id text NOT NULL REFERENCES desktops(id),
expires_at timestamptz NOT NULL, consumed_at timestamptz
);
CREATE INDEX IF NOT EXISTS audit_created ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS tasks_created ON tasks(created_at DESC);
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_id text NOT NULL DEFAULT '';
ALTER TABLE terminals ADD COLUMN IF NOT EXISTS session_state text NOT NULL DEFAULT 'idle';
CREATE TABLE IF NOT EXISTS task_diagnostics (
task_id text PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
);
+119
View File
@@ -0,0 +1,119 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"time"
"vdi-cloud/platform/internal/model"
)
//go:embed schema.sql
var schema string
func Open(ctx context.Context, url string) (*sqlx.DB, error) {
db, err := sqlx.ConnectContext(ctx, "pgx", url)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(12)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
if _, err = db.ExecContext(ctx, schema); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func ID(prefix string) string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return prefix + hex.EncodeToString(b)
}
func Hash(secret string) string { h := sha256.Sum256([]byte(secret)); return hex.EncodeToString(h[:]) }
func Bootstrap(ctx context.Context, db *sqlx.DB, mode, username, password string) error {
if _, err := db.ExecContext(ctx, "INSERT INTO platform_metadata(singleton,mode) VALUES(true,$1) ON CONFLICT DO NOTHING", mode); err != nil {
return err
}
var existingMode string
if err := db.GetContext(ctx, &existingMode, "SELECT mode FROM platform_metadata WHERE singleton=true"); err != nil {
return err
}
if existingMode != mode {
return fmt.Errorf("database mode is %s; use a separate database for %s", existingMode, mode)
}
admins := []struct{ username, name, role, password string }{{username, "平台管理员", "super_admin", password}}
if mode == "demo" {
admins = []struct{ username, name, role, password string }{{"admin", "系统管理员", "super_admin", "Demo@2026"}, {"operator", "运维人员", "operator", "Demo@2026"}, {"viewer", "只读人员", "viewer", "Demo@2026"}}
}
for _, a := range admins {
var exists bool
if err := db.GetContext(ctx, &exists, "SELECT EXISTS(SELECT 1 FROM administrators WHERE username=$1)", a.username); err != nil {
return err
}
if exists {
continue
}
if mode == "production" && len(a.password) < 12 {
return fmt.Errorf("initial administrator password must contain at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(a.password), bcrypt.DefaultCost)
if err != nil {
return err
}
if _, err = db.ExecContext(ctx, "INSERT INTO administrators VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ID("adm_"), a.username, a.name, a.role, string(hash)); err != nil {
return err
}
}
if mode != "demo" {
return nil
}
// Seed the demo only once; restarting must not restore revoked bindings.
var seeded bool
if err := db.GetContext(ctx, &seeded, "SELECT EXISTS(SELECT 1 FROM desktops WHERE provider='demo')"); err != nil {
return err
}
if seeded {
return nil
}
tx, err := db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
statements := []string{
`INSERT INTO desktops(id,name,os,node,vmid,pool,power_state,service_state,provider) VALUES ('desk_demo_windows','设计专属桌面','Windows','demo-node-a',201,'设计专属池','running','unknown','demo'),('desk_demo_ubuntu','研发办公桌面','Ubuntu','demo-node-a',202,'研发专属池','running','unknown','demo'),('desk_demo_kylin','国产办公桌面','麒麟','demo-node-a',203,'通用办公池','stopped','unknown','demo') ON CONFLICT DO NOTHING`,
`INSERT INTO terminals(id,sn,name,group_name,status,agent_version,last_seen,bootstrap_hash) VALUES ('term_demo_001','DEMO-RK-001','设计工位 01','设计部','offline','演示版本',now()-interval '2 hours','disabled'),('term_demo_002','DEMO-RK-002','研发工位 02','研发部','offline','演示版本',now()-interval '3 hours','disabled') ON CONFLICT DO NOTHING`,
`INSERT INTO users(id,username,display_name,terminal_id) VALUES ('user_demo_wang','wang.demo','王工','term_demo_001'),('user_demo_chen','chen.demo','陈工','term_demo_002') ON CONFLICT DO NOTHING`,
`INSERT INTO bindings VALUES ('user_demo_wang','desk_demo_windows'),('user_demo_wang','desk_demo_ubuntu'),('user_demo_chen','desk_demo_ubuntu') ON CONFLICT DO NOTHING`,
`INSERT INTO resources VALUES ('demo-node-a','demo-node-a','online',0.24,0.48,0.35,'demo',now()) ON CONFLICT DO NOTHING`,
}
for _, q := range statements {
if _, err = tx.ExecContext(ctx, q); err != nil {
return fmt.Errorf("demo seed: %w", err)
}
}
return tx.Commit()
}
func Users(ctx context.Context, db *sqlx.DB) ([]model.User, error) {
users := []model.User{}
if err := db.SelectContext(ctx, &users, "SELECT * FROM users ORDER BY username"); err != nil {
return nil, err
}
for i := range users {
users[i].DesktopIDs = []string{}
if err := db.SelectContext(ctx, &users[i].DesktopIDs, "SELECT desktop_id FROM bindings WHERE user_id=$1 ORDER BY desktop_id", users[i].ID); err != nil {
return nil, err
}
}
return users, nil
}
+146
View File
@@ -0,0 +1,146 @@
package proxmox
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Client struct {
BaseURL, TokenID, TokenSecret string
HTTP *http.Client
}
type Resource struct {
Type string `json:"type"`
Node string `json:"node"`
Name string `json:"name"`
Status string `json:"status"`
VMID int `json:"vmid"`
CPU float64 `json:"cpu"`
Memory float64 `json:"mem"`
MaxMemory float64 `json:"maxmem"`
Disk float64 `json:"disk"`
MaxDisk float64 `json:"maxdisk"`
Template int `json:"template"`
}
type TaskStatus struct {
Status string `json:"status"`
ExitStatus string `json:"exitstatus"`
}
var ErrTaskFailed = errors.New("PVE task failed; inspect task logs in PVE")
type HTTPError struct{ Status int }
func (e *HTTPError) Error() string { return fmt.Sprintf("PVE returned HTTP %d", e.Status) }
func New(base, id, secret, caFile string) (*Client, error) {
u, err := url.Parse(base)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("PVE_URL must be an HTTPS origin without credentials or query")
}
if u.Path != "" && u.Path != "/" {
return nil, errors.New("PVE_URL must not include a path")
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if caFile != "" {
data, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
if !pool.AppendCertsFromPEM(data) {
return nil, errors.New("invalid PVE CA file")
}
}
return &Client{BaseURL: strings.TrimRight(base, "/"), TokenID: id, TokenSecret: secret, HTTP: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: pool}}}}, nil
}
func (c *Client) request(ctx context.Context, method, path string, form url.Values, out any) error {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/api2/json"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "PVEAPIToken="+c.TokenID+"="+c.TokenSecret)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
res, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("PVE request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return &HTTPError{Status: res.StatusCode}
}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&envelope); err != nil {
return err
}
return json.Unmarshal(envelope.Data, out)
}
func (c *Client) Resources(ctx context.Context) ([]Resource, error) {
var list []Resource
err := c.request(ctx, "GET", "/cluster/resources", nil, &list)
return list, err
}
func (c *Client) Action(ctx context.Context, node string, vmid int, action string) (string, error) {
if action != "start" && action != "shutdown" && action != "reboot" {
return "", errors.New("unsupported VM action")
}
var upid string
err := c.request(ctx, "POST", fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", url.PathEscape(node), vmid, action), url.Values{}, &upid)
if err == nil && upid == "" {
err = errors.New("PVE did not return a task ID")
}
return upid, err
}
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
var status TaskStatus
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status)
return status, err
}
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
var status struct {
Status string `json:"status"`
}
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status)
return status.Status, err
}
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s, err := c.Task(ctx, node, upid)
if err != nil {
return err
}
if s.Status == "stopped" {
if s.ExitStatus != "OK" {
return ErrTaskFailed
}
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package proxmox
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestTLSAndTaskLifecycle(t *testing.T) {
polls := 0
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "PVEAPIToken=vdi@pve!console=secret" {
t.Error("missing PVE token")
}
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/status/start"):
if r.Method != "POST" {
t.Error("action should POST")
}
w.Write([]byte(`{"data":"UPID:node:123"}`))
case strings.HasSuffix(r.URL.Path, "/status/current"):
w.Write([]byte(`{"data":{"status":"running"}}`))
case strings.Contains(r.URL.Path, "/tasks/"):
polls++
if polls < 2 {
w.Write([]byte(`{"data":{"status":"running"}}`))
} else {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"OK"}}`))
}
default:
w.WriteHeader(404)
}
}))
defer ts.Close()
c, err := New(ts.URL, "vdi@pve!console", "secret", "")
if err != nil {
t.Fatal(err)
}
if _, err = c.Action(context.Background(), "node", 201, "start"); err == nil {
t.Fatal("untrusted PVE certificate must not be accepted")
}
c.HTTP = ts.Client()
upid, err := c.Action(context.Background(), "node", 201, "start")
if err != nil {
t.Fatal(err)
}
if err = c.Wait(context.Background(), "node", upid, time.Millisecond); err != nil {
t.Fatal(err)
}
if polls != 2 {
t.Fatalf("expected task polling; got %d", polls)
}
if _, err = c.Action(context.Background(), "node", 201, "restore"); err == nil {
t.Fatal("restore must never map to hard reset")
}
}
func TestFailedPVEJobIsNotSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))
}))
defer ts.Close()
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
if err := c.Wait(context.Background(), "node", "task", time.Millisecond); err == nil {
t.Fatal("failed PVE job reported as success")
}
}
func TestRejectInsecureOrigin(t *testing.T) {
for _, base := range []string{"http://pve:8006", "https://user:password@pve:8006", "https://pve:8006/other"} {
if _, err := New(base, "id", "secret", ""); err == nil {
t.Fatalf("accepted %s", base)
}
}
}