diff --git a/Makefile b/Makefile index f6138eadb993f55c60dbeb5c78c9d37e96289819..dff0fd076e8e93a7fd56a3f904ce18fb9e2d390a 100644 --- a/Makefile +++ b/Makefile @@ -215,9 +215,6 @@ check_bin_var: check_server_var: $(call check_defined, SERVER) -check_serverip_var: - $(call check_defined, SERVER_IP) - check_login_var: $(call check_defined, LOGIN) @@ -241,7 +238,7 @@ help_client: @echo "└──────────────────────────────────────────────────────────────────────────────┘" @echo " run_nexush Run nexush for Linux/amd64; require LOGIN variable" @echo " Example: make run_nexush LOGIN=janedoe@nexus.org" - @echo " run_nexus-exam Run nexus-exam; require CERT and SERVER_IP variables" + @echo " run_nexus-exam Run nexus-exam; require CERT and SERVER variables" @echo "" @echo "┌──────────────────────────────────────────────────────────────────────────────┐" @echo "│ VALIDATION tests │" @@ -250,10 +247,9 @@ help_client: @echo " Require LOGIN variable. Example:" @echo "" @echo "────────────────────────────────────────────────────────────────────────────────" - @echo " CERT specifies the path to the public CA certificate." - @echo " SERVER specifies the server ip address and port, separated by a colon," - @echo " for instance: SERVER=127.0.0.1:1077" - @echo " SERVER_IP specifies the server ip address." + @echo " CERT: directory where the public CA certificate resides ($(CA_CERT_FILE))." + @echo " SERVER: server ip address and port, separated by a colon," + @echo " for instance: SERVER=127.0.0.1:1077" copy_resources_client: @mkdir -p $(RESOURCES_DIR_CLIENT) @@ -306,8 +302,8 @@ clean_client: run_nexush: check_login_var $(BUILD_DIR_CLIENT)/nexush $(BUILD_DIR_CLIENT)/nexush $(LOGIN) -run_nexus-exam: check_serverip_var $(BUILD_DIR_CLIENT)/nexus-exam - @NEXUS_SERVER=$(SERVER_IP) NEXUS_CERT=$(CERT)/$(CA_CERT_FILE) $(BUILD_DIR_CLIENT)/nexus-exam +run_nexus-exam: check_server_var $(BUILD_DIR_CLIENT)/nexus-exam + @NEXUS_SERVER=$(SERVER) NEXUS_CERT=$(CERT)/$(CA_CERT_FILE) $(BUILD_DIR_CLIENT)/nexus-exam tests: check_login_var tests/run_tests build_nexus-cli $(BUILD_DIR_CLIENT)/nexus-cli @cd tests && ./run_tests $(BUILD_ABS_CLIENT)/nexus-cli $(LOGIN) diff --git a/docs/rest_api.md b/docs/rest_api.md index 9b5991e4ac13a3e71343444976242296f1cdab89..6b0f16d594b2f42527b5f74ee47347e372940866 100644 --- a/docs/rest_api.md +++ b/docs/rest_api.md @@ -25,35 +25,37 @@ ### VM management -| Route | Description | Method | Input | Req. user cap. | Op. | Req. VM access cap. | Output | -|--- |--- |--- |--- |--- |--- |--- |--- | -| `/vms` | returns VMs that can be listed | GET | - | `VM_LIST_ANY` | OR | `VM_LIST` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | -| `/vms/{id}` | returns the specified VM | GET | - | `VM_LIST_ANY` | OR | `VM_LIST` | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | -| `/vms/start` | returns VM creds info for VMs that can be started | GET | - | `VM_START_ANY` | OR | `VM_START` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/attach` | returns VM creds info for VMs that can be attached to | GET | - | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/{id}/attach` | returns VM creds info for the specified VM | GET | - | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | [common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/stop` | returns VM creds info for VMs that can be killed/shutdown | GET | - | `VM_STOP_ANY` | OR | `VM_STOP` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/reboot` | returns VM creds info for VMs that can be rebooted | GET | - | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/edit` | returns VM creds info for VMs that can be edited | GET | - | `VM_EDIT_ANY` | OR | `VM_EDIT` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/editaccess` | returns VM creds info for VMs that can have their access changed | GET | - | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/del` | returns VM creds info for VMs that can be deleted | GET | - | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/exportdir` | returns VM creds info for VMs that can have a dir downloaded | GET | - | `VM_READFS_ANY` | OR | `VM_READFS` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | -| `/vms/importfiles` | returns VM creds info for VMs allowing files upload | GET | - | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | [\[\]common.vm.VMCredentialsSerialized](../src/common/vm/vm.go) | +| Route | Description | Method | Input | Req. user cap. | Op. | Req. VM access cap. | Output | +|--- |--- |--- |--- |--- |--- |--- |--- | +| `/vms` | returns VMs that can be listed | GET | - | `VM_LIST_ANY` | OR | `VM_LIST` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/{id}` | returns the specified VM | GET | - | `VM_LIST_ANY` | OR | `VM_LIST` | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/start` | returns VMs that can be started | GET | - | `VM_START_ANY` | OR | `VM_START` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/attach` | returns "attach creds" for attachable VMs | GET | - | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | [\[\]common.vm.VMAttachCredentialsSerialized](../src/common/vm/vm.go) | +| `/vms/{id}/attach` | returns "attach creds" for the specified VM | GET | - | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | [common.vm.VMAttachCredentialsSerialized](../src/common/vm/vm.go) | +| `/vms/stop` | returns VMs that can be killed/shutdown | GET | - | `VM_STOP_ANY` | OR | `VM_STOP` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/reboot` | returns VMs that can be rebooted | GET | - | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/edit` | returns VMs that can be edited | GET | - | `VM_EDIT_ANY` | OR | `VM_EDIT` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/editaccess` | returns VMs that can have their access changed | GET | - | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/del` | returns VMs that can be deleted | GET | - | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/exportdir` | returns VMs that can have a dir downloaded | GET | - | `VM_READFS_ANY` | OR | `VM_READFS` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/importfiles` | returns VMs that can have files upload to | GET | - | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | [\[\]common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | -| Route | Description | Method | Input | Req. user cap. | Op. | Req. VM access cap. | Output | -|--- |--- |--- |--- |--- |--- |--- |--- | -| `/vms` | create a VM | POST | [commmon.params.VMCreate](../src/common/params/vms.go) | `VM_CREATE` | - | - | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | -| `/vms/{id}` | delete a VM | DELETE | - | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | - | -| `/vms/{id}` | edit a VM | PUT | [common.params.VMEdit](../src/common/params/vms.go) | `VM_EDIT_ANY` | OR | `VM_EDIT` | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | -| `/vms/{id}/start` | start a VM | PUT | - | `VM_START_ANY` | OR | `VM_START` | - | -| `/vms/{id}/startwithcreds` | start a VM with credentials | PUT | [common.params.VMStartWithCreds](../src/common/params/vms.go) | `VM_START_ANY` | OR | `VM_START` | - | -| `/vms/{id}/stop` | kill a VM | PUT | - | `VM_STOP_ANY` | OR | `VM_STOP` | - | -| `/vms/{id}/reboot` | reboot a VM | PUT | - | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | - | -| `/vms/{id}/shutdown` | gracefully shutdown a VM | PUT | - | `VM_STOP_ANY` | OR | `VM_STOP` | - | -| `/vms/{id}/access/{email}` | set VM access for a user | PUT | [common.params.VMAddAccess](../src/common/params/vms.go) | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | - | -| `/vms/{id}/access/{email}` | del VM access for a user | DELETE | - | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | - | -| `/vms/{id}/exportdir` | download a VM's dir | GET | [common.params.VMExportDir](../src/common/params/vms.go) | `VM_READFS_ANY` | OR | `VM_READFS` | tar.gz archive | -| `/vms/{id}/importfiles` | upload files into a VM's dir | POST | multipart form: { "vmDir" (path), "file" (tar.gz archive) } | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | | +| Route | Description | Method | Input | Req. user cap. | Op. | Req. VM access cap. | Output | +|--- |--- |--- |--- |--- |--- |--- |--- | +| `/vms` | create a VM | POST | [commmon.params.VMCreate](../src/common/params/vms.go) | `VM_CREATE` | - | - | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/{id}` | delete the VM | DELETE | - | `VM_DESTROY_ANY` | OR | `VM_DESTROY` | - | +| `/vms/{id}` | edit the VM | PUT | [common.params.VMEdit](../src/common/params/vms.go) | `VM_EDIT_ANY` | OR | `VM_EDIT` | [common.vm.VMNetworkSerialized](../src/common/vm/vm.go) | +| `/vms/{id}/start` | start the VM | PUT | - | `VM_START_ANY` | OR | `VM_START` | - | +| `/vms/{id}/startwithcreds` | start the VM with credentials | PUT | [common.params.VMStartWithCreds](../src/common/params/vms.go) | `VM_START_ANY` | OR | `VM_START` | - | +| `/vms/{id}/spicecreds` | returns Spice creds for the VM | POST | [common.params.VMAttachCreds](../src/common/params/vms.go) | `VM_ATTACH_ANY` | OR | `VM_ATTACH` | [common.vm.VMSpiceCredentialsSerialized](../src/common/vm/vm.go) | +| `/vms/spicecreds` | returns Spice creds for a VM | POST | [common.params.VMAttachCreds](../src/common/params/vms.go) | `VM_ATTACH_ANY` | OR | - | [common.vm.VMSpiceCredentialsSerialized](../src/common/vm/vm.go) | +| `/vms/{id}/stop` | kill the VM | PUT | - | `VM_STOP_ANY` | OR | `VM_STOP` | - | +| `/vms/{id}/reboot` | reboot the VM | PUT | - | `VM_REBOOT_ANY` | OR | `VM_REBOOT` | - | +| `/vms/{id}/shutdown` | gracefully shutdown the VM | PUT | - | `VM_STOP_ANY` | OR | `VM_STOP` | - | +| `/vms/{id}/access/{email}` | set the VM's VM access for the user | PUT | [common.params.VMAddAccess](../src/common/params/vms.go) | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | - | +| `/vms/{id}/access/{email}` | del the VM's VM access for the user | DELETE | - | `VM_SET_ACCESS` | AND | `VM_SET_ACCESS` | - | +| `/vms/{id}/exportdir` | download a dir from the VM | GET | [common.params.VMExportDir](../src/common/params/vms.go) | `VM_READFS_ANY` | OR | `VM_READFS` | tar.gz archive | +| `/vms/{id}/importfiles` | upload files into a dir in the VM | POST | multipart form: { "vmDir" (path), "file" (tar.gz archive) } | `VM_WRITEFS_ANY` | OR | `VM_WRITEFS` | | ### Template management diff --git a/src/client/cmdUser/userUpdatePwd.go b/src/client/cmdUser/userUpdatePwd.go index 172cac8db6be53e2db859881ad2b868cbf2fe006..d6b151edce9139432debc13f6149a1b4ca0cecdd 100644 --- a/src/client/cmdUser/userUpdatePwd.go +++ b/src/client/cmdUser/userUpdatePwd.go @@ -69,7 +69,7 @@ func (cmd *UpdatePwd)Run(args []string) int { return 1 } else { if resp.IsSuccess() { - u.Println(resp) + u.Println("Password changed successfully.") return 0 } else { u.PrintlnErr("Error: "+resp.Status()+": "+resp.String()) diff --git a/src/client/cmdVM/attachHelper.go b/src/client/cmdVM/attachHelper.go new file mode 100644 index 0000000000000000000000000000000000000000..645400a9dded24142e74cd55bb12a2343a269468 --- /dev/null +++ b/src/client/cmdVM/attachHelper.go @@ -0,0 +1,71 @@ +package cmdVM + +import ( + "fmt" + "sync" + "errors" + "encoding/json" + "nexus-common/vm" + "nexus-common/params" + "nexus-client/exec" + u "nexus-client/utils" + g "nexus-client/globals" + "github.com/go-playground/validator/v10" +) + +// The boolean indicates whether attaching is blocking (synchronous) or non-blocking (asynchronous). +func AttachToVMs(vms []vm.VMAttachCredentialsSerialized, synchronous bool) (int, error) { + statusCode := 0 + + hostname := g.GetInstance().Hostname + cert := g.GetInstance().PubCert + + client := g.GetInstance().Client + host := g.GetInstance().Host + + var wg sync.WaitGroup + + if synchronous { + // Use wait groups to wait until all viewers threads have completed. + wg.Add(len(vms)) + } + + for _, v := range(vms) { + uuid := v.ID.String() + p := ¶ms.VMAttachCreds{ Pwd: v.Pwd } + resp, err := client.R().SetBody(p).Post(host+"/vms/"+uuid+"/spicecreds") + if err != nil { + return 1, err + } + + if resp.IsSuccess() { + var creds vm.VMSpiceCredentialsSerialized + if err := json.Unmarshal(resp.Body(), &creds); err != nil { + return 1, err + } + if err := validator.New(validator.WithRequiredStructEnabled()).Struct(creds); err != nil { + return 1, err + } + + go func(v vm.VMAttachCredentialsSerialized, creds vm.VMSpiceCredentialsSerialized) { + stdoutStderr, err := exec.RunRemoteViewer(hostname, cert, v.Name, creds.SpicePort, creds.SpicePwd, false) + if err != nil { + u.PrintlnErr("Failed attaching to VM ", v.ID, ": ", fmt.Sprintf("%s", stdoutStderr)) + statusCode |= 1 + } + if synchronous { + wg.Done() + } + } (v, creds) + + } else { + return 1, errors.New("Error: "+resp.Status()+": "+resp.String()) + } + } + + if synchronous { + wg.Wait() + } + + return statusCode, nil +} diff --git a/src/client/cmdVM/helper.go b/src/client/cmdVM/helper.go index c8e7bd77d05ecb6533916b7108ccbbb43c0f2738..8ff69a37b911c72a100782bd25d52726425846b8 100644 --- a/src/client/cmdVM/helper.go +++ b/src/client/cmdVM/helper.go @@ -118,7 +118,7 @@ func getFilteredVMs(route string, patterns []string) ([]vm.VMNetworkSerialized, // Regular expression examples: // "." -> matches everything // "bla" -> matches any VM name containing "bla" -func getFilteredVMCredentials(route string, patterns []string) ([]vm.VMCredentialsSerialized, error) { +func getFilteredVMCredentials(route string, patterns []string) ([]vm.VMAttachCredentialsSerialized, error) { if len(patterns) < 1 { return nil, errors.New("At least one ID or regex must be specified") } @@ -143,7 +143,7 @@ func getFilteredVMCredentials(route string, patterns []string) ([]vm.VMCredentia return nil, err } - vmsList := []vm.VMCredentialsSerialized{} + vmsList := []vm.VMAttachCredentialsSerialized{} if resp.IsSuccess() { vms, err := deserializeVMsCredentials(resp) @@ -203,8 +203,8 @@ func deserializeVM(resp *resty.Response) (*vm.VMNetworkSerialized, error) { } // Deserialize a list of VM credentials from an http response. -func deserializeVMsCredentials(resp *resty.Response) ([]vm.VMCredentialsSerialized, error) { - vmsCreds := []vm.VMCredentialsSerialized{} +func deserializeVMsCredentials(resp *resty.Response) ([]vm.VMAttachCredentialsSerialized, error) { + vmsCreds := []vm.VMAttachCredentialsSerialized{} if err := json.Unmarshal(resp.Body(), &vmsCreds); err != nil { return nil, err } @@ -212,8 +212,8 @@ func deserializeVMsCredentials(resp *resty.Response) ([]vm.VMCredentialsSerializ } // Deserialize a VM credentials from an http response. -func deserializeVMCredentials(resp *resty.Response) (*vm.VMCredentialsSerialized, error) { - var vmCreds vm.VMCredentialsSerialized +func deserializeVMCredentials(resp *resty.Response) (*vm.VMAttachCredentialsSerialized, error) { + var vmCreds vm.VMAttachCredentialsSerialized if err := json.Unmarshal(resp.Body(), &vmCreds); err != nil { return nil, err } diff --git a/src/client/cmdVM/vmAttachAsync.go b/src/client/cmdVM/vmAttachAsync.go index 7fd0f28538c4fbafe7286decdaf61c4b26398b33..d9959b165918e70dee96c577558467a7705814fd 100644 --- a/src/client/cmdVM/vmAttachAsync.go +++ b/src/client/cmdVM/vmAttachAsync.go @@ -1,11 +1,8 @@ package cmdVM import ( - "fmt" - "nexus-common/vm" "nexus-client/exec" u "nexus-client/utils" - g "nexus-client/globals" ) type AttachAsync struct { @@ -33,37 +30,27 @@ func (cmd *AttachAsync)Run(args []string) int { return 1 } - hostname := g.GetInstance().Hostname - cert := g.GetInstance().PubCert - argc := len(args) if argc < 1 { cmd.PrintUsage() return 1 } - vms, err := getFilteredVMCredentials("/vms/attach", args) + creds, err := getFilteredVMCredentials("/vms/attach", args) if err != nil { u.PrintlnErr(err.Error()) return 1 } - if len(vms) == 0 { + if len(creds) == 0 { u.PrintlnErr("Error: VM(s) not found.") return 1 } - statusCode := 0 - - for _, v := range(vms) { - go func(v vm.VMCredentialsSerialized) { - stdoutStderr, err := exec.RunRemoteViewer(hostname, cert, v.Name, v.Port, v.Pwd, false) - if err != nil { - u.PrintlnErr("Failed attaching to VM ", v.ID, ": ", fmt.Sprintf("%s", stdoutStderr)) - statusCode |= 1 - } - } (v) - } + statusCode, err := AttachToVMs(creds, true) + if err != nil { + u.PrintlnErr(err) + } return statusCode } diff --git a/src/client/cmdVM/vmAttachAsyncSingle.go b/src/client/cmdVM/vmAttachAsyncSingle.go index 65e71c3352c0a51a842fd2a4f4e6d35650e12a07..ff235105f3d6fd8dd37b6d907d8a088ee083e7fe 100644 --- a/src/client/cmdVM/vmAttachAsyncSingle.go +++ b/src/client/cmdVM/vmAttachAsyncSingle.go @@ -1,9 +1,8 @@ package cmdVM import ( - "fmt" - "nexus-common/vm" "nexus-client/exec" + "nexus-common/vm" u "nexus-client/utils" g "nexus-client/globals" ) @@ -18,7 +17,7 @@ func (cmd *AttachAsyncSingle)GetName() string { func (cmd *AttachAsyncSingle)GetDesc() []string { return []string{ - "Attach to a VM.", + "Attaches to a VM in order to use its desktop environment.", "If not the VM's owner: requires VM_ATTACH VM access capability or VM_ATTACH_ANY user capability."} } @@ -37,17 +36,15 @@ func (cmd *AttachAsyncSingle)Run(args []string) int { return 1 } - hostname := g.GetInstance().Hostname - cert := g.GetInstance().PubCert - client := g.GetInstance().Client - host := g.GetInstance().Host - argc := len(args) - if argc < 1 { + if argc != 1 { cmd.PrintUsage() return 1 } + client := g.GetInstance().Client + host := g.GetInstance().Host + vmID := args[0] resp, err := client.R().Get(host+"/vms/"+vmID+"/attach") if err != nil { @@ -55,18 +52,16 @@ func (cmd *AttachAsyncSingle)Run(args []string) int { return 1 } else { if resp.IsSuccess() { - myvm, err := deserializeVMCredentials(resp) + creds, err := deserializeVMCredentials(resp) if err != nil { u.PrintlnErr("Failed retrieving server's response: "+err.Error()) return 1 } - - go func(v vm.VMCredentialsSerialized) { - stdoutStderr, err := exec.RunRemoteViewer(hostname, cert, v.Name, v.Port, v.Pwd, false) - if err != nil { - u.PrintlnErr("Failed attaching to VM ", v.ID, ": ", fmt.Sprintf("%s", stdoutStderr)) - } - } (*myvm) + statusCode, err := AttachToVMs([]vm.VMAttachCredentialsSerialized{*creds}, false) + if err != nil { + u.PrintlnErr(err) + } + return statusCode } else { u.PrintlnErr("Failed retrieving VM credentials for VM \""+vmID+"\": "+resp.Status()+": "+resp.String()) return 1 diff --git a/src/client/cmdVM/vmAttachSync.go b/src/client/cmdVM/vmAttachSync.go index b8244870b4265d585d2f0d6c88d86c2612dd2c4b..b6dd9f5821ec430191665aa1f5801ecf9ec8d279 100644 --- a/src/client/cmdVM/vmAttachSync.go +++ b/src/client/cmdVM/vmAttachSync.go @@ -1,12 +1,8 @@ package cmdVM import ( - "fmt" - "sync" "nexus-client/exec" - "nexus-common/vm" u "nexus-client/utils" - g "nexus-client/globals" ) type AttachSync struct { @@ -20,7 +16,7 @@ func (cmd *AttachSync)GetName() string { func (cmd *AttachSync)GetDesc() []string { return []string{ "Attaches to one or more VMs in order to use their desktop environment.", - "If not the VM's owner: requires VM_LIST VM access capability or VM_LIST_ANY user capability."} + "If not the VM's owner: requires VM_ATTACH VM access capability or VM_ATTACH_ANY user capability."} } func (cmd *AttachSync)PrintUsage() { @@ -34,44 +30,27 @@ func (cmd *AttachSync)Run(args []string) int { return 1 } - hostname := g.GetInstance().Hostname - cert := g.GetInstance().PubCert - argc := len(args) if argc < 1 { cmd.PrintUsage() return 1 } - vms, err := getFilteredVMCredentials("/vms/attach", args) + creds, err := getFilteredVMCredentials("/vms/attach", args) if err != nil { u.PrintlnErr(err.Error()) return 1 } - if len(vms) == 0 { + if len(creds) == 0 { u.PrintlnErr("Error: VM(s) not found.") return 1 } - // Use wait groups to wait until all viewers threads have completed. - var wg sync.WaitGroup - wg.Add(len(vms)) - - statusCode := 0 - - for _, v := range(vms) { - go func(v vm.VMCredentialsSerialized) { - stdoutStderr, err := exec.RunRemoteViewer(hostname, cert, v.Name, v.Port, v.Pwd, false) - if err != nil { - u.PrintlnErr("Failed attaching to VM ", v.ID, ": ", fmt.Sprintf("%s", stdoutStderr)) - statusCode |= 1 - } - wg.Done() - } (v) - } - - wg.Wait() + statusCode, err := AttachToVMs(creds, true) + if err != nil { + u.PrintlnErr(err) + } return statusCode } diff --git a/src/client/cmdVM/vmCreds2csv.go b/src/client/cmdVM/vmCreds2csv.go index 3033e487b420fb14c4d09e41ea6f76748e4bdba9..b164f3fa1866d63af9ae5d6203c61cf95191da7f 100644 --- a/src/client/cmdVM/vmCreds2csv.go +++ b/src/client/cmdVM/vmCreds2csv.go @@ -3,7 +3,6 @@ package cmdVM import ( "os" "fmt" - "strconv" u "nexus-client/utils" g "nexus-client/globals" ) @@ -19,7 +18,7 @@ func (cmd *Creds2csv)GetName() string { func (cmd *Creds2csv)GetDesc() []string { return []string{ "Creates a CSV file with the credentials required to attach to running VMs.", - "The written CSV file contains 4 columns: VM ID;VM name;port;password", + "The written CSV file contains 3 columns: VM ID;VM name;password", "If not the VM's owner: requires VM_ATTACH VM access capability or VM_ATTACH_ANY user capability."} } @@ -42,13 +41,13 @@ func (cmd *Creds2csv)Run(args []string) int { csvFile := args[argc-1] - vms, err := getFilteredVMCredentials("/vms/attach", args[:argc-1]) + credsList, err := getFilteredVMCredentials("/vms/attach", args[:argc-1]) if err != nil { u.PrintlnErr("Error: "+err.Error()) return 1 } - if len(vms) == 0 { + if len(credsList) == 0 { u.PrintlnErr("Error: VM(s) not found.") return 1 } @@ -62,9 +61,9 @@ func (cmd *Creds2csv)Run(args []string) int { defer f.Close() - for _, vm := range vms { + for _, creds := range credsList { sep := string(g.CsvFieldSeparator) - _, err := fmt.Fprintln(f, vm.ID.String()+sep+vm.Name+sep+strconv.Itoa(vm.Port)+sep+vm.Pwd) + _, err := fmt.Fprintln(f, creds.ID.String()+sep+creds.Name+sep+creds.Pwd) if err != nil { u.PrintlnErr("Error: "+err.Error()) return 1 diff --git a/src/client/cmdVM/vmCreds2pdf.go b/src/client/cmdVM/vmCreds2pdf.go index cba3c23a9dbecfa20ef38b34eab275b086affc7d..303491069057e4ed61dbe4c1ea5211e38e4ade11 100644 --- a/src/client/cmdVM/vmCreds2pdf.go +++ b/src/client/cmdVM/vmCreds2pdf.go @@ -1,7 +1,6 @@ package cmdVM import ( - "strconv" u "nexus-client/utils" "github.com/go-pdf/fpdf" ) @@ -43,14 +42,14 @@ func (cmd *Creds2pdf)Run(args []string) int { pdfFile := args[argc-1] - vms, err := getFilteredVMCredentials("/vms/attach", args[:argc-1]) + credsList, err := getFilteredVMCredentials("/vms/attach", args[:argc-1]) if err != nil { u.PrintlnErr("Error: "+err.Error()) return 1 } - if len(vms) == 0 { - u.PrintlnErr("Error: VM(s) not found (possible causes: no VM by that ID/regex, VM(s) not started, no right to list requested VM(s)).") + if len(credsList) == 0 { + u.PrintlnErr("Error: VM(s) not found (possible causes: no VM by that ID/regex, VM(s) not started, missing capabilities).") return 1 } @@ -59,7 +58,7 @@ func (cmd *Creds2pdf)Run(args []string) int { const rightMargin = 0. // Max number of chars allowed in the VM's name given the used font and size. const maxChar = 70 - columnWidth := [...]float64 {156.,14.,23.} + columnWidth := [...]float64 {156.,30.} const rowHeight = 15. const fontSize = 12. const cellBorder = "1" // full cell border; use "" for no border @@ -76,16 +75,15 @@ func (cmd *Creds2pdf)Run(args []string) int { // Read a specific monospace bold font known to support UTF8 encoding pdf.AddUTF8FontFromBytes("cmuntb", "b", []byte(embeddedCMUTypewriterBold)) - for _, vm := range vms { - if len(vm.Name) > maxChar { - vm.Name = vm.Name[0:maxChar-1] - vm.Name = vm.Name+"…" + for _, creds := range credsList { + if len(creds.Name) > maxChar { + creds.Name = creds.Name[0:maxChar-1] + creds.Name = creds.Name+"…" } pdf.SetX(leftMargin) pdf.SetFont("cmuntb", "b", fontSize) // "b" means bold; use "" for normal - pdf.CellFormat(columnWidth[0], rowHeight, vm.Name, cellBorder, nextPos, align, bkgd, link, url) - pdf.CellFormat(columnWidth[1], rowHeight, strconv.Itoa(vm.Port), cellBorder, nextPos, "C", bkgd, link, url) - pdf.CellFormat(columnWidth[2], rowHeight, vm.Pwd, cellBorder, nextPos, "C", bkgd, link, url) + pdf.CellFormat(columnWidth[0], rowHeight, creds.Name, cellBorder, nextPos, align, bkgd, link, url) + pdf.CellFormat(columnWidth[1], rowHeight, creds.Pwd, cellBorder, nextPos, "C", bkgd, link, url) pdf.Ln(-1) // line break; -1 means move by the height of the last printed cell } diff --git a/src/client/cmdVM/vmStartWithCreds.go b/src/client/cmdVM/vmStartWithCreds.go index 79ead12e4c992320dd558b69a31bae2b8d0cfa2f..5d2d758a27ceaa677cec376eaa5fcc2a2904a34c 100644 --- a/src/client/cmdVM/vmStartWithCreds.go +++ b/src/client/cmdVM/vmStartWithCreds.go @@ -2,7 +2,6 @@ package cmdVM import ( "errors" - "strconv" "nexus-common/params" u "nexus-client/utils" g "nexus-client/globals" @@ -29,32 +28,27 @@ func (cmd *StartWithCreds)PrintUsage() { u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") u.PrintlnErr("USAGE: "+cmd.GetName()+" file.csv") u.PrintlnErr("―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――") - const usage string = `file.csv 4-column CSV file defining the VMs to start and their credentials. - Content of the 4 columns: VM ID;VM name;port;password.` + const usage string = `file.csv 3-column CSV file defining the VMs to start and their credentials. + Content of the 3 columns: VM ID;VM name;password.` u.PrintlnErr(usage) } -func (cmd *StartWithCreds)parseCSVFile(csvFile string) ([]string, []string, []string, error) { +func (cmd *StartWithCreds)parseCSVFile(csvFile string) ([]string, []string, error) { // Column 0: VM IDs vmIDs, err := u.ReadCSVColumn(csvFile, 0) if err != nil { - return nil, nil, nil, err + return nil, nil, err } - // Column 2: ports - ports, err := u.ReadCSVColumn(csvFile, 2) + // Column 2: passwords + pwds, err := u.ReadCSVColumn(csvFile, 2) if err != nil { - return nil, nil, nil, err - } - // Column 3: passwords - pwds, err := u.ReadCSVColumn(csvFile, 3) - if err != nil { - return nil, nil, nil, err + return nil, nil, err } count := len(vmIDs) - if (len(ports) != count) || (len(pwds) != count) { - return nil, nil, nil, errors.New("Invalid CSV file: all columns must have the same number of entries!") + if (len(pwds) != count) { + return nil, nil, errors.New("Invalid CSV file: all columns must have the same number of entries!") } - return vmIDs, ports, pwds, nil + return vmIDs, pwds, nil } func (cmd *StartWithCreds)Run(args []string) int { @@ -67,7 +61,7 @@ func (cmd *StartWithCreds)Run(args []string) int { return 1 } - vmIDs, ports, pwds, err := cmd.parseCSVFile(args[0]) + vmIDs, pwds, err := cmd.parseCSVFile(args[0]) if err != nil { u.PrintlnErr(err.Error()) return 1 @@ -78,11 +72,6 @@ func (cmd *StartWithCreds)Run(args []string) int { vmArgs := ¶ms.VMStartWithCreds {} for i, vmID := range(vmIDs) { - port, err := strconv.Atoi(ports[i]) - if err != nil { - u.PrintlnErr("Line ",i,": invalid port number") - } - vmArgs.Port = port vmArgs.Pwd = pwds[i] resp, err := client.R().SetBody(vmArgs).Put(host+"/vms/"+vmID+"/startwithcreds") if err != nil { diff --git a/src/client/nexus-exam/go.mod b/src/client/nexus-exam/go.mod index 350be8af3c0be8c06029ee3efa23fbd0ec3a5d0b..62db63cccdd8c1281463da007694ff5aae077c12 100644 --- a/src/client/nexus-exam/go.mod +++ b/src/client/nexus-exam/go.mod @@ -2,14 +2,42 @@ module nexus-exam go 1.18 +replace nexus-common/caps => ../../common/caps + +replace nexus-common/params => ../../common/params + +replace nexus-common/vm => ../../common/vm + +replace nexus-common/template => ../../common/template + +replace nexus-client/version => ../version + +replace nexus-client/globals => ../globals + +replace nexus-client/defaults => ../defaults + replace nexus-common/utils => ../../common/utils replace nexus-client/utils => ../utils -replace nexus-client/globals => ../globals +replace nexus-client/cmd => ../cmd + +replace nexus-client/cmdMisc => ../cmdMisc + +replace nexus-client/cmdLogin => ../cmdLogin + +replace nexus-client/cmdToken => ../cmdToken + +replace nexus-client/cmdTemplate => ../cmdTemplate + +replace nexus-client/cmdUser => ../cmdUser + +replace nexus-client/cmdVM => ../cmdVM replace nexus-client/exec => ../exec +replace nexus-client/cmdVersion => ../cmdVersion + require fyne.io/fyne/v2 v2.2.1 require ( @@ -25,7 +53,7 @@ require ( github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff // indirect - github.com/google/uuid v1.1.2 // indirect + github.com/google/uuid v1.3.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -38,11 +66,19 @@ require ( golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee // indirect golang.org/x/net v0.0.0-20211029224645-99673261e6eb // indirect golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect + nexus-client/cmd v0.0.0-00010101000000-000000000000 // indirect + nexus-client/cmdLogin v0.0.0-00010101000000-000000000000 // indirect + nexus-client/cmdVersion v0.0.0-00010101000000-000000000000 // indirect nexus-client/exec v0.0.0-00010101000000-000000000000 // indirect nexus-client/globals v0.0.0-00010101000000-000000000000 // indirect nexus-client/utils v0.0.0-00010101000000-000000000000 // indirect + nexus-client/version v0.0.0-00010101000000-000000000000 // indirect + nexus-common/caps v0.0.0-00010101000000-000000000000 // indirect + nexus-common/params v0.0.0-00010101000000-000000000000 // indirect nexus-common/utils v0.0.0-00010101000000-000000000000 // indirect + nexus-common/vm v0.0.0-00010101000000-000000000000 // indirect ) diff --git a/src/client/nexus-exam/go.sum b/src/client/nexus-exam/go.sum index 61c507975706de65a2618b06686e0d43fc92582b..72dd0f6b977ef46655d1bfa8edb6f788d2771bd1 100644 --- a/src/client/nexus-exam/go.sum +++ b/src/client/nexus-exam/go.sum @@ -163,6 +163,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -457,6 +459,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/src/client/nexus-exam/nexus-exam.go b/src/client/nexus-exam/nexus-exam.go index 1cbf2904dd7af3973485b16190de03fce2ffee4b..579996a55a6314f9c6d1b08dfec2adb590769c96 100644 --- a/src/client/nexus-exam/nexus-exam.go +++ b/src/client/nexus-exam/nexus-exam.go @@ -2,16 +2,20 @@ package main import ( "os" - "errors" - "strconv" + // "errors" + "strings" + // "strconv" "nexus-common/utils" "nexus-client/exec" u "nexus-client/utils" g "nexus-client/globals" + "nexus-client/cmdVersion" + // "nexus-client/cmdLogin" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/container" + "github.com/go-resty/resty/v2" ) const ( @@ -24,11 +28,11 @@ func run() int { return 1 } - server, found := os.LookupEnv(g.ENV_NEXUS_SERVER) + serverEnvVar, found := os.LookupEnv(g.ENV_NEXUS_SERVER) if !found { u.PrintlnErr("Environment variable \""+g.ENV_NEXUS_SERVER+"\" must be set!") - u.PrintlnErr("It defines the nexus server to connect to.") - u.PrintlnErr("Example: export "+g.ENV_NEXUS_SERVER+"=192.168.1.42") + u.PrintlnErr("It defines the nexus server to connect to along the port number.") + u.PrintlnErr("Example: export "+g.ENV_NEXUS_SERVER+"=192.168.1.42:1077") return 1 } @@ -45,36 +49,47 @@ func run() int { return 1 } + parts := strings.Split(serverEnvVar, ":") + hostname := parts[0] + + client := resty.New() + client.SetRootCertificate(certPath) + host := "https://"+serverEnvVar + + g.Init(hostname, host, certPath, client) + + // Checks the client version is compatible with the server's API. + if !cmdVersion.CheckServerCompatibility("nexus-exam") { + return 1 + } + + // Logins and obtains a JWT token. + // email := "" + // pwd := "" + + // token, err := cmdLogin.GetToken(email, pwd) + // u.PrintlnErr("") + // if err != nil { + // u.PrintlnErr("Error: "+err.Error()) + // return 1 + // } + app := app.New() app.Settings().SetTheme(theme.LightTheme()) win := app.NewWindow(windowTitle) - portEntry := widget.NewEntry() - portEntry.Validator = func(val string) error { - port, err := strconv.Atoi(val) - if err != nil { - return errors.New("Please enter an integer") - } - if port < 1024 || port > 65535 { - return errors.New("Please enter a value within [1024,65535]") - } - return nil - } - pwdEntry := widget.NewEntry() pwdEntry.Password = true - label := widget.NewLabel("Enter port and password to connect to your VM:") + label := widget.NewLabel("Enter password to connect to your VM:") form := &widget.Form{ Items: []*widget.FormItem { - {Text: "Port", Widget: portEntry}, - { Text: "Password", Widget: pwdEntry}, + {Text: "Password", Widget: pwdEntry}, }, OnSubmit: func() { - port, _ := strconv.Atoi(portEntry.Text) - exec.RunRemoteViewer(server, certPath, windowTitle, port, pwdEntry.Text, true) + // exec.RunRemoteViewer(server, certPath, windowTitle, port, pwdEntry.Text, true) }, SubmitText: "Connect", } diff --git a/src/client/version/version.go b/src/client/version/version.go index f406b364bdec025db6d2c29b5eaed18fc66f35af..2b4b5abd8935ff089a7a2b38873c47cb6ae4dc69 100644 --- a/src/client/version/version.go +++ b/src/client/version/version.go @@ -6,8 +6,8 @@ import ( const ( major = 1 - minor = 10 - bugfix = 1 + minor = 11 + bugfix = 0 ) var version params.Version = params.NewVersion(major, minor, bugfix) diff --git a/src/common/params/vms.go b/src/common/params/vms.go index 0991271f088b287c2bacfec5da3e1ce57de9c941..dc2a44329142f61575166173bfa1fa1b1ff0a035 100644 --- a/src/common/params/vms.go +++ b/src/common/params/vms.go @@ -16,8 +16,11 @@ type VMCreate struct { } type VMStartWithCreds struct { - Port int `json:"port" validate:"required,gte=1025,lte=65535"` - Pwd string `json:"pwd" validate:"required,min=8,max=64"` + Pwd string `json:"pwd" validate:"required,min=10,max=64"` +} + +type VMAttachCreds struct { + Pwd string `json:"pwd" validate:"required,min=10,max=64"` } type VMEdit struct { diff --git a/src/common/utils/go.sum b/src/common/utils/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..c4a57955c6e0d8c358a48f375daf966e42220935 --- /dev/null +++ b/src/common/utils/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= diff --git a/src/common/utils/utils.go b/src/common/utils/utils.go index b607ad28db86b3794ef2df4ef702fc993d83d885..6675ab7cc814c94c4de5ce7cae99161bf98ee996 100644 --- a/src/common/utils/utils.go +++ b/src/common/utils/utils.go @@ -2,13 +2,11 @@ package utils import ( "os" + "errors" ) // Returns true if the specified file exists, false otherwise. func FileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() + _, err := os.Stat(filename) + return !errors.Is(err, os.ErrNotExist) } diff --git a/src/common/vm/vm.go b/src/common/vm/vm.go index 696ca02a0f7605c213af2c3978b13b641ba3155c..ecdb82fd608623d9b1bd40a01ce28635d3b630b9 100644 --- a/src/common/vm/vm.go +++ b/src/common/vm/vm.go @@ -40,12 +40,15 @@ type ( StartTime time.Time `json:"startTime"` } - // VM id, name and credentials to be serialized over the network. - VMCredentialsSerialized struct { + VMSpiceCredentialsSerialized struct { + SpicePort int `json:"spicePort" validate:"required,gte=1025,lte=65535"` + SpicePwd string `json:"spicePwd" validate:"required,min=16,max=64"` + } + + VMAttachCredentialsSerialized struct { ID uuid.UUID `json:"id" validate:"required"` Name string `json:"name" validate:"required,min=1,max=256"` - Port int `json:"port" validate:"required,gte=1025,lte=65535"` - Pwd string `json:"pwd" validate:"required,min=1,max=64"` + Pwd string `json:"pwd" validate:"required,min=8,max=64"` } VMState string // see stateXXX below diff --git a/src/server/config/config.go b/src/server/config/config.go index 23fdbef97b997122a5fbbcf69a627e0134bdcbd0..23a31f1ca1b9f88c48f903525adcd752e4e40e90 100644 --- a/src/server/config/config.go +++ b/src/server/config/config.go @@ -40,10 +40,15 @@ type Config struct { // Unix permissions when creating directories: when creating templates and VMs and for tmp directory. MkDirPerm os.FileMode - VMPwdLength int - VMPwdDigitCount int - VMPwdSymbolCount int - VMPwdRepeatChars bool + VMSpicePwdLength int + VMSpicePwdDigitCount int + VMSpicePwdSymbolCount int + VMSpicePwdRepeatChars bool + + VMAttachPwdLength int + VMAttachPwdDigitCount int + VMAttachPwdSymbolCount int + VMAttachPwdRepeatChars bool UsersFile string DataDir string @@ -76,12 +81,20 @@ func GetInstance() *Config { } sanityChecks(config) - + config.MkDirPerm = 0750 - config.VMPwdLength = 8 - config.VMPwdDigitCount = 4 - config.VMPwdSymbolCount = 0 - config.VMPwdRepeatChars = false + + // Spice password + config.VMSpicePwdLength = 32 + config.VMSpicePwdDigitCount = 10 + config.VMSpicePwdSymbolCount = 0 + config.VMSpicePwdRepeatChars = true + + // VM attach password + config.VMAttachPwdLength = 10 + config.VMAttachPwdDigitCount = 4 + config.VMAttachPwdSymbolCount = 0 + config.VMAttachPwdRepeatChars = true config.UsersFile = filepath.Join(configDir, "/users.json") config.DataDir = dataDir diff --git a/src/server/router/router.go b/src/server/router/router.go index 445e1514afb1c4d63bddd29cba91268be2776d42..fc66077aaa84179bd325ad6ed926b023bbddc711 100644 --- a/src/server/router/router.go +++ b/src/server/router/router.go @@ -91,6 +91,8 @@ func (router *Router)Start(port int) { vmsGroup.PUT("/:id", router.vms.EditVM) vmsGroup.PUT("/:id/start", router.vms.StartVM) vmsGroup.PUT("/:id/startwithcreds", router.vms.StartVMWithCreds) + vmsGroup.POST("/:id/spicecreds", router.vms.VMSpiceCreds) + vmsGroup.POST("/spicecreds", router.vms.AnyVMSpiceCreds) vmsGroup.PUT("/:id/stop", router.vms.KillVM) vmsGroup.PUT("/:id/shutdown", router.vms.ShutdownVM) vmsGroup.PUT("/:id/reboot", router.vms.RebootVM) diff --git a/src/server/router/routerTemplates.go b/src/server/router/routerTemplates.go index f98ce7c7e9c1bda0dac96ca70e551d119a909c1e..1b8d15e79c926e9a6fdba475176462e6ef10f14b 100644 --- a/src/server/router/routerTemplates.go +++ b/src/server/router/routerTemplates.go @@ -236,7 +236,7 @@ func (r *RouterTemplates)DeleteTemplate(c echo.Context) error { if err := r.tpl.DeleteTemplate(tplID, r.vms); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } else if user.HasCapability(caps.CAP_TPL_DESTROY) { template, err := r.tpl.GetTemplate(tplID) if err != nil { @@ -247,7 +247,7 @@ func (r *RouterTemplates)DeleteTemplate(c echo.Context) error { if err := r.tpl.DeleteTemplate(tplID, r.vms); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } } diff --git a/src/server/router/routerUsers.go b/src/server/router/routerUsers.go index 5858fb046e9925cf5aa99a67cc133dd7679436a2..361a1de3e5a303848bc95d6a86db81d68d042534 100644 --- a/src/server/router/routerUsers.go +++ b/src/server/router/routerUsers.go @@ -76,7 +76,7 @@ func (r *RouterUsers)DeleteUserByEmail(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Creates a user. @@ -113,7 +113,7 @@ func (r *RouterUsers)CreateUser(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusCreated, "") + return c.NoContent(http.StatusCreated) } // Update a user's capabilities. @@ -155,7 +155,7 @@ func (r *RouterUsers)SetUserCaps(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Update the logged user's password. @@ -186,7 +186,7 @@ func (r *RouterUsers)SetUserPwd(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Unlock a user. @@ -212,7 +212,7 @@ func (r *RouterUsers)UnlockUser(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Reset a user's password. diff --git a/src/server/router/routerVMs.go b/src/server/router/routerVMs.go index ba358b0fe89e5228b8a4a795ee0b76fcf6836e9b..29fbd142af7508ec2be0c591bcea8a13f669f179 100644 --- a/src/server/router/routerVMs.go +++ b/src/server/router/routerVMs.go @@ -84,18 +84,18 @@ func (r *RouterVMs)GetListableVM(c echo.Context) error { } } -// Returns VMs credentials for VMs that are running and that can be attached to. +// Returns VMs Spice credentials for VMs that are running and that can be attached to. // Requires to be the VM's owner, or either capability: // User cap: CAP_VM_ATTACH_ANY: returns VMs credentials for all running VMs. // VM access cap: CAP_VM_ATTACH: returns VMs credentials for all running VMs with this cap for the logged user. // curl --cacert ca.pem -X GET https://localhost:1077/vms/attach -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)GetAttachableVMs(c echo.Context) error { - return r.getFilteredVMCredentials(c, caps.CAP_VM_ATTACH_ANY, caps.CAP_VM_ATTACH, func(vm *vms.VM) bool { + return r.getFilteredVMAttachCredentials(c, caps.CAP_VM_ATTACH_ANY, caps.CAP_VM_ATTACH, func(vm *vms.VM) bool { return vm.IsRunning() }) } -// Returns VM credentials for a VM specified by its UUID. +// Returns VM Spice credentials for a VM specified by its UUID. // Requires to be the VM's owner, or either capability: // User cap: CAP_VM_ATTACH_ANY: returns VM credentials for the VM. // VM access cap: CAP_VM_ATTACH: returns VM credentials for the VM with this cap for the logged user. @@ -123,16 +123,16 @@ func (r *RouterVMs)GetAttachableVM(c echo.Context) error { // If user has CAP_VM_ATTACH_ANY capability, returns the VM. if user.HasCapability(caps.CAP_VM_ATTACH_ANY) { - return c.JSONPretty(http.StatusOK, vm.SerializeCredentials(), " ") + return c.JSONPretty(http.StatusOK, vm.SerializeAttachCredentials(), " ") } else { if vm.IsOwner(user.Email) { - return c.JSONPretty(http.StatusOK, vm.SerializeCredentials(), " ") + return c.JSONPretty(http.StatusOK, vm.SerializeAttachCredentials(), " ") } else { capabilities, exists := vm.GetAccess()[user.Email] if exists { _, found := capabilities[caps.CAP_VM_ATTACH] if found { - return c.JSONPretty(http.StatusOK, vm.SerializeCredentials(), " ") + return c.JSONPretty(http.StatusOK, vm.SerializeAttachCredentials(), " ") } else { return echo.NewHTTPError(http.StatusUnauthorized, msgInsufficientCaps) } @@ -143,6 +143,93 @@ func (r *RouterVMs)GetAttachableVM(c echo.Context) error { } } +// Returns the Spice credentials for the specific VM and specific VM attach password. +// Requires to be the VM's owner, or either capability: +// User cap: CAP_VM_ATTACH_ANY: returns VM credentials for the VM. +// VM access cap: CAP_VM_ATTACH: returns VM credentials for the VM with this cap for the logged user. +// curl --cacert ca.pem -X POST https://localhost:1077/vms/62ae8791-c108-4235-a7d6-074e9b6a9017/spicecreds -H 'Content-Type: application/json' -d '{"pwd":"46L8drgZ5Dx"}' -H "Authorization: Bearer <AccessToken>" +func (r *RouterVMs)VMSpiceCreds(c echo.Context) error { + // Retrieves logged user from context. + user, err := getLoggedUser(r.users, c) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + + // Retrieves the VM based on its UUID. + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + vm, err := r.vms.GetVM(id) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + if !vm.IsRunning() { + return echo.NewHTTPError(http.StatusBadRequest, errors.New("VM is not running")) + } + + // Deserializes and validates client's parameters. + p := new(params.VMAttachCreds) + if err := decodeJson(c, &p); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Does the received password match the "VM Attach" password stored in the VM? + if p.Pwd != vm.Run.AttachPwd { + return echo.NewHTTPError(http.StatusUnauthorized, errors.New("Access denied")) + } + + // If user has CAP_VM_ATTACH_ANY capability, returns the VM. + if user.HasCapability(caps.CAP_VM_ATTACH_ANY) { + return c.JSONPretty(http.StatusOK, vm.SerializeSpiceCredentials(), " ") + } else { + if vm.IsOwner(user.Email) { + return c.JSONPretty(http.StatusOK, vm.SerializeSpiceCredentials(), " ") + } else { + capabilities, exists := vm.GetAccess()[user.Email] + if exists { + _, found := capabilities[caps.CAP_VM_ATTACH] + if found { + return c.JSONPretty(http.StatusOK, vm.SerializeSpiceCredentials(), " ") + } else { + return echo.NewHTTPError(http.StatusUnauthorized, msgInsufficientCaps) + } + } else { + return echo.NewHTTPError(http.StatusUnauthorized, msgInsufficientCaps) + } + } + } +} + +// Returns the Spice credentials for the VM matching the specific VM attach password. +// Requires the CAP_VM_ATTACH_ANY user capability: +// curl --cacert ca.pem -X POST https://localhost:1077/vms/spicecreds -H 'Content-Type: application/json' -d '{"pwd":"46L8drgZ5Dx"}' -H "Authorization: Bearer <AccessToken>" +func (r *RouterVMs)AnyVMSpiceCreds(c echo.Context) error { + // Retrieves logged user from context. + user, err := getLoggedUser(r.users, c) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + + if user.HasCapability(caps.CAP_VM_ATTACH_ANY) { + return echo.NewHTTPError(http.StatusUnauthorized, errors.New("Access denied")) + } + + // Deserializes and validates client's parameters. + p := new(params.VMAttachCreds) + if err := decodeJson(c, &p); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + vm, err := r.vms.GetVMByPwd(p.Pwd) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, err.Error()) + } + + return c.JSONPretty(http.StatusOK, vm.SerializeSpiceCredentials(), " ") +} + // Returns VMs that are stopped and that can be deleted. // Requires to be the VM's owner, or either capability: // User cap: CAP_VM_DESTROY_ANY: returns all VMs. @@ -311,7 +398,7 @@ func (r *RouterVMs)DeleteVM(c echo.Context) error { if err := r.vms.DeleteVM(vm.GetID()); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } @@ -322,19 +409,20 @@ func (r *RouterVMs)DeleteVM(c echo.Context) error { // curl --cacert ca.pem -X PUT https://localhost:1077/vms/e41f3556-ca24-4658-bd79-8c85bd6bff59/start -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)StartVM(c echo.Context) error { return r.applyOnFilteredVMs(c, caps.CAP_VM_START_ANY, caps.CAP_VM_START, func(c echo.Context, vm *vms.VM) error { - _, _, err := r.vms.StartVM(vm.GetID()) + // "" means a unique password to attach to the VM must be generated + err := r.vms.StartVM(vm.GetID(), "") if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } -// Starts a VM based on its ID using the specified credentials (port and password). +// Starts a VM based on its ID using the specified credentials (attach password). // Requires to be the VM's owner, or either capability: // User cap: CAP_VM_START_ANY: any VM can be started. // VM access cap: CAP_VM_START: any of the VMs with this cap for the logged user can be started. -// curl --cacert ca.pem -X PUT https://localhost:1077/vms/e41f3556-ca24-4658-bd79-8c85bd6bff59/startwithcreds -H "Authorization: Bearer <AccessToken>" +// curl --cacert ca.pem -X PUT https://localhost:1077/vms/e41f3556-ca24-4658-bd79-8c85bd6bff59/startwithcreds -H 'Content-Type: application/json' -d '{"pwd":"46L8drgZ5Dx"}' -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)StartVMWithCreds(c echo.Context) error { return r.applyOnFilteredVMs(c, caps.CAP_VM_START_ANY, caps.CAP_VM_START, func(c echo.Context, vm *vms.VM) error { // Deserializes and validates client's parameters. @@ -343,10 +431,10 @@ func (r *RouterVMs)StartVMWithCreds(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - if err := r.vms.StartVMWithCreds(vm.GetID(), p.Port, true, p.Pwd); err != nil { + if err := r.vms.StartVM(vm.GetID(), p.Pwd); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } @@ -360,7 +448,7 @@ func (r *RouterVMs)KillVM(c echo.Context) error { if err := r.vms.KillVM(vm.GetID()); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } @@ -374,7 +462,7 @@ func (r *RouterVMs)ShutdownVM(c echo.Context) error { if err := r.vms.ShutdownVM(vm.GetID()); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } @@ -388,7 +476,7 @@ func (r *RouterVMs)RebootVM(c echo.Context) error { if err := r.vms.RebootVM(vm.GetID()); err != nil { return echo.NewHTTPError(http.StatusNotFound, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) }) } @@ -451,7 +539,7 @@ func (r *RouterVMs)SetVMAccessForUser(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Delete a VM Access for a given user. @@ -480,7 +568,7 @@ func (r *RouterVMs)DeleteVMAccessForUser(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusOK, "") + return c.NoContent(http.StatusOK) } // Exports a VM's directory into a compressed archive. @@ -559,7 +647,7 @@ func (r *RouterVMs)ImportFilesToVM(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - return c.JSON(http.StatusCreated, "") + return c.NoContent(http.StatusCreated) }) } @@ -599,12 +687,7 @@ func (r *RouterVMs)getFilteredVMs(c echo.Context, userCapabilityAny, vmAccessCap } } -// Helper function that returns a list of serialized VM credentials if either condition is true: -// - the logged user has the userCapabilityAny capability. -// - the logged user is the VM's owner. -// - the VM access for the logged user matches the vmAccessCapability capability. -// Additionally, VMs for which the cond function returns false are filtered out. -func (r *RouterVMs)getFilteredVMCredentials(c echo.Context, userCapabilityAny, vmAccessCapability string, cond vms.VMKeeperFn) error { +func (r *RouterVMs)getFilteredVMAttachCredentials(c echo.Context, userCapabilityAny, vmAccessCapability string, cond vms.VMKeeperFn) error { // Retrieves logged user from context. user, err := getLoggedUser(r.users, c) if err != nil { @@ -614,12 +697,12 @@ func (r *RouterVMs)getFilteredVMCredentials(c echo.Context, userCapabilityAny, v // If user has the XX_ANY capability, returns all VMs. if user.HasCapability(userCapabilityAny) { // Returns all VMs that pass the condition. - return c.JSONPretty(http.StatusOK, r.vms.GetNetworkSerializedVMCredentials(cond), " ") + return c.JSONPretty(http.StatusOK, r.vms.GetVMAttachCredentialsSerialized(cond), " ") } else { // Returns all VMs: // - owned by the user // - for which the user has the specified capability (vmAccessCapability) in the VM's access - return c.JSONPretty(http.StatusOK, r.vms.GetNetworkSerializedVMCredentials(func(vm *vms.VM) bool { + return c.JSONPretty(http.StatusOK, r.vms.GetVMAttachCredentialsSerialized(func(vm *vms.VM) bool { if vm.IsOwner(user.Email) { return cond(vm) } else { diff --git a/src/server/version/version.go b/src/server/version/version.go index f406b364bdec025db6d2c29b5eaed18fc66f35af..2b4b5abd8935ff089a7a2b38873c47cb6ae4dc69 100644 --- a/src/server/version/version.go +++ b/src/server/version/version.go @@ -6,8 +6,8 @@ import ( const ( major = 1 - minor = 10 - bugfix = 1 + minor = 11 + bugfix = 0 ) var version params.Version = params.NewVersion(major, minor, bugfix) diff --git a/src/server/vms/vm.go b/src/server/vms/vm.go index 681e35db4e4dca7ea2273168d7abec51c1a1b2ac..5f9f00b2c3904b9d19473d5c99e3042ceff5a47f 100644 --- a/src/server/vms/vm.go +++ b/src/server/vms/vm.go @@ -26,15 +26,16 @@ type ( dir string // VM directory qgaSock string // QEMU Guest Agent (QGA) UNIX socket Run runStates - DiskBusy bool // When true, the disk image must not be modified (e.g. write access) + DiskBusy bool // When true, the disk image MUST NOT be modified (e.g. write access) mutex *sync.Mutex } runStates struct { State vmc.VMState - Pid int - Port int - Pwd string + Pid int // QEMU process ID + SpicePort int // Port for Spice access + SpicePwd string // Password for Spice access + AttachPwd string // Password required to attach to this VM StartTime time.Time } @@ -117,12 +118,18 @@ func (vm *VM)Serialize() vmc.VMNetworkSerialized { // Returns a serialized version of the VM with basic fields and credentials. // Concurrency: safe -func (vm *VM)SerializeCredentials() vmc.VMCredentialsSerialized { - return vmc.VMCredentialsSerialized { +func (vm *VM)SerializeAttachCredentials() vmc.VMAttachCredentialsSerialized { + return vmc.VMAttachCredentialsSerialized { ID: vm.v.ID, Name: vm.v.Name, - Port: vm.Run.Port, - Pwd: vm.Run.Pwd, + Pwd: vm.Run.AttachPwd, + } +} + +func (vm *VM)SerializeSpiceCredentials() vmc.VMSpiceCredentialsSerialized { + return vmc.VMSpiceCredentialsSerialized { + SpicePort: vm.Run.SpicePort, + SpicePwd: vm.Run.SpicePwd, } } @@ -180,7 +187,7 @@ func newEmptyVM() *VM { }, dir: "", qgaSock: "", - Run: runStates { State: vmc.StateStopped, Pid: 0, Port: 0, Pwd: "" }, + Run: runStates { State: vmc.StateStopped, Pid: 0, SpicePort: 0, SpicePwd: "" }, DiskBusy: false, mutex: new(sync.Mutex), } diff --git a/src/server/vms/vms.go b/src/server/vms/vms.go index 00b7c99caa4acc96cdffb3f289feaa2d717c7539..94c1d2d06c691fca94b170ad15a11850317757ca 100644 --- a/src/server/vms/vms.go +++ b/src/server/vms/vms.go @@ -8,6 +8,7 @@ import ( "time" "errors" "syscall" + osexec "os/exec" "io/ioutil" "path/filepath" "encoding/base64" @@ -27,11 +28,14 @@ type ( VMKeeperFn func(vm *VM) bool VMs struct { - m map[string]*VM - dir string // Base directory where VMs are stored - rwlock *sync.RWMutex // RWlock to ensure the VMs' map (m) coherency - usedPorts [65536]bool // Ports used by VMs for spice - usedRAM int // RAM used by running VMs (in MB) + m map[string]*VM // Map of VM IDs to VMs + pwdToVM map[string]uuid.UUID // Map of VM attach passwords to VM IDs + mutexPwdToVM *sync.Mutex + dir string // Base directory where VMs are stored + rwlock *sync.RWMutex // RWlock to ensure the VMs' map (m) coherency + usedPorts [65536]bool // Ports used by VMs for spice + mutexUsedPorts *sync.Mutex + usedRAM int // RAM used by running VMs (in MB) } ) @@ -48,10 +52,10 @@ func GetVMsInstance() *VMs { // Creates all VMs from their files on disk. // REMARK: path is the root directory where VMs reside. -// Concurrency: unsafe +// Concurrency: unsafe! func InitVMs() error { vmsDir := conf.VMsDir - vms = &VMs { m: make(map[string]*VM), dir: vmsDir, rwlock: new(sync.RWMutex), usedRAM: 0 } + vms = &VMs { m: make(map[string]*VM), pwdToVM: make(map[string]uuid.UUID), mutexPwdToVM: new(sync.Mutex), dir: vmsDir, rwlock: new(sync.RWMutex), mutexUsedPorts: new(sync.Mutex), usedRAM: 0 } for i, _ := range vms.usedPorts { vms.usedPorts[i] = false } @@ -122,16 +126,16 @@ func (vms *VMs)GetNetworkSerializedVMs(keepFn VMKeeperFn) []vmc.VMNetworkSeriali return list } -// Returns the list of serialized VM credentials for which VMKeeperFn is true. +// Returns the list of serialized "VM attach" credentials for which VMKeeperFn is true. // Concurrency: safe -func (vms *VMs)GetNetworkSerializedVMCredentials(keepFn VMKeeperFn) []vmc.VMCredentialsSerialized { +func (vms *VMs)GetVMAttachCredentialsSerialized(keepFn VMKeeperFn) []vmc.VMAttachCredentialsSerialized { vms.rwlock.RLock() - list := []vmc.VMCredentialsSerialized{} + list := []vmc.VMAttachCredentialsSerialized{} for _, vm := range vms.m { vm.mutex.Lock() if keepFn(vm) { - list = append(list, vm.SerializeCredentials()) + list = append(list, vm.SerializeAttachCredentials()) } vm.mutex.Unlock() } @@ -152,8 +156,20 @@ func (vms *VMs)GetVM(vmID uuid.UUID) (*VM, error) { return vms.getVMUnsafe(vmID) } +// Returns the running VM matching a "access password". +// Concurrency: safe +func (vms *VMs)GetVMByPwd(pwd string) (*VM, error) { + vms.rwlock.RLock() + defer vms.rwlock.RUnlock() + vmID, exists := vms.pwdToVM[pwd] + if !exists { + return nil, errors.New("VM not found") + } + return vms.getVMUnsafe(vmID) +} + // Returns a VM by its ID. Concurrency-unsafe version. -// Concurrency: unsafe +// Concurrency: unsafe! func (vms *VMs)getVMUnsafe(vmID uuid.UUID) (*VM, error) { vm, exists := vms.m[vmID.String()] if !exists { @@ -215,43 +231,75 @@ func (vms *VMs)AddVM(vm *VM) error { return nil } -// Starts a VM by its ID, using the specified port and password. -// If checkPort is true, a check is performed on the specified port and if it is already -// in use, the function fails and returns a corresponding error. +// Prepares a VM to be started. +// If attachPwd is empty, then a random unique password is generated, otherwise the password +// in argument is used. // Concurrency: safe -func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd string) error { +func (vms *VMs)prepareStartVM(vmID uuid.UUID, attachPwd string) (*osexec.Cmd, *VM, string, int, int, error) { prefix := "Failed starting VM: " + var err error + prepareCompleted := false; + + // Randomly generates a unique password used to attach to the VM + if attachPwd == "" { + attachPwd, err = vms.allocateAttachPwd(vmID) + if err != nil { + msg := prefix+vmID.String()+": attach password generation error: "+err.Error() + log.Error(msg) + return nil, nil, "", 0, 0, errors.New(msg) + } + } + + // Function that frees the allocated "VM attach" password. + freeAttachPwdFn := func(attachPwd string) { + if !prepareCompleted { vms.freeAttachPwd(attachPwd) } + } + defer freeAttachPwdFn(attachPwd) + + // Randomly generates a password for Spice + spicePwd, err := utils.CustomPwd.Generate(conf.VMSpicePwdLength, conf.VMSpicePwdDigitCount, conf.VMSpicePwdSymbolCount, false, conf.VMSpicePwdRepeatChars) + if err != nil { + msg := prefix+vmID.String()+": Spice password generation error: "+err.Error() + log.Error(msg) + return nil, nil, "", 0, 0, errors.New(msg) + } + + // Allocates a free random port for Spice + spicePort, err := vms.allocateSpiceRandomPort() + if err != nil { + msg := prefix+vmID.String()+": "+err.Error() + log.Error(msg) + return nil, nil, "", 0, 0, errors.New(msg) + } + + // Function that frees the port allocated for Spice. + freeSpiceRandomPortFn := func(spicePort int) { + if !prepareCompleted { vms.freeSpiceRandomPort(spicePort) } + } + defer freeSpiceRandomPortFn(spicePort) + + totalRAM, availRAM, err := utils.GetRAM() + if err != nil { + return nil, nil, "", 0, 0, errors.New(prefix+"failed obtaining memory info: "+err.Error()) + } vms.rwlock.Lock() defer vms.rwlock.Unlock() vm, err := vms.getVMUnsafe(vmID) if err != nil { - return err + return nil, nil, "", 0, 0, err } vm.mutex.Lock() defer vm.mutex.Unlock() if vm.IsRunning() { - return errors.New(prefix+"already running") + return nil, nil, "", 0, 0, errors.New(prefix+"already running") } if vm.IsDiskBusy() { - return errors.New(prefix+"disk in use (busy)") - } - - if checkPort { - if vms.usedPorts[port] { - return errors.New(prefix+"port already in use") - } else if !utils.IsPortAvailable(port) { - return errors.New(prefix+"port not available") - } - } - - totalRAM, availRAM, err := utils.GetRAM() - if err != nil { - return errors.New(prefix+"failed obtaining memory info: "+err.Error()) + return nil, nil, "", 0, 0, errors.New(prefix+"disk in use (busy)") } // We estimate ~30% of RAM saving thanks to KSM (due to page sharing across VMs). @@ -263,95 +311,91 @@ func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd st // otherwise, refuses to run it in order to avoid RAM saturation. if availRAM - vms.usedRAM <= int(math.Round(float64(totalRAM)*(1.-conf.Limits.RamUsageLimit))) { vms.usedRAM -= estimatedVmRAM - return errors.New(prefix+"insufficient free RAM") + return nil, nil, "", 0, 0, errors.New(prefix+"insufficient free RAM") } - // Function that executes the VM in QEMU using the specified spice port and password. - runQemuFn := func(vm *VM, port int, pwd, pwdFile string, endofExecFn endOfExecCallback) error { - certsDir := conf.CertsDir - cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.v.Cpus, vm.v.Ram, string(vm.v.Nic), vm.v.UsbDevs, filepath.Join(vm.dir, vmDiskFile), port, pwdFile, certsDir) - if err != nil { - log.Error(prefix+"filepath join error: "+err.Error()) - return err - } - - if err := cmd.Start(); err != nil { - log.Error(prefix+vm.v.ID.String()+": exec.Start error: "+err.Error()) - log.Error("Failed cmd: "+cmd.String()) - return err - } - - vm.Run = runStates { State: vmc.StateRunning, Pid: cmd.Process.Pid, Port: port, Pwd: pwd, StartTime: time.Now() } - vm.DiskBusy = true - - // Go routine that executes cmd.Wait() which is a blocking call (hence the go routine). - // From here on, there are 2 flows of execution! - go func() { - if err := cmd.Wait(); err != nil { - log.Error(prefix+vm.v.ID.String()+": exec.Wait error: "+err.Error()) - log.Error("Failed cmd: "+cmd.String()) - } - endofExecFn(vm) - } () - - return nil + // Writes the password in Base64 in a secret file inside the VM's directory. + pwdBase64 := base64.StdEncoding.EncodeToString([]byte(spicePwd)) + content := []byte(pwdBase64) + spicePwdFile := filepath.Join(vm.dir, vmSecretFile) + if err := ioutil.WriteFile(spicePwdFile, content, 0600); err != nil { + msg := prefix+"error creating secret file: "+err.Error() + log.Error(msg) + return nil, nil, "", 0, 0, errors.New(msg) } // Function that deletes a VM's secret file. removeSecretFileFn := func(vm *VM) { - pwdFile := filepath.Join(vm.dir, vmSecretFile) - os.Remove(pwdFile) - } - - // Function that starts a VM on the given port with the given password. - // endofExecFn is called once the VM's execution is over. - startFn := func(vm *VM, port int, pwd string, endofExecFn endOfExecCallback) error { - // Writes the password in Base64 in a secret file inside the VM's directory. - pwdBase64 := base64.StdEncoding.EncodeToString([]byte(pwd)) - content := []byte(pwdBase64) - pwdFile := filepath.Join(vm.dir, vmSecretFile) - if err := ioutil.WriteFile(pwdFile, content, 0600); err != nil { - msg := prefix+"error creating secret file: "+err.Error() - log.Error(msg) - return errors.New(msg) + if !prepareCompleted { + spicePwdFile := filepath.Join(vm.dir, vmSecretFile) + os.Remove(spicePwdFile) } - - if err = runQemuFn(vm, port, pwd, pwdFile, endofExecFn); err != nil { - removeSecretFileFn(vm) - os.Remove(vm.qgaSock) // If QEMU fails it's likely the Guest Agent file it created is still there. - return errors.New(prefix+"error running QEMU: "+err.Error()) - } - - return nil } + defer removeSecretFileFn(vm) - // Function that resets the VM's states, frees its port and deletes its secret file. - // Called once the VM's execution is over. - endofExecFn := func(vm *VM) { - vms.rwlock.Lock() - vms.usedPorts[port] = false - vms.usedRAM -= estimatedVmRAM - vms.rwlock.Unlock() - vm.mutex.Lock() - removeSecretFileFn(vm) - // Resets VM states and disk busy flag. - zeroTime := time.Time{} - vm.Run = runStates { State: vmc.StateStopped, Pid: 0, Port: 0, Pwd: "", StartTime: zeroTime } - vm.DiskBusy = false - vm.mutex.Unlock() + // Executes the QEMU process. + certsDir := conf.CertsDir + cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.v.Cpus, vm.v.Ram, string(vm.v.Nic), vm.v.UsbDevs, filepath.Join(vm.dir, vmDiskFile), spicePort, spicePwdFile, certsDir) + if err != nil { + os.Remove(vm.qgaSock) // If QEMU fails it's likely the Guest Agent file it created is still there. + log.Error(prefix+"filepath join error: "+err.Error()) + return nil, nil, "", 0, 0, err + } + if err := cmd.Start(); err != nil { + os.Remove(vm.qgaSock) // If QEMU fails it's likely the Guest Agent file it created is still there. + log.Error(prefix+vm.v.ID.String()+": exec.Start error: "+err.Error()) + log.Error("Failed cmd: "+cmd.String()) + return nil, nil, "", 0, 0, err } - vms.usedPorts[port] = true + // Updates the VM's running states. + vm.Run = runStates { State: vmc.StateRunning, Pid: cmd.Process.Pid, SpicePort: spicePort, SpicePwd: spicePwd, AttachPwd: attachPwd, StartTime: time.Now() } + vm.DiskBusy = true + + prepareCompleted = true + + return cmd, vm, attachPwd, spicePort, estimatedVmRAM, nil +} + +// Starts a VM by its ID. +// If attachPwd is empty, then a random unique password is generated, otherwise the password +// in argument is used. +// Concurrency: safe +func (vms *VMs)StartVM(vmID uuid.UUID, attachPwd string) error { + prefix := "Failed starting VM[2]: " - if err = startFn(vm, port, pwd, endofExecFn); err != nil { - vms.usedPorts[port] = false + cmd, vm, attachPwd, spicePort, estimatedVmRAM, err := vms.prepareStartVM(vmID, attachPwd) + if err != nil { return err + } else { + go func() { + if err := cmd.Wait(); err != nil { + log.Error(prefix+vm.v.ID.String()+": exec.Wait error: "+err.Error()) + log.Error("Failed cmd: "+cmd.String()) + } + + // Resets the VM's running states, frees its port, free its attach pwd and deletes its secret file. + // Executed only when the VM's execution is over, i.e. when the QEMU process terminates (both normal and aborted terminations). + vms.freeAttachPwd(attachPwd) + vms.freeSpiceRandomPort(spicePort) + vms.rwlock.Lock() + vms.usedRAM -= estimatedVmRAM + vms.rwlock.Unlock() + vm.mutex.Lock() + spicePwdFile := filepath.Join(vm.dir, vmSecretFile) + os.Remove(spicePwdFile) + // Resets VM states and disk busy flag. + zeroTime := time.Time{} + vm.Run = runStates { State: vmc.StateStopped, Pid: 0, SpicePort: 0, SpicePwd: "", AttachPwd: "", StartTime: zeroTime } + vm.DiskBusy = false + vm.mutex.Unlock() + } () } return nil } -// Allocates and returns a free port, randomly chosen between [VMSpiceMinPort,VMSpiceMaxPort]. +// Allocates and returns a free port for Spice, randomly chosen between [VMSpiceMinPort,VMSpiceMaxPort]. // When the number of free port becomes very small, // randomly picking a port is very time consuming (many attempts). // TODO: A better approach would be to do this instead: @@ -359,9 +403,9 @@ func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd st // Otherwise: pick the first available one either from the beginning or the end (randomly). // REMARK: this function updates the vms map. // Concurrency: safe -func (vms *VMs)allocateFreeRandomPort() (int, error) { - vms.rwlock.Lock() - defer vms.rwlock.Unlock() +func (vms *VMs)allocateSpiceRandomPort() (int, error) { + vms.mutexUsedPorts.Lock() + defer vms.mutexUsedPorts.Unlock() minPort := conf.Core.VMSpiceMinPort maxPort := conf.Core.VMSpiceMaxPort @@ -390,30 +434,41 @@ func (vms *VMs)allocateFreeRandomPort() (int, error) { } } -// Starts a VM by its ID using randomly generated port number and password. -// Returns the port on which the VM is running and the access password. +// Frees a port previously allocated with allocateSpiceRandomPort(). // Concurrency: safe -func (vms *VMs)StartVM(vmID uuid.UUID) (int, string, error) { - port, err := vms.allocateFreeRandomPort() - if err != nil { - msg := "Failed starting VM "+vmID.String()+": "+err.Error() - log.Error(msg) - return -1, "", errors.New(msg) - } +func (vms *VMs)freeSpiceRandomPort(port int) { + vms.mutexUsedPorts.Lock() + defer vms.mutexUsedPorts.Unlock() + vms.usedPorts[port] = false +} - // Randomly generates a 8 characters long password with 4 digits, 0 symbols, - // allowing upper and lower case letters, disallowing repeat characters. - pwd, err := utils.CustomPwd.Generate(conf.VMPwdLength, conf.VMPwdDigitCount, conf.VMPwdSymbolCount, false, conf.VMPwdRepeatChars) - if err != nil { - msg := "Failed starting VM "+vmID.String()+": password generation error: "+err.Error() - log.Error(msg) - return -1, "", errors.New(msg) - } +// Randomly generates a unique password as credentials to attach to a VM. +// Returns the generated password. +// Concurrency: safe +func (vms *VMs)allocateAttachPwd(vmID uuid.UUID) (string, error) { + vms.mutexPwdToVM.Lock() + defer vms.mutexPwdToVM.Unlock() - if err = vms.StartVMWithCreds(vmID, port, false, pwd); err != nil { - return -1, "", err + for { + attachPwd, err := utils.CustomPwd.Generate(conf.VMAttachPwdLength, conf.VMAttachPwdDigitCount, conf.VMAttachPwdSymbolCount, false, conf.VMAttachPwdRepeatChars) + if err != nil { + return "", err + } + _, exists := vms.pwdToVM[attachPwd] + if exists { + continue + } + vms.pwdToVM[attachPwd] = vmID + return attachPwd, nil } - return port, pwd, nil +} + +// Frees a password that was previously allocated with allocateAttachPwd(). +// Concurrency: safe +func (vms *VMs)freeAttachPwd(attachPwd string) { + vms.mutexPwdToVM.Lock() + defer vms.mutexPwdToVM.Unlock() + delete(vms.pwdToVM, attachPwd) } // Kills a VM by its ID.