From 078123fbbf5935e4282593d20549a92090148821 Mon Sep 17 00:00:00 2001
From: Florent Gluck <florent.gluck@hesge.ch>
Date: Mon, 24 Mar 2025 01:33:30 +0100
Subject: [PATCH] ongoing work on clean solution for CA certificate

---
 client/nexus-exam/nexus-exam.go | 48 ++++++++++++++-----
 client/nexush/nexush.go         | 29 ++++++++----
 libclient/client.go             | 29 +++++++-----
 libclient/remote-viewer.go      | 28 ++++++-----
 libclient/vm.go                 | 83 ++++++++++++---------------------
 5 files changed, 123 insertions(+), 94 deletions(-)

diff --git a/client/nexus-exam/nexus-exam.go b/client/nexus-exam/nexus-exam.go
index 8187cb73..62283c75 100644
--- a/client/nexus-exam/nexus-exam.go
+++ b/client/nexus-exam/nexus-exam.go
@@ -33,16 +33,23 @@ var (
 	//go:embed nexus_exam_pwd.val
 	nexus_exam_pwd string
 
-	client *nc.NexusClient
-
+	client              *nc.NexusClient
+	exitFn              func()
 	isAuthenticated     bool
 	isServerUp          bool
 	statusLabel         *widget.Label
 	credentialsInputBox *widget.Form
 )
 
-func attachVM(parent fyne.Window, pwd string) {
-	err := client.VMAttachFromPwd(pwd, true)
+func exit(code int) {
+	if exitFn != nil {
+		exitFn()
+	}
+	os.Exit(code)
+}
+
+func attachVM(parent fyne.Window, certificateFile string, pwd string) {
+	err := client.VMAttachFromPwd(pwd, certificateFile, true)
 	if err != nil {
 		errorPopup(parent, "Failed attaching to VM: "+err.Error())
 		return
@@ -55,12 +62,12 @@ func abortWindow(msg string) {
 	win := a.NewWindow(windowTitle)
 	win.SetOnClosed(func() {
 		win.Close()
-		os.Exit(1)
+		exit(1)
 	})
-	label := widget.NewLabel("FATAL: " + msg)
+	label := widget.NewLabel("FATAL ERROR: " + msg)
 	button := widget.NewButton("Quit", func() {
 		win.Close()
-		os.Exit(1)
+		exit(1)
 	})
 	content := container.NewPadded(container.NewVBox(label, button))
 	win.SetContent(content)
@@ -151,8 +158,25 @@ func checkServerUp(parent fyne.Window) {
 func run() int {
 	hypervisorCheck()
 
-	client = nc.New(defaults.NexusServer)
-	client.SetTimeout(4 * time.Second)
+	var err error
+	client, err = nc.New(defaults.NexusServer)
+	if err != nil {
+		abortWindow(err.Error())
+	}
+
+	certFile, err := client.GetVMAttachCertificate()
+	if err != nil {
+		abortWindow(err.Error())
+	}
+	exitFn = func() { os.Remove(certFile) }
+
+	// This thread acts as a signal handler for SIGINT or SIGTERM.
+	// When one of these signals is received, the temporary certificate file is deleted.
+	// Without this "handler", the temporary certificate file wouldn't be deleted.
+	go func() {
+		u.WaitForSignals()
+		exit(1)
+	}()
 
 	isServerUp = false
 	isAuthenticated = false
@@ -180,7 +204,7 @@ func run() int {
 		return nil
 	}
 
-	statusLabel = widget.NewLabel("")
+	statusLabel = widget.NewLabel("\n\n\n")
 	statusLabel.Show()
 
 	credentialsInputBox = &widget.Form{
@@ -188,7 +212,7 @@ func run() int {
 			{Text: "Password", Widget: pwdEntry},
 		},
 		OnSubmit: func() {
-			attachVM(win, pwdEntry.Text)
+			attachVM(win, certFile, pwdEntry.Text)
 		},
 		SubmitText: "Connect",
 	}
@@ -205,5 +229,5 @@ func run() int {
 }
 
 func main() {
-	os.Exit(run())
+	exit(run())
 }
diff --git a/client/nexush/nexush.go b/client/nexush/nexush.go
index e5674504..4b28602b 100644
--- a/client/nexush/nexush.go
+++ b/client/nexush/nexush.go
@@ -102,14 +102,11 @@ func run() int {
 
 	savedTermState, _ = term.GetState(int(os.Stdin.Fd()))
 
-	// This thread acts as a signal handler for SIGINT or SIGTERM.
-	go func() {
-		u.WaitForSignals()
-		restoreTerm()
-		os.Exit(1)
-	}()
-
-	nc := nc.New(serverEnvVar)
+	nc, err := nc.New(serverEnvVar)
+	if err != nil {
+		u.PrintlnErr("Error: " + err.Error())
+		return 1
+	}
 
 	// Checks the client version is compatible with the server's API.
 	if !cmdVersion.CheckServerCompatibility(nc, appname) {
@@ -133,6 +130,22 @@ func run() int {
 		return 1
 	}
 
+	certFile, err := nc.GetVMAttachCertificate()
+	if err != nil {
+		u.PrintlnErr("Error: " + err.Error())
+		return 1
+	}
+
+	// This thread acts as a signal handler for SIGINT or SIGTERM.
+	// When one of these signals is received, the temporary certificate file is deleted.
+	// Without this "handler", the temporary certificate file wouldn't be deleted.
+	go func() {
+		u.WaitForSignals()
+		os.Remove(certFile)
+		restoreTerm()
+		os.Exit(1)
+	}()
+
 	shell(nc)
 	return 0
 }
diff --git a/libclient/client.go b/libclient/client.go
index 956e3ed6..8434649f 100644
--- a/libclient/client.go
+++ b/libclient/client.go
@@ -9,30 +9,37 @@ import (
 )
 
 type NexusClient struct {
-	client *resty.Client
+	client   *resty.Client
+	hostname string // host, e.g. "127.0.0.1" or "my.domain.net"
 }
 
-// host has the following format:
-// ip:port, e.g. 127.0.0.1:1077
-func New(host string) *NexusClient {
-	nc := &NexusClient{resty.New()}
+// The host argument has the following format:
+// "ip:port" or "domain:port", e.g. "127.0.0.1:1077" or "my.domain.net:80"
+func New(host string) (*NexusClient, error) {
+	nc := &NexusClient{}
+	nc.client = resty.New()
 
 	// To disable the check of the CA signed certificate
 	nc.client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
 	nc.client.SetBaseURL("https://" + host)
 	nc.client.SetAllowGetMethodPayload(true)
+	// Change the default timeout to 10 seconds
+	nc.client.SetTimeout(10 * time.Second)
 
-	return nc
-}
-
-func (nc *NexusClient) GetBaseURL() (string, error) {
 	url, err := url.Parse(nc.client.BaseURL)
 	if err != nil {
-		return "", err
+		return nil, err
 	}
-	return url.Hostname(), nil
+	nc.hostname = url.Hostname()
+	return nc, nil
+}
+
+// Returns the hostname, e.g. "127.0.0.1" or "my.domain.net".
+func (nc *NexusClient) GetHostname() string {
+	return nc.hostname
 }
 
+// Change the default timeout.
 func (nc *NexusClient) SetTimeout(timeout time.Duration) {
 	nc.client.SetTimeout(timeout)
 }
diff --git a/libclient/remote-viewer.go b/libclient/remote-viewer.go
index 9f6b9200..f69239e3 100644
--- a/libclient/remote-viewer.go
+++ b/libclient/remote-viewer.go
@@ -2,9 +2,9 @@ package nexusclient
 
 import (
 	"errors"
+	"fmt"
 	"os/exec"
 	"runtime"
-	"strconv"
 	"strings"
 )
 
@@ -45,20 +45,26 @@ func checkRemoteViewer() error {
 // name is the VM's name.
 // port is the port the VM spice server is listening to.
 // pwd is the spice password to connect to the running VM.
-func runRemoteViewer(hostname string, cert string, name string, port int, pwd string, fullscreen bool) ([]byte, error) {
-	portStr := strconv.Itoa(port)
-	spice := "spice://" + hostname + "?tls-port=" + portStr + "&password=" + pwd
-	spiceCert := "--spice-ca-file=" + cert
-	spiceSecure := "--spice-secure-channels=all"
+func runRemoteViewer(hostname string, cert string, name string, port int, pwd string, fullscreen bool) error {
+	// portStr := strconv.Itoa(port)
+	// spice := "spice://" + hostname + "?tls-port=" + portStr + "&password=" + pwd
+	// spiceCert := "--spice-ca-file=" + cert
+	// spiceSecure := "--spice-secure-channels=all"
 	// To exit fullscreen mode, either:
 	// - move the mouse to the middle of the top of the screen.
 	// - use ctrl+F12 as specified below
 	// To activate kiosk mode, add these options: "-k --kiosk-quit=on-disconnect"
 	var c *exec.Cmd
-	if fullscreen {
-		c = exec.Command("remote-viewer", spice, spiceCert, spiceSecure, "-t", name, "--hotkeys=toggle-fullscreen=ctrl+f12", "-f")
-	} else {
-		c = exec.Command("remote-viewer", spice, spiceCert, spiceSecure, "-t", name, "--hotkeys=toggle-fullscreen=ctrl+f12")
+	// if fullscreen {
+	// 	c = exec.Command("remote-viewer", spice, spiceCert, spiceSecure, "-t", name, "--hotkeys=toggle-fullscreen=ctrl+f12", "-f")
+	// } else {
+	// 	c = exec.Command("remote-viewer", spice, spiceCert, spiceSecure, "-t", name, "--hotkeys=toggle-fullscreen=ctrl+f12")
+	// }
+	c = exec.Command("remote-viewer", "spice://crap")
+	stdoutStderr, err := c.CombinedOutput()
+	if err != nil {
+		return errors.New("err: remote-viewer failed: " + string(stdoutStderr[:]))
 	}
-	return c.CombinedOutput()
+	fmt.Println("err: remote-viewer failed: " + string(stdoutStderr[:]))
+	return nil
 }
diff --git a/libclient/vm.go b/libclient/vm.go
index 55124742..5db4c4ce 100644
--- a/libclient/vm.go
+++ b/libclient/vm.go
@@ -182,35 +182,24 @@ func (nc *NexusClient) VMStop(vmID string) error {
 	}
 }
 
-func (nc *NexusClient) VMAttachFromID(vmID string, fullscreen bool) error {
+func (nc *NexusClient) VMAttachFromID(vmID string, certificateFile string, fullscreen bool) error {
 	creds, err := nc.vmIdToAttachCreds(vmID)
 	if err != nil {
 		return err
 	}
 
-	err = nc.VMAttachFromCreds(*creds, fullscreen)
+	err = nc.VMAttachFromCreds(*creds, certificateFile, fullscreen)
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
-func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, fullscreen bool) error {
+func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, certificateFile string, fullscreen bool) error {
 	if err := checkRemoteViewer(); err != nil {
 		return err
 	}
 
-	hostname, err := nc.GetBaseURL()
-	if err != nil {
-		return err
-	}
-
-	certFile, err := nc.getSpiceCertificate()
-	if err != nil {
-		return err
-	}
-	defer os.Remove(certFile)
-
 	p := params.VMAttachCreds{Pwd: v.Pwd}
 	creds, err := nc.vmGetSpiceCreds(v.ID.String(), p)
 	if err != nil {
@@ -218,7 +207,7 @@ func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, ful
 	}
 
 	// First return value (ignored) is of type: stdoutStderr
-	_, err = runRemoteViewer(hostname, certFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen)
+	err = runRemoteViewer(nc.hostname, certificateFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen)
 	if err != nil {
 		return err
 	}
@@ -226,28 +215,17 @@ func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, ful
 	return nil
 }
 
-func (nc *NexusClient) VMAttachFromPwd(pwd string, fullscreen bool) error {
+func (nc *NexusClient) VMAttachFromPwd(pwd string, certificateFile string, fullscreen bool) error {
 	if err := checkRemoteViewer(); err != nil {
 		return err
 	}
 
-	hostname, err := nc.GetBaseURL()
-	if err != nil {
-		return err
-	}
-
-	certFile, err := nc.getSpiceCertificate()
-	if err != nil {
-		return err
-	}
-	defer os.Remove(certFile)
-
 	p := params.VMAttachCreds{Pwd: pwd}
 	creds, err := nc.vmGetAnySpiceCreds(p)
 	if err != nil {
 		return err
 	} else {
-		_, err := runRemoteViewer(hostname, certFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen)
+		err := runRemoteViewer(nc.hostname, certificateFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen)
 		if err != nil {
 			return err
 		}
@@ -256,6 +234,31 @@ func (nc *NexusClient) VMAttachFromPwd(pwd string, fullscreen bool) error {
 	return nil
 }
 
+// Obtain the certificate required by the various VMAttachXXX methods.
+// This public CA certificate is required by Spice remote viewers.
+// The certificate file is saved in the OS' temporary directory.
+// Its name is randomly generated and garanteed to be unique.
+// It is the caller's responsability to delete it before exiting.
+func (nc *NexusClient) GetVMAttachCertificate() (string, error) {
+	uniqPostfix, err := uuid.NewRandom()
+	if err != nil {
+		return "", errors.New("Failed saving CA certificate, code 1:\n" + err.Error())
+	}
+
+	outputFile := filepath.Join(os.TempDir(), "cert_"+uniqPostfix.String()+".pem")
+	resp, err := nc.client.R().SetOutput(outputFile).Get("/misc/cert")
+	if err != nil {
+		return "", errors.New("Failed saving CA certificate, code 2:\n" + err.Error())
+	}
+
+	if resp.IsSuccess() {
+		return outputFile, nil
+	} else {
+		errorMsg, _ := u.FileToString(outputFile)
+		return "", errors.New("Failed saving CA certificate, code 3\n" + resp.Status() + ": " + errorMsg)
+	}
+}
+
 func (nc *NexusClient) VMAddAccess(vmID, vmName, email string, vmAccessCaps *params.VMAddAccess) error {
 	resp, err := nc.client.R().SetBody(vmAccessCaps).Put("/vms/" + vmID + "/access/" + email)
 	if err != nil {
@@ -343,30 +346,6 @@ func (nc *NexusClient) GetListVM(vmID string) (*vm.VMNetworkSerialized, error) {
 // Private functions
 //========================================================================================
 
-// Obtain a public CA certificate for use with a Spice remote viewer.
-// The certificate file is saved in the OS' temporary directory.
-// Its name is randomly generated and garanteed to be unique.
-// It is the caller's responsability to delete it before exiting.
-func (nc *NexusClient) getSpiceCertificate() (string, error) {
-	uniqPostfix, err := uuid.NewRandom()
-	if err != nil {
-		return "", errors.New("Failed saving CA certificate: " + err.Error())
-	}
-
-	outputFile := filepath.Join(os.TempDir(), "cert_"+uniqPostfix.String()+".pem")
-	resp, err := nc.client.R().SetOutput(outputFile).Get("/misc/cert")
-	if err != nil {
-		return "", errors.New("Failed saving CA certificate: " + err.Error())
-	}
-
-	if resp.IsSuccess() {
-		return outputFile, nil
-	} else {
-		errorMsg, _ := u.FileToString(outputFile)
-		return "", errors.New("Failed saving CA certificate" + resp.Status() + ": " + errorMsg)
-	}
-}
-
 func (nc *NexusClient) vmGetSpiceCreds(vmID string, p params.VMAttachCreds) (*vm.VMSpiceCredentialsSerialized, error) {
 	resp, err := nc.client.R().SetBody(p).Post("/vms/" + vmID + "/spicecreds")
 	if err != nil {
-- 
GitLab