|
|
|
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)]
|
|
|
|
}
|