Files
ubicloud/cli/ubi.go
Jeremy Evans a94f0e5081 Have cli program send version in header
This allows the server to take appropriate action based on client version.
It we add new features that require an updated client version, we can
check the client's version, and give them a give the clients a nice error
message instructing them to upgrade.

This required a minor change to Rodish to execute code after option
parsing and before subcommand processing, even if there are no valid
subcommands provided.  I made that change and released a new version,
so this bumps the rodish version.

The cli version is stored in cli/version.txt.  bin/ubi reads this
file when run.  In the rake ubi/ubi-cross tasks, the Rakefile
reads this file and sets the version in the compiled go file.
2025-03-04 16:55:53 -08:00

232 lines
5.5 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"slices"
"strings"
)
var version = "undefined"
var allowConfirmation bool = true
var debugEnabled = os.Getenv("UBI_DEBUG") == "1"
func getToken() string {
token := os.Getenv("UBI_TOKEN")
if token == "" {
fmt.Fprintln(os.Stderr, "! Personal access token must be provided in UBI_TOKEN env variable for use")
os.Exit(1)
}
return token
}
func baseURL() string {
if url := os.Getenv("UBI_URL"); url != "" {
return url
}
return "https://api.ubicloud.com/cli"
}
type Client struct {
http.Client
Headers http.Header
}
func NewClient() *Client {
return &Client{
Headers: make(http.Header),
}
}
func sendRequest(args []string) {
requestBodyHash := make(map[string][]string)
requestBodyHash["argv"] = args
request_body, err := json.Marshal(requestBodyHash)
if err != nil {
fmt.Fprintf(os.Stderr, "! Error encoding request body\n")
os.Exit(1)
}
client := &http.Client{}
req, err := http.NewRequest("POST", baseURL(), bytes.NewBuffer(request_body))
if err != nil {
fmt.Fprintf(os.Stderr, "! Error creating http request\n")
os.Exit(1)
}
req.Header.Set("Authorization", "Bearer: "+getToken())
req.Header.Set("X-Ubi-Version", version)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/plain")
req.Header.Set("Connection", "close")
debugLog("sending: %+v\n", args)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "! Error sending http request\n")
os.Exit(1)
}
defer resp.Body.Close()
processResponse(resp, args)
}
func main() {
sendRequest(os.Args[1:])
}
func processResponse(resp *http.Response, args []string) {
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
handleSuccess(resp, args)
default:
_, err := io.Copy(os.Stderr, resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "! Error copying response body to stderr\n")
}
os.Exit(1)
}
}
func handleSuccess(resp *http.Response, args []string) {
if prog := resp.Header.Get("ubi-command-execute"); prog != "" {
executeValidatedCommand(prog, resp.Body)
} else if prompt := resp.Header.Get("ubi-confirm"); prompt != "" {
handleConfirmation(prompt, resp.Body, args)
} else {
_, err := io.Copy(os.Stdout, resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "! Error copying response body to stdout\n")
}
}
}
var allowedCommands = map[string]bool{
"ssh": true, "scp": true, "sftp": true,
"psql": true, "pg_dump": true, "pg_dumpall": true,
}
func getExecutablePath(prog string) string {
envProg := os.Getenv("UBI_" + strings.ToUpper(prog))
if envProg != "" {
prog = envProg
}
return prog
}
func executeValidatedCommand(prog string, args io.Reader) {
if !slices.Contains(os.Args, prog) {
fmt.Fprintf(os.Stderr, "! Invalid server response, not executing program not in original argv\n")
os.Exit(1)
}
if !allowedCommands[prog] {
fmt.Fprintf(os.Stderr, "! Invalid server response, unsupported program requested\n")
os.Exit(1)
}
argsBuf := make([]byte, 1024*1024)
n, err := args.Read(argsBuf)
if err != nil && err != io.EOF {
fmt.Fprintf(os.Stderr, "! Error reading response body: %v\n", err)
fmt.Println(err)
os.Exit(1)
}
argsBuf = argsBuf[:n]
cmdArgs := strings.Split(string(argsBuf[:]), "\x00")
validateArguments(prog, cmdArgs)
prog = getExecutablePath(prog)
debugLog("exec: %s %+v\n", prog, cmdArgs)
cmd := exec.Command(prog, cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
os.Exit(exitError.ExitCode())
}
fmt.Fprintf(os.Stderr, "! Error executing program: %v\n", err)
os.Exit(1)
}
}
func handleConfirmation(prompt string, body io.Reader, args []string) {
if !allowConfirmation {
fmt.Fprintf(os.Stderr, "! Invalid server response, repeated confirmation attempt\n")
os.Exit(1)
}
allowConfirmation = false
_, err := io.Copy(os.Stdout, body)
if err != nil {
fmt.Fprintf(os.Stderr, "! Error copying response body to stdout\n")
os.Exit(1)
}
fmt.Printf("\n%s: ", prompt)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
fmt.Fprintln(os.Stderr, "! Error reading confirmation")
os.Exit(1)
}
confirmation := scanner.Text()
args = append([]string{"--confirm", confirmation}, args...)
sendRequest(args)
}
func debugLog(format string, args ...interface{}) {
if debugEnabled {
fmt.Printf(format, args...)
}
}
func validateArguments(prog string, received []string) {
originalSet := make(map[string]bool)
for _, arg := range os.Args {
originalSet[arg] = true
}
//debugLog("original: %+v\n", os.Args)
//debugLog("received: %+v\n", received)
seenCustom := false
pg_dumpall := false
seenSep := false
invalid_message := ""
for _, arg := range received {
if arg == "--" {
seenSep = true
} else if !originalSet[arg] {
if seenCustom {
invalid_message = "! Invalid server response, multiple arguments not in submitted argv"
break
} else if seenSep {
seenCustom = true
} else if prog == "pg_dumpall" && strings.HasPrefix(arg, "-d") {
seenCustom = true
pg_dumpall = true
} else {
invalid_message = "! Invalid server response, argument before '--' not in submitted argv"
break
}
}
}
if !seenSep && !pg_dumpall && invalid_message == "" {
invalid_message = "! Invalid server response, no '--' in returned argv"
}
if invalid_message != "" {
debugLog("failure: %s %v", getExecutablePath(prog), received)
fmt.Fprintln(os.Stderr, invalid_message)
os.Exit(1)
}
}