feat: improve webvnc bridge ergonomics
This commit is contained in:
parent
6ab061716c
commit
0ca412a8d5
@ -5,11 +5,14 @@
|
||||
### Added
|
||||
|
||||
- Added `crabbox desktop launch --webvnc --open` to launch a desktop browser/app and immediately bridge the same lease into the WebVNC portal.
|
||||
- Added `crabbox webvnc --daemon`/`--background` plus `--status`/`--stop` for background WebVNC bridges without tmux.
|
||||
- Added `crabbox media preview` for creating motion-trimmed GIF previews and optional trimmed MP4 clips from desktop recordings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto-shell command reconstruction so arguments with spaces stay quoted when shell operators such as `&&` are present.
|
||||
- Fixed human WebVNC desktop launches to keep browser windows windowed by default and reserve fullscreen for explicit capture/video workflows.
|
||||
- Fixed WebVNC portal status text and bridge commands so waiting/reset states explain the exact local bridge command to run.
|
||||
- Fixed Windows WebVNC credential handling so generated portal links preserve special characters and managed TightVNC sessions copy service passwords into the logged-in user's registry profile.
|
||||
- Fixed managed Linux browser setup so Chrome/Chromium launches skip first-run and default-browser prompts.
|
||||
- Fixed WebVNC portal passwords with escaped special characters and kept the bridge alive across viewer resets and transient coordinator EOFs.
|
||||
|
||||
@ -16,6 +16,8 @@ With `--browser`, Crabbox probes the target browser the same way `run --browser`
|
||||
does and launches `BROWSER` when no explicit command is provided.
|
||||
With `--webvnc`, the command keeps running after launch and bridges the desktop
|
||||
into the authenticated WebVNC portal. Add `--open` to open that portal locally.
|
||||
Browser launches default to a windowed human desktop with the remote panel and
|
||||
title bar visible; use `--fullscreen` only for capture/video workflows.
|
||||
|
||||
On Windows, SSH sessions cannot directly own the visible console desktop, so
|
||||
Crabbox writes a one-shot PowerShell launcher under `C:\ProgramData\crabbox` and
|
||||
@ -39,5 +41,6 @@ Flags:
|
||||
--url <url>
|
||||
--webvnc
|
||||
--open
|
||||
--fullscreen
|
||||
--reclaim
|
||||
```
|
||||
|
||||
@ -8,6 +8,7 @@ crabbox warmup --desktop
|
||||
crabbox webvnc --id blue-lobster
|
||||
crabbox webvnc --id blue-lobster --network tailscale
|
||||
crabbox webvnc --id blue-lobster --open
|
||||
crabbox webvnc --id blue-lobster --daemon --open
|
||||
```
|
||||
|
||||
The command resolves the lease like `crabbox vnc`, verifies that the lease has
|
||||
@ -27,6 +28,11 @@ This keeps the security boundary the same as `crabbox vnc`:
|
||||
- The local `crabbox webvnc` process must keep running while the browser uses
|
||||
the desktop.
|
||||
|
||||
Use `--daemon` (or `--background`) to keep the bridge running without a tmux or
|
||||
foreground shell. Crabbox writes the bridge log and pid file under its local
|
||||
state directory and prints both paths. Use `--status` to print those paths
|
||||
again, and `--stop` to kill the background bridge for that lease.
|
||||
|
||||
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
|
||||
The runner VNC service stays bound to loopback.
|
||||
|
||||
@ -52,6 +58,10 @@ Flags:
|
||||
--network auto|tailscale|public
|
||||
--local-port <port>
|
||||
--open
|
||||
--daemon
|
||||
--background
|
||||
--status
|
||||
--stop
|
||||
--reclaim
|
||||
```
|
||||
|
||||
|
||||
@ -100,6 +100,11 @@ session without keeping the SSH command attached:
|
||||
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
|
||||
```
|
||||
|
||||
For human demos, Crabbox keeps launched browsers windowed so the remote desktop
|
||||
panel, title bar, and surrounding session remain visible. Use
|
||||
`desktop launch --fullscreen` only when you intentionally want browser-only
|
||||
video or capture output.
|
||||
|
||||
## Network Model
|
||||
|
||||
Managed VNC is tunnel-first:
|
||||
|
||||
@ -528,7 +528,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
|
||||
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
|
||||
}
|
||||
if cfg.Desktop {
|
||||
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
|
||||
if [ ! -s /var/lib/crabbox/vnc.password ]; then
|
||||
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
|
||||
|
||||
@ -39,7 +39,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
|
||||
got := cloudInit(cfg, "ssh-ed25519 test")
|
||||
for _, want := range []string{
|
||||
"xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11",
|
||||
"x11-xserver-utils xterm scrot",
|
||||
"x11-xserver-utils xterm scrot xdotool wmctrl",
|
||||
"/etc/systemd/system/crabbox-xvfb.service",
|
||||
"/etc/systemd/system/crabbox-desktop.service",
|
||||
"/usr/local/bin/crabbox-desktop-session",
|
||||
|
||||
@ -16,6 +16,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
url := fs.String("url", "", "URL to pass to the launched browser")
|
||||
webvnc := fs.Bool("webvnc", false, "bridge the launched desktop into the authenticated WebVNC portal")
|
||||
openPortal := fs.Bool("open", false, "open the WebVNC portal when --webvnc is set")
|
||||
fullscreen := fs.Bool("fullscreen", false, "leave launched browser fullscreen for capture/video workflows")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -91,7 +92,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> -- <command...>")
|
||||
}
|
||||
workdir := remoteJoin(cfg, leaseID, repo.Name)
|
||||
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command)); err != nil {
|
||||
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
|
||||
return exit(5, "launch desktop command: %v", err)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "launched: %s\n", strings.Join(command, " "))
|
||||
@ -123,17 +124,17 @@ func firstNonBlank(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func desktopLaunchRemoteCommand(target SSHTarget, workdir string, env map[string]string, command []string) string {
|
||||
func desktopLaunchRemoteCommand(target SSHTarget, workdir string, env map[string]string, command []string, windowedBrowser bool) string {
|
||||
if isWindowsNativeTarget(target) {
|
||||
return windowsDesktopLaunchRemoteCommand(workdir, env, command)
|
||||
}
|
||||
if target.TargetOS == targetMacOS {
|
||||
return posixDesktopLaunchRemoteCommand(workdir, env, command)
|
||||
return posixDesktopLaunchRemoteCommand(workdir, env, command, windowedBrowser)
|
||||
}
|
||||
return posixDesktopLaunchRemoteCommand(workdir, env, command)
|
||||
return posixDesktopLaunchRemoteCommand(workdir, env, command, windowedBrowser)
|
||||
}
|
||||
|
||||
func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string) string {
|
||||
func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string, windowedBrowser bool) string {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("set -eu\n")
|
||||
if workdir != "" {
|
||||
@ -154,9 +155,32 @@ func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, comm
|
||||
writeShellArgv(&b, command)
|
||||
b.WriteString(" >\"$log\" 2>&1 < /dev/null &\n")
|
||||
b.WriteString("fi\n")
|
||||
if windowedBrowser {
|
||||
b.WriteString(posixWindowBrowserCommand())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func posixWindowBrowserCommand() string {
|
||||
return `(
|
||||
sleep 2
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if command -v wmctrl >/dev/null 2>&1; then
|
||||
wmctrl -r :ACTIVE: -b remove,fullscreen,maximized_vert,maximized_horz >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v xdotool >/dev/null 2>&1; then
|
||||
window="$(xdotool search --onlyvisible --class google-chrome 2>/dev/null | tail -1 || true)"
|
||||
if [ -z "$window" ]; then
|
||||
window="$(xdotool search --onlyvisible --class chromium 2>/dev/null | tail -1 || true)"
|
||||
fi
|
||||
if [ -n "$window" ]; then
|
||||
xdotool windowactivate "$window" windowmove "$window" 80 80 windowsize "$window" 1500 900 >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
) >/dev/null 2>&1 &
|
||||
`
|
||||
}
|
||||
|
||||
func writeShellArgv(b *bytes.Buffer, command []string) {
|
||||
for i, arg := range command {
|
||||
if i > 0 {
|
||||
|
||||
@ -11,6 +11,7 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
|
||||
"/work/crabbox/cbx_1/repo",
|
||||
map[string]string{"DISPLAY": ":99", "BROWSER": "/usr/bin/chromium"},
|
||||
[]string{"/usr/bin/chromium", "https://example.com"},
|
||||
true,
|
||||
)
|
||||
for _, want := range []string{
|
||||
"mkdir -p '/work/crabbox/cbx_1/repo'",
|
||||
@ -19,6 +20,9 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
|
||||
"BROWSER='/usr/bin/chromium'",
|
||||
"setsid '/usr/bin/chromium' 'https://example.com'",
|
||||
"crabbox-desktop-launch.log",
|
||||
"wmctrl -r :ACTIVE: -b remove,fullscreen",
|
||||
"xdotool search --onlyvisible --class google-chrome",
|
||||
"windowsize \"$window\" 1500 900",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("desktop launch command missing %q:\n%s", want, got)
|
||||
@ -53,6 +57,7 @@ func TestWindowsDesktopLaunchRemoteCommandUsesInteractiveTask(t *testing.T) {
|
||||
`C:\crabbox\cbx_1\repo`,
|
||||
map[string]string{"BROWSER": `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`},
|
||||
[]string{`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, "https://example.com"},
|
||||
true,
|
||||
)
|
||||
for _, want := range []string{
|
||||
"CrabboxDesktopLaunch-",
|
||||
|
||||
@ -7,7 +7,10 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -22,6 +25,10 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
localPort := fs.String("local-port", "", "local VNC tunnel port")
|
||||
openPortal := fs.Bool("open", false, "open the web portal VNC page")
|
||||
daemon := fs.Bool("daemon", false, "start the WebVNC bridge in the background")
|
||||
background := fs.Bool("background", false, "alias for --daemon")
|
||||
daemonStatus := fs.Bool("status", false, "show WebVNC background bridge pid/log paths")
|
||||
stopDaemon := fs.Bool("stop", false, "stop the WebVNC background bridge for this lease")
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -31,6 +38,15 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox webvnc --id <lease-id-or-slug>")
|
||||
}
|
||||
if *daemonStatus {
|
||||
return a.webVNCDaemonStatus(*id)
|
||||
}
|
||||
if *stopDaemon {
|
||||
return a.stopWebVNCDaemon(*id)
|
||||
}
|
||||
if *daemon || *background {
|
||||
return a.startWebVNCDaemon(args, *id)
|
||||
}
|
||||
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
|
||||
if err != nil {
|
||||
return err
|
||||
@ -132,6 +148,168 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) startWebVNCDaemon(args []string, leaseID string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return exit(2, "resolve crabbox executable: %v", err)
|
||||
}
|
||||
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil {
|
||||
return exit(2, "create WebVNC daemon directory: %v", err)
|
||||
}
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return exit(2, "open WebVNC daemon log: %v", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
childArgs := append([]string{"webvnc"}, stripWebVNCDaemonFlags(args)...)
|
||||
cmd := exec.Command(exe, childArgs...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
return exit(5, "start WebVNC daemon: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", pid)), 0o600); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return exit(2, "write WebVNC daemon pid: %v", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
return exit(5, "release WebVNC daemon process: %v", err)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: stop with kill $(cat %s)\n", pidPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) webVNCDaemonStatus(leaseID string) error {
|
||||
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := readWebVNCDaemonPID(pidPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: expected log=%s\n", logPath)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
command, alive := webVNCDaemonProcessCommand(pid)
|
||||
if !alive {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: stale pid=%d log=%s\n", pid, logPath)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
|
||||
if strings.TrimSpace(command) != "" {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: command=%s\n", strings.TrimSpace(command))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) stopWebVNCDaemon(leaseID string) error {
|
||||
_, pidPath, err := webVNCDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pid, err := readWebVNCDaemonPID(pidPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
command, alive := webVNCDaemonProcessCommand(pid)
|
||||
if !alive {
|
||||
_ = os.Remove(pidPath)
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: removed stale pid=%d\n", pid)
|
||||
return nil
|
||||
}
|
||||
if !isWebVNCDaemonCommand(command) {
|
||||
return exit(5, "refusing to stop pid %d; command does not look like crabbox webvnc: %s", pid, strings.TrimSpace(command))
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return exit(5, "find WebVNC daemon pid %d: %v", pid, err)
|
||||
}
|
||||
if err := process.Kill(); err != nil {
|
||||
return exit(5, "stop WebVNC daemon pid %d: %v", pid, err)
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: stopped pid=%d\n", pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func webVNCDaemonProcessCommand(pid int) (string, bool) {
|
||||
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
command := strings.TrimSpace(string(out))
|
||||
return command, command != ""
|
||||
}
|
||||
|
||||
func isWebVNCDaemonCommand(command string) bool {
|
||||
command = strings.ToLower(command)
|
||||
return strings.Contains(command, "crabbox") && strings.Contains(command, "webvnc")
|
||||
}
|
||||
|
||||
func readWebVNCDaemonPID(pidPath string) (int, error) {
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, exit(2, "invalid WebVNC daemon pid file %s", pidPath)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func stripWebVNCDaemonFlags(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
if arg == "--daemon" || arg == "--background" ||
|
||||
strings.HasPrefix(arg, "--daemon=") || strings.HasPrefix(arg, "--background=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func webVNCDaemonPaths(leaseID string) (string, string, error) {
|
||||
dir, err := crabboxStateDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
name := safeWebVNCDaemonName(leaseID)
|
||||
bridgeDir := filepath.Join(dir, "webvnc")
|
||||
return filepath.Join(bridgeDir, name+".log"), filepath.Join(bridgeDir, name+".pid"), nil
|
||||
}
|
||||
|
||||
func safeWebVNCDaemonName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "bridge"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func startVNCForegroundTunnel(ctx context.Context, target SSHTarget, localPort, remoteHost, remotePort string) (*exec.Cmd, error) {
|
||||
cmd := exec.CommandContext(ctx, "ssh", vncTunnelArgs(target, localPort, remoteHost, remotePort)...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -153,3 +155,49 @@ func TestRetryableWebVNCBridgeErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
|
||||
got := strings.Join(stripWebVNCDaemonFlags([]string{
|
||||
"--provider",
|
||||
"aws",
|
||||
"--daemon",
|
||||
"--target",
|
||||
"linux",
|
||||
"--background=true",
|
||||
"--id",
|
||||
"pearl-krill",
|
||||
"--open",
|
||||
}), " ")
|
||||
if got != "--provider aws --target linux --id pearl-krill --open" {
|
||||
t.Fatalf("stripped args=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeWebVNCDaemonName(t *testing.T) {
|
||||
if got := safeWebVNCDaemonName("pearl/krill :99"); got != "pearl_krill__99" {
|
||||
t.Fatalf("safe daemon name=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWebVNCDaemonPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bridge.pid")
|
||||
if err := os.WriteFile(path, []byte("12345\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readWebVNCDaemonPID(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 12345 {
|
||||
t.Fatalf("pid=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWebVNCDaemonCommand(t *testing.T) {
|
||||
if !isWebVNCDaemonCommand("/usr/local/bin/crabbox webvnc --id pearl-krill") {
|
||||
t.Fatal("expected crabbox webvnc command")
|
||||
}
|
||||
if isWebVNCDaemonCommand("/bin/sleep 999") {
|
||||
t.Fatal("sleep must not be treated as WebVNC daemon")
|
||||
}
|
||||
}
|
||||
|
||||
@ -408,7 +408,7 @@ function optionalBootstrap(config: LeaseConfig): string {
|
||||
parts.push(tailscaleBootstrap(config));
|
||||
}
|
||||
if (config.desktop) {
|
||||
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
|
||||
if [ ! -s /var/lib/crabbox/vnc.password ]; then
|
||||
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
|
||||
|
||||
@ -4,7 +4,7 @@ import { leaseConfig, validCIDRs } from "./config";
|
||||
import { HetznerClient } from "./hetzner";
|
||||
import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
|
||||
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
|
||||
import { portalError, portalHome, portalVNC } from "./portal";
|
||||
import { portalError, portalHome, portalVNC, webVNCBridgeCommand } from "./portal";
|
||||
import { leaseSlugFromID, normalizeLeaseSlug, slugWithCollisionSuffix } from "./slug";
|
||||
import {
|
||||
createTailscaleAuthKey,
|
||||
@ -423,6 +423,15 @@ export class FleetDurableObject implements DurableObject {
|
||||
}
|
||||
return portalVNC(lease);
|
||||
}
|
||||
if (
|
||||
method === "GET" &&
|
||||
parts[1] === "leases" &&
|
||||
parts[2] &&
|
||||
parts[3] === "vnc" &&
|
||||
parts[4] === "status"
|
||||
) {
|
||||
return await this.webVNCStatus(request, parts[2]);
|
||||
}
|
||||
if (
|
||||
method === "GET" &&
|
||||
parts[1] === "leases" &&
|
||||
@ -508,6 +517,32 @@ export class FleetDurableObject implements DurableObject {
|
||||
});
|
||||
}
|
||||
|
||||
private async webVNCStatus(request: Request, identifier: string): Promise<Response> {
|
||||
const lease = await this.resolveLease(identifier, request, false);
|
||||
if (!lease) {
|
||||
return notFound();
|
||||
}
|
||||
const error = webVNCLeaseError(lease);
|
||||
if (error) {
|
||||
return json({ error: "webvnc_unavailable", message: error }, { status: 409 });
|
||||
}
|
||||
const agent = this.webVNCAgents.get(lease.id);
|
||||
const viewer = this.webVNCViewers.get(lease.id);
|
||||
const bridgeConnected = agent?.readyState === WebSocket.OPEN;
|
||||
const viewerConnected = viewer?.readyState === WebSocket.OPEN;
|
||||
const command = webVNCBridgeCommand(lease);
|
||||
return json({
|
||||
bridgeConnected,
|
||||
viewerConnected,
|
||||
command,
|
||||
message: bridgeConnected
|
||||
? viewerConnected
|
||||
? "bridge connected; another viewer is active"
|
||||
: "bridge connected"
|
||||
: `no bridge connected; run: ${command}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async webVNCViewer(request: Request, identifier: string): Promise<Response> {
|
||||
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
||||
return json(
|
||||
@ -525,10 +560,12 @@ export class FleetDurableObject implements DurableObject {
|
||||
}
|
||||
const agent = this.webVNCAgents.get(lease.id);
|
||||
if (!agent || agent.readyState !== WebSocket.OPEN) {
|
||||
const command = webVNCBridgeCommand(lease);
|
||||
return json(
|
||||
{
|
||||
error: "webvnc_bridge_missing",
|
||||
message: `start the bridge with: crabbox webvnc --id ${lease.slug || lease.id}`,
|
||||
message: `start the bridge with: ${command}`,
|
||||
command,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
@ -537,10 +574,12 @@ export class FleetDurableObject implements DurableObject {
|
||||
if (existingViewer) {
|
||||
closeSocket(existingViewer, 1012, "replaced by a newer WebVNC viewer");
|
||||
this.clearWebVNCViewer(lease.id, existingViewer);
|
||||
const command = webVNCBridgeCommand(lease);
|
||||
return json(
|
||||
{
|
||||
error: "webvnc_bridge_reset",
|
||||
message: `restart the bridge with: crabbox webvnc --id ${lease.slug || lease.id}`,
|
||||
message: `another viewer was active; restart or wait for the bridge to reconnect with: ${command}`,
|
||||
command,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
|
||||
@ -45,6 +45,7 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
const nonce = scriptNonce();
|
||||
const title = `WebVNC ${lease.slug || lease.id}`;
|
||||
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
|
||||
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
|
||||
const bridgeCmd = webVNCBridgeCommand(lease);
|
||||
return html(
|
||||
title,
|
||||
@ -59,12 +60,15 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
<a class="button secondary" href="/portal/logout">log out</a>
|
||||
</nav>
|
||||
</header>
|
||||
<section id="status" class="status">waiting for bridge</section>
|
||||
<section id="status" class="status">checking bridge</section>
|
||||
<section id="screen" class="screen" aria-label="WebVNC display"></section>
|
||||
<section class="panel commands">
|
||||
<h2>bridge</h2>
|
||||
<p>run this locally while the browser tab is open:</p>
|
||||
<code>${escapeHTML(bridgeCmd)}</code>
|
||||
<div class="command-row">
|
||||
<code id="bridgeCommand">${escapeHTML(bridgeCmd)}</code>
|
||||
<button id="copyBridge" class="button secondary" type="button">copy</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" nonce="${nonce}">
|
||||
@ -72,8 +76,11 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
const RFB = RFBModule.default || RFBModule;
|
||||
const status = document.getElementById("status");
|
||||
const screen = document.getElementById("screen");
|
||||
const copyBridge = document.getElementById("copyBridge");
|
||||
const bridgeCommand = document.getElementById("bridgeCommand")?.textContent || "";
|
||||
const wsURL = new URL(${JSON.stringify(wsPath)}, window.location.href);
|
||||
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const username = fragment.get("username") || "";
|
||||
const password = fragment.get("password") || "";
|
||||
@ -93,6 +100,14 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
function retryDelay() {
|
||||
return Math.min(5000, 500 * 2 ** retryAttempt);
|
||||
}
|
||||
async function bridgeState() {
|
||||
try {
|
||||
const response = await fetch(statusURL, { cache: "no-store" });
|
||||
return response.ok ? await response.json() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function scheduleRetry(label) {
|
||||
if (stopped) return;
|
||||
const delay = retryDelay();
|
||||
@ -101,12 +116,21 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
window.clearTimeout(retryTimer);
|
||||
retryTimer = window.setTimeout(connect, delay);
|
||||
}
|
||||
function connect() {
|
||||
async function connect() {
|
||||
if (stopped) return;
|
||||
connected = false;
|
||||
screen.replaceChildren();
|
||||
try {
|
||||
setStatus(retryAttempt ? "waiting for bridge" : "connecting");
|
||||
const state = await bridgeState();
|
||||
if (state && !state.bridgeConnected) {
|
||||
scheduleRetry(state.message || "no bridge connected; run the bridge command below");
|
||||
return;
|
||||
}
|
||||
if (state?.viewerConnected) {
|
||||
setStatus("another viewer is connected; close stale tabs if this resets", "warn");
|
||||
} else {
|
||||
setStatus(retryAttempt ? "bridge connected; opening viewer" : "connecting");
|
||||
}
|
||||
rfb = new RFB(screen, wsURL.toString(), options);
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
@ -139,6 +163,16 @@ export function portalVNC(lease: LeaseRecord): Response {
|
||||
scheduleRetry(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
copyBridge?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bridgeCommand);
|
||||
copyBridge.textContent = "copied";
|
||||
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
|
||||
} catch {
|
||||
copyBridge.textContent = "failed";
|
||||
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
|
||||
}
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopped = true;
|
||||
window.clearTimeout(retryTimer);
|
||||
@ -165,7 +199,7 @@ export function portalError(title: string, message: string, status = 400): Respo
|
||||
);
|
||||
}
|
||||
|
||||
function webVNCBridgeCommand(lease: LeaseRecord): string {
|
||||
export function webVNCBridgeCommand(lease: LeaseRecord): string {
|
||||
const target = lease.target || "linux";
|
||||
const args = [
|
||||
"crabbox",
|
||||
@ -244,6 +278,8 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
|
||||
.status[data-tone="warn"] { color:var(--warn); }
|
||||
.status[data-tone="bad"] { color:var(--bad); }
|
||||
.commands { padding:12px; display:grid; gap:8px; }
|
||||
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:stretch; }
|
||||
.command-row code { min-width:0; }
|
||||
.error { margin-top:20vh; padding:24px; display:grid; gap:12px; }
|
||||
@media (max-width: 760px) { main { width:min(100vw - 20px, 1180px); padding:10px 0; } th:nth-child(4),td:nth-child(4),th:nth-child(6),td:nth-child(6){ display:none; } .top{align-items:flex-start;} }
|
||||
</style>
|
||||
|
||||
@ -79,7 +79,7 @@ describe("cloud-init bootstrap", () => {
|
||||
expect(got).toContain("ExecStart=/usr/bin/startxfce4");
|
||||
expect(got).toContain("systemctl is-active --quiet crabbox-desktop.service");
|
||||
expect(got).toContain("systemctl is-active --quiet crabbox-desktop-session.service");
|
||||
expect(got).toContain("x11-xserver-utils xterm scrot");
|
||||
expect(got).toContain("x11-xserver-utils xterm scrot xdotool wmctrl");
|
||||
expect(got).toContain("xsetroot -solid '#20242b'");
|
||||
expect(got).toContain("xterm -title 'Crabbox Desktop'");
|
||||
expect(got).toContain("(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)");
|
||||
|
||||
@ -541,10 +541,25 @@ describe("fleet lease identity and idle", () => {
|
||||
);
|
||||
expect(pageBody).toContain("/portal/assets/novnc/rfb.js");
|
||||
expect(pageBody).toContain("function scheduleRetry");
|
||||
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
|
||||
expect(pageBody).toContain("copyBridge");
|
||||
expect(pageBody).toContain("no bridge connected; run the bridge command below");
|
||||
expect(pageBody).toContain('fragment.get("username")');
|
||||
expect(pageBody).toContain('types.includes("username")');
|
||||
expect(pageBody).not.toContain("cdn.jsdelivr.net");
|
||||
|
||||
const status = await fleet.fetch(
|
||||
request("GET", "/portal/leases/blue-lobster/vnc/status", { headers }),
|
||||
);
|
||||
expect(status.status).toBe(200);
|
||||
await expect(status.json()).resolves.toMatchObject({
|
||||
bridgeConnected: false,
|
||||
viewerConnected: false,
|
||||
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
||||
message:
|
||||
"no bridge connected; run: crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
|
||||
});
|
||||
|
||||
const plain = await fleet.fetch(
|
||||
request("GET", "/portal/leases/plain-lobster/vnc", { headers }),
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user