From bae874cb392fc00ca3575b53d9ce9f502af9d33b Mon Sep 17 00:00:00 2001 From: Florent Gluck <florent.gluck@hesge.ch> Date: Mon, 24 Mar 2025 19:02:59 +0100 Subject: [PATCH] stateless client: ongoing work on a solution dealing with CA certificate --- client/cmdToken/tokenRefresh.go | 2 +- client/nexus-cli/nexus-cli.go | 29 ++++++++- client/nexush/nexush.go | 21 +++--- libclient/client.go | 110 ++++++++++++++++++++++++++++++++ libclient/login.go | 56 ---------------- libclient/remote-viewer.go | 27 ++++---- libclient/vm.go | 45 +++---------- 7 files changed, 170 insertions(+), 120 deletions(-) delete mode 100644 libclient/login.go diff --git a/client/cmdToken/tokenRefresh.go b/client/cmdToken/tokenRefresh.go index 2fb9e590..107f05e4 100644 --- a/client/cmdToken/tokenRefresh.go +++ b/client/cmdToken/tokenRefresh.go @@ -34,7 +34,7 @@ func (cmd *Refresh) Run(nc *nc.NexusClient, args []string) int { return 1 } - err := nc.RefreshAuthToken() + err := nc.RefreshToken() if err != nil { u.PrintlnErr(err) return 1 diff --git a/client/nexus-cli/nexus-cli.go b/client/nexus-cli/nexus-cli.go index 9c5437ed..3c33d28b 100644 --- a/client/nexus-cli/nexus-cli.go +++ b/client/nexus-cli/nexus-cli.go @@ -70,6 +70,15 @@ var cmdList = []cmd.Command{ &cmdVM.StartWithCreds{Name: "vmstartwithcreds"}, } +var exitFn func() + +func exit(code int) { + if exitFn != nil { + exitFn() + } + os.Exit(code) +} + func run() int { var appname = path.Base(os.Args[0]) clientVersion := version.Get() @@ -94,7 +103,11 @@ func run() int { // return 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) { @@ -106,13 +119,23 @@ func run() int { found, cmd := cmd.Match(cmdName, cmdList) if found { + exitFn = func() { nc.Close() } + + // 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) + }() + if cmdName != "login" { token, found := os.LookupEnv(g.ENV_NEXUS_TOKEN) if !found || len(token) == 0 { u.PrintlnErr("Environment variable \"" + g.ENV_NEXUS_TOKEN + "\" must be set!") return 1 } - nc.SetAuthToken(token) + nc.AuthenticateWithToken(token) } return cmd.Run(nc, cmdArgs) } @@ -122,5 +145,5 @@ func run() int { } func main() { - os.Exit(run()) + exit(run()) } diff --git a/client/nexush/nexush.go b/client/nexush/nexush.go index 4b28602b..5adbdf96 100644 --- a/client/nexush/nexush.go +++ b/client/nexush/nexush.go @@ -76,9 +76,17 @@ var cmdList = []cmd.Command{ &cmdVM.StartWithCreds{Name: "vmstartwithcreds"}, } +var exitFn func() var prompt *liner.State = nil var savedTermState *term.State = nil +func exit(code int) { + if exitFn != nil { + exitFn() + } + os.Exit(code) +} + func run() int { var appname = path.Base(os.Args[0]) clientVersion := version.Get() @@ -130,20 +138,15 @@ func run() int { return 1 } - certFile, err := nc.GetVMAttachCertificate() - if err != nil { - u.PrintlnErr("Error: " + err.Error()) - return 1 - } + exitFn = func() { nc.Close() } // 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) + exit(1) }() shell(nc) @@ -211,7 +214,7 @@ Type: "help" for help on commands found, cmd := cmd.Match(typedCmd, cmdList) if found { if typedCmd == "refresh" { - err := nc.RefreshAuthToken() + err := nc.RefreshToken() if err != nil { u.PrintlnErr("refresh error: " + err.Error()) break @@ -286,5 +289,5 @@ func extractArgs(line string) ([]string, error) { } func main() { - os.Exit(run()) + exit(run()) } diff --git a/libclient/client.go b/libclient/client.go index 8434649f..adfa41fd 100644 --- a/libclient/client.go +++ b/libclient/client.go @@ -2,15 +2,25 @@ package nexusclient import ( "crypto/tls" + "encoding/json" + "errors" "net/url" + "os" + "path/filepath" "time" + "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/common/params" + "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/libclient/response" + u "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/libclient/utils" "github.com/go-resty/resty/v2" + "github.com/google/uuid" ) type NexusClient struct { client *resty.Client hostname string // host, e.g. "127.0.0.1" or "my.domain.net" + certFile string // path to the CA certificate tied to the hostname above + // (this cert is required by the spice client) } // The host argument has the following format: @@ -31,9 +41,61 @@ func New(host string) (*NexusClient, error) { return nil, err } nc.hostname = url.Hostname() + nc.certFile = "" return nc, nil } +// Authenticate a user. +// curl --cacert ca-cert.pem -X POST https://localhost:8000/login -H 'Content-Type: application/json' -d '{"email": "johndoe@nexus.org", "pwd":"pipomolo"}' +// Returns an JWT token if authentication succeeded or an error if it failed. +// IMPORTANT: caller MUST call the Close() function before exiting! +func (nc *NexusClient) Authenticate(user, pwd string) (string, error) { + loginArgs := ¶ms.Login{user, pwd} + resp, err := nc.client.R().SetBody(loginArgs).Post("/login") + if err != nil { + return "", err + } + + if resp.IsSuccess() { + var token params.Token + err = json.Unmarshal(resp.Body(), &token) + if err != nil { + return "", err + } + nc.client.SetAuthToken(token.Token) + // Download the CA certificate required by Spice clients + nc.certFile, err = nc.downloadVMAttachCert() + if err != nil { + return "", err + } + return token.Token, nil + } else { + return "", response.ErrorToMsg(resp) + } +} + +// "Authenticate" by using an already established connection using a previously received token. +// Useful when wanting to access the API without authenticating. +// IMPORTANT: caller MUST call the Close() function before exiting! +func (nc *NexusClient) AuthenticateWithToken(token string) error { + nc.client.SetAuthToken(token) + // Download the CA certificate required by Spice clients + var err error + nc.certFile, err = nc.downloadVMAttachCert() + if err != nil { + return err + } + return nil +} + +// Releases/deletes resources allocated by Authenticate and AuthenticateWithToken. +// IMPORTANT: caller MUST call this function before exiting! +func (nc *NexusClient) Close() { + if nc.certFile != "" { + os.Remove(nc.certFile) + } +} + // Returns the hostname, e.g. "127.0.0.1" or "my.domain.net". func (nc *NexusClient) GetHostname() string { return nc.hostname @@ -43,3 +105,51 @@ func (nc *NexusClient) GetHostname() string { func (nc *NexusClient) SetTimeout(timeout time.Duration) { nc.client.SetTimeout(timeout) } + +// Obtain a new JWT token from the server. +func (nc *NexusClient) RefreshToken() error { + resp, err := nc.client.R().Get("/misc/token") + if err != nil { + return err + } else { + if resp.IsSuccess() { + var token params.Token + err = json.Unmarshal(resp.Body(), &token) + if err != nil { + return err + } + nc.client.SetAuthToken(token.Token) + return nil + } else { + return response.ErrorToMsg(resp) + } + } +} + +func (nc *NexusClient) getVMAttachCertFile() string { + return nc.certFile +} + +// Retrieve and save 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. +func (nc *NexusClient) downloadVMAttachCert() (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) + } +} diff --git a/libclient/login.go b/libclient/login.go deleted file mode 100644 index 21cfe129..00000000 --- a/libclient/login.go +++ /dev/null @@ -1,56 +0,0 @@ -package nexusclient - -import ( - "encoding/json" - - "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/common/params" - "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/libclient/response" -) - -// Authenticate a user. -// curl --cacert ca-cert.pem -X POST https://localhost:8000/login -H 'Content-Type: application/json' -d '{"email": "johndoe@nexus.org", "pwd":"pipomolo"}' -// Returns an JWT token if authentication succeeded or an error if it failed. -func (nc *NexusClient) Authenticate(user, pwd string) (string, error) { - loginArgs := ¶ms.Login{user, pwd} - resp, err := nc.client.R().SetBody(loginArgs).Post("/login") - if err != nil { - return "", err - } - - if resp.IsSuccess() { - var token params.Token - err = json.Unmarshal(resp.Body(), &token) - if err != nil { - return "", err - } - nc.client.SetAuthToken(token.Token) - return token.Token, nil - } else { - return "", response.ErrorToMsg(resp) - } -} - -// Obtain a new JWT token from the server. -func (nc *NexusClient) RefreshAuthToken() error { - resp, err := nc.client.R().Get("/misc/token") - if err != nil { - return err - } else { - if resp.IsSuccess() { - var token params.Token - err = json.Unmarshal(resp.Body(), &token) - if err != nil { - return err - } - nc.client.SetAuthToken(token.Token) - return nil - } else { - return response.ErrorToMsg(resp) - } - } -} - -// Set the JWT token in the client. -func (nc *NexusClient) SetAuthToken(token string) { - nc.client.SetAuthToken(token) -} diff --git a/libclient/remote-viewer.go b/libclient/remote-viewer.go index f69239e3..b089b164 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" ) @@ -12,7 +12,7 @@ const ( remoteViewerBinary = "remote-viewer" ) -func checkRemoteViewer() error { +func (nc *NexusClient) checkRemoteViewer() error { os := runtime.GOOS output, err := exec.Command(remoteViewerBinary, "--version").Output() if os == "linux" { @@ -45,26 +45,25 @@ 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) error { - // portStr := strconv.Itoa(port) - // spice := "spice://" + hostname + "?tls-port=" + portStr + "&password=" + pwd - // spiceCert := "--spice-ca-file=" + cert - // spiceSecure := "--spice-secure-channels=all" +func (nc *NexusClient) runRemoteViewer(name string, port int, pwd string, fullscreen bool) error { + certFile := nc.getVMAttachCertFile() + portStr := strconv.Itoa(port) + spice := "spice://" + nc.GetHostname() + "?tls-port=" + portStr + "&password=" + pwd + spiceCert := "--spice-ca-file=" + certFile + 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") - // } - c = exec.Command("remote-viewer", "spice://crap") + 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") + } stdoutStderr, err := c.CombinedOutput() if err != nil { return errors.New("err: remote-viewer failed: " + string(stdoutStderr[:])) } - fmt.Println("err: remote-viewer failed: " + string(stdoutStderr[:])) return nil } diff --git a/libclient/vm.go b/libclient/vm.go index 5db4c4ce..bbeedf26 100644 --- a/libclient/vm.go +++ b/libclient/vm.go @@ -3,8 +3,6 @@ package nexusclient import ( "encoding/json" "errors" - "os" - "path/filepath" "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/common/params" "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/common/vm" @@ -12,7 +10,6 @@ import ( u "gitedu.hesge.ch/flg_projects/nexus_vdi/nexus/libclient/utils" "github.com/go-playground/validator/v10" "github.com/go-resty/resty/v2" - "github.com/google/uuid" ) const ( @@ -182,21 +179,21 @@ func (nc *NexusClient) VMStop(vmID string) error { } } -func (nc *NexusClient) VMAttachFromID(vmID string, certificateFile string, fullscreen bool) error { +func (nc *NexusClient) VMAttachFromID(vmID string, fullscreen bool) error { creds, err := nc.vmIdToAttachCreds(vmID) if err != nil { return err } - err = nc.VMAttachFromCreds(*creds, certificateFile, fullscreen) + err = nc.VMAttachFromCreds(*creds, fullscreen) if err != nil { return err } return nil } -func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, certificateFile string, fullscreen bool) error { - if err := checkRemoteViewer(); err != nil { +func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, fullscreen bool) error { + if err := nc.checkRemoteViewer(); err != nil { return err } @@ -206,8 +203,7 @@ func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, cer return err } - // First return value (ignored) is of type: stdoutStderr - err = runRemoteViewer(nc.hostname, certificateFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen) + err = nc.runRemoteViewer(creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen) if err != nil { return err } @@ -215,8 +211,8 @@ func (nc *NexusClient) VMAttachFromCreds(v vm.VMAttachCredentialsSerialized, cer return nil } -func (nc *NexusClient) VMAttachFromPwd(pwd string, certificateFile string, fullscreen bool) error { - if err := checkRemoteViewer(); err != nil { +func (nc *NexusClient) VMAttachFromPwd(pwd string, fullscreen bool) error { + if err := nc.checkRemoteViewer(); err != nil { return err } @@ -225,7 +221,7 @@ func (nc *NexusClient) VMAttachFromPwd(pwd string, certificateFile string, fulls if err != nil { return err } else { - err := runRemoteViewer(nc.hostname, certificateFile, creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen) + err := nc.runRemoteViewer(creds.Name, creds.SpicePort, creds.SpicePwd, fullscreen) if err != nil { return err } @@ -234,31 +230,6 @@ func (nc *NexusClient) VMAttachFromPwd(pwd string, certificateFile string, fulls 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 { -- GitLab