1# Copyright (C) 2006-2008, 2015 Red Hat, Inc.
2# Copyright (C) 2006 Daniel P. Berrange <berrange@redhat.com>
3# Copyright (C) 2010 Marc-Andre Lureau <marcandre.lureau@redhat.com>
4#
5# This work is licensed under the GNU GPLv2 or later.
6# See the COPYING file in the top-level directory.
7
8from gi.repository import Gdk
9from gi.repository import GObject
10
11import gi
12gi.require_version('GtkVnc', '2.0')
13from gi.repository import GtkVnc
14try:
15    gi.require_version('SpiceClientGtk', '3.0')
16    from gi.repository import SpiceClientGtk
17    from gi.repository import SpiceClientGLib
18    have_spice_gtk = True
19except (ValueError, ImportError):  # pragma: no cover
20    have_spice_gtk = False
21
22from virtinst import log
23
24from .sshtunnels import SSHTunnels
25from ..baseclass import vmmGObject
26
27
28##################################
29# VNC/Spice abstraction handling #
30##################################
31
32class Viewer(vmmGObject):
33    """
34    Base class for viewer abstraction
35    """
36    __gsignals__ = {
37        "add-display-widget": (vmmGObject.RUN_FIRST, None, [object]),
38        "size-allocate": (vmmGObject.RUN_FIRST, None, [object]),
39        "pointer-grab": (vmmGObject.RUN_FIRST, None, []),
40        "pointer-ungrab": (vmmGObject.RUN_FIRST, None, []),
41        "keyboard-grab": (vmmGObject.RUN_FIRST, None, []),
42        "keyboard-ungrab": (vmmGObject.RUN_FIRST, None, []),
43        "connected": (vmmGObject.RUN_FIRST, None, []),
44        "disconnected": (vmmGObject.RUN_FIRST, None, [str, str]),
45        "auth-error": (vmmGObject.RUN_FIRST, None, [str, bool]),
46        "need-auth": (vmmGObject.RUN_FIRST, None, [bool, bool]),
47        "agent-connected": (vmmGObject.RUN_FIRST, None, []),
48        "usb-redirect-error": (vmmGObject.RUN_FIRST, None, [str]),
49    }
50
51    def __init__(self, vm, ginfo):
52        vmmGObject.__init__(self)
53        self._display = None
54        self._vm = vm
55        self._ginfo = ginfo
56        self._tunnels = SSHTunnels(self._ginfo)
57        self._keyboard_grab = False
58
59        self.add_gsettings_handle(
60            self.config.on_keys_combination_changed(self._refresh_grab_keys))
61
62        self.connect("add-display-widget", self._common_init)
63
64    def _cleanup(self):
65        self.close()
66
67        if self._display:
68            self._display.destroy()
69        self._display = None
70        self._vm = None
71
72        self._tunnels.close_all()
73
74
75    ########################
76    # Internal helper APIs #
77    ########################
78
79    def _make_signal_proxy(self, new_signal):
80        """
81        Helper for redirecting a signal from self._display out
82        through the viewer
83        """
84        def _proxy_signal(src, *args, **kwargs):
85            ignore = src
86            self.emit(new_signal, *args, **kwargs)
87        return _proxy_signal
88
89    def _common_init(self, ignore1, ignore2):
90        self._refresh_grab_keys()
91
92        self._display.connect("size-allocate",
93            self._make_signal_proxy("size-allocate"))
94
95
96
97    #########################
98    # Generic internal APIs #
99    #########################
100
101    def _grab_focus(self):
102        if self._display:
103            self._display.grab_focus()
104    def _set_size_request(self, *args, **kwargs):
105        return self._display.set_size_request(*args, **kwargs)
106    def _size_allocate(self, *args, **kwargs):
107        return self._display.size_allocate(*args, **kwargs)
108
109    def _get_pixbuf(self):
110        return self._display.get_pixbuf()
111
112    def _get_fd_for_open(self):
113        if self._ginfo.need_tunnel():
114            return self._tunnels.open_new()
115
116        if self._vm.conn.is_remote():
117            # OpenGraphics only works for local libvirtd connections
118            return None
119
120        if self._ginfo.gtlsport and not self._ginfo.gport:
121            # This makes spice loop requesting an fd. Disable until spice is
122            # fixed: https://bugzilla.redhat.com/show_bug.cgi?id=1334071
123            return None  # pragma: no cover
124
125        if not self._vm.conn.support.domain_open_graphics():
126            return None
127
128        return self._vm.open_graphics_fd()
129
130    def _open(self):
131        if self._ginfo.bad_config():
132            raise RuntimeError(self._ginfo.bad_config())
133
134        fd = self._get_fd_for_open()
135        if fd is not None:
136            self._open_fd(fd)
137        else:
138            self._open_host()
139
140    def _get_grab_keys(self):
141        return self._display.get_grab_keys().as_string()
142
143    def _emit_disconnected(self, errdetails=None):
144        ssherr = self._tunnels.get_err_output()
145        self.emit("disconnected", errdetails, ssherr)
146
147
148    #######################################################
149    # Internal API that will be overwritten by subclasses #
150    #######################################################
151
152    def close(self):
153        raise NotImplementedError()
154
155    def _is_open(self):
156        raise NotImplementedError()
157
158    def _set_username(self, cred):
159        raise NotImplementedError()
160    def _set_password(self, cred):
161        raise NotImplementedError()
162
163    def _send_keys(self, keys):
164        raise NotImplementedError()
165
166    def _refresh_grab_keys(self):
167        raise NotImplementedError()
168
169    def _open_host(self):
170        raise NotImplementedError()
171    def _open_fd(self, fd):
172        raise NotImplementedError()
173
174    def _get_desktop_resolution(self):
175        raise NotImplementedError()
176
177    def _get_scaling(self):
178        raise NotImplementedError()
179    def _set_scaling(self, scaling):
180        raise NotImplementedError()
181
182    def _set_resizeguest(self, val):
183        raise NotImplementedError()
184    def _get_resizeguest(self):
185        raise NotImplementedError()
186
187    def _get_usb_widget(self):
188        raise NotImplementedError()
189    def _has_usb_redirection(self):
190        raise NotImplementedError()
191    def _has_agent(self):
192        raise NotImplementedError()
193
194
195    ####################################
196    # APIs accessed by vmmConsolePages #
197    ####################################
198
199    def console_is_open(self):
200        return self._is_open()
201
202    def console_grab_focus(self):
203        return self._grab_focus()
204    def console_has_keyboard_grab(self):
205        return bool(self._display and self._keyboard_grab)
206    def console_set_size_request(self, *args, **kwargs):
207        return self._set_size_request(*args, **kwargs)
208    def console_size_allocate(self, *args, **kwargs):
209        return self._size_allocate(*args, **kwargs)
210
211    def console_get_pixbuf(self):
212        return self._get_pixbuf()
213
214    def console_open(self):
215        return self._open()
216
217    def console_set_password(self, val):
218        return self._set_password(val)
219    def console_set_username(self, val):
220        return self._set_username(val)
221
222    def console_send_keys(self, keys):
223        return self._send_keys(keys)
224
225    def console_get_grab_keys(self):
226        return self._get_grab_keys()
227
228    def console_get_desktop_resolution(self):
229        ret = self._get_desktop_resolution()
230        if not ret:
231            return ret  # pragma: no cover
232
233        # Don't pass on bogus resolutions
234        if (ret[0] == 0) or (ret[1] == 0):
235            return None
236        return ret
237
238    def console_get_scaling(self):
239        return self._get_scaling()
240    def console_set_scaling(self, val):
241        return self._set_scaling(val)
242
243    def console_get_resizeguest(self):
244        return self._get_resizeguest()
245    def console_set_resizeguest(self, val):
246        return self._set_resizeguest(val)
247
248    def console_get_usb_widget(self):
249        return self._get_usb_widget()
250    def console_has_usb_redirection(self):
251        return self._has_usb_redirection()
252    def console_has_agent(self):
253        return self._has_agent()
254
255    def console_remove_display_from_widget(self, widget):
256        if self._display and self._display in widget.get_children():
257            widget.remove(self._display)
258
259
260####################
261# VNC viewer class #
262####################
263
264class VNCViewer(Viewer):
265    viewer_type = "vnc"
266
267    def __init__(self, *args, **kwargs):
268        Viewer.__init__(self, *args, **kwargs)
269        self._display = None
270        self._desktop_resolution = None
271
272
273    ###################
274    # Private helpers #
275    ###################
276
277    def _init_widget(self):
278        self._display = GtkVnc.Display()
279
280        # Make sure viewer doesn't force resize itself
281        self._display.set_force_size(False)
282        self._display.set_pointer_grab(True)
283
284        self.emit("add-display-widget", self._display)
285        self._display.realize()
286
287        self._display.connect("vnc-pointer-grab",
288            self._make_signal_proxy("pointer-grab"))
289        self._display.connect("vnc-pointer-ungrab",
290            self._make_signal_proxy("pointer-ungrab"))
291        self._display.connect("vnc-keyboard-grab", self._keyboard_grab_cb)
292        self._display.connect("vnc-keyboard-ungrab", self._keyboard_ungrab_cb)
293
294        self._display.connect("vnc-auth-credential", self._auth_credential)
295        self._display.connect("vnc-auth-failure", self._auth_failure_cb)
296        self._display.connect("vnc-initialized", self._connected_cb)
297        self._display.connect("vnc-disconnected", self._disconnected_cb)
298        self._display.connect("vnc-desktop-resize", self._desktop_resize)
299
300        self._display.show()
301
302    def _keyboard_grab_cb(self, src):
303        self._keyboard_grab = True
304        self.emit("keyboard-grab")
305
306    def _keyboard_ungrab_cb(self, src):
307        self._keyboard_grab = False
308        self.emit("keyboard-ungrab")
309
310    def _connected_cb(self, ignore):
311        self._tunnels.unlock()
312        self.emit("connected")
313
314    def _disconnected_cb(self, ignore):
315        self._tunnels.unlock()
316        self._emit_disconnected()
317
318    def _desktop_resize(self, src_ignore, w, h):
319        self._desktop_resolution = (w, h)
320        # Queue a resize
321        self.emit("size-allocate", None)
322
323    def _auth_failure_cb(self, ignore, msg):
324        log.debug("VNC auth failure. msg=%s", msg)
325        self.emit("auth-error", msg, True)
326
327    def _auth_credential(self, src_ignore, credList):
328        values = []
329        for idx in range(int(credList.n_values)):
330            values.append(credList.get_nth(idx))
331
332        if self.config.CLITestOptions.fake_vnc_username:
333            values.append(GtkVnc.DisplayCredential.USERNAME)
334
335        withUsername = False
336        withPassword = False
337        for cred in values:
338            log.debug("Got credential request %s", cred)
339            if cred == GtkVnc.DisplayCredential.PASSWORD:
340                withPassword = True
341            elif cred == GtkVnc.DisplayCredential.USERNAME:
342                withUsername = True
343            elif cred == GtkVnc.DisplayCredential.CLIENTNAME:  # pragma: no cover
344                self._display.set_credential(cred, "libvirt-vnc")
345            else:  # pragma: no cover
346                errmsg = (
347                        _("Unable to provide requested credentials to the VNC server.\n"
348                        "The credential type %s is not supported") %
349                        str(cred.value_name))
350                self.emit("auth-error", errmsg, True)
351                return
352
353        if withUsername or withPassword:
354            self.emit("need-auth", withPassword, withUsername)
355
356
357    ###############################
358    # Private API implementations #
359    ###############################
360
361    def close(self):
362        self._display.close()
363
364    def _is_open(self):
365        return self._display.is_open()
366
367    def _get_scaling(self):
368        if self._display:
369            return self._display.get_scaling()
370    def _set_scaling(self, scaling):
371        if self._display:
372            return self._display.set_scaling(scaling)
373
374    def _get_grab_keys(self):
375        return self._display.get_grab_keys().as_string()
376
377    def _refresh_grab_keys(self):
378        if not self._display:
379            return  # pragma: no cover
380
381        try:
382            keys = self.config.get_keys_combination()
383            if not keys:
384                return  # pragma: no cover
385
386            try:
387                keys = [int(k) for k in keys.split(',')]
388            except Exception:  # pragma: no cover
389                log.debug("Error in grab_keys configuration in Gsettings",
390                              exc_info=True)
391                return
392
393            seq = GtkVnc.GrabSequence.new(keys)
394            self._display.set_grab_keys(seq)
395        except Exception as e:  # pragma: no cover
396            log.debug("Error when getting the grab keys combination: %s",
397                          str(e))
398
399    def _send_keys(self, keys):
400        return self._display.send_keys([Gdk.keyval_from_name(k) for k in keys])
401
402    def _get_desktop_resolution(self):
403        return self._desktop_resolution
404
405    def _set_username(self, cred):
406        self._display.set_credential(GtkVnc.DisplayCredential.USERNAME, cred)
407    def _set_password(self, cred):
408        self._display.set_credential(GtkVnc.DisplayCredential.PASSWORD, cred)
409
410    def _set_resizeguest(self, val):
411        ignore = val
412    def _get_resizeguest(self):
413        return False
414
415    def _get_usb_widget(self):
416        return None  # pragma: no cover
417    def _has_usb_redirection(self):
418        return False
419    def _has_agent(self):
420        return False  # pragma: no cover
421
422
423    #######################
424    # Connection routines #
425    #######################
426
427    def _open(self):
428        self._init_widget()
429        return Viewer._open(self)
430
431    def _open_host(self):
432        host, port, ignore = self._ginfo.get_conn_host()
433        log.debug("VNC connecting to host=%s port=%s", host, port)
434        self._display.open_host(host, port)
435
436    def _open_fd(self, fd):
437        self._display.open_fd(fd)
438
439
440######################
441# Spice viewer class #
442######################
443
444class _SignalTracker:
445    # Helper class to more conveniently connect and disconnect signals
446    # from spice objects. This ensures we don't leave circular references
447    # at object destroy time
448    def __init__(self):
449        self._sigmap = {}
450
451    def _add_hid(self, obj, hid):
452        if obj not in self._sigmap:
453            self._sigmap[obj] = []
454        self._sigmap[obj].append(hid)
455
456    def connect(self, obj, name, handler, *args):
457        hid = GObject.GObject.connect(obj, name, handler, *args)
458        self._add_hid(obj, hid)
459
460    def connect_after(self, obj, name, handler, *args):
461        hid = GObject.GObject.connect_after(obj, name, handler, *args)
462        self._add_hid(obj, hid)
463
464    def disconnect_obj_signals(self, obj):
465        for hid in self._sigmap.get(obj, []):
466            GObject.GObject.disconnect(obj, hid)
467
468
469_SIGS = _SignalTracker()
470
471
472class SpiceViewer(Viewer):
473    viewer_type = "spice"
474
475    def __init__(self, *args, **kwargs):
476        Viewer.__init__(self, *args, **kwargs)
477        self._spice_session = None
478        self._display = None
479        self._audio = None
480        self._main_channel = None
481        self._display_channel = None
482        self._usbdev_manager = None
483        self._channels = set()
484
485
486    ###################
487    # Private helpers #
488    ###################
489
490    def _mouse_grab_cb(self, src, grab):
491        if grab:
492            self.emit("pointer-grab")
493        else:
494            self.emit("pointer-ungrab")
495
496    def _keyboard_grab_cb(self, src, grab):
497        self._keyboard_grab = grab
498        if grab:
499            self.emit("keyboard-grab")
500        else:
501            self.emit("keyboard-ungrab")
502
503    def _init_widget(self):
504        self.emit("add-display-widget", self._display)
505        self._display.realize()
506
507        self._display.connect("mouse-grab", self._mouse_grab_cb)
508        self._display.connect("keyboard-grab", self._keyboard_grab_cb)
509
510        self._display.show()
511
512    def _create_spice_session(self):
513        self._spice_session = SpiceClientGLib.Session()
514        SpiceClientGLib.set_session_option(self._spice_session)
515        gtk_session = SpiceClientGtk.GtkSession.get(self._spice_session)
516        gtk_session.set_property("auto-clipboard", True)
517
518        _SIGS.connect(self._spice_session, "channel-new", self._channel_new_cb)
519
520        # Distros might have usb redirection compiled out, like OpenBSD
521        # https://bugzilla.redhat.com/show_bug.cgi?id=1348479
522        try:
523            self._usbdev_manager = SpiceClientGLib.UsbDeviceManager.get(
524                                        self._spice_session)
525            _SIGS.connect(
526                    self._usbdev_manager, "auto-connect-failed",
527                    self._usbdev_redirect_error)
528            _SIGS.connect(
529                    self._usbdev_manager, "device-error",
530                    self._usbdev_redirect_error)
531
532            autoredir = self.config.get_auto_usbredir()
533            if autoredir:
534                gtk_session.set_property("auto-usbredir", True)
535        except Exception:  # pragma: no cover
536            self._usbdev_manager = None
537            log.debug("Error initializing spice usb device manager",
538                exc_info=True)
539
540
541    #####################
542    # Channel listeners #
543    #####################
544
545    def _main_channel_event_cb(self, channel, event):
546        self._tunnels.unlock()
547
548        if event == SpiceClientGLib.ChannelEvent.CLOSED:
549            self._emit_disconnected()
550        elif event == SpiceClientGLib.ChannelEvent.ERROR_AUTH:
551            if not self._spice_session.get_property("password"):
552                log.debug("Spice channel received ERROR_AUTH, but no "
553                    "password set, assuming it wants credentials.")
554                self.emit("need-auth", True, False)
555            else:
556                log.debug("Spice channel received ERROR_AUTH, but a "
557                    "password is already set. Assuming authentication failed.")
558                self.emit("auth-error", channel.get_error().message, False)
559        elif "ERROR" in str(event):
560            # SpiceClientGLib.ChannelEvent.ERROR_CONNECT
561            # SpiceClientGLib.ChannelEvent.ERROR_IO
562            # SpiceClientGLib.ChannelEvent.ERROR_LINK
563            # SpiceClientGLib.ChannelEvent.ERROR_TLS
564            error = None
565            if channel.get_error():
566                error = channel.get_error().message
567            log.debug("Spice channel event=%s message=%s", event, error)
568
569            msg = _("Encountered SPICE %(error-name)s") % {
570                "error-name": event.value_nick}
571            if error:
572                msg += ": %s" % error
573            self._emit_disconnected(msg)
574
575    def _fd_channel_event_cb(self, channel, event):
576        # When we see any event from the channel, release the
577        # associated tunnel lock
578        channel.disconnect_by_func(self._fd_channel_event_cb)
579        self._tunnels.unlock()
580
581    def _channel_open_fd_request(self, channel, tls_ignore):
582        if not self._tunnels:
583            # Can happen if we close the details window and clear self._tunnels
584            # while initially connecting to spice and channel FD requests
585            # are still rolling in
586            return  # pragma: no cover
587
588        log.debug("Requesting fd for channel: %s", channel)
589        channel.connect_after("channel-event", self._fd_channel_event_cb)
590
591        fd = self._get_fd_for_open()
592        channel.open_fd(fd)
593
594    def _channel_new_cb(self, session, channel):
595        self._channels.add(channel)
596        _SIGS.connect(channel, "open-fd", self._channel_open_fd_request)
597
598        if (isinstance(channel, SpiceClientGLib.MainChannel) and
599            not self._main_channel):
600            self._main_channel = channel
601            _SIGS.connect_after(
602                self._main_channel, "channel-event",
603                self._main_channel_event_cb)
604            _SIGS.connect_after(
605                self._main_channel, "notify::agent-connected",
606                self._agent_connected_cb)
607
608        elif (type(channel) == SpiceClientGLib.DisplayChannel and
609                not self._display):
610            channel_id = channel.get_property("channel-id")
611
612            if channel_id != 0:  # pragma: no cover
613                log.debug("Spice multi-head unsupported")
614                return
615
616            self._display_channel = channel
617            self._display = SpiceClientGtk.Display.new(self._spice_session,
618                                                      channel_id)
619            self._init_widget()
620            self.emit("connected")
621
622        elif (type(channel) in [SpiceClientGLib.PlaybackChannel,
623                                SpiceClientGLib.RecordChannel] and
624                                not self._audio):
625            # It's unclear why we need this audio handle, but it
626            # does matter:
627            # https://bugzilla.redhat.com/show_bug.cgi?id=1881080
628            self._audio = SpiceClientGLib.Audio.get(self._spice_session, None)
629
630    def _agent_connected_cb(self, src, val):
631        self.emit("agent-connected")  # pragma: no cover
632
633
634    ################################
635    # Internal API implementations #
636    ################################
637
638    def close(self):
639        if self._spice_session is not None:
640            _SIGS.disconnect_obj_signals(self._spice_session)
641            self._spice_session.disconnect()
642        self._spice_session = None
643        self._audio = None
644        if self._display:
645            self._display.destroy()
646        self._display = None
647        self._display_channel = None
648
649        for channel in self._channels:
650            _SIGS.disconnect_obj_signals(channel)
651        self._channels = None
652
653        self._main_channel = None
654
655        _SIGS.disconnect_obj_signals(self._usbdev_manager)
656        self._usbdev_manager = None
657
658    def _is_open(self):
659        return self._spice_session is not None
660
661    def _refresh_grab_keys(self):
662        if not self._display:
663            return  # pragma: no cover
664
665        try:
666            keys = self.config.get_keys_combination()
667            if not keys:
668                return  # pragma: no cover
669
670            try:
671                keys = [int(k) for k in keys.split(',')]
672            except Exception:  # pragma: no cover
673                log.debug("Error in grab_keys configuration in Gsettings",
674                              exc_info=True)
675                return
676
677            seq = SpiceClientGtk.GrabSequence.new(keys)
678            self._display.set_grab_keys(seq)
679        except Exception as e:  # pragma: no cover
680            log.debug("Error when getting the grab keys combination: %s",
681                          str(e))
682
683    def _send_keys(self, keys):
684        return self._display.send_keys([Gdk.keyval_from_name(k) for k in keys],
685                                      SpiceClientGtk.DisplayKeyEvent.CLICK)
686
687    def _get_desktop_resolution(self):
688        if not self._display_channel:
689            return None  # pragma: no cover
690        return self._display_channel.get_properties("width", "height")
691
692    def _has_agent(self):
693        if not self._main_channel:
694            return False  # pragma: no cover
695        return (self._main_channel.get_property("agent-connected") or
696                self.config.CLITestOptions.spice_agent)
697
698    def _open_host(self):
699        host, port, tlsport = self._ginfo.get_conn_host()
700        self._create_spice_session()
701
702        log.debug("Spice connecting to host=%s port=%s tlsport=%s",
703            host, port, tlsport)
704        self._spice_session.set_property("host", str(host))
705        if port:
706            self._spice_session.set_property("port", str(port))
707        if tlsport:
708            self._spice_session.set_property("tls-port", str(tlsport))
709
710        self._spice_session.connect()
711
712    def _open_fd(self, fd):
713        self._create_spice_session()
714        self._spice_session.open_fd(fd)
715
716    def _set_username(self, cred):
717        ignore = cred  # pragma: no cover
718    def _set_password(self, cred):
719        self._spice_session.set_property("password", cred)
720        fd = self._get_fd_for_open()
721        if fd is not None:
722            self._spice_session.open_fd(fd)
723        else:
724            self._spice_session.connect()  # pragma: no cover
725
726    def _get_scaling(self):
727        if self._display:
728            return self._display.get_property("scaling")
729    def _set_scaling(self, scaling):
730        if self._display:
731            self._display.set_property("scaling", scaling)
732
733    def _set_resizeguest(self, val):
734        if self._display:
735            self._display.set_property("resize-guest", val)
736
737    def _get_resizeguest(self):
738        if self._display:
739            return self._display.get_property("resize-guest")
740        return False  # pragma: no cover
741
742    def _usbdev_redirect_error(self, spice_usbdev_widget, spice_usb_device,
743            errstr):  # pragma: no cover
744        ignore = spice_usbdev_widget
745        ignore = spice_usb_device
746        self.emit("usb-redirect-error", errstr)
747
748    def _get_usb_widget(self):
749        if not self._spice_session:
750            return  # pragma: no cover
751
752        usbwidget = SpiceClientGtk.UsbDeviceWidget.new(self._spice_session,
753            None)
754        usbwidget.connect("connect-failed", self._usbdev_redirect_error)
755        return usbwidget
756
757    def _has_usb_redirection(self):
758        if not self._spice_session or not self._usbdev_manager:
759            return False  # pragma: no cover
760
761        for c in self._spice_session.get_channels():
762            if c.__class__ is SpiceClientGLib.UsbredirChannel:
763                return True
764        return False
765