| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| package cmd |
| |
| import ( |
| "bytes" |
| "crypto/hmac" |
| "crypto/sha1" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io/ioutil" |
| "net/http" |
| "net/http/cookiejar" |
| "net/url" |
| "os" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/apache/cloudstack-cloudmonkey/config" |
| ) |
| |
| func findSessionCookie(cookies []*http.Cookie) *http.Cookie { |
| if cookies == nil { |
| return nil |
| } |
| for _, cookie := range cookies { |
| if cookie.Name == "sessionkey" { |
| return cookie |
| } |
| } |
| return nil |
| } |
| |
| func getLoginResponse(responseBody []byte) (map[string]interface{}, error) { |
| var responseMap map[string]interface{} |
| err := json.Unmarshal(responseBody, &responseMap) |
| if err != nil { |
| return nil, errors.New("failed to parse login response: " + err.Error()) |
| } |
| loginRespRaw, ok := responseMap["loginresponse"] |
| if !ok { |
| return nil, errors.New("failed to parse login response, expected 'loginresponse' key not found") |
| } |
| loginResponse, ok := loginRespRaw.(map[string]interface{}) |
| if !ok { |
| return nil, errors.New("failed to parse login response, expected 'loginresponse' to be a map") |
| } |
| return loginResponse, nil |
| } |
| |
| func getResponseBooleanValue(response map[string]interface{}, key string) (bool, bool) { |
| v, found := response[key] |
| if !found { |
| return false, false |
| } |
| switch value := v.(type) { |
| case bool: |
| return true, value |
| case string: |
| return true, strings.ToLower(value) == "true" |
| case float64: |
| return true, value != 0 |
| default: |
| return true, false |
| } |
| } |
| |
| func checkLogin2FAPromptAndValidate(r *Request, response map[string]interface{}, sessionKey string) error { |
| if !r.Config.HasShell { |
| return nil |
| } |
| config.Debug("Checking if 2FA is enabled and verified for the user ", response) |
| found, is2faEnabled := getResponseBooleanValue(response, "is2faenabled") |
| if !found || !is2faEnabled { |
| config.Debug("2FA is not enabled for the user, skipping 2FA validation") |
| return nil |
| } |
| found, is2faVerified := getResponseBooleanValue(response, "is2faverified") |
| if !found || is2faVerified { |
| config.Debug("2FA is already verified for the user, skipping 2FA validation") |
| return nil |
| } |
| activeSpinners := r.Config.PauseActiveSpinners() |
| fmt.Print("Enter 2FA code: ") |
| var code string |
| fmt.Scanln(&code) |
| if activeSpinners > 0 { |
| r.Config.ResumePausedSpinners() |
| } |
| params := make(url.Values) |
| params.Add("command", "validateUserTwoFactorAuthenticationCode") |
| params.Add("codefor2fa", code) |
| params.Add("sessionkey", sessionKey) |
| |
| msURL, _ := url.Parse(r.Config.ActiveProfile.URL) |
| |
| config.Debug("Validating 2FA with POST URL:", msURL, params) |
| spinner := r.Config.StartSpinner("trying to validate 2FA...") |
| resp, err := r.Client().PostForm(msURL.String(), params) |
| r.Config.StopSpinner(spinner) |
| if err != nil { |
| return errors.New("failed to failed to validate 2FA code: " + err.Error()) |
| } |
| config.Debug("ValidateUserTwoFactorAuthenticationCode POST response status code:", resp.StatusCode) |
| if resp.StatusCode != http.StatusOK { |
| r.Client().Jar, _ = cookiejar.New(nil) |
| return errors.New("failed to validate 2FA code, please check the code. Invalidating session") |
| } |
| return nil |
| } |
| |
| // Login logs in a user based on provided request and returns http client and session key |
| func Login(r *Request) (string, error) { |
| params := make(url.Values) |
| params.Add("command", "login") |
| params.Add("username", r.Config.ActiveProfile.Username) |
| params.Add("password", r.Config.ActiveProfile.Password) |
| params.Add("domain", r.Config.ActiveProfile.Domain) |
| params.Add("response", "json") |
| |
| msURL, _ := url.Parse(r.Config.ActiveProfile.URL) |
| if sessionCookie := findSessionCookie(r.Client().Jar.Cookies(msURL)); sessionCookie != nil { |
| return sessionCookie.Value, nil |
| } |
| |
| config.Debug("Login POST URL:", msURL, params) |
| spinner := r.Config.StartSpinner("trying to log in...") |
| resp, err := r.Client().PostForm(msURL.String(), params) |
| r.Config.StopSpinner(spinner) |
| |
| if err != nil { |
| return "", errors.New("failed to authenticate with the CloudStack server, please check the settings: " + err.Error()) |
| } |
| |
| config.Debug("Login POST response status code:", resp.StatusCode) |
| if resp.StatusCode != http.StatusOK { |
| e := errors.New("failed to authenticate, please check the credentials") |
| if err != nil { |
| e = errors.New("failed to authenticate due to " + err.Error()) |
| } |
| return "", e |
| } |
| |
| body, _ := ioutil.ReadAll(resp.Body) |
| config.Debug("Login response body:", string(body)) |
| loginResponse, err := getLoginResponse(body) |
| if err != nil { |
| return "", err |
| } |
| |
| var sessionKey string |
| curTime := time.Now() |
| expiryDuration := 15 * time.Minute |
| for _, cookie := range resp.Cookies() { |
| if cookie.Expires.After(curTime) { |
| expiryDuration = cookie.Expires.Sub(curTime) |
| } |
| if cookie.Name == "sessionkey" { |
| sessionKey = cookie.Value |
| } |
| } |
| go func() { |
| time.Sleep(expiryDuration) |
| r.Client().Jar, _ = cookiejar.New(nil) |
| }() |
| |
| config.Debug("Login sessionkey:", sessionKey) |
| if err := checkLogin2FAPromptAndValidate(r, loginResponse, sessionKey); err != nil { |
| return "", err |
| } |
| return sessionKey, nil |
| } |
| |
| func encodeRequestParams(params url.Values) string { |
| if params == nil { |
| return "" |
| } |
| |
| keys := make([]string, 0, len(params)) |
| for key := range params { |
| keys = append(keys, key) |
| } |
| sort.Strings(keys) |
| |
| var buf bytes.Buffer |
| for _, key := range keys { |
| value := params.Get(key) |
| if buf.Len() > 0 { |
| buf.WriteByte('&') |
| } |
| buf.WriteString(key) |
| buf.WriteString("=") |
| escaped := url.QueryEscape(value) |
| // we need to ensure + (representing a space) is encoded as %20 |
| escaped = strings.Replace(escaped, "+", "%20", -1) |
| // we need to ensure * is not escaped |
| escaped = strings.Replace(escaped, "%2A", "*", -1) |
| buf.WriteString(escaped) |
| } |
| return buf.String() |
| } |
| |
| func getResponseData(data map[string]interface{}) map[string]interface{} { |
| for k := range data { |
| if strings.HasSuffix(k, "response") { |
| return data[k].(map[string]interface{}) |
| } |
| } |
| return nil |
| } |
| |
| func pollAsyncJob(r *Request, jobID string) (map[string]interface{}, error) { |
| timeout := time.NewTimer(time.Duration(float64(r.Config.Core.Timeout)) * time.Second) |
| ticker := time.NewTicker(time.Duration(2 * time.Second)) |
| defer ticker.Stop() |
| defer timeout.Stop() |
| |
| spinner := r.Config.StartSpinner("polling for async API result") |
| defer r.Config.StopSpinner(spinner) |
| |
| for { |
| select { |
| case <-r.Config.C: |
| return nil, errors.New("async API job polling interrupted") |
| |
| case <-timeout.C: |
| return nil, errors.New("async API job query timed out") |
| |
| case <-ticker.C: |
| args := []string{"jobid=" + jobID} |
| if r.Args != nil { |
| for _, arg := range r.Args { |
| if strings.HasPrefix(strings.ToLower(arg), "filter=") { |
| args = append(args, arg) |
| break |
| } |
| } |
| } |
| queryResult, queryError := NewAPIRequest(r, "queryAsyncJobResult", args, false) |
| if queryError != nil { |
| return queryResult, queryError |
| } |
| jobStatus := queryResult["jobstatus"].(float64) |
| |
| switch jobStatus { |
| case 0: |
| continue |
| |
| case 1: |
| return queryResult["jobresult"].(map[string]interface{}), nil |
| |
| case 2: |
| return queryResult, errors.New("async API failed for job " + jobID) |
| } |
| } |
| } |
| } |
| |
| // NewAPIRequest makes an API request to configured management server |
| func NewAPIRequest(r *Request, api string, args []string, isAsync bool) (map[string]interface{}, error) { |
| params := make(url.Values) |
| params.Add("command", api) |
| apiData := r.Config.GetCache()[api] |
| for _, arg := range args { |
| if apiData != nil { |
| skip := false |
| for _, fakeArg := range apiData.FakeArgs { |
| if strings.HasPrefix(arg, fakeArg) { |
| skip = true |
| break |
| } |
| } |
| if skip { |
| continue |
| } |
| |
| } |
| parts := strings.SplitN(arg, "=", 2) |
| if len(parts) == 2 { |
| key := parts[0] |
| value := parts[1] |
| if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") { |
| value = value[1 : len(value)-1] |
| } |
| if strings.HasPrefix(value, "@") { |
| possibleFileName := value[1:] |
| if fileInfo, err := os.Stat(possibleFileName); err == nil && !fileInfo.IsDir() { |
| bytes, err := ioutil.ReadFile(possibleFileName) |
| config.Debug() |
| if err == nil { |
| value = string(bytes) |
| config.Debug("Content for argument ", key, " read from file: ", possibleFileName, " is: ", value) |
| } |
| } |
| } |
| params.Add(key, value) |
| } |
| } |
| signatureversion := "3" |
| expiresKey := "expires" |
| params.Add("response", "json") |
| params.Add("signatureversion", signatureversion) |
| params.Add(expiresKey, time.Now().UTC().Add(15*time.Minute).Format(time.RFC3339)) |
| |
| var encodedParams string |
| var err error |
| |
| if len(r.Config.ActiveProfile.APIKey) > 0 && len(r.Config.ActiveProfile.SecretKey) > 0 { |
| apiKey := r.Config.ActiveProfile.APIKey |
| secretKey := r.Config.ActiveProfile.SecretKey |
| |
| if len(apiKey) > 0 { |
| params.Add("apiKey", apiKey) |
| } |
| encodedParams = encodeRequestParams(params) |
| |
| mac := hmac.New(sha1.New, []byte(secretKey)) |
| mac.Write([]byte(strings.ToLower(encodedParams))) |
| signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
| if r.Config.Core.PostRequest { |
| params.Add("signature", signature) |
| } else { |
| encodedParams = encodedParams + fmt.Sprintf("&signature=%s", url.QueryEscape(signature)) |
| params = nil |
| } |
| } else if len(r.Config.ActiveProfile.Username) > 0 && len(r.Config.ActiveProfile.Password) > 0 { |
| sessionKey, err := Login(r) |
| if err != nil { |
| return nil, err |
| } |
| params.Add("sessionkey", sessionKey) |
| encodedParams = encodeRequestParams(params) |
| } else { |
| fmt.Println("Please provide either apikey/secretkey or username/password to make an API call") |
| return nil, errors.New("failed to authenticate to make API call") |
| } |
| |
| requestURL := fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodedParams) |
| config.Debug("NewAPIRequest API request URL:", requestURL) |
| |
| var response *http.Response |
| response, err = executeRequest(r, requestURL, params) |
| if err != nil { |
| return nil, err |
| } |
| config.Debug("NewAPIRequest response status code:", response.StatusCode) |
| |
| if r.CredentialsSupplied { |
| config.Debug("Credentials supplied on command-line, not falling back to login") |
| } |
| |
| if response.StatusCode == http.StatusUnauthorized && !r.CredentialsSupplied { |
| r.Client().Jar, _ = cookiejar.New(nil) |
| sessionKey, err := Login(r) |
| if err != nil { |
| return nil, err |
| } |
| params.Del("sessionkey") |
| params.Add("sessionkey", sessionKey) |
| requestURL = fmt.Sprintf("%s?%s", r.Config.ActiveProfile.URL, encodeRequestParams(params)) |
| config.Debug("NewAPIRequest API request URL:", requestURL) |
| |
| response, err = executeRequest(r, requestURL, params) |
| if err != nil { |
| return nil, err |
| } |
| } |
| |
| body, _ := ioutil.ReadAll(response.Body) |
| config.Debug("NewAPIRequest response body:", string(body)) |
| |
| var data map[string]interface{} |
| _ = json.Unmarshal([]byte(body), &data) |
| |
| if isAsync && r.Config.Core.AsyncBlock { |
| if jobResponse := getResponseData(data); jobResponse != nil && jobResponse["jobid"] != nil { |
| jobID := jobResponse["jobid"].(string) |
| return pollAsyncJob(r, jobID) |
| } |
| } |
| |
| if apiResponse := getResponseData(data); apiResponse != nil { |
| if _, ok := apiResponse["errorcode"]; ok { |
| return nil, fmt.Errorf("(HTTP %v, error code %v) %v", apiResponse["errorcode"], apiResponse["cserrorcode"], apiResponse["errortext"]) |
| } |
| return apiResponse, nil |
| } |
| |
| return nil, errors.New("failed to decode response") |
| } |
| |
| // we can implement further conditions to do POST or GET (or other http commands) here |
| func executeRequest(r *Request, requestURL string, params url.Values) (*http.Response, error) { |
| config.SetupContext(r.Config) |
| if params.Has("password") || params.Has("userdata") || r.Config.Core.PostRequest { |
| requestURL = r.Config.ActiveProfile.URL |
| config.Debug("Using HTTP POST for the request: ", requestURL) |
| return r.Client().PostForm(requestURL, params) |
| } |
| config.Debug("Using HTTP GET for the request: ", requestURL) |
| req, _ := http.NewRequestWithContext(*r.Config.Context, "GET", requestURL, nil) |
| return r.Client().Do(req) |
| } |