Skip to content

Commit f495817

Browse files
committed
Add VNC video display including password
The other display options open a window always, while the vnc is more "on demand" by using a separate vnc viewer. Add localhost and password support for some minimal security. The password is generated, and is stored as an instance file. Signed-off-by: Anders F Björklund <[email protected]>
1 parent eb49205 commit f495817

File tree

10 files changed

+196
-4
lines changed

10 files changed

+196
-4
lines changed

docs/internal.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ SSH:
5656
- `ssh.sock`: SSH control master socket
5757
- `ssh.config`: SSH config file for `ssh -F`. Not consumed by Lima itself.
5858

59+
VNC:
60+
- `vncdisplay`: VNC display host/port
61+
- `vncpassword`: VNC display password
62+
5963
Guest agent:
6064
- `ga.sock`: Forwarded to `/run/lima-guestagent.sock` in the guest, via SSH
6165

examples/default.yaml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,21 @@ firmware:
245245
legacyBIOS: null
246246

247247
video:
248-
# QEMU display, e.g., "none", "cocoa", "sdl", "gtk", "default".
248+
# QEMU display, e.g., "none", "cocoa", "sdl", "gtk", "vnc", "default".
249249
# Choosing "none" will hide the video output, and not show any window.
250+
# Choosing "vnc" will use a network server, and not show any window.
250251
# Choosing "default" will pick the first available of: gtk, sdl, cocoa.
251-
# As of QEMU v6.2, enabling this is known to have negative impact
252+
# As of QEMU v6.2, enabling anything but none or vnc is known to have negative impact
252253
# on performance on macOS hosts: https://gitlab.com/qemu-project/qemu/-/issues/334
253254
# 🟢 Builtin default: "none"
254255
display: null
256+
# VNC (Virtual Network Computing) is a platform-independent graphical
257+
# desktop-sharing system that uses the Remote Frame Buffer protocol (RFB)
258+
vnc:
259+
# VNC display, e.g.,"to=L", "host:d", "unix:path", "none"
260+
# By convention the TCP port is 5900+d, connections from any host.
261+
# 🟢 Builtin default: "127.0.0.1:0,to=9"
262+
display: null
255263

256264
# The instance can get routable IP addresses from the vmnet framework using
257265
# https://github.com/lima-vm/socket_vmnet.

pkg/driver/driver.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ type Driver interface {
1515
Start(_ context.Context) (chan error, error)
1616

1717
Stop(_ context.Context) error
18+
19+
ChangeDisplayPassword(_ context.Context, password string) error
20+
21+
GetDisplayConnection(_ context.Context) (string, error)
1822
}
1923

2024
type BaseDriver struct {
@@ -39,3 +43,11 @@ func (d *BaseDriver) Start(_ context.Context) (chan error, error) {
3943
func (d *BaseDriver) Stop(_ context.Context) error {
4044
return nil
4145
}
46+
47+
func (d *BaseDriver) ChangeDisplayPassword(_ context.Context, password string) error {
48+
return nil
49+
}
50+
51+
func (d *BaseDriver) GetDisplayConnection(_ context.Context) (string, error) {
52+
return "", nil
53+
}

pkg/hostagent/hostagent.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"math/rand"
1011
"net"
1112
"os"
1213
"os/exec"
@@ -251,6 +252,15 @@ func (a *HostAgent) emitEvent(_ context.Context, ev events.Event) {
251252
}
252253
}
253254

255+
func generatePassword(length int) (string, error) {
256+
passwd := ""
257+
rand.Seed(time.Now().UnixNano())
258+
for i := 0; i < length; i++ {
259+
passwd += strconv.Itoa(rand.Intn(10))
260+
}
261+
return passwd, nil
262+
}
263+
254264
func (a *HostAgent) Run(ctx context.Context) error {
255265
defer func() {
256266
exitingEv := events.Event{
@@ -285,6 +295,50 @@ func (a *HostAgent) Run(ctx context.Context) error {
285295
return err
286296
}
287297

298+
if *a.y.Video.Display == "vnc" {
299+
vncdisplay, vncoptions, _ := strings.Cut(*a.y.Video.VNC.Display, ",")
300+
vnchost, vncnum, err := net.SplitHostPort(vncdisplay)
301+
if err != nil {
302+
return err
303+
}
304+
n, err := strconv.Atoi(vncnum)
305+
if err != nil {
306+
return err
307+
}
308+
vncport := strconv.Itoa(5900 + n)
309+
vncpwdfile := filepath.Join(a.instDir, filenames.VNCPasswordFile)
310+
vncpasswd, err := generatePassword(8)
311+
if err != nil {
312+
return err
313+
}
314+
if err := a.driver.ChangeDisplayPassword(ctx, vncpasswd); err != nil {
315+
return err
316+
}
317+
if err := os.WriteFile(vncpwdfile, []byte(vncpasswd), 0600); err != nil {
318+
return err
319+
}
320+
if strings.Contains(vncoptions, "to=") {
321+
vncport, err = a.driver.GetDisplayConnection(ctx)
322+
if err != nil {
323+
return err
324+
}
325+
p, err := strconv.Atoi(vncport)
326+
if err != nil {
327+
return err
328+
}
329+
vncnum = strconv.Itoa(p - 5900)
330+
vncdisplay = net.JoinHostPort(vnchost, vncnum)
331+
}
332+
vncfile := filepath.Join(a.instDir, filenames.VNCDisplayFile)
333+
if err := os.WriteFile(vncfile, []byte(vncdisplay), 0600); err != nil {
334+
return err
335+
}
336+
vncurl := "vnc://:" + vncpasswd + "@" + net.JoinHostPort(vnchost, vncport)
337+
logrus.Infof("VNC server running at <%s>", vncurl)
338+
logrus.Infof("VNC Display: \"%s\" `%s`", vncdisplay, vncfile)
339+
logrus.Infof("VNC Password: \"%s\" `%s`", vncpasswd, vncpwdfile)
340+
}
341+
288342
stBase := events.Status{
289343
SSHLocalPort: a.sshLocalPort,
290344
}

pkg/limayaml/defaults.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,16 @@ func FillDefault(y, d, o *LimaYAML, filePath string) {
186186
y.Video.Display = pointer.String("none")
187187
}
188188

189+
if y.Video.VNC.Display == nil {
190+
y.Video.VNC.Display = d.Video.VNC.Display
191+
}
192+
if o.Video.VNC.Display != nil {
193+
y.Video.VNC.Display = o.Video.VNC.Display
194+
}
195+
if y.Video.VNC.Display == nil || *y.Video.VNC.Display == "" {
196+
y.Video.VNC.Display = pointer.String("127.0.0.1:0,to=9")
197+
}
198+
189199
if y.Firmware.LegacyBIOS == nil {
190200
y.Firmware.LegacyBIOS = d.Firmware.LegacyBIOS
191201
}

pkg/limayaml/defaults_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ func TestFillDefault(t *testing.T) {
7272
},
7373
Video: Video{
7474
Display: pointer.String("none"),
75+
VNC: VNCOptions{
76+
Display: pointer.String("127.0.0.1:0,to=9"),
77+
},
7578
},
7679
HostResolver: HostResolver{
7780
Enabled: pointer.Bool(true),
@@ -262,6 +265,9 @@ func TestFillDefault(t *testing.T) {
262265
},
263266
Video: Video{
264267
Display: pointer.String("cocoa"),
268+
VNC: VNCOptions{
269+
Display: pointer.String("none"),
270+
},
265271
},
266272
HostResolver: HostResolver{
267273
Enabled: pointer.Bool(false),
@@ -418,6 +424,9 @@ func TestFillDefault(t *testing.T) {
418424
},
419425
Video: Video{
420426
Display: pointer.String("cocoa"),
427+
VNC: VNCOptions{
428+
Display: pointer.String("none"),
429+
},
421430
},
422431
HostResolver: HostResolver{
423432
Enabled: pointer.Bool(false),

pkg/limayaml/limayaml.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,14 @@ type Firmware struct {
122122
LegacyBIOS *bool `yaml:"legacyBIOS,omitempty" json:"legacyBIOS,omitempty"`
123123
}
124124

125+
type VNCOptions struct {
126+
Display *string `yaml:"display,omitempty" json:"display,omitempty"`
127+
}
128+
125129
type Video struct {
126130
// Display is a QEMU display string
127-
Display *string `yaml:"display,omitempty" json:"display,omitempty"`
131+
Display *string `yaml:"display,omitempty" json:"display,omitempty"`
132+
VNC VNCOptions `yaml:"vnc" json:"vnc"`
128133
}
129134

130135
type ProvisionMode = string

pkg/qemu/qemu.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,12 @@ func Cmdline(cfg Config) (string, []string, error) {
549549

550550
// Graphics
551551
if *y.Video.Display != "" {
552-
args = appendArgsIfNoConflict(args, "-display", *y.Video.Display)
552+
display := *y.Video.Display
553+
if display == "vnc" {
554+
display += "=" + *y.Video.VNC.Display
555+
display += ",password=on"
556+
}
557+
args = appendArgsIfNoConflict(args, "-display", display)
553558
}
554559
switch *y.Arch {
555560
case limayaml.X8664, limayaml.RISCV64:

pkg/qemu/qemu_driver.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"context"
7+
"errors"
78
"fmt"
89
"io"
910
"net"
@@ -106,6 +107,86 @@ func (l *LimaQemuDriver) Stop(ctx context.Context) error {
106107
return l.shutdownQEMU(ctx, 3*time.Minute, l.qCmd, l.qWaitCh)
107108
}
108109

110+
func (l *LimaQemuDriver) ChangeDisplayPassword(ctx context.Context, password string) error {
111+
return l.changeVNCPassword(ctx, password)
112+
}
113+
114+
func (l *LimaQemuDriver) GetDisplayConnection(ctx context.Context) (string, error) {
115+
return l.getVNCDisplayPort(ctx)
116+
}
117+
118+
func waitFileExists(path string, timeout time.Duration) error {
119+
startWaiting := time.Now()
120+
for {
121+
_, err := os.Stat(path)
122+
if err == nil {
123+
break
124+
}
125+
if !errors.Is(err, os.ErrNotExist) {
126+
return err
127+
}
128+
if time.Since(startWaiting) > timeout {
129+
return fmt.Errorf("timeout waiting for %s", path)
130+
}
131+
time.Sleep(500 * time.Millisecond)
132+
}
133+
return nil
134+
}
135+
136+
func (l *LimaQemuDriver) changeVNCPassword(ctx context.Context, password string) error {
137+
qmpSockPath := filepath.Join(l.Instance.Dir, filenames.QMPSock)
138+
err := waitFileExists(qmpSockPath, 30*time.Second)
139+
if err != nil {
140+
return err
141+
}
142+
qmpClient, err := qmp.NewSocketMonitor("unix", qmpSockPath, 5*time.Second)
143+
if err != nil {
144+
return err
145+
}
146+
if err := qmpClient.Connect(); err != nil {
147+
return err
148+
}
149+
defer func() { _ = qmpClient.Disconnect() }()
150+
rawClient := raw.NewMonitor(qmpClient)
151+
err = rawClient.ChangeVNCPassword(password)
152+
if err != nil {
153+
return err
154+
}
155+
return nil
156+
}
157+
158+
func (l *LimaQemuDriver) getVNCDisplayPort(ctx context.Context) (string, error) {
159+
qmpSockPath := filepath.Join(l.Instance.Dir, filenames.QMPSock)
160+
qmpClient, err := qmp.NewSocketMonitor("unix", qmpSockPath, 5*time.Second)
161+
if err != nil {
162+
return "", err
163+
}
164+
if err := qmpClient.Connect(); err != nil {
165+
return "", err
166+
}
167+
defer func() { _ = qmpClient.Disconnect() }()
168+
rawClient := raw.NewMonitor(qmpClient)
169+
info, err := rawClient.QueryVNC()
170+
if err != nil {
171+
return "", err
172+
}
173+
return *info.Service, nil
174+
}
175+
176+
func (l *LimaQemuDriver) removeVNCFiles() error {
177+
vncfile := filepath.Join(l.Instance.Dir, filenames.VNCDisplayFile)
178+
err := os.RemoveAll(vncfile)
179+
if err != nil {
180+
return err
181+
}
182+
vncpwdfile := filepath.Join(l.Instance.Dir, filenames.VNCPasswordFile)
183+
err = os.RemoveAll(vncpwdfile)
184+
if err != nil {
185+
return err
186+
}
187+
return nil
188+
}
189+
109190
func (l *LimaQemuDriver) shutdownQEMU(ctx context.Context, timeout time.Duration, qCmd *exec.Cmd, qWaitCh <-chan error) error {
110191
logrus.Info("Shutting down QEMU with ACPI")
111192
qmpSockPath := filepath.Join(l.Instance.Dir, filenames.QMPSock)
@@ -129,6 +210,7 @@ func (l *LimaQemuDriver) shutdownQEMU(ctx context.Context, timeout time.Duration
129210
select {
130211
case qWaitErr := <-qWaitCh:
131212
logrus.WithError(qWaitErr).Info("QEMU has exited")
213+
l.removeVNCFiles()
132214
return qWaitErr
133215
case <-deadline:
134216
}
@@ -144,6 +226,7 @@ func (l *LimaQemuDriver) killQEMU(_ context.Context, _ time.Duration, qCmd *exec
144226
logrus.WithError(qWaitErr).Info("QEMU has exited, after killing forcibly")
145227
qemuPIDPath := filepath.Join(l.Instance.Dir, filenames.PIDFile(*l.Yaml.VMType))
146228
_ = os.RemoveAll(qemuPIDPath)
229+
l.removeVNCFiles()
147230
return qWaitErr
148231
}
149232

pkg/store/filenames/filenames.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const (
3838
SerialSock = "serial.sock"
3939
SSHSock = "ssh.sock"
4040
SSHConfig = "ssh.config"
41+
VNCDisplayFile = "vncdisplay"
42+
VNCPasswordFile = "vncpassword"
4143
GuestAgentSock = "ga.sock"
4244
HostAgentPID = "ha.pid"
4345
HostAgentSock = "ha.sock"

0 commit comments

Comments
 (0)