TUN-2928, TUN-2929, TUN-2930: Add tunnel subcommands to interact with tunnel store service

This commit is contained in:
Igor Postelnik 2020-05-21 15:36:49 -05:00
parent 7a77ead423
commit a908453aa4
5 changed files with 412 additions and 14 deletions

View File

@ -1,12 +1,35 @@
package cliutil package cliutil
import "gopkg.in/urfave/cli.v2" import (
"fmt"
"gopkg.in/urfave/cli.v2"
)
type usageError string
func (ue usageError) Error() string {
return string(ue)
}
func UsageError(format string, args ...interface{}) error {
if len(args) == 0 {
return usageError(format)
} else {
msg := fmt.Sprintf(format, args...)
return usageError(msg)
}
}
// Ensures exit with error code if actionFunc returns an error // Ensures exit with error code if actionFunc returns an error
func ErrorHandler(actionFunc cli.ActionFunc) cli.ActionFunc { func ErrorHandler(actionFunc cli.ActionFunc) cli.ActionFunc {
return func(ctx *cli.Context) error { return func(ctx *cli.Context) error {
err := actionFunc(ctx) err := actionFunc(ctx)
if err != nil { if err != nil {
if _, ok := err.(usageError); ok {
msg := fmt.Sprintf("%s\nSee 'cloudflared %s --help'.", err.Error(), ctx.Command.FullName())
return cli.Exit(msg, -1)
}
// os.Exits with error code if err is cli.ExitCoder or cli.MultiError // os.Exits with error code if err is cli.ExitCoder or cli.MultiError
cli.HandleExitCoder(err) cli.HandleExitCoder(err)
err = cli.Exit(err.Error(), 1) err = cli.Exit(err.Error(), 1)
@ -14,4 +37,3 @@ func ErrorHandler(actionFunc cli.ActionFunc) cli.ActionFunc {
return err return err
} }
} }

View File

@ -168,6 +168,9 @@ func Commands() []*cli.Command {
c.Hidden = false c.Hidden = false
subcommands = append(subcommands, &c) subcommands = append(subcommands, &c)
} }
subcommands = append(subcommands, buildCreateCommand())
subcommands = append(subcommands, buildListCommand())
subcommands = append(subcommands, buildDeleteCommand())
cmds = append(cmds, &cli.Command{ cmds = append(cmds, &cli.Command{
Name: "tunnel", Name: "tunnel",
@ -175,7 +178,7 @@ func Commands() []*cli.Command {
Before: Before, Before: Before,
Category: "Tunnel", Category: "Tunnel",
Usage: "Make a locally-running web service accessible over the internet using Argo Tunnel.", Usage: "Make a locally-running web service accessible over the internet using Argo Tunnel.",
ArgsUsage: "[origin-url]", ArgsUsage: " ",
Description: `Argo Tunnel asks you to specify a hostname on a Cloudflare-powered Description: `Argo Tunnel asks you to specify a hostname on a Cloudflare-powered
domain you control and a local address. Traffic from that hostname is routed domain you control and a local address. Traffic from that hostname is routed
(optionally via a Cloudflare Load Balancer) to this machine and appears on the (optionally via a Cloudflare Load Balancer) to this machine and appears on the
@ -843,6 +846,13 @@ func tunnelFlags(shouldHide bool) []cli.Flag {
EnvVars: []string{"TUNNEL_API_CA_KEY"}, EnvVars: []string{"TUNNEL_API_CA_KEY"},
Hidden: true, Hidden: true,
}), }),
altsrc.NewStringFlag(&cli.StringFlag{
Name: "api-url",
Usage: "Base URL for Cloudflare API v4",
EnvVars: []string{"TUNNEL_API_URL"},
Value: "https://api.cloudflare.com/client/v4",
Hidden: true,
}),
altsrc.NewStringFlag(&cli.StringFlag{ altsrc.NewStringFlag(&cli.StringFlag{
Name: "metrics", Name: "metrics",
Value: "localhost:", Value: "localhost:",

View File

@ -102,27 +102,29 @@ func dnsProxyStandAlone(c *cli.Context) bool {
return c.IsSet("proxy-dns") && (!c.IsSet("hostname") && !c.IsSet("tag") && !c.IsSet("hello-world")) return c.IsSet("proxy-dns") && (!c.IsSet("hostname") && !c.IsSet("tag") && !c.IsSet("hello-world"))
} }
func getOriginCert(c *cli.Context) ([]byte, error) { func findOriginCert(c *cli.Context) (string, error) {
if c.String("origincert") == "" { originCertPath := c.String("origincert")
if originCertPath == "" {
logger.Warnf("Cannot determine default origin certificate path. No file %s in %v", config.DefaultCredentialFile, config.DefaultConfigDirs) logger.Warnf("Cannot determine default origin certificate path. No file %s in %v", config.DefaultCredentialFile, config.DefaultConfigDirs)
if isRunningFromTerminal() { if isRunningFromTerminal() {
logger.Errorf("You need to specify the origin certificate path with --origincert option, or set TUNNEL_ORIGIN_CERT environment variable. See %s for more information.", argumentsUrl) logger.Errorf("You need to specify the origin certificate path with --origincert option, or set TUNNEL_ORIGIN_CERT environment variable. See %s for more information.", argumentsUrl)
return nil, fmt.Errorf("Client didn't specify origincert path when running from terminal") return "", fmt.Errorf("Client didn't specify origincert path when running from terminal")
} else { } else {
logger.Errorf("You need to specify the origin certificate path by specifying the origincert option in the configuration file, or set TUNNEL_ORIGIN_CERT environment variable. See %s for more information.", serviceUrl) logger.Errorf("You need to specify the origin certificate path by specifying the origincert option in the configuration file, or set TUNNEL_ORIGIN_CERT environment variable. See %s for more information.", serviceUrl)
return nil, fmt.Errorf("Client didn't specify origincert path") return "", fmt.Errorf("Client didn't specify origincert path")
} }
} }
// Check that the user has acquired a certificate using the login command var err error
originCertPath, err := homedir.Expand(c.String("origincert")) originCertPath, err = homedir.Expand(originCertPath)
if err != nil { if err != nil {
logger.WithError(err).Errorf("Cannot resolve path %s", c.String("origincert")) logger.WithError(err).Errorf("Cannot resolve path %s", originCertPath)
return nil, fmt.Errorf("Cannot resolve path %s", c.String("origincert")) return "", fmt.Errorf("Cannot resolve path %s", originCertPath)
} }
// Check that the user has acquired a certificate using the login command
ok, err := config.FileExists(originCertPath) ok, err := config.FileExists(originCertPath)
if err != nil { if err != nil {
logger.Errorf("Cannot check if origin cert exists at path %s", c.String("origincert")) logger.Errorf("Cannot check if origin cert exists at path %s", originCertPath)
return nil, fmt.Errorf("Cannot check if origin cert exists at path %s", c.String("origincert")) return "", fmt.Errorf("Cannot check if origin cert exists at path %s", originCertPath)
} }
if !ok { if !ok {
logger.Errorf(`Cannot find a valid certificate for your origin at the path: logger.Errorf(`Cannot find a valid certificate for your origin at the path:
@ -134,8 +136,15 @@ If you don't have a certificate signed by Cloudflare, run the command:
%s login %s login
`, originCertPath, os.Args[0]) `, originCertPath, os.Args[0])
return nil, fmt.Errorf("Cannot find a valid certificate at the path %s", originCertPath) return "", fmt.Errorf("Cannot find a valid certificate at the path %s", originCertPath)
} }
return originCertPath, nil
}
func readOriginCert(originCertPath string) ([]byte, error) {
logger.Debugf("Reading origin cert from %s", originCertPath)
// Easier to send the certificate as []byte via RPC than decoding it at this point // Easier to send the certificate as []byte via RPC than decoding it at this point
originCert, err := ioutil.ReadFile(originCertPath) originCert, err := ioutil.ReadFile(originCertPath)
if err != nil { if err != nil {
@ -145,6 +154,14 @@ If you don't have a certificate signed by Cloudflare, run the command:
return originCert, nil return originCert, nil
} }
func getOriginCert(c *cli.Context) ([]byte, error) {
if originCertPath, err := findOriginCert(c); err != nil {
return nil, err
} else {
return readOriginCert(originCertPath)
}
}
func prepareTunnelConfig( func prepareTunnelConfig(
c *cli.Context, c *cli.Context,
buildInfo *buildinfo.BuildInfo, buildInfo *buildinfo.BuildInfo,

View File

@ -0,0 +1,165 @@
package tunnel
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/pkg/errors"
"gopkg.in/urfave/cli.v2"
"gopkg.in/yaml.v2"
"github.com/cloudflare/cloudflared/certutil"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
"github.com/cloudflare/cloudflared/tunnelstore"
)
var (
outputFormatFlag = &cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Render output using given `FORMAT`. Valid options are 'json' or 'yaml'",
}
)
const hideSubcommands = true
func buildCreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Action: cliutil.ErrorHandler(createTunnel),
Usage: "Create a new tunnel with given name",
ArgsUsage: "TUNNEL-NAME",
Hidden: hideSubcommands,
Flags: []cli.Flag{outputFormatFlag},
}
}
func createTunnel(c *cli.Context) error {
if c.NArg() != 1 {
return cliutil.UsageError(`"cloudflared tunnel create" requires exactly 1 argument, the name of tunnel to create.`)
}
name := c.Args().First()
client, err := newTunnelstoreClient(c)
if err != nil {
return err
}
tunnel, err := client.CreateTunnel(name)
if err != nil {
return errors.Wrap(err, "Error creating a new tunnel")
}
if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" {
return renderOutput(outputFormat, &tunnel)
}
logger.Infof("Created tunnel %s with id %s", tunnel.Name, tunnel.ID)
return nil
}
func buildListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Action: cliutil.ErrorHandler(listTunnels),
Usage: "List existing tunnels",
ArgsUsage: " ",
Hidden: hideSubcommands,
Flags: []cli.Flag{outputFormatFlag},
}
}
func listTunnels(c *cli.Context) error {
client, err := newTunnelstoreClient(c)
if err != nil {
return err
}
tunnels, err := client.ListTunnels()
if err != nil {
return errors.Wrap(err, "Error listing tunnels")
}
if outputFormat := c.String(outputFormatFlag.Name); outputFormat != "" {
return renderOutput(outputFormat, tunnels)
}
if len(tunnels) > 0 {
const listFormat = "%-40s%-40s%s\n"
fmt.Printf(listFormat, "ID", "NAME", "CREATED")
for _, t := range tunnels {
fmt.Printf(listFormat, t.ID, t.Name, t.CreatedAt.Format(time.RFC3339))
}
} else {
fmt.Println("You have no tunnels, use 'cloudflared tunnel create' to define a new tunnel")
}
return nil
}
func buildDeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Action: cliutil.ErrorHandler(deleteTunnel),
Usage: "Delete existing tunnel with given ID",
ArgsUsage: "TUNNEL-ID",
Hidden: hideSubcommands,
}
}
func deleteTunnel(c *cli.Context) error {
if c.NArg() != 1 {
return cliutil.UsageError(`"cloudflared tunnel delete" requires exactly 1 argument, the ID of the tunnel to delete.`)
}
id := c.Args().First()
client, err := newTunnelstoreClient(c)
if err != nil {
return err
}
if err := client.DeleteTunnel(id); err != nil {
return errors.Wrapf(err, "Error deleting tunnel %s", id)
}
return nil
}
func renderOutput(format string, v interface{}) error {
switch format {
case "json":
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(v)
case "yaml":
return yaml.NewEncoder(os.Stdout).Encode(v)
default:
return errors.Errorf("Unknown output format '%s'", format)
}
}
func newTunnelstoreClient(c *cli.Context) (tunnelstore.Client, error) {
originCertPath, err := findOriginCert(c)
if err != nil {
return nil, errors.Wrap(err, "Error locating origin cert")
}
blocks, err := readOriginCert(originCertPath)
if err != nil {
return nil, errors.Wrapf(err, "Can't read origin cert from %s", originCertPath)
}
cert, err := certutil.DecodeOriginCert(blocks)
if err != nil {
return nil, errors.Wrap(err, "Error decoding origin cert")
}
if cert.AccountID == "" {
return nil, errors.Errorf(`Origin certificate needs to be refreshed before creating new tunnels.\nDelete %s and run "cloudflared login" to obtain a new cert.`, originCertPath)
}
client := tunnelstore.NewRESTClient(c.String("api-url"), cert.AccountID, cert.ServiceKey)
return client, nil
}

184
tunnelstore/client.go Normal file
View File

@ -0,0 +1,184 @@
package tunnelstore
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
const (
defaultTimeout = 15 * time.Second
jsonContentType = "application/json"
)
var (
ErrTunnelNameConflict = errors.New("tunnel with name already exists")
ErrUnauthorized = errors.New("unauthorized")
ErrBadRequest = errors.New("incorrect request parameters")
ErrNotFound = errors.New("not found")
)
type Tunnel struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type Client interface {
CreateTunnel(name string) (*Tunnel, error)
GetTunnel(id string) (*Tunnel, error)
DeleteTunnel(id string) error
ListTunnels() ([]Tunnel, error)
}
type RESTClient struct {
baseURL string
authToken string
client http.Client
}
var _ Client = (*RESTClient)(nil)
func NewRESTClient(baseURL string, accountTag string, authToken string) *RESTClient {
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
url := fmt.Sprintf("%s/accounts/%s/tunnels", baseURL, accountTag)
return &RESTClient{
baseURL: url,
authToken: authToken,
client: http.Client{
Transport: &http.Transport{
TLSHandshakeTimeout: defaultTimeout,
ResponseHeaderTimeout: defaultTimeout,
},
Timeout: defaultTimeout,
},
}
}
type newTunnel struct {
Name string `json:"name"`
}
func (r *RESTClient) CreateTunnel(name string) (*Tunnel, error) {
if name == "" {
return nil, errors.New("tunnel name required")
}
body, err := json.Marshal(&newTunnel{
Name: name,
})
if err != nil {
return nil, errors.Wrap(err, "Failed to serialize new tunnel request")
}
resp, err := r.sendRequest("POST", "", bytes.NewBuffer(body))
if err != nil {
return nil, errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return unmarshalTunnel(resp.Body)
case http.StatusConflict:
return nil, ErrTunnelNameConflict
}
return nil, statusCodeToError("create", resp)
}
func (r *RESTClient) GetTunnel(id string) (*Tunnel, error) {
resp, err := r.sendRequest("GET", id, nil)
if err != nil {
return nil, errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return unmarshalTunnel(resp.Body)
}
return nil, statusCodeToError("read", resp)
}
func (r *RESTClient) DeleteTunnel(id string) error {
resp, err := r.sendRequest("DELETE", id, nil)
if err != nil {
return errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
return statusCodeToError("delete", resp)
}
func (r *RESTClient) ListTunnels() ([]Tunnel, error) {
resp, err := r.sendRequest("GET", "", nil)
if err != nil {
return nil, errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var tunnels []Tunnel
if err := json.NewDecoder(resp.Body).Decode(&tunnels); err != nil {
return nil, errors.Wrap(err, "failed to decode response")
}
return tunnels, nil
}
return nil, statusCodeToError("list", resp)
}
func (r *RESTClient) resolve(target string) string {
if target != "" {
return r.baseURL + "/" + target
}
return r.baseURL
}
func (r *RESTClient) sendRequest(method string, target string, body io.Reader) (*http.Response, error) {
url := r.resolve(target)
logrus.Debugf("%s %s", method, url)
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, errors.Wrapf(err, "can't create %s request", method)
}
if body != nil {
req.Header.Set("Content-Type", jsonContentType)
}
req.Header.Add("X-Auth-User-Service-Key", r.authToken)
return r.client.Do(req)
}
func unmarshalTunnel(reader io.Reader) (*Tunnel, error) {
var tunnel Tunnel
if err := json.NewDecoder(reader).Decode(&tunnel); err != nil {
return nil, errors.Wrap(err, "failed to decode response")
}
return &tunnel, nil
}
func statusCodeToError(op string, resp *http.Response) error {
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusBadRequest:
return ErrBadRequest
case http.StatusUnauthorized, http.StatusForbidden:
return ErrUnauthorized
case http.StatusNotFound:
return ErrNotFound
}
return errors.Errorf("API call to %s tunnel failed with status %d: %s", op,
resp.StatusCode, http.StatusText(resp.StatusCode))
}