Add VNC remote desktop support for sandbox containers
- Add VNC-enabled Docker images (playwright, desktop) with Xvfb, x11vnc, noVNC
- Implement VNC proxy service for WebSocket-based VNC access
- Add API endpoints: /sandbox/{id}/vnc, /vnc/client, /vnc/ws
- Support dynamic VNC port mapping for Docker Desktop (macOS/Windows)
- Add YAO_SANDBOX_VNC_PORT_MAPPING config option for local development
- Update build.sh to support building VNC images
- Include design document and updated README
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
015fc9ef92
commit
69058787f9
15 changed files with 2811 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -56,6 +56,7 @@ agent/test/MULTI_TURN_DESIGN.md
|
||||||
agent/test/UPGRADE_PLAN.md
|
agent/test/UPGRADE_PLAN.md
|
||||||
introduction/*
|
introduction/*
|
||||||
!sandbox/docker/build.sh
|
!sandbox/docker/build.sh
|
||||||
|
!sandbox/docker/vnc/*.sh
|
||||||
sandbox/docker/yao-bridge-*
|
sandbox/docker/yao-bridge-*
|
||||||
sandbox/docker/claude-proxy-*
|
sandbox/docker/claude-proxy-*
|
||||||
sandbox/docker/claude/claude-proxy-*
|
sandbox/docker/claude/claude-proxy-*
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/yaoapp/yao/openapi/oauth/acl"
|
"github.com/yaoapp/yao/openapi/oauth/acl"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
|
"github.com/yaoapp/yao/openapi/sandbox"
|
||||||
"github.com/yaoapp/yao/openapi/team"
|
"github.com/yaoapp/yao/openapi/team"
|
||||||
openapiTrace "github.com/yaoapp/yao/openapi/trace"
|
openapiTrace "github.com/yaoapp/yao/openapi/trace"
|
||||||
"github.com/yaoapp/yao/openapi/user"
|
"github.com/yaoapp/yao/openapi/user"
|
||||||
|
|
@ -159,6 +160,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
|
||||||
// App handlers (menu, etc.)
|
// App handlers (menu, etc.)
|
||||||
app.Attach(group.Group("/app"), openapi.OAuth)
|
app.Attach(group.Group("/app"), openapi.OAuth)
|
||||||
|
|
||||||
|
// Sandbox handlers (VNC proxy for visual browser automation)
|
||||||
|
sandbox.Attach(group.Group("/sandbox"), openapi.OAuth)
|
||||||
|
|
||||||
// Custom handlers (Defined by developer)
|
// Custom handlers (Defined by developer)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
openapi/sandbox/sandbox.go
Normal file
98
openapi/sandbox/sandbox.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
"github.com/yaoapp/yao/sandbox/vncproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
var vncProxy *vncproxy.Proxy
|
||||||
|
|
||||||
|
// Attach attaches sandbox handlers to the router group
|
||||||
|
// Routes:
|
||||||
|
// - GET /sandbox/:id/vnc - Get VNC status
|
||||||
|
// - GET /sandbox/:id/vnc/client - Get noVNC client page
|
||||||
|
// - GET /sandbox/:id/vnc/ws - WebSocket proxy to container VNC
|
||||||
|
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
|
// Initialize VNC proxy lazily on first request
|
||||||
|
// This avoids startup errors if Docker is not available
|
||||||
|
|
||||||
|
// VNC status endpoint (requires auth)
|
||||||
|
group.GET("/:id/vnc", oauth.Guard, handleVNCStatus)
|
||||||
|
|
||||||
|
// VNC client page (requires auth)
|
||||||
|
group.GET("/:id/vnc/client", oauth.Guard, handleVNCClient)
|
||||||
|
|
||||||
|
// VNC WebSocket proxy (requires auth)
|
||||||
|
// Note: WebSocket upgrade happens after auth middleware
|
||||||
|
group.GET("/:id/vnc/ws", oauth.Guard, handleVNCWebSocket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureProxy ensures the VNC proxy is initialized
|
||||||
|
func ensureProxy() error {
|
||||||
|
if vncProxy != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
vncProxy, err = vncproxy.NewProxy(nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVNCStatus returns VNC status for a sandbox container
|
||||||
|
func handleVNCStatus(c *gin.Context) {
|
||||||
|
if err := ensureProxy(); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "VNC service not available",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite path to match vncproxy expected format
|
||||||
|
sandboxID := c.Param("id")
|
||||||
|
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc"
|
||||||
|
|
||||||
|
vncProxy.HandleVNCStatus(c.Writer, c.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVNCClient serves the noVNC client page
|
||||||
|
func handleVNCClient(c *gin.Context) {
|
||||||
|
if err := ensureProxy(); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "VNC service not available",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite path to match vncproxy expected format
|
||||||
|
sandboxID := c.Param("id")
|
||||||
|
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc/client"
|
||||||
|
|
||||||
|
vncProxy.HandleVNCClient(c.Writer, c.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVNCWebSocket proxies WebSocket to container VNC
|
||||||
|
func handleVNCWebSocket(c *gin.Context) {
|
||||||
|
if err := ensureProxy(); err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "VNC service not available",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite path to match vncproxy expected format
|
||||||
|
sandboxID := c.Param("id")
|
||||||
|
c.Request.URL.Path = "/v1/sandbox/" + sandboxID + "/vnc/ws"
|
||||||
|
|
||||||
|
vncProxy.HandleVNCWebSocket(c.Writer, c.Request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the VNC proxy and releases resources
|
||||||
|
func Close() error {
|
||||||
|
if vncProxy != nil {
|
||||||
|
return vncProxy.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1498
sandbox/DESIGN-PLAYWRIGHT-VNC.md
Normal file
1498
sandbox/DESIGN-PLAYWRIGHT-VNC.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
|
||||||
- IPC communication via Unix sockets
|
- IPC communication via Unix sockets
|
||||||
- Resource limits (CPU, memory)
|
- Resource limits (CPU, memory)
|
||||||
- Security isolation
|
- Security isolation
|
||||||
|
- **VNC remote desktop** for visual transparency (optional)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -26,11 +27,20 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ └────────────────────────┬────────────────────────────────┘ │
|
│ └────────────────────────┬────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
|
│ ┌────────────────────────┴────────────────────────────────┐ │
|
||||||
|
│ │ VNC Proxy Service │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ - GET /v1/sandbox/{id}/vnc → VNC status │ │
|
||||||
|
│ │ - GET /v1/sandbox/{id}/vnc/client → noVNC page │ │
|
||||||
|
│ │ - GET /v1/sandbox/{id}/vnc/ws → WebSocket proxy │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
│ ┌───────────────┼───────────────┐ │
|
│ ┌───────────────┼───────────────┐ │
|
||||||
│ ▼ ▼ ▼ │
|
│ ▼ ▼ ▼ │
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||||
│ │ Container │ │ Container │ │ Container │ │
|
│ │ sandbox- │ │ sandbox- │ │ sandbox- │ │
|
||||||
│ │ (user1) │ │ (user2) │ │ (user3) │ │
|
│ │ claude │ │ playwright │ │ desktop │ │
|
||||||
|
│ │ (No VNC) │ │ (VNC) │ │ (VNC) │ │
|
||||||
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
|
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
|
||||||
│ │ │ │ │
|
│ │ │ │ │
|
||||||
│ ──────┴───────────────┴───────────────┴──── │
|
│ ──────┴───────────────┴───────────────┴──── │
|
||||||
|
|
@ -45,7 +55,16 @@ The sandbox module enables Yao to safely run external AI coding agents (like Cla
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sandbox/docker
|
cd sandbox/docker
|
||||||
|
|
||||||
|
# Build base image
|
||||||
./build.sh claude
|
./build.sh claude
|
||||||
|
|
||||||
|
# Build VNC-enabled images
|
||||||
|
./build.sh playwright # Playwright + Fluxbox + VNC
|
||||||
|
./build.sh desktop # XFCE Desktop + VNC
|
||||||
|
|
||||||
|
# Build all images
|
||||||
|
./build.sh all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
@ -84,23 +103,37 @@ data, err := manager.ReadFile(ctx, container.Name, "/workspace/test.txt")
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| -------------------------- | ----------------------------------- | ------------------------- |
|
| ------------------------------ | ----------------------------------- | ---------------------------------------------- |
|
||||||
| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Docker image |
|
| `YAO_SANDBOX_IMAGE` | `yao/sandbox-claude:latest` | Docker image |
|
||||||
| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace directory |
|
| `YAO_SANDBOX_WORKSPACE` | `{YAO_DATA_ROOT}/sandbox/workspace` | Workspace directory |
|
||||||
| `YAO_SANDBOX_IPC` | `{YAO_DATA_ROOT}/sandbox/ipc` | IPC socket directory |
|
| `YAO_SANDBOX_IPC` | `{YAO_DATA_ROOT}/sandbox/ipc` | IPC socket directory |
|
||||||
| `YAO_SANDBOX_MAX` | `100` | Max concurrent containers |
|
| `YAO_SANDBOX_MAX` | `100` | Max concurrent containers |
|
||||||
| `YAO_SANDBOX_IDLE_TIMEOUT` | `30m` | Idle timeout |
|
| `YAO_SANDBOX_IDLE_TIMEOUT` | `30m` | Idle timeout |
|
||||||
| `YAO_SANDBOX_MEMORY` | `2g` | Memory limit |
|
| `YAO_SANDBOX_MEMORY` | `2g` | Memory limit |
|
||||||
| `YAO_SANDBOX_CPU` | `1.0` | CPU limit |
|
| `YAO_SANDBOX_CPU` | `1.0` | CPU limit |
|
||||||
|
| `YAO_SANDBOX_VNC_PORT_MAPPING` | `false` | Enable VNC port mapping (for Docker Desktop) |
|
||||||
|
|
||||||
|
### Docker Desktop (macOS/Windows)
|
||||||
|
|
||||||
|
Docker Desktop runs containers in a LinuxKit VM, so container IPs are not directly accessible from the host. Enable VNC port mapping for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export YAO_SANDBOX_VNC_PORT_MAPPING=true
|
||||||
|
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-playwright:latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
When enabled, VNC ports (6080, 5900) are automatically mapped to random available host ports on `127.0.0.1`.
|
||||||
|
|
||||||
## Docker Images
|
## Docker Images
|
||||||
|
|
||||||
| Image | Description |
|
| Image | VNC | Description |
|
||||||
| --------------------------- | ------------------------------------- |
|
| ------------------------------------------ | --- | ------------------------------------- |
|
||||||
| `yao/sandbox-base:latest` | Base image with git, curl, yao-bridge |
|
| `yaoapp/sandbox-base:latest` | ❌ | Base image with git, curl, yao-bridge |
|
||||||
| `yao/sandbox-claude:latest` | + Claude CLI, Node.js 20, Python 3.11 |
|
| `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 |
|
||||||
| `yao/sandbox-claude:full` | + Go 1.23 |
|
| `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 |
|
||||||
|
| `yaoapp/sandbox-claude-playwright:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
|
||||||
|
| `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
|
||||||
|
|
||||||
## IPC Communication
|
## IPC Communication
|
||||||
|
|
||||||
|
|
@ -112,6 +145,25 @@ Supported methods:
|
||||||
- `tools/list` - List available tools
|
- `tools/list` - List available tools
|
||||||
- `tools/call` - Execute a tool
|
- `tools/call` - Execute a tool
|
||||||
|
|
||||||
|
## VNC Remote Desktop
|
||||||
|
|
||||||
|
VNC-enabled images (playwright, desktop) provide real-time visibility into Claude's operations.
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
| ------------------------------- | ---------------------------------- |
|
||||||
|
| `GET /v1/sandbox/{id}/vnc` | VNC status (ready/starting/unavailable) |
|
||||||
|
| `GET /v1/sandbox/{id}/vnc/client` | noVNC HTML client page |
|
||||||
|
| `GET /v1/sandbox/{id}/vnc/ws` | WebSocket proxy to container VNC |
|
||||||
|
|
||||||
|
### View Modes
|
||||||
|
|
||||||
|
- **Interactive** (default): User can use keyboard and mouse
|
||||||
|
- **View-only** (`?viewonly=true`): User can only watch
|
||||||
|
|
||||||
|
For detailed design, see [DESIGN-PLAYWRIGHT-VNC.md](./DESIGN-PLAYWRIGHT-VNC.md).
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -120,11 +172,18 @@ sandbox/
|
||||||
├── docker/ # Dockerfiles and build script
|
├── docker/ # Dockerfiles and build script
|
||||||
│ ├── base/
|
│ ├── base/
|
||||||
│ ├── claude/
|
│ ├── claude/
|
||||||
|
│ ├── playwright/ # Playwright + VNC image
|
||||||
|
│ ├── desktop/ # XFCE Desktop + VNC image
|
||||||
|
│ ├── vnc/ # Shared VNC scripts
|
||||||
│ └── build.sh
|
│ └── build.sh
|
||||||
├── ipc/ # IPC system
|
├── ipc/ # IPC system
|
||||||
│ ├── manager.go
|
│ ├── manager.go
|
||||||
│ ├── session.go
|
│ ├── session.go
|
||||||
│ └── types.go
|
│ └── types.go
|
||||||
|
├── vncproxy/ # VNC proxy service
|
||||||
|
│ ├── proxy.go
|
||||||
|
│ ├── config.go
|
||||||
|
│ └── proxy_test.go
|
||||||
├── config.go # Configuration
|
├── config.go # Configuration
|
||||||
├── errors.go # Error types
|
├── errors.go # Error types
|
||||||
├── helpers.go # Helper functions
|
├── helpers.go # Helper functions
|
||||||
|
|
@ -135,11 +194,17 @@ sandbox/
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Load environment variables first
|
||||||
|
source env.local.sh
|
||||||
|
|
||||||
# Unit tests (no Docker required)
|
# Unit tests (no Docker required)
|
||||||
go test -v ./sandbox/... -run "^Test.*Validation|^Test.*Generation|^Test.*Parsing"
|
go test -v ./sandbox/... -run "^Test.*Validation|^Test.*Generation|^Test.*Parsing"
|
||||||
|
|
||||||
# All tests (requires Docker)
|
# All tests (requires Docker)
|
||||||
go test -v ./sandbox/...
|
go test -v ./sandbox/...
|
||||||
|
|
||||||
|
# VNC proxy tests only
|
||||||
|
go test -v ./sandbox/vncproxy/...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ type Config struct {
|
||||||
ContainerWorkDir string `json:"container_workdir,omitempty"` // Container working directory, default: /workspace
|
ContainerWorkDir string `json:"container_workdir,omitempty"` // Container working directory, default: /workspace
|
||||||
ContainerIPCSocket string `json:"container_ipc_socket,omitempty"` // Container IPC socket path, default: /tmp/yao.sock
|
ContainerIPCSocket string `json:"container_ipc_socket,omitempty"` // Container IPC socket path, default: /tmp/yao.sock
|
||||||
ContainerUser string `json:"container_user,omitempty"` // Container user, default: "" (use image default). Set to "0" for root.
|
ContainerUser string `json:"container_user,omitempty"` // Container user, default: "" (use image default). Set to "0" for root.
|
||||||
|
|
||||||
|
// VNC port mapping (for Docker Desktop on macOS/Windows where container IPs are not directly accessible)
|
||||||
|
VNCPortMapping bool `json:"vnc_port_mapping,omitempty"` // Enable VNC port mapping to host, default: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns a Config with default values
|
// DefaultConfig returns a Config with default values
|
||||||
|
|
@ -116,4 +119,9 @@ func (c *Config) Init(dataRoot string) {
|
||||||
if env := os.Getenv("YAO_SANDBOX_CONTAINER_USER"); env != "" {
|
if env := os.Getenv("YAO_SANDBOX_CONTAINER_USER"); env != "" {
|
||||||
c.ContainerUser = env
|
c.ContainerUser = env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNC port mapping (for Docker Desktop on macOS/Windows)
|
||||||
|
if env := os.Getenv("YAO_SANDBOX_VNC_PORT_MAPPING"); env != "" {
|
||||||
|
c.VNCPortMapping = env == "true" || env == "1" || env == "yes"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,22 @@ case $TOOL in
|
||||||
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
|
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
|
||||||
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
|
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
|
||||||
;;
|
;;
|
||||||
|
claude-vnc)
|
||||||
|
echo ""
|
||||||
|
echo "=== Building Claude VNC images (Playwright + Desktop) ==="
|
||||||
|
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
|
||||||
|
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
|
||||||
|
;;
|
||||||
|
playwright)
|
||||||
|
echo ""
|
||||||
|
echo "=== Building Claude Playwright image ==="
|
||||||
|
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
|
||||||
|
;;
|
||||||
|
desktop)
|
||||||
|
echo ""
|
||||||
|
echo "=== Building Claude Desktop image ==="
|
||||||
|
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
|
||||||
|
;;
|
||||||
cursor)
|
cursor)
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Building Cursor images ==="
|
echo "=== Building Cursor images ==="
|
||||||
|
|
@ -116,14 +132,20 @@ case $TOOL in
|
||||||
# Claude
|
# Claude
|
||||||
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
|
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
|
||||||
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
|
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
|
||||||
|
# Claude VNC variants
|
||||||
|
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
|
||||||
|
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
|
||||||
# Cursor (uncomment when ready)
|
# Cursor (uncomment when ready)
|
||||||
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
|
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown tool: $TOOL"
|
echo "Unknown tool: $TOOL"
|
||||||
echo "Usage: $0 [claude|cursor|all] [true|false]"
|
echo "Usage: $0 [claude|claude-vnc|playwright|desktop|cursor|all] [true|false]"
|
||||||
echo " $0 claude # Build Claude images locally"
|
echo " $0 claude # Build Claude images locally"
|
||||||
echo " $0 claude true # Build and push Claude images"
|
echo " $0 claude true # Build and push Claude images"
|
||||||
|
echo " $0 claude-vnc # Build Claude VNC images (Playwright + Desktop)"
|
||||||
|
echo " $0 playwright # Build Claude Playwright image only"
|
||||||
|
echo " $0 desktop # Build Claude Desktop image only"
|
||||||
echo " $0 all true # Build and push all images"
|
echo " $0 all true # Build and push all images"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|
@ -142,9 +164,21 @@ if [ "$PUSH" = "true" ]; then
|
||||||
echo " - ${REGISTRY}/sandbox-claude:latest"
|
echo " - ${REGISTRY}/sandbox-claude:latest"
|
||||||
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
||||||
;;
|
;;
|
||||||
|
claude-vnc)
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
||||||
|
;;
|
||||||
|
playwright)
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
|
||||||
|
;;
|
||||||
|
desktop)
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
||||||
|
;;
|
||||||
all)
|
all)
|
||||||
echo " - ${REGISTRY}/sandbox-claude:latest"
|
echo " - ${REGISTRY}/sandbox-claude:latest"
|
||||||
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
echo " - ${REGISTRY}/sandbox-claude-full:latest"
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
|
||||||
|
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
91
sandbox/docker/desktop/Dockerfile
Normal file
91
sandbox/docker/desktop/Dockerfile
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Claude sandbox with full XFCE desktop + VNC preview
|
||||||
|
# Image: sandbox-claude-desktop
|
||||||
|
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
|
||||||
|
# Adds: Xvfb + x11vnc + noVNC + XFCE desktop + File Manager + Terminal
|
||||||
|
#
|
||||||
|
# Supports both amd64 and arm64 architectures
|
||||||
|
|
||||||
|
ARG REGISTRY=yaoapp
|
||||||
|
FROM ${REGISTRY}/sandbox-claude:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Use MIT mirror (USA) for ARM64
|
||||||
|
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
|
||||||
|
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install X11, VNC, and XFCE desktop environment
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# Virtual display
|
||||||
|
xvfb \
|
||||||
|
# VNC server
|
||||||
|
x11vnc \
|
||||||
|
# noVNC (HTML5 VNC client) and websockify
|
||||||
|
novnc \
|
||||||
|
python3-websockify \
|
||||||
|
# XFCE Desktop (full-featured but lightweight)
|
||||||
|
xfce4 \
|
||||||
|
xfce4-terminal \
|
||||||
|
thunar \
|
||||||
|
# Fonts (required for proper rendering)
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
# X11 utilities
|
||||||
|
x11-utils \
|
||||||
|
xdotool \
|
||||||
|
# Audio
|
||||||
|
pulseaudio \
|
||||||
|
# Remove screensaver (causes issues in container)
|
||||||
|
&& apt-get remove -y xfce4-screensaver xscreensaver || true \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Optional: Install Playwright system dependencies (requires root)
|
||||||
|
# Users can run browser automation in desktop mode too
|
||||||
|
RUN npx playwright install-deps chromium || true
|
||||||
|
|
||||||
|
# Optional: Install Playwright for browser automation
|
||||||
|
USER sandbox
|
||||||
|
RUN npm install -g playwright && \
|
||||||
|
pip install --user --break-system-packages playwright && \
|
||||||
|
npx playwright install chromium || true
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Copy VNC startup scripts
|
||||||
|
# Note: Build context should be sandbox/docker/, so paths are relative to that
|
||||||
|
COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
|
||||||
|
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Environment variables for VNC
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
ENV VNC_PORT=5900
|
||||||
|
ENV NOVNC_PORT=6080
|
||||||
|
ENV RESOLUTION=1920x1080x24
|
||||||
|
ENV SANDBOX_VNC_ENABLED=true
|
||||||
|
ENV SANDBOX_DESKTOP=xfce
|
||||||
|
|
||||||
|
# Node.js environment - ensure global modules are accessible
|
||||||
|
ENV NODE_PATH=/home/sandbox/.npm-global/lib/node_modules
|
||||||
|
|
||||||
|
# Expose VNC ports (internal use only, accessed via proxy)
|
||||||
|
EXPOSE 5900 6080
|
||||||
|
|
||||||
|
USER sandbox
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Verify installations
|
||||||
|
RUN echo "=== Verifying installations ===" && \
|
||||||
|
node --version && \
|
||||||
|
npm --version && \
|
||||||
|
python3 --version && \
|
||||||
|
which startxfce4 && \
|
||||||
|
which thunar && \
|
||||||
|
which xfce4-terminal && \
|
||||||
|
which x11vnc && \
|
||||||
|
which Xvfb && \
|
||||||
|
echo "=== All installations verified ==="
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
89
sandbox/docker/playwright/Dockerfile
Normal file
89
sandbox/docker/playwright/Dockerfile
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Claude sandbox with Playwright browser automation + VNC preview
|
||||||
|
# Image: sandbox-claude-playwright
|
||||||
|
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
|
||||||
|
# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright browsers
|
||||||
|
#
|
||||||
|
# Supports both amd64 and arm64 architectures
|
||||||
|
|
||||||
|
ARG REGISTRY=yaoapp
|
||||||
|
FROM ${REGISTRY}/sandbox-claude:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Use MIT mirror (USA) for ARM64
|
||||||
|
RUN sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list.d/ubuntu.sources 2>/dev/null || \
|
||||||
|
sed -i 's|http://ports.ubuntu.com/ubuntu-ports|http://mirrors.mit.edu/ubuntu-ports|g' /etc/apt/sources.list 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install X11, VNC, and minimal window manager
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# Virtual display
|
||||||
|
xvfb \
|
||||||
|
# VNC server
|
||||||
|
x11vnc \
|
||||||
|
# noVNC (HTML5 VNC client) and websockify
|
||||||
|
novnc \
|
||||||
|
python3-websockify \
|
||||||
|
# Minimal window manager (lightweight, perfect for Playwright)
|
||||||
|
fluxbox \
|
||||||
|
# Fonts (required for proper browser rendering)
|
||||||
|
fonts-liberation \
|
||||||
|
fonts-noto-cjk \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
|
# X11 utilities
|
||||||
|
x11-utils \
|
||||||
|
xdotool \
|
||||||
|
# Audio (for video playback in browsers, can be disabled)
|
||||||
|
pulseaudio \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Playwright system dependencies (requires root)
|
||||||
|
# This installs system libraries needed by Chromium/Firefox
|
||||||
|
RUN npx playwright install-deps chromium firefox || true
|
||||||
|
|
||||||
|
# Install Playwright and browsers as sandbox user
|
||||||
|
USER sandbox
|
||||||
|
|
||||||
|
# Install Playwright for Node.js (global) and Python
|
||||||
|
RUN npm install -g playwright && \
|
||||||
|
pip install --user --break-system-packages playwright && \
|
||||||
|
npx playwright install chromium firefox
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Copy VNC startup scripts
|
||||||
|
# Note: Build context should be sandbox/docker/, so paths are relative to that
|
||||||
|
COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
|
||||||
|
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Environment variables for VNC
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
ENV VNC_PORT=5900
|
||||||
|
ENV NOVNC_PORT=6080
|
||||||
|
ENV RESOLUTION=1920x1080x24
|
||||||
|
ENV SANDBOX_VNC_ENABLED=true
|
||||||
|
ENV SANDBOX_DESKTOP=fluxbox
|
||||||
|
|
||||||
|
# Node.js environment - ensure global modules are accessible
|
||||||
|
ENV NODE_PATH=/home/sandbox/.npm-global/lib/node_modules
|
||||||
|
|
||||||
|
# Expose VNC ports (internal use only, accessed via proxy)
|
||||||
|
EXPOSE 5900 6080
|
||||||
|
|
||||||
|
USER sandbox
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Verify installations
|
||||||
|
RUN echo "=== Verifying installations ===" && \
|
||||||
|
node --version && \
|
||||||
|
npm --version && \
|
||||||
|
python3 --version && \
|
||||||
|
npx playwright --version && \
|
||||||
|
python3 -c "from playwright.sync_api import sync_playwright; print('Python Playwright: OK')" && \
|
||||||
|
which fluxbox && \
|
||||||
|
which x11vnc && \
|
||||||
|
which Xvfb && \
|
||||||
|
echo "=== All installations verified ==="
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
40
sandbox/docker/vnc/entrypoint-vnc.sh
Normal file
40
sandbox/docker/vnc/entrypoint-vnc.sh
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Container entrypoint for VNC-enabled sandbox images
|
||||||
|
# This extends the original sandbox-claude entrypoint with VNC support
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# VNC Services Startup
|
||||||
|
# ============================================
|
||||||
|
if [ "$SANDBOX_VNC_ENABLED" = "true" ]; then
|
||||||
|
echo "[Entrypoint] Starting VNC services..."
|
||||||
|
/usr/local/bin/start-vnc.sh &
|
||||||
|
# Wait for VNC to initialize
|
||||||
|
sleep 3
|
||||||
|
echo "[Entrypoint] VNC services started in background"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Original sandbox-claude entrypoint logic
|
||||||
|
# (from sandbox-claude Dockerfile)
|
||||||
|
# ============================================
|
||||||
|
WORKSPACE="${WORKSPACE:-/workspace}"
|
||||||
|
PORT="${CLAUDE_PROXY_PORT:-3456}"
|
||||||
|
ENV_FILE="/tmp/claude-proxy-env"
|
||||||
|
|
||||||
|
# If proxy env vars are set AND proxy is not running, start it
|
||||||
|
# This supports docker run -e CLAUDE_PROXY_BACKEND=... usage
|
||||||
|
if [ -n "$CLAUDE_PROXY_BACKEND" ] && [ -n "$CLAUDE_PROXY_API_KEY" ] && [ -n "$CLAUDE_PROXY_MODEL" ]; then
|
||||||
|
if ! curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
/usr/local/bin/start-claude-proxy
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write env vars to a file that can be sourced
|
||||||
|
if curl -s "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1; then
|
||||||
|
echo "export ANTHROPIC_BASE_URL=http://127.0.0.1:${PORT}" > "$ENV_FILE"
|
||||||
|
echo "export ANTHROPIC_API_KEY=dummy" >> "$ENV_FILE"
|
||||||
|
chmod 644 "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute the command passed to docker run
|
||||||
|
exec "$@"
|
||||||
94
sandbox/docker/vnc/start-vnc.sh
Normal file
94
sandbox/docker/vnc/start-vnc.sh
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# VNC services startup script
|
||||||
|
# Shared by sandbox-claude-playwright and sandbox-claude-desktop
|
||||||
|
# Starts: Xvfb (virtual display) + Window Manager + x11vnc + websockify (noVNC)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DISPLAY_NUM="${DISPLAY_NUM:-99}"
|
||||||
|
RESOLUTION="${RESOLUTION:-1920x1080x24}"
|
||||||
|
VNC_PORT="${VNC_PORT:-5900}"
|
||||||
|
NOVNC_PORT="${NOVNC_PORT:-6080}"
|
||||||
|
VNC_PASSWORD="${VNC_PASSWORD:-}"
|
||||||
|
DESKTOP="${SANDBOX_DESKTOP:-fluxbox}"
|
||||||
|
|
||||||
|
export DISPLAY=:${DISPLAY_NUM}
|
||||||
|
|
||||||
|
echo "[VNC] Starting VNC services..."
|
||||||
|
echo "[VNC] Display: :${DISPLAY_NUM}"
|
||||||
|
echo "[VNC] Resolution: ${RESOLUTION}"
|
||||||
|
echo "[VNC] Desktop: ${DESKTOP}"
|
||||||
|
|
||||||
|
# Start Xvfb (virtual framebuffer)
|
||||||
|
echo "[VNC] Starting Xvfb..."
|
||||||
|
Xvfb :${DISPLAY_NUM} -screen 0 ${RESOLUTION} &
|
||||||
|
XVFB_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if ! kill -0 $XVFB_PID 2>/dev/null; then
|
||||||
|
echo "[VNC] ERROR: Xvfb failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[VNC] Xvfb started (PID: $XVFB_PID)"
|
||||||
|
|
||||||
|
# Start window manager / desktop environment
|
||||||
|
echo "[VNC] Starting ${DESKTOP}..."
|
||||||
|
case "$DESKTOP" in
|
||||||
|
xfce|xfce4)
|
||||||
|
# XFCE desktop environment
|
||||||
|
startxfce4 &
|
||||||
|
;;
|
||||||
|
fluxbox)
|
||||||
|
# Minimal window manager for Playwright
|
||||||
|
fluxbox &
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Default to fluxbox
|
||||||
|
fluxbox &
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start x11vnc server
|
||||||
|
echo "[VNC] Starting x11vnc on port ${VNC_PORT}..."
|
||||||
|
VNC_ARGS="-display :${DISPLAY_NUM} -forever -shared -rfbport ${VNC_PORT} -noxdamage"
|
||||||
|
|
||||||
|
if [ -n "$VNC_PASSWORD" ]; then
|
||||||
|
mkdir -p ~/.vnc
|
||||||
|
x11vnc -storepasswd "$VNC_PASSWORD" ~/.vnc/passwd
|
||||||
|
VNC_ARGS="$VNC_ARGS -rfbauth ~/.vnc/passwd"
|
||||||
|
else
|
||||||
|
VNC_ARGS="$VNC_ARGS -nopw"
|
||||||
|
fi
|
||||||
|
|
||||||
|
x11vnc $VNC_ARGS &
|
||||||
|
X11VNC_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if ! kill -0 $X11VNC_PID 2>/dev/null; then
|
||||||
|
echo "[VNC] ERROR: x11vnc failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[VNC] x11vnc started (PID: $X11VNC_PID)"
|
||||||
|
|
||||||
|
# Start websockify (noVNC WebSocket proxy)
|
||||||
|
echo "[VNC] Starting websockify on port ${NOVNC_PORT}..."
|
||||||
|
websockify --web=/usr/share/novnc/ ${NOVNC_PORT} localhost:${VNC_PORT} &
|
||||||
|
WEBSOCKIFY_PID=$!
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if ! kill -0 $WEBSOCKIFY_PID 2>/dev/null; then
|
||||||
|
echo "[VNC] ERROR: websockify failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[VNC] websockify started (PID: $WEBSOCKIFY_PID)"
|
||||||
|
|
||||||
|
echo "[VNC] =================================="
|
||||||
|
echo "[VNC] VNC services started successfully"
|
||||||
|
echo "[VNC] Desktop: ${DESKTOP}"
|
||||||
|
echo "[VNC] VNC port: ${VNC_PORT}"
|
||||||
|
echo "[VNC] noVNC port: ${NOVNC_PORT}"
|
||||||
|
echo "[VNC] =================================="
|
||||||
|
|
||||||
|
# Note: Don't wait here - let the entrypoint continue
|
||||||
|
# Background processes will keep running
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -17,6 +18,7 @@ import (
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/docker/docker/pkg/stdcopy"
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/yaoapp/yao/sandbox/ipc"
|
"github.com/yaoapp/yao/sandbox/ipc"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -306,6 +308,24 @@ func (m *Manager) createContainer(ctx context.Context, userID, chatID string) (*
|
||||||
CapDrop: []string{"ALL"},
|
CapDrop: []string{"ALL"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNC port mapping for Docker Desktop (macOS/Windows)
|
||||||
|
// Only enable for VNC-capable images (playwright/desktop) when config is enabled
|
||||||
|
if m.config.VNCPortMapping && isVNCImage(m.config.Image) {
|
||||||
|
// Expose VNC ports in container config
|
||||||
|
containerConfig.ExposedPorts = nat.PortSet{
|
||||||
|
"6080/tcp": struct{}{}, // noVNC websockify
|
||||||
|
"5900/tcp": struct{}{}, // VNC
|
||||||
|
}
|
||||||
|
// Enable SANDBOX_VNC_ENABLED environment variable
|
||||||
|
containerConfig.Env = append(containerConfig.Env, "SANDBOX_VNC_ENABLED=true")
|
||||||
|
|
||||||
|
// Map to random available ports on 127.0.0.1
|
||||||
|
hostConfig.PortBindings = nat.PortMap{
|
||||||
|
"6080/tcp": []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}}, // empty = random port
|
||||||
|
"5900/tcp": []nat.PortBinding{{HostIP: "127.0.0.1", HostPort: ""}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create container
|
// Create container
|
||||||
resp, err := m.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, name)
|
resp, err := m.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -905,3 +925,19 @@ func (m *Manager) fixIPCSocketPermissions(ctx context.Context, containerID strin
|
||||||
// Wait briefly for the chmod to complete
|
// Wait briefly for the chmod to complete
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isVNCImage checks if the image is VNC-capable (playwright or desktop variants)
|
||||||
|
func isVNCImage(imageName string) bool {
|
||||||
|
return strings.Contains(imageName, "playwright") || strings.Contains(imageName, "desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAvailablePort finds an available port on the host
|
||||||
|
// This is used as a fallback; Docker can auto-assign ports when HostPort is empty
|
||||||
|
func findAvailablePort() (int, error) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
81
sandbox/vncproxy/config.go
Normal file
81
sandbox/vncproxy/config.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package vncproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds VNC proxy configuration
|
||||||
|
type Config struct {
|
||||||
|
// Network settings
|
||||||
|
DockerNetwork string `json:"docker_network,omitempty"` // Docker network name (default: bridge)
|
||||||
|
ContainerNoVNCPort int `json:"container_novnc_port,omitempty"` // noVNC port inside container (default: 6080)
|
||||||
|
ContainerVNCPort int `json:"container_vnc_port,omitempty"` // VNC port inside container (default: 5900)
|
||||||
|
ContainerNamePrefix string `json:"container_name_prefix,omitempty"` // Container name prefix (default: yao-sandbox-)
|
||||||
|
|
||||||
|
// Cache settings
|
||||||
|
IPCacheTTL time.Duration `json:"ip_cache_ttl,omitempty"` // IP cache TTL (default: 30s)
|
||||||
|
|
||||||
|
// VNC status check
|
||||||
|
VNCCheckTimeout time.Duration `json:"vnc_check_timeout,omitempty"` // Timeout for VNC ready check (default: 2s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns default configuration
|
||||||
|
func DefaultConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
DockerNetwork: "bridge",
|
||||||
|
ContainerNoVNCPort: 6080,
|
||||||
|
ContainerVNCPort: 5900,
|
||||||
|
ContainerNamePrefix: "yao-sandbox-",
|
||||||
|
IPCacheTTL: 30 * time.Second,
|
||||||
|
VNCCheckTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes config from environment variables
|
||||||
|
func (c *Config) Init() {
|
||||||
|
if env := os.Getenv("YAO_VNC_DOCKER_NETWORK"); env != "" {
|
||||||
|
c.DockerNetwork = env
|
||||||
|
} else if c.DockerNetwork == "" {
|
||||||
|
c.DockerNetwork = "bridge"
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("YAO_VNC_CONTAINER_NOVNC_PORT"); env != "" {
|
||||||
|
if v, err := strconv.Atoi(env); err == nil && v > 0 {
|
||||||
|
c.ContainerNoVNCPort = v
|
||||||
|
}
|
||||||
|
} else if c.ContainerNoVNCPort == 0 {
|
||||||
|
c.ContainerNoVNCPort = 6080
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("YAO_VNC_CONTAINER_VNC_PORT"); env != "" {
|
||||||
|
if v, err := strconv.Atoi(env); err == nil && v > 0 {
|
||||||
|
c.ContainerVNCPort = v
|
||||||
|
}
|
||||||
|
} else if c.ContainerVNCPort == 0 {
|
||||||
|
c.ContainerVNCPort = 5900
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("YAO_VNC_CONTAINER_NAME_PREFIX"); env != "" {
|
||||||
|
c.ContainerNamePrefix = env
|
||||||
|
} else if c.ContainerNamePrefix == "" {
|
||||||
|
c.ContainerNamePrefix = "yao-sandbox-"
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("YAO_VNC_IP_CACHE_TTL"); env != "" {
|
||||||
|
if v, err := time.ParseDuration(env); err == nil && v > 0 {
|
||||||
|
c.IPCacheTTL = v
|
||||||
|
}
|
||||||
|
} else if c.IPCacheTTL == 0 {
|
||||||
|
c.IPCacheTTL = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := os.Getenv("YAO_VNC_CHECK_TIMEOUT"); env != "" {
|
||||||
|
if v, err := time.ParseDuration(env); err == nil && v > 0 {
|
||||||
|
c.VNCCheckTimeout = v
|
||||||
|
}
|
||||||
|
} else if c.VNCCheckTimeout == 0 {
|
||||||
|
c.VNCCheckTimeout = 2 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
565
sandbox/vncproxy/proxy.go
Normal file
565
sandbox/vncproxy/proxy.go
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
package vncproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipCacheEntry holds cached container IP with expiration
|
||||||
|
type ipCacheEntry struct {
|
||||||
|
IP string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy handles VNC proxy requests
|
||||||
|
type Proxy struct {
|
||||||
|
config *Config
|
||||||
|
dockerClient *client.Client
|
||||||
|
ipCache sync.Map // containerName -> *ipCacheEntry
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxy creates a new VNC proxy
|
||||||
|
func NewProxy(config *Config) (*Proxy, error) {
|
||||||
|
if config == nil {
|
||||||
|
config = DefaultConfig()
|
||||||
|
}
|
||||||
|
config.Init()
|
||||||
|
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create Docker client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Docker connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := cli.Ping(ctx); err != nil {
|
||||||
|
cli.Close()
|
||||||
|
return nil, fmt.Errorf("Docker not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Proxy{
|
||||||
|
config: config,
|
||||||
|
dockerClient: cli,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // Allow all origins for VNC
|
||||||
|
},
|
||||||
|
Subprotocols: []string{"binary"}, // noVNC uses binary subprotocol
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the proxy and releases resources
|
||||||
|
func (p *Proxy) Close() error {
|
||||||
|
return p.dockerClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSandboxID extracts sandbox ID from request path
|
||||||
|
// Expected format: /v1/sandbox/{id}/vnc/...
|
||||||
|
func extractSandboxID(r *http.Request) string {
|
||||||
|
path := r.URL.Path
|
||||||
|
// Remove prefix /v1/sandbox/
|
||||||
|
path = strings.TrimPrefix(path, "/v1/sandbox/")
|
||||||
|
// Get ID (first segment before next /)
|
||||||
|
if idx := strings.Index(path, "/"); idx > 0 {
|
||||||
|
return path[:idx]
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleVNCStatus returns VNC status for a container
|
||||||
|
// GET /v1/sandbox/{id}/vnc
|
||||||
|
func (p *Proxy) HandleVNCStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxID := extractSandboxID(r)
|
||||||
|
containerName := p.config.ContainerNamePrefix + sandboxID
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"sandbox_id": sandboxID,
|
||||||
|
"container": containerName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if container exists and is running
|
||||||
|
_, err := p.getContainerIP(r.Context(), containerName)
|
||||||
|
if err != nil {
|
||||||
|
response["available"] = false
|
||||||
|
response["status"] = "unavailable"
|
||||||
|
response["message"] = "Container not available"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if VNC is enabled for this container
|
||||||
|
if !p.checkVNCEnabled(r.Context(), containerName) {
|
||||||
|
response["available"] = false
|
||||||
|
response["status"] = "not_supported"
|
||||||
|
response["message"] = "VNC not available for this container type"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if VNC services are ready (try to connect to websockify port)
|
||||||
|
if !p.checkVNCReady(r.Context(), containerName) {
|
||||||
|
response["available"] = false
|
||||||
|
response["status"] = "starting"
|
||||||
|
response["message"] = "VNC services are starting..."
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// VNC is ready
|
||||||
|
response["available"] = true
|
||||||
|
response["status"] = "ready"
|
||||||
|
response["client_url"] = fmt.Sprintf("/v1/sandbox/%s/vnc/client", sandboxID)
|
||||||
|
response["websocket_url"] = fmt.Sprintf("/v1/sandbox/%s/vnc/ws", sandboxID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleVNCClient serves the noVNC client page
|
||||||
|
// GET /v1/sandbox/{id}/vnc/client?viewonly=true|false
|
||||||
|
func (p *Proxy) HandleVNCClient(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxID := extractSandboxID(r)
|
||||||
|
containerName := p.config.ContainerNamePrefix + sandboxID
|
||||||
|
|
||||||
|
// Verify container exists and is running
|
||||||
|
_, err := p.getContainerIP(r.Context(), containerName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Container not available", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.checkVNCEnabled(r.Context(), containerName) {
|
||||||
|
http.Error(w, "VNC not available for this container", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get viewonly parameter (default: false = interactive)
|
||||||
|
viewOnly := r.URL.Query().Get("viewonly") == "true"
|
||||||
|
|
||||||
|
// Serve inline noVNC HTML page with status checking
|
||||||
|
wsPath := fmt.Sprintf("/v1/sandbox/%s/vnc/ws", sandboxID)
|
||||||
|
p.serveNoVNCPage(w, sandboxID, wsPath, viewOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleVNCWebSocket proxies WebSocket connection to container VNC
|
||||||
|
// GET /v1/sandbox/{id}/vnc/ws
|
||||||
|
func (p *Proxy) HandleVNCWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxID := extractSandboxID(r)
|
||||||
|
containerName := p.config.ContainerNamePrefix + sandboxID
|
||||||
|
|
||||||
|
// Get VNC endpoint (uses port mapping if available, otherwise container IP)
|
||||||
|
targetAddr, err := p.getVNCEndpoint(r.Context(), containerName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Container not available", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade HTTP to WebSocket
|
||||||
|
clientConn, err := p.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return // Upgrader already sent error response
|
||||||
|
}
|
||||||
|
defer clientConn.Close()
|
||||||
|
|
||||||
|
// Connect to container's websockify
|
||||||
|
targetConn, err := net.DialTimeout("tcp", targetAddr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
clientConn.WriteMessage(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "VNC connection failed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer targetConn.Close()
|
||||||
|
|
||||||
|
// Bidirectional proxy
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
// Client -> Container
|
||||||
|
go func() {
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
for {
|
||||||
|
messageType, data, err := clientConn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if messageType == websocket.BinaryMessage {
|
||||||
|
if _, err := targetConn.Write(data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Container -> Client
|
||||||
|
go func() {
|
||||||
|
defer func() { done <- struct{}{} }()
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := targetConn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := clientConn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for either direction to close
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContainerIP gets the IP address of a container, using cache with TTL
|
||||||
|
func (p *Proxy) getContainerIP(ctx context.Context, containerName string) (string, error) {
|
||||||
|
// Check cache
|
||||||
|
if cached, ok := p.ipCache.Load(containerName); ok {
|
||||||
|
entry := cached.(*ipCacheEntry)
|
||||||
|
if time.Now().Before(entry.ExpiresAt) {
|
||||||
|
return entry.IP, nil
|
||||||
|
}
|
||||||
|
// Cache expired, delete it
|
||||||
|
p.ipCache.Delete(containerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get from Docker
|
||||||
|
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("container not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.State.Running {
|
||||||
|
return "", fmt.Errorf("container not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP from the specified network or default bridge
|
||||||
|
var ip string
|
||||||
|
if info.NetworkSettings != nil && info.NetworkSettings.Networks != nil {
|
||||||
|
if net, ok := info.NetworkSettings.Networks[p.config.DockerNetwork]; ok {
|
||||||
|
ip = net.IPAddress
|
||||||
|
} else {
|
||||||
|
// Try to get IP from any network
|
||||||
|
for _, net := range info.NetworkSettings.Networks {
|
||||||
|
if net.IPAddress != "" {
|
||||||
|
ip = net.IPAddress
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip == "" {
|
||||||
|
return "", fmt.Errorf("container has no IP address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
p.ipCache.Store(containerName, &ipCacheEntry{
|
||||||
|
IP: ip,
|
||||||
|
ExpiresAt: time.Now().Add(p.config.IPCacheTTL),
|
||||||
|
})
|
||||||
|
|
||||||
|
return ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVNCEndpoint returns the host:port to connect to for VNC
|
||||||
|
// It first checks for port mapping (for Docker Desktop), then falls back to container IP
|
||||||
|
func (p *Proxy) getVNCEndpoint(ctx context.Context, containerName string) (string, error) {
|
||||||
|
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("container not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.State.Running {
|
||||||
|
return "", fmt.Errorf("container not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for port mapping first (for Docker Desktop on macOS/Windows)
|
||||||
|
if info.NetworkSettings != nil && info.NetworkSettings.Ports != nil {
|
||||||
|
portKey := nat.Port(fmt.Sprintf("%d/tcp", p.config.ContainerNoVNCPort))
|
||||||
|
if bindings, ok := info.NetworkSettings.Ports[portKey]; ok && len(bindings) > 0 {
|
||||||
|
binding := bindings[0]
|
||||||
|
if binding.HostPort != "" {
|
||||||
|
// Use mapped port on localhost
|
||||||
|
host := binding.HostIP
|
||||||
|
if host == "" || host == "0.0.0.0" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, binding.HostPort), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to container IP (works on Linux with native Docker)
|
||||||
|
ip, err := p.getContainerIP(ctx, containerName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(ip, fmt.Sprintf("%d", p.config.ContainerNoVNCPort)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVNCEnabled checks if container has VNC enabled by checking env vars
|
||||||
|
func (p *Proxy) checkVNCEnabled(ctx context.Context, containerName string) bool {
|
||||||
|
info, err := p.dockerClient.ContainerInspect(ctx, containerName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variables for VNC_ENABLED or SANDBOX_VNC_ENABLED
|
||||||
|
for _, env := range info.Config.Env {
|
||||||
|
if strings.HasPrefix(env, "SANDBOX_VNC_ENABLED=true") ||
|
||||||
|
strings.HasPrefix(env, "VNC_ENABLED=true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if container image is a VNC-enabled variant
|
||||||
|
imageName := info.Config.Image
|
||||||
|
if strings.Contains(imageName, "playwright") ||
|
||||||
|
strings.Contains(imageName, "desktop") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkVNCReady tests if VNC services are ready
|
||||||
|
// Uses docker exec to test port connectivity (works across platforms including macOS Docker Desktop)
|
||||||
|
func (p *Proxy) checkVNCReady(ctx context.Context, containerName string) bool {
|
||||||
|
// Use docker exec to test port connectivity from inside the container
|
||||||
|
// This approach works regardless of host network configuration
|
||||||
|
execConfig := container.ExecOptions{
|
||||||
|
Cmd: []string{"sh", "-c", fmt.Sprintf("nc -z localhost %d 2>/dev/null || (echo | timeout 1 cat < /dev/tcp/localhost/%d > /dev/null 2>&1)", p.config.ContainerNoVNCPort, p.config.ContainerNoVNCPort)},
|
||||||
|
AttachStdout: false,
|
||||||
|
AttachStderr: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
execResp, err := p.dockerClient.ContainerExecCreate(ctx, containerName, execConfig)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.dockerClient.ContainerExecStart(ctx, execResp.ID, container.ExecStartOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for exec to complete and check exit code
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
inspect, err := p.dockerClient.ContainerExecInspect(ctx, execResp.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !inspect.Running {
|
||||||
|
return inspect.ExitCode == 0
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveNoVNCPage serves an inline HTML page with noVNC client
|
||||||
|
func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) {
|
||||||
|
viewOnlyStr := "false"
|
||||||
|
modeIndicator := "可交互"
|
||||||
|
modeColor := "#4CAF50"
|
||||||
|
if viewOnly {
|
||||||
|
viewOnlyStr = "true"
|
||||||
|
modeIndicator = "只读模式"
|
||||||
|
modeColor = "#FF9800"
|
||||||
|
}
|
||||||
|
|
||||||
|
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VNC - %s</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 100%%; height: 100%%; overflow: hidden; background: #1e1e1e; }
|
||||||
|
#loading {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
background: #1e1e1e; color: #fff; font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 50px; height: 50px; border: 4px solid #333;
|
||||||
|
border-top-color: #4CAF50; border-radius: 50%%;
|
||||||
|
animation: spin 1s linear infinite; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
#status { font-size: 16px; margin-bottom: 10px; }
|
||||||
|
#retry-count { font-size: 14px; color: #888; }
|
||||||
|
#error { color: #f44336; display: none; }
|
||||||
|
#screen { width: 100%%; height: 100%%; display: none; }
|
||||||
|
#mode-indicator {
|
||||||
|
position: fixed; top: 10px; right: 10px; padding: 5px 12px;
|
||||||
|
background: %s; color: white; border-radius: 4px;
|
||||||
|
font-family: system-ui, sans-serif; font-size: 12px;
|
||||||
|
z-index: 1000; opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div id="status">正在连接 VNC...</div>
|
||||||
|
<div id="retry-count"></div>
|
||||||
|
<div id="error"></div>
|
||||||
|
</div>
|
||||||
|
<div id="mode-indicator">%s</div>
|
||||||
|
<div id="screen"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/lib/rfb.js';
|
||||||
|
|
||||||
|
const sandboxID = '%s';
|
||||||
|
const wsPath = '%s';
|
||||||
|
const viewOnly = %s;
|
||||||
|
const statusAPI = '/v1/sandbox/' + sandboxID + '/vnc';
|
||||||
|
const maxRetries = 30;
|
||||||
|
let retryCount = 0;
|
||||||
|
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const screen = document.getElementById('screen');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const retryCountEl = document.getElementById('retry-count');
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
const modeIndicator = document.getElementById('mode-indicator');
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(statusAPI);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.status === 'ready') {
|
||||||
|
status.textContent = '正在初始化 VNC 客户端...';
|
||||||
|
connectVNC();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'starting') {
|
||||||
|
status.textContent = 'VNC 服务启动中...';
|
||||||
|
} else if (data.status === 'not_supported') {
|
||||||
|
showError('此容器不支持 VNC');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
status.textContent = '等待容器就绪...';
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCount++;
|
||||||
|
retryCountEl.textContent = '重试 ' + retryCount + '/' + maxRetries;
|
||||||
|
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
showError('连接超时,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
retryCount++;
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
showError('无法连接到服务器');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(checkStatus, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
status.style.display = 'none';
|
||||||
|
retryCountEl.style.display = 'none';
|
||||||
|
document.querySelector('.spinner').style.display = 'none';
|
||||||
|
errorEl.textContent = msg;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectVNC() {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
screen.style.display = 'block';
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsURL = protocol + '//' + window.location.host + wsPath;
|
||||||
|
|
||||||
|
const rfb = new RFB(screen, wsURL);
|
||||||
|
rfb.viewOnly = viewOnly;
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
|
||||||
|
rfb.addEventListener('connect', () => {
|
||||||
|
console.log('VNC connected');
|
||||||
|
modeIndicator.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
rfb.addEventListener('disconnect', (e) => {
|
||||||
|
console.log('VNC disconnected', e.detail);
|
||||||
|
loading.style.display = 'flex';
|
||||||
|
screen.style.display = 'none';
|
||||||
|
modeIndicator.style.display = 'none';
|
||||||
|
if (e.detail.clean) {
|
||||||
|
status.textContent = 'VNC 连接已关闭';
|
||||||
|
} else {
|
||||||
|
showError('VNC 连接断开');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start checking status
|
||||||
|
checkStatus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`, sandboxID, modeColor, modeIndicator, sandboxID, wsPath, viewOnlyStr)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
io.WriteString(w, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers VNC proxy routes to an HTTP mux
|
||||||
|
func (p *Proxy) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("/v1/sandbox/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
// Match /v1/sandbox/{id}/vnc
|
||||||
|
if strings.HasSuffix(path, "/vnc") {
|
||||||
|
p.HandleVNCStatus(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /v1/sandbox/{id}/vnc/client
|
||||||
|
if strings.HasSuffix(path, "/vnc/client") {
|
||||||
|
p.HandleVNCClient(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /v1/sandbox/{id}/vnc/ws
|
||||||
|
if strings.HasSuffix(path, "/vnc/ws") {
|
||||||
|
p.HandleVNCWebSocket(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if request requires VNC container
|
||||||
|
func (p *Proxy) isVNCRequest(r *http.Request) bool {
|
||||||
|
path := r.URL.Path
|
||||||
|
return strings.Contains(path, "/vnc")
|
||||||
|
}
|
||||||
90
sandbox/vncproxy/proxy_test.go
Normal file
90
sandbox/vncproxy/proxy_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package vncproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractSandboxID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "VNC status path",
|
||||||
|
path: "/v1/sandbox/abc123/vnc",
|
||||||
|
expected: "abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VNC client path",
|
||||||
|
path: "/v1/sandbox/user-chat-123/vnc/client",
|
||||||
|
expected: "user-chat-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VNC websocket path",
|
||||||
|
path: "/v1/sandbox/test-sandbox-id/vnc/ws",
|
||||||
|
expected: "test-sandbox-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex ID",
|
||||||
|
path: "/v1/sandbox/user_123-chat_456/vnc",
|
||||||
|
expected: "user_123-chat_456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||||
|
got := extractSandboxID(req)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("extractSandboxID() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDefaults(t *testing.T) {
|
||||||
|
config := DefaultConfig()
|
||||||
|
|
||||||
|
if config.DockerNetwork != "bridge" {
|
||||||
|
t.Errorf("DockerNetwork = %q, want %q", config.DockerNetwork, "bridge")
|
||||||
|
}
|
||||||
|
if config.ContainerNoVNCPort != 6080 {
|
||||||
|
t.Errorf("ContainerNoVNCPort = %d, want %d", config.ContainerNoVNCPort, 6080)
|
||||||
|
}
|
||||||
|
if config.ContainerVNCPort != 5900 {
|
||||||
|
t.Errorf("ContainerVNCPort = %d, want %d", config.ContainerVNCPort, 5900)
|
||||||
|
}
|
||||||
|
if config.ContainerNamePrefix != "yao-sandbox-" {
|
||||||
|
t.Errorf("ContainerNamePrefix = %q, want %q", config.ContainerNamePrefix, "yao-sandbox-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigInit(t *testing.T) {
|
||||||
|
config := &Config{}
|
||||||
|
config.Init()
|
||||||
|
|
||||||
|
// Should have defaults after Init
|
||||||
|
if config.DockerNetwork != "bridge" {
|
||||||
|
t.Errorf("DockerNetwork = %q, want %q", config.DockerNetwork, "bridge")
|
||||||
|
}
|
||||||
|
if config.ContainerNoVNCPort != 6080 {
|
||||||
|
t.Errorf("ContainerNoVNCPort = %d, want %d", config.ContainerNoVNCPort, 6080)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration tests require Docker - skip if not available
|
||||||
|
func TestProxyCreation(t *testing.T) {
|
||||||
|
// This will fail if Docker is not available, which is expected in CI
|
||||||
|
proxy, err := NewProxy(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
||||||
|
}
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
if proxy.config == nil {
|
||||||
|
t.Error("Proxy config should not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue