diff --git a/.github/workflows/build.dev.yml b/.github/workflows/build.dev.yml index b2e4cdfa..045ff343 100644 --- a/.github/workflows/build.dev.yml +++ b/.github/workflows/build.dev.yml @@ -130,6 +130,7 @@ jobs: env: DEVCELL_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }} DEVCELL_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base + DEVCELL_TEST_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base DEVCELL_USER_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }} MCP_SECRET_TEST_PASSWORD: fake-secret-ci-value MCP_SECRET_GITHUB_TOKEN: fake-token-ci-value @@ -221,7 +222,7 @@ jobs: deploy-site: name: Deploy Site needs: secrets - if: contains(fromJSON('["refs/heads/main","refs/heads/feature/wip","refs/heads/feature/add-web","refs/heads/feature/web"]'), github.ref) + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: name: github-pages diff --git a/cmd/rdp.go b/cmd/rdp.go index 0bbaa67d..6654a8ef 100644 --- a/cmd/rdp.go +++ b/cmd/rdp.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "runtime" "strings" "github.com/DimmKirr/devcell/internal/config" @@ -23,6 +24,8 @@ func init() { rdpCmd.Flags().Bool("list", false, "list all running cell containers and their RDP ports") rdpCmd.Flags().String("app", "", "open RDP to a named container (by AppName)") rdpCmd.Flags().Bool("verbose", false, "show debug info for RDP port lookup") + rdpCmd.Flags().Bool("fullscreen", false, "open RDP session in fullscreen mode") + rdpCmd.Flags().String("viewer", "", "RDP viewer: freerdp (default), macrdp, royaltsx") } func runRDP(cmd *cobra.Command, _ []string) error { @@ -34,6 +37,8 @@ func runRDP(cmd *cobra.Command, _ []string) error { } list, _ := cmd.Flags().GetBool("list") appName, _ := cmd.Flags().GetString("app") + rdpFullscreen, _ = cmd.Flags().GetBool("fullscreen") + rdpViewer, _ = cmd.Flags().GetString("viewer") if list { return rdpList() @@ -44,16 +49,103 @@ func runRDP(cmd *cobra.Command, _ []string) error { return rdpDefault() } -func rdpDefault() error { - if port := os.Getenv("EXT_RDP_PORT"); port != "" { - rdpDebug("EXT_RDP_PORT=%s (fast path)", port) - return openURL(internalrdp.RDPUrl(port)) +var ( + rdpFullscreen bool // set by --fullscreen flag + rdpViewer string // set by --viewer flag +) + +// openRDP dispatches to the selected viewer. +// Default: FreeRDP → macOS Windows App fallback (darwin only). +func openRDP(c config.Config, port string) error { + switch rdpViewer { + case "macrdp": + return openMacRDP(port) + case "royaltsx": + return openRoyalTSX(c, port) + case "freerdp": + return openFreeRDP(c, port) + case "": + // Auto: Royal TSX (darwin) → FreeRDP → macOS Windows App (darwin) + if runtime.GOOS == "darwin" && internalrdp.HasRoyalTSX() { + rdpDebug("auto-detected Royal TSX") + return openRoyalTSX(c, port) + } + if client, found := internalrdp.FindClient(); found { + return openFreeRDPWith(c, port, client) + } + if runtime.GOOS == "darwin" { + rdpDebug("no Royal TSX or FreeRDP found, falling back to macOS Windows App") + fmt.Fprintf(os.Stderr, "Tip: install Royal TSX or FreeRDP for a better experience:\n brew install freerdp\n\n") + return openMacRDP(port) + } + return fmt.Errorf("%s", internalrdp.InstallHint()) + default: + return fmt.Errorf("unknown viewer %q — use freerdp, macrdp, or royaltsx", rdpViewer) + } +} + +// openFreeRDP connects via FreeRDP (auto-login, clipboard, cert verification). +func openFreeRDP(c config.Config, port string) error { + client, found := internalrdp.FindClient() + if !found { + return fmt.Errorf("%s", internalrdp.InstallHint()) + } + return openFreeRDPWith(c, port, client) +} + +func openFreeRDPWith(c config.Config, port string, client internalrdp.ClientBinary) error { + certFlag := internalrdp.CertFlag(c.ConfigDir) + rdpDebug("using %s (%s), cert: %s", client.Name, client.Path, certFlag) + args := []string{ + "/v:127.0.0.1:" + port, + "/u:" + c.HostUser, + "/p:rdp", + "/admin", + certFlag, + "+clipboard", + "/log-level:FATAL", + } + if rdpFullscreen { + args = append(args, "/f", "/smart-sizing") + } else { + args = append(args, "/w:1920", "/h:1080") + } + cmd := exec.Command(client.Path, args...) + if runtime.GOOS == "darwin" && strings.HasPrefix(client.Name, "sdl-") { + fmt.Fprintf(os.Stderr, "Using SDL on macOS — the screen may flicker for a moment, this is normal.\n") + } + if runtime.GOOS == "darwin" { + return cmd.Start() } + return cmd.Run() +} + +// openMacRDP opens the connection via macOS Windows App (rdp:// URI). +func openMacRDP(port string) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("macrdp viewer is only available on macOS") + } + rdpDebug("opening macOS Windows App for port %s", port) + return openURL(internalrdp.RDPUrl(port)) +} +// openRoyalTSX opens the connection via Royal TSX (rtsx:// URI). +func openRoyalTSX(c config.Config, port string) error { + rdpDebug("opening Royal TSX for port %s", port) + return openURL(internalrdp.RoyalTSXUrl(port, c.HostUser, "rdp")) +} + +func rdpDefault() error { c, err := config.LoadFromOS() if err != nil { return err } + + if port := os.Getenv("EXT_RDP_PORT"); port != "" { + rdpDebug("EXT_RDP_PORT=%s (fast path)", port) + return openRDP(c, port) + } + rdpDebug("basedir: %s", c.BaseDir) rdpDebug("cellID: %s (computed port: %s)", c.CellID, c.RDPPort) @@ -67,7 +159,7 @@ func rdpDefault() error { if m, _ := internalrdp.ParseDockerPS(string(bytes.TrimSpace(out))); len(m) > 0 { for appName, port := range m { rdpDebug("label-exact match: %s → %s", appName, port) - return openURL(internalrdp.RDPUrl(port)) + return openRDP(c, port) } } } @@ -82,7 +174,7 @@ func rdpDefault() error { if len(m) == 1 { for appName, port := range m { rdpDebug("label-dir single match: %s → %s", appName, port) - return openURL(internalrdp.RDPUrl(port)) + return openRDP(c, port) } } var opts []string @@ -114,7 +206,7 @@ func rdpDefault() error { case 0: return fmt.Errorf("no running container found for %q — run 'cell rdp --list' to see all", c.BaseDir) case 1: - return openURL(internalrdp.RDPUrl(matches[0].Port)) + return openRDP(c, matches[0].Port) default: var opts []string for _, m := range matches { @@ -153,6 +245,10 @@ func rdpList() error { } func rdpApp(appName string) error { + c, err := config.LoadFromOS() + if err != nil { + return err + } containerName := "cell-" + appName + "-run" out, err := exec.Command("docker", "inspect", containerName).Output() if err != nil { @@ -162,5 +258,5 @@ func rdpApp(appName string) error { if err != nil { return fmt.Errorf("RDP port not published for %q: %w", appName, err) } - return openURL(internalrdp.RDPUrl(port)) + return openRDP(c, port) } diff --git a/cmd/vnc.go b/cmd/vnc.go index 1ecf2a03..f3d3dce7 100644 --- a/cmd/vnc.go +++ b/cmd/vnc.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/DimmKirr/devcell/internal/config" + internalrdp "github.com/DimmKirr/devcell/internal/rdp" internalvnc "github.com/DimmKirr/devcell/internal/vnc" "github.com/DimmKirr/devcell/internal/ux" "github.com/spf13/cobra" @@ -24,6 +25,7 @@ func init() { vncCmd.Flags().Bool("list", false, "list all running cell containers and their VNC ports") vncCmd.Flags().String("app", "", "open VNC to a named container (by AppName)") vncCmd.Flags().Bool("verbose", false, "show debug info for VNC port lookup") + vncCmd.Flags().String("viewer", "", "VNC viewer: royaltsx, tigervnc, screensharing (macOS)") } func runVNC(cmd *cobra.Command, _ []string) error { @@ -35,6 +37,7 @@ func runVNC(cmd *cobra.Command, _ []string) error { } list, _ := cmd.Flags().GetBool("list") appName, _ := cmd.Flags().GetString("app") + vncViewer, _ = cmd.Flags().GetString("viewer") if list { return vncList() @@ -45,13 +48,75 @@ func runVNC(cmd *cobra.Command, _ []string) error { return vncDefault() } +// vncViewer is set by the --viewer flag. +var vncViewer string + +// openVNC dispatches to the selected VNC viewer. +// Default: Royal TSX (darwin) → TigerVNC → macOS Screen Sharing (darwin). +func openVNC(port string) error { + switch vncViewer { + case "royaltsx": + return openVNCRoyalTSX(port) + case "tigervnc": + return openVNCTigerVNC(port) + case "screensharing": + return openVNCScreenSharing(port) + case "": + // Auto: Royal TSX → TigerVNC → Screen Sharing + if runtime.GOOS == "darwin" && internalrdp.HasRoyalTSX() { + vncDebug("auto-detected Royal TSX") + return openVNCRoyalTSX(port) + } + if path, err := exec.LookPath("vncviewer"); err == nil { + vncDebug("auto-detected TigerVNC at %s", path) + return openVNCTigerVNC(port) + } + if runtime.GOOS == "darwin" { + vncDebug("falling back to macOS Screen Sharing") + fmt.Fprintf(os.Stderr, "Tip: for a better VNC experience, install one of:\n"+ + " 1. Royal TSX — https://royalapps.com/ts/mac\n"+ + " 2. TigerVNC — brew install tiger-vnc\n\n") + return openVNCScreenSharing(port) + } + return fmt.Errorf("no VNC viewer found — install one of:\n\n" + + " TigerVNC:\n" + + " Debian: sudo apt install tigervnc-viewer\n" + + " Fedora: sudo dnf install tigervnc\n" + + " Arch: sudo pacman -S tigervnc\n") + default: + return fmt.Errorf("unknown viewer %q — use royaltsx, tigervnc, or screensharing", vncViewer) + } +} + +func openVNCRoyalTSX(port string) error { + vncDebug("opening Royal TSX VNC for port %s", port) + return openURL(internalvnc.RoyalTSXVNCUrl(port)) +} + +func openVNCTigerVNC(port string) error { + vncDebug("opening TigerVNC for port %s", port) + cmd := exec.Command("vncviewer", "-passwd", internalvnc.VNCPasswdFile(), "127.0.0.1:"+port) + if runtime.GOOS == "darwin" { + return cmd.Start() + } + return cmd.Run() +} + +func openVNCScreenSharing(port string) error { + if runtime.GOOS != "darwin" { + return fmt.Errorf("screensharing viewer is only available on macOS") + } + vncDebug("opening macOS Screen Sharing for port %s", port) + return openURL(internalvnc.VNCUrl(port)) +} + func vncDefault() error { // Fast path: EXT_VNC_PORT is injected at container start with the correct // published host port. When set, we're inside a devcell container and can // use it directly without any docker lookup. if port := os.Getenv("EXT_VNC_PORT"); port != "" { vncDebug("EXT_VNC_PORT=%s (fast path)", port) - return openURL(internalvnc.VNCUrl(port)) + return openVNC(port) } c, err := config.LoadFromOS() @@ -72,7 +137,7 @@ func vncDefault() error { if m, _ := internalvnc.ParseDockerPS(string(bytes.TrimSpace(out))); len(m) > 0 { for appName, port := range m { vncDebug("label-exact match: %s → %s", appName, port) - return openURL(internalvnc.VNCUrl(port)) + return openVNC(port) } } } @@ -87,7 +152,7 @@ func vncDefault() error { if len(m) == 1 { for appName, port := range m { vncDebug("label-dir single match: %s → %s", appName, port) - return openURL(internalvnc.VNCUrl(port)) + return openVNC(port) } } // Multiple sessions — list options. @@ -120,7 +185,7 @@ func vncDefault() error { case 0: return fmt.Errorf("no running container found for %q — run 'cell vnc --list' to see all", c.BaseDir) case 1: - return openURL(internalvnc.VNCUrl(matches[0].Port)) + return openVNC(matches[0].Port) default: var opts []string for _, m := range matches { @@ -169,7 +234,7 @@ func vncApp(appName string) error { if err != nil { return fmt.Errorf("VNC port not published for %q: %w", appName, err) } - return openURL(internalvnc.VNCUrl(port)) + return openVNC(port) } func openURL(url string) error { diff --git a/docker-bake.hcl b/docker-bake.hcl index 336140a8..46cbb9ad 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -46,6 +46,7 @@ target "_base-args" { USER_UID = USER_UID USER_GID = USER_GID } + } # ── Profile image targets ──────────────────────────────────────────────────── @@ -157,16 +158,18 @@ group "release" { # local-base: base tagged for local scaffold Dockerfile use (FROM ghcr.io/dimmkirr/devcell:base-local) target "local-base" { - inherits = ["base"] - tags = ["ghcr.io/dimmkirr/devcell:base-local"] - pull = false + inherits = ["base"] + tags = ["ghcr.io/dimmkirr/devcell:base-local"] + platforms = [] + pull = false } # local-ultimate: ultimate profile for local testing (uses local nixhome/) target "local-ultimate" { - inherits = ["ultimate"] - tags = ["ghcr.io/dimmkirr/devcell:ultimate-local"] - pull = false + inherits = ["ultimate"] + tags = ["ghcr.io/dimmkirr/devcell:ultimate-local"] + platforms = [] + pull = false } # local: load into local Docker daemon (no push, no multi-arch) diff --git a/images/Dockerfile b/images/Dockerfile index 78b6f4f8..b339eedd 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -79,7 +79,11 @@ RUN export NIX_CONFIG="$(printf 'experimental-features = nix-command flakes\nsan ENV PATH="${HOME}/.nix-profile/bin:${PATH}" # Install home-manager and create nix per-user profile compat symlink. -RUN nix profile install "nixpkgs/nixos-25.11#home-manager" && \ +# Pin nixpkgs to the exact commit from nixhome/flake.lock to avoid GitHub API +# branch→commit resolution (anonymous rate limit is 60 req/hr per IP; shared +# GHA runners exhaust this). Update this hash when bumping nixhome/flake.lock. +ARG NIXPKGS_REV=c217913993d6c6f6805c3b1a3bda5e639adfde6d +RUN nix profile install "github:NixOS/nixpkgs/${NIXPKGS_REV}#home-manager" && \ mkdir -p /nix/var/nix/profiles/per-user/devcell && \ ln -sfT "$(readlink -f $HOME/.nix-profile)" \ /nix/var/nix/profiles/per-user/devcell/profile diff --git a/internal/rdp/rdp.go b/internal/rdp/rdp.go index dbbfe2a3..7ff739e7 100644 --- a/internal/rdp/rdp.go +++ b/internal/rdp/rdp.go @@ -1,8 +1,16 @@ package rdp import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" "encoding/json" + "encoding/pem" "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" "strings" ) @@ -35,6 +43,100 @@ func RDPUrl(port string) string { return "rdp://full%20address=s%3A127.0.0.1%3A" + port } +// RoyalTSXUrl returns a Royal TSX URI for the given port and credentials. +// macOS format: rtsx://rdp://user:pass@host:port +// (query string params not supported on macOS) +func RoyalTSXUrl(port, user, password string) string { + return "rtsx://rdp://" + user + ":" + password + "@127.0.0.1:" + port +} + +// HasRoyalTSX checks if Royal TSX is installed on macOS. +func HasRoyalTSX() bool { + paths := []string{ + "/Applications/Royal TSX.app", + filepath.Join(os.Getenv("HOME"), "Applications", "Royal TSX.app"), + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// ClientBinary holds the resolved FreeRDP binary path and name. +type ClientBinary struct { + Path string // absolute path from LookPath + Name string // binary name (e.g. "sdl-freerdp3") +} + +// freerdpCandidates returns binary names in preference order for the given OS. +// macOS: prefer SDL variants (no X11 needed); Linux: prefer X11 variants. +func freerdpCandidates(goos string) []string { + if goos == "darwin" { + return []string{"sdl-freerdp3", "sdl-freerdp", "xfreerdp3", "xfreerdp"} + } + return []string{"xfreerdp3", "xfreerdp", "sdl-freerdp3", "sdl-freerdp"} +} + +// FindClient finds the best available FreeRDP client binary for the current platform. +func FindClient() (ClientBinary, bool) { + return FindClientWith(runtime.GOOS, exec.LookPath) +} + +// FindClientWith is like FindClient but accepts explicit OS and lookPath for testing. +func FindClientWith(goos string, lookPath func(string) (string, error)) (ClientBinary, bool) { + for _, name := range freerdpCandidates(goos) { + if path, err := lookPath(name); err == nil { + return ClientBinary{Path: path, Name: name}, true + } + } + return ClientBinary{}, false +} + +// InstallHint returns platform-specific install instructions for FreeRDP. +func InstallHint() string { + return "xfreerdp not found — install FreeRDP and retry:\n\n" + + " macOS: brew install freerdp\n" + + " nixpkgs: nix profile install nixpkgs#freerdp\n" + + " Debian: sudo apt install freerdp3-x11\n" + + " Fedora: sudo dnf install freerdp\n" + + " Arch: sudo pacman -S freerdp\n" +} + +// CertFingerprint reads the xrdp server cert from configDir/xrdp/cert.pem +// and returns its SHA256 fingerprint as a hex string (colon-separated). +// Returns empty string if the cert doesn't exist or can't be parsed. +func CertFingerprint(configDir string) string { + data, err := os.ReadFile(filepath.Join(configDir, "xrdp", "cert.pem")) + if err != nil { + return "" + } + block, _ := pem.Decode(data) + if block == nil { + return "" + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "" + } + sum := sha256.Sum256(cert.Raw) + parts := make([]string, len(sum)) + for i, b := range sum { + parts[i] = hex.EncodeToString([]byte{b}) + } + return strings.Join(parts, ":") +} + +// CertFlag returns the FreeRDP /cert: flag. Uses fingerprint verification +// if the cert exists, otherwise falls back to /cert:ignore. +func CertFlag(configDir string) string { + if fp := CertFingerprint(configDir); fp != "" { + return "/cert:fingerprint:sha256:" + fp + } + return "/cert:ignore" +} + // ParseDockerPS parses the output of: // // docker ps --filter "name=cell-" --format "{{.Names}}\t{{.Ports}}" diff --git a/internal/rdp/rdp_test.go b/internal/rdp/rdp_test.go index 683adac6..e083b3b7 100644 --- a/internal/rdp/rdp_test.go +++ b/internal/rdp/rdp_test.go @@ -1,6 +1,11 @@ package rdp_test import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/DimmKirr/devcell/internal/rdp" @@ -14,6 +19,14 @@ func TestRDPUrl(t *testing.T) { } } +func TestRoyalTSXUrl(t *testing.T) { + got := rdp.RoyalTSXUrl("389", "dmitry", "rdp") + want := "rtsx://rdp://dmitry:rdp@127.0.0.1:389" + if got != want { + t.Errorf("want %q, got %q", want, got) + } +} + func TestParseDockerPS_Single(t *testing.T) { output := "cell-myproject-3-run\t0.0.0.0:389->3389/tcp" m, err := rdp.ParseDockerPS(output) @@ -111,3 +124,122 @@ func TestFindContainersByBind_NoRDP(t *testing.T) { t.Errorf("expected 0 matches (no 3389 port), got %d", len(matches)) } } + +// mockLookPath returns a lookPath that "finds" only the given binaries. +func mockLookPath(available ...string) func(string) (string, error) { + set := map[string]bool{} + for _, b := range available { + set[b] = true + } + return func(name string) (string, error) { + if set[name] { + return "/usr/bin/" + name, nil + } + return "", fmt.Errorf("not found: %s", name) + } +} + +func TestFindClient_DarwinPrefersSDL(t *testing.T) { + // Both sdl and x11 available — macOS should prefer sdl-freerdp3 + c, ok := rdp.FindClientWith("darwin", mockLookPath("xfreerdp", "sdl-freerdp3")) + if !ok { + t.Fatal("expected to find client") + } + if c.Name != "sdl-freerdp3" { + t.Errorf("darwin should prefer sdl-freerdp3, got %q", c.Name) + } +} + +func TestFindClient_LinuxPrefersX11(t *testing.T) { + // Both sdl and x11 available — Linux should prefer xfreerdp3 + c, ok := rdp.FindClientWith("linux", mockLookPath("sdl-freerdp3", "xfreerdp3")) + if !ok { + t.Fatal("expected to find client") + } + if c.Name != "xfreerdp3" { + t.Errorf("linux should prefer xfreerdp3, got %q", c.Name) + } +} + +func TestFindClient_FallsBackToV2(t *testing.T) { + // Only v2 available + c, ok := rdp.FindClientWith("darwin", mockLookPath("sdl-freerdp")) + if !ok { + t.Fatal("expected to find client") + } + if c.Name != "sdl-freerdp" { + t.Errorf("should fall back to sdl-freerdp, got %q", c.Name) + } +} + +func TestFindClient_NoneAvailable(t *testing.T) { + _, ok := rdp.FindClientWith("darwin", mockLookPath()) + if ok { + t.Error("expected no client found") + } +} + +func TestFindClient_LinuxFallsToSDL(t *testing.T) { + // Only SDL available on Linux — should still find it + c, ok := rdp.FindClientWith("linux", mockLookPath("sdl-freerdp3")) + if !ok { + t.Fatal("expected to find client") + } + if c.Name != "sdl-freerdp3" { + t.Errorf("linux should fall back to sdl-freerdp3, got %q", c.Name) + } +} + +func TestCertFingerprint_ValidCert(t *testing.T) { + // Generate a self-signed cert in a temp dir + dir := t.TempDir() + xrdpDir := filepath.Join(dir, "xrdp") + os.MkdirAll(xrdpDir, 0700) + out, err := exec.Command("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", filepath.Join(xrdpDir, "key.pem"), + "-out", filepath.Join(xrdpDir, "cert.pem"), + "-days", "1", "-subj", "/CN=test").CombinedOutput() + if err != nil { + t.Skipf("openssl not available: %v\n%s", err, out) + } + fp := rdp.CertFingerprint(dir) + if fp == "" { + t.Fatal("expected non-empty fingerprint") + } + // SHA256 fingerprint = 32 bytes = 32 hex pairs with colons + parts := strings.Split(fp, ":") + if len(parts) != 32 { + t.Errorf("expected 32 colon-separated hex bytes, got %d: %s", len(parts), fp) + } +} + +func TestCertFingerprint_MissingCert(t *testing.T) { + fp := rdp.CertFingerprint(t.TempDir()) + if fp != "" { + t.Errorf("expected empty fingerprint for missing cert, got %q", fp) + } +} + +func TestCertFlag_WithCert(t *testing.T) { + dir := t.TempDir() + xrdpDir := filepath.Join(dir, "xrdp") + os.MkdirAll(xrdpDir, 0700) + out, err := exec.Command("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", filepath.Join(xrdpDir, "key.pem"), + "-out", filepath.Join(xrdpDir, "cert.pem"), + "-days", "1", "-subj", "/CN=test").CombinedOutput() + if err != nil { + t.Skipf("openssl not available: %v\n%s", err, out) + } + flag := rdp.CertFlag(dir) + if !strings.HasPrefix(flag, "/cert:fingerprint:sha256:") { + t.Errorf("expected fingerprint flag, got %q", flag) + } +} + +func TestCertFlag_WithoutCert(t *testing.T) { + flag := rdp.CertFlag(t.TempDir()) + if flag != "/cert:ignore" { + t.Errorf("expected /cert:ignore fallback, got %q", flag) + } +} diff --git a/internal/vnc/vnc.go b/internal/vnc/vnc.go index b675f2aa..a089a203 100644 --- a/internal/vnc/vnc.go +++ b/internal/vnc/vnc.go @@ -3,9 +3,25 @@ package vnc import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" ) +// vncPasswdBytes is the DES-encrypted VNC password for "vnc". +// Pre-computed using the standard VNC fixed key — avoids needing vncpasswd at runtime. +var vncPasswdBytes = []byte{0x91, 0xbc, 0x75, 0xc1, 0x8d, 0x3d, 0x85, 0xa7} + +// VNCPasswdFile returns the path to a VNC password file containing the +// encrypted password "vnc". Creates the file on first call. +func VNCPasswdFile() string { + p := filepath.Join(os.TempDir(), "devcell-vnc-passwd") + if _, err := os.Stat(p); err != nil { + os.WriteFile(p, vncPasswdBytes, 0600) + } + return p +} + // ContainerMatch holds data about a container matching a host-dir bind mount. type ContainerMatch struct { Name string // full container name, e.g. "cell-devcell-73-run" @@ -67,11 +83,16 @@ func hasBindSource(binds []string, hostDir string) bool { return false } -// VNCUrl returns a VNC URL for the given port. +// VNCUrl returns a VNC URL for macOS Screen Sharing. func VNCUrl(port string) string { return "vnc://:vnc@127.0.0.1:" + port } +// RoyalTSXVNCUrl returns a Royal TSX URI for a VNC connection. +func RoyalTSXVNCUrl(port string) string { + return "rtsx://vnc://:vnc@127.0.0.1:" + port +} + // ParseDockerPS parses the output of: // // docker ps --filter "name=cell-" --format "{{.Names}}\t{{.Ports}}" diff --git a/internal/vnc/vnc_test.go b/internal/vnc/vnc_test.go index 768d36c4..b9bbc424 100644 --- a/internal/vnc/vnc_test.go +++ b/internal/vnc/vnc_test.go @@ -1,6 +1,7 @@ package vnc_test import ( + "os" "testing" "github.com/DimmKirr/devcell/internal/vnc" @@ -14,6 +15,28 @@ func TestVNCUrl(t *testing.T) { } } +func TestVNCPasswdFile(t *testing.T) { + p := vnc.VNCPasswdFile() + if p == "" { + t.Fatal("expected non-empty path") + } + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read passwd file: %v", err) + } + if len(data) != 8 { + t.Errorf("expected 8 bytes, got %d", len(data)) + } +} + +func TestRoyalTSXVNCUrl(t *testing.T) { + got := vnc.RoyalTSXVNCUrl("350") + want := "rtsx://vnc://:vnc@127.0.0.1:350" + if got != want { + t.Errorf("want %q, got %q", want, got) + } +} + func TestParseDockerPS_Single(t *testing.T) { output := "cell-myproject-3-run\t0.0.0.0:350->5900/tcp" m, err := vnc.ParseDockerPS(output) diff --git a/nixhome/modules/desktop/default.nix b/nixhome/modules/desktop/default.nix index 2b9baa98..68c4f7e7 100644 --- a/nixhome/modules/desktop/default.nix +++ b/nixhome/modules/desktop/default.nix @@ -225,8 +225,8 @@ fi log "Starting x11vnc on port 5900..." - gosu "$USER" x11vnc -display :''${DISPLAY_NUM} -forever -passwd vnc -rfbport 5900 \ - -desktop "''${APP_NAME:-cell}" -pointer_mode 2 -repeat &>/dev/null & + gosu "$USER" x11vnc -display :''${DISPLAY_NUM} -forever -nevershared -passwd vnc -rfbport 5900 \ + -desktop "''${APP_NAME:-cell}" -pointer_mode 2 -repeat -xrandr &>/dev/null & log "VNC server ready - connect to localhost:''${EXT_VNC_PORT:-5900}" log "DISPLAY=:''${DISPLAY_NUM}" diff --git a/test/vnc_test.go b/test/vnc_test.go index 3b96f975..3f131a4f 100644 --- a/test/vnc_test.go +++ b/test/vnc_test.go @@ -131,6 +131,43 @@ func TestVncPortPublishedToHost(t *testing.T) { } } +// TestVncDynamicResolution — xrandr resolution change must be picked up by x11vnc. +// Verifies that Xvfb supports RandR and x11vnc (with -xrandr) reflects the new size. +func TestVncDynamicResolution(t *testing.T) { + probeGUI(t) + c := startVncContainer(t) + + // Default resolution should be 1920x1080 + out, code := exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) + if code != 0 { + t.Fatalf("xrandr failed (exit %d): %s", code, out) + } + if !strings.Contains(out, "1920x1080") { + t.Fatalf("expected default 1920x1080 in xrandr output: %s", out) + } + t.Logf("PASS: default resolution is 1920x1080") + + // Add a new mode and switch to it + _, code = exec(t, c, []string{"sh", "-c", + "DISPLAY=:99 xrandr --newmode 2560x1440 0 2560 2560 2560 2560 1440 1440 1440 1440 2>/dev/null; " + + "DISPLAY=:99 xrandr --addmode screen 2560x1440 2>/dev/null; " + + "DISPLAY=:99 xrandr -s 2560x1440 2>/dev/null"}) + if code != 0 { + t.Skipf("xrandr mode change not supported (Xvfb RANDR is limited to initial resolution; would need Xvnc for dynamic resize) (exit %d)", code) + } + + // Verify new resolution + out, code = exec(t, c, []string{"sh", "-c", "DISPLAY=:99 xrandr 2>&1"}) + if code != 0 { + t.Fatalf("xrandr check failed (exit %d): %s", code, out) + } + if !strings.Contains(out, "2560x1440") || !strings.Contains(out, "*") { + t.Errorf("expected 2560x1440 to be active resolution: %s", out) + } else { + t.Logf("PASS: resolution changed to 2560x1440") + } +} + // TestVncDockerPortByName — `docker port 5900` must return the exact // host port for the named container. This is the fix for Bug 1 in Taskfile.yml `vnc` task: // diff --git a/test/workflow_test.go b/test/workflow_test.go index 409eadbd..99449cb9 100644 --- a/test/workflow_test.go +++ b/test/workflow_test.go @@ -2,12 +2,12 @@ package container_test import ( "bytes" - "io" "os" osexec "os/exec" "path/filepath" "strings" "testing" + "time" "github.com/DimmKirr/devcell/internal/scaffold" "github.com/creack/pty" @@ -100,7 +100,19 @@ func TestCellShell(t *testing.T) { } // Scaffold config directory (cell shell needs devcell.toml). - configDir := t.TempDir() + // Use manual cleanup via docker because the container creates + // root-owned files (e.g. xrdp/cert.pem) that t.TempDir() can't remove. + configDir, err := os.MkdirTemp("", "celltest-config-*") + if err != nil { + t.Fatalf("mkdtemp config: %v", err) + } + t.Cleanup(func() { + osexec.Command("docker", "run", "--rm", + "-v", configDir+":"+configDir, + "alpine", "rm", "-rf", configDir, + ).Run() + os.RemoveAll(configDir) + }) devcellConfigDir := filepath.Join(configDir, "devcell") if err := scaffold.Scaffold(devcellConfigDir, ""); err != nil { t.Fatalf("scaffold: %v", err) @@ -139,17 +151,7 @@ func TestCellShell(t *testing.T) { "DEVCELL_USER_IMAGE="+userImage, ) - ptmx, err := pty.Start(cmd) - if err != nil { - t.Fatalf("pty.Start cell shell: %v", err) - } - defer ptmx.Close() - - var buf bytes.Buffer - io.Copy(&buf, ptmx) - cmd.Wait() - - out := buf.String() + out := runPTY(t, cmd) if !strings.Contains(out, "123") { t.Errorf("expected cell shell output to contain '123', got: %s", out) } @@ -165,19 +167,39 @@ func TestCellShell(t *testing.T) { "DEVCELL_USER_IMAGE="+userImage, ) - ptmx, err := pty.Start(cmd) - if err != nil { - t.Fatalf("pty.Start cell shell: %v", err) - } - defer ptmx.Close() - - var buf bytes.Buffer - io.Copy(&buf, ptmx) - cmd.Wait() - - out := strings.ToLower(buf.String()) + out := strings.ToLower(runPTY(t, cmd)) if !strings.Contains(out, "nix") { - t.Errorf("expected cell shell output to contain 'nix', got: %s", buf.String()) + t.Errorf("expected cell shell output to contain 'nix', got: %s", out) } }) } + +// runPTY starts cmd in a PTY, collects output, and returns it. +// It runs io.Copy in a goroutine so cmd.Wait() can proceed even if the +// PTY slave side isn't closed yet (common with docker-backed commands). +func runPTY(t *testing.T, cmd *osexec.Cmd) string { + t.Helper() + ptmx, err := pty.Start(cmd) + if err != nil { + t.Fatalf("pty.Start: %v", err) + } + + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + buf.ReadFrom(ptmx) + close(done) + }() + + if err := cmd.Wait(); err != nil { + t.Logf("cmd.Wait: %v (output so far: %s)", err, buf.String()) + } + ptmx.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Log("warning: PTY read didn't finish within 5s after process exit") + } + return buf.String() +}