🌱🏠🧑‍💻 command line greenhouse client application: an interface to the greenhouse daemon https://greenhouse.server.garden/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

497 lines
13 KiB

package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"math"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
errors "git.sequentialread.com/forest/pkg-errors"
)
var httpClient *http.Client
var baseURL string
func main() {
args := os.Args[1:]
isHelpString := func(s string) bool {
lowerCase := strings.ToLower(s)
return lowerCase == "help" || lowerCase == "--help" || lowerCase == "-h" || lowerCase == "-help"
}
if len(args) == 0 {
displayHelpAndExit("")
} else if isHelpString(args[0]) {
if len(args) > 1 {
displayHelpAndExit(args[1])
}
displayHelpAndExit("")
} else if len(args) > 1 && isHelpString(args[1]) {
displayHelpAndExit(args[0])
}
hostname := os.Getenv("GREENHOUSE_DAEMON_HOSTNAME")
if hostname == "" {
hostname = "localhost"
}
portString := os.Getenv("GREENHOUSE_DAEMON_PORT")
if portString == "" {
portString = "9572"
}
port, err := strconv.Atoi(portString)
if err != nil {
log.Fatalf("could not convert GREENHOUSE_DAEMON_PORT '%s' to an integer: %s", portString, err)
}
caFile := os.Getenv("GREENHOUSE_DAEMON_CA_FILE")
unixSocket := os.Getenv("GREENHOUSE_DAEMON_UNIX_SOCKET")
if unixSocket != "" {
baseURL = "http://unix"
httpClient = &http.Client{
Transport: CreateUnixTransport(unixSocket),
Timeout: 10 * time.Second,
}
} else {
baseURL = fmt.Sprintf("https://%s:%d", hostname, port)
httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
if caFile != "" {
caCertPool := x509.NewCertPool()
caCert, err := ioutil.ReadFile(caFile)
if err != nil {
fmt.Printf("\nCouldn't read GREENHOUSE_DAEMON_CA_FILE '%s': %s\n\n", caFile, err)
os.Exit(1)
return
}
ok, errors := AppendCertsFromPEM(caCertPool, caCert)
if !ok {
errorStrings := make([]string, len(errors))
for i := range errors {
errorStrings[i] = fmt.Sprintf(" - %s", errors[i])
}
fmt.Printf("\nFailed to add GREENHOUSE_DAEMON_CA_FILE '%s' to cert pool because:\n%s\n\n", caFile, strings.Join(errorStrings, "\n"))
os.Exit(1)
return
}
tlsClientConfig := &tls.Config{
RootCAs: caCertPool,
}
tlsClientConfig.BuildNameToCertificate()
httpClient.Transport = &http.Transport{
TLSClientConfig: tlsClientConfig,
}
}
}
command := args[0]
args = args[1:]
if command == "status" {
status(args)
} else if command == "register" {
register(args)
} else if command == "tunnel" {
tunnel(args)
} else if command == "ls" {
ls(args)
} else if command == "rm" {
rm(args)
} else if command == "logs" {
logs(args)
} else {
fmt.Printf(`
Unknown command '%s'.
`, command)
displayHelpAndExit("")
}
}
func displayHelpAndExit(topic string) {
defaultHelp := `
Usage: greenhouse COMMAND [...]
Commands: status, register, tunnel, ls, rm, logs
To read the instructions for a specific command, you can run for example:
greenhouse help register
greenhouse tunnel -h
greenhouse ls --help
Advanced users may wish to configure this cli with environment variables.
For instructions on how to do that, run 'greenhouse help config'
`
helpTopics := map[string]string{
"status": `
Usage: greenhouse status
Display the current status of the greenhouse daemon and the registered greenhouse account, if any.
`,
"register": `
Usage: greenhouse register GREENHOUSE_API_TOKEN [SERVER_NAME]
Registers this computer as a server.
You must register first before you can run any other commands, but you only have to do it once.
If a SERVER_NAME is not provided, it will use your computer's hostname as a default.
e.g:
greenhouse register 09hBjaaEXAMPLETOKENaa8f
greenhouse register 09hBjaaEXAMPLETOKENaa8f my_cool_server
`,
"ls": `
Usage: greenhouse ls [LS_OPTIONS]...
List tunnels configured for your account. Only displays tunnels directed at *this* server by default.
Valid options are:
--json to output tunnels information in json format.
e.g:
greenhouse ls
greenhouse ls --json
`,
"rm": `
Usage: greenhouse rm LISTEN_URL [...]
Remove one or more tunnels by LISTEN_URL.
Specifying "all" will remove all tunnels.
e.g:
greenhouse rm https://www.cli-user.greenhouseusers.com
greenhouse rm https://www.cli-user.greenhouseusers.com https://files.cli-user.greenhouseusers.com
greenhouse rm all
`,
"tunnel": `
Usage: greenhouse tunnel LISTEN_URL to LOCAL_URL
OR: greenhouse tunnel --json '{...}'
Open a new tunnel.
LISTEN_URL supports the protocol schemes https://, http://, tls://, and tcp://.
LOCAL_URL supports the protocol schemes http://, tcp://, and file://
For details on the format that --json accepts, run 'greenhouse help json'
e.g:
greenhouse tunnel https://www.cli-user.greenhouseusers.com to http://localhost:80
greenhouse tunnel https://files.cli-user.greenhouseusers.com to file:///home/cli-user/Public
greenhouse tunnel tcp://:10014 to tcp://localhost:22
greenhouse tunnel --json '{"domain": "cli-user.greenhouseusers.com", "public_port": 443, ...}'
`,
"logs": `
Usage: greenhouse logs SOURCE [OPTIONS]
Display the logs from the Greenhouse Daemon and its two underlying services, threshold and caddy.
SOURCE must be 'daemon', 'threshold', 'caddy', or 'all'
Valid options are:
-n <number> to only display the most recent <number> lines from the log.
-f to follow the logs as they are written in real time.
e.g:
greenhouse logs daemon
greenhouse logs ALL -n 100
greenhouse logs caddy -f
`,
"config": `
This greenhouse CLI accepts the following configuration environment or shell variables:
GREENHOUSE_CLI_DAEMON_HOSTNAME
The IP address or hostname of the machine that the Greenhouse Daemon is running on.
Default value: localhost
GREENHOUSE_CLI_DAEMON_PORT
The port number that the greenhouse daemon is listening on.
Default value: 9572
GREENHOUSE_CLI_DAEMON_CA_FILE
File path of a pem-encoded CA certificate to authenticate the greenhouse-daemon's TLS certificate.
Default value: null
GREENHOUSE_CLI_DAEMON_UNIX_SOCKET
File path of the unix socket that the Greenhouse Daemon is listening on (an alternative to hostname and port)
Default value: null
If you want to set one or more of these variables, you may either set them in your computer's
environment variables somehow, or set them as shell variables when you run the command, like this:
GREENHOUSE_CLI_DAEMON_UNIX_SOCKET=/var/run/greenhouse-daemon.sock greenhouse register ...
`,
"json": `
Here is an example of the json object used to represent a tunnel:
{
"protocol": "https",
"has_subdomain": true,
"subdomain": "test",
"domain": "cli-user.greenhouseusers.com",
"public_port": 443,
"destination_type": "local_port",
"destination_hostname": "127.0.0.1",
"destination_port": 80,
"destination_folder_path": ""
}
The allowed values for protocol are https, tls, and tcp
The tcp protocol does not require a domain, however it does require
a public_port within the port range allocated to your greenhouse account.
The tls and https protocols can be used with any port number as long as
the specified domain is authorized on your greenhouse account.
The only difference between tls and https: https will also open up a
secondary tunnel on port 80, to be used for http -> https redirects.
The allowed values for destination_type are local_port, host_port, and folder
The folder destination type requires a destination_folder_path & only works
with the https protocol.
`,
}
topicHelp, hasTopicHelp := helpTopics[strings.ToLower(topic)]
if topic == "" {
fmt.Println(defaultHelp)
} else if hasTopicHelp {
fmt.Println(topicHelp)
} else {
fmt.Printf(`
No help information was found for the topic '%s'.
`, topic)
fmt.Println(defaultHelp)
os.Exit(1)
return
}
os.Exit(0)
}
func saveConfiguration(tunnels []GUITunnel) {
requestBodyBytes, err := json.Marshal(tunnels)
if err != nil {
fmt.Printf(`
Internal JSON Serialization Error: %s
`, err)
os.Exit(1)
return
}
applyURL := fmt.Sprintf("%s/apply_config", baseURL)
response, err := httpClient.Post(applyURL, "application/json", bytes.NewBuffer(requestBodyBytes))
if err != nil {
fmt.Printf("\nCouldn't reach greenhouse daemon at %s: %s\n\n", baseURL, err)
os.Exit(1)
return
}
responseBytes, err := ioutil.ReadAll(response.Body)
responseString := "http read error, failed to read response from greenhouse"
if err == nil {
responseString = string(responseBytes)
}
if response.StatusCode != 200 {
fmt.Printf("\nPOST %s returned HTTP %d:\n%s\n\n", applyURL, response.StatusCode, responseString)
os.Exit(1)
return
}
fmt.Println("\nNow applying new tunnel configuration...\n")
doneApplying := false
pollingDuration := time.Millisecond * 300
previousApplyConfigStatusIndex := 0
for !doneApplying {
status, err := getStatus(false)
if err != nil {
fmt.Printf("\nError polling the daemon: %s\n\n", err)
time.Sleep(pollingDuration)
continue
}
if status.ApplyConfigStatusError != "" {
fmt.Printf("\nError applying configuration: %s\n\n", status.ApplyConfigStatusError)
os.Exit(1)
return
}
if previousApplyConfigStatusIndex != status.ApplyConfigStatusIndex {
for i := previousApplyConfigStatusIndex; i < status.ApplyConfigStatusIndex && i < len(status.ApplyConfigStatuses); i++ {
fmt.Printf(" - %s\n", status.ApplyConfigStatuses[i])
}
previousApplyConfigStatusIndex = status.ApplyConfigStatusIndex
}
if previousApplyConfigStatusIndex >= len(status.ApplyConfigStatuses) {
doneApplying = true
}
time.Sleep(pollingDuration)
}
}
func tunnelListenURL(tunnel *GUITunnel) string {
domain := tunnel.Domain
if tunnel.HasSubdomain {
domain = fmt.Sprintf("%s.%s", tunnel.Subdomain, tunnel.Domain)
}
if tunnel.Protocol != "https" || tunnel.PublicPort != 443 {
domain = fmt.Sprintf("%s:%d", tunnel.PublicPort)
}
return fmt.Sprintf("%s://%s", tunnel.Protocol, domain)
}
func getStatus(updateTenantInfo bool) (*DaemonStatus, error) {
updateTenantInfoString := ""
if updateTenantInfo {
updateTenantInfoString = "?updateTenantInfo=true"
}
statusURL := fmt.Sprintf("%s/status%s", baseURL, updateTenantInfoString)
response, err := httpClient.Get(statusURL)
if err != nil {
return nil, errors.Wrapf(err, "Couldn't reach greenhouse daemon at %s: %s", baseURL, err)
}
responseBytes, err := ioutil.ReadAll(response.Body)
responseString := "http read error, failed to read response from greenhouse"
if err == nil {
responseString = string(responseBytes)
}
if response.StatusCode != 200 {
return nil, errors.Errorf("GET %s returned HTTP %d:\n%s", statusURL, response.StatusCode, responseString)
}
var status DaemonStatus
err = json.Unmarshal(responseBytes, &status)
if err != nil {
return nil, errors.Wrapf(err, "The response from the daemon at %s could not be parsed.\nResponse text:\n%s\n\nError was: ", baseURL, responseString)
}
return &status, nil
}
func yesOrNoConfirmation(prompt string) bool {
fmt.Printf("\n%s (y or n): ", prompt)
var answer *bool = nil
for answer == nil {
var answerString string
fmt.Scanf("%s", &answerString)
answerString = strings.ToLower(answerString)
if answerString == "y" || answerString == "yes" {
a := true
answer = &a
} else if answerString == "n" || answerString == "no" {
a := false
answer = &a
} else {
fmt.Printf("\nYou answered '%s', but I don't know what that means.\n\n%s (y or n): ", prompt)
}
}
return *answer
}
func CreateUnixTransport(socketFile string) *http.Transport {
return &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketFile)
},
}
}
// This code was lifted from the golang standard library so I could add proper error handling / reporting to it.
func AppendCertsFromPEM(s *x509.CertPool, pemCerts []byte) (ok bool, errorz []error) {
errorz = []error{}
for len(pemCerts) > 0 {
var block *pem.Block
block, pemCerts = pem.Decode(pemCerts)
if block == nil {
errorz = append(errorz, errors.New("pem.Decode() was not able to find any valid PEM blocks in this file."))
break
}
if block.Type != "CERTIFICATE" || len(block.Headers) != 0 {
errorz = append(errorz, errors.New("a PEM block was skipped because it was not a certificate or did not contain any headers."))
continue
}
certBytes := block.Bytes
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
errorz = append(errorz, errors.Wrap(err, "parsing a PEM block as a Certificate failed because: "))
continue
}
s.AddCert(cert)
ok = true
}
return ok, errorz
}
// https://gist.github.com/laurent22/8025413
func levenshteinDistance(s string, t string) int {
s = strings.ToLower(s)
t = strings.ToLower(t)
if s == t {
return 0
}
if len(s) == 0 {
return len(t)
}
if len(t) == 0 {
return len(s)
}
v0 := make([]int, len(t)+1)
v1 := make([]int, len(t)+1)
for i := 0; i < len(v0); i++ {
v0[i] = i
}
for i := 0; i < len(s); i++ {
v1[0] = i + 1
for j := 0; j < len(t); j++ {
var cost int
if s[i] == t[j] {
cost = 0
} else {
cost = 1
}
v1[j+1] = int(math.Min(float64(v1[j]+1), math.Min(float64(v0[j+1]+1), float64(v0[j]+cost))))
}
for j := 0; j < len(v0); j++ {
v0[j] = v1[j]
}
}
return v1[len(t)]
}