cloudflare/cloudflared

Public

mirrored from https://github.com/cloudflare/cloudflaredAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
2021.12.4

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

cmd/cloudflared/access/cmd.go

524lines · modecode

1package access
2
3import (
4 "fmt"
5 "io"
6 "net/http"
7 "net/url"
8 "os"
9 "os/exec"
10 "strings"
11 "text/template"
12 "time"
13
14 "github.com/getsentry/raven-go"
15 "github.com/pkg/errors"
16 "github.com/rs/zerolog"
17 "github.com/urfave/cli/v2"
18 "golang.org/x/net/idna"
19
20 "github.com/cloudflare/cloudflared/carrier"
21 "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
22 "github.com/cloudflare/cloudflared/logger"
23 "github.com/cloudflare/cloudflared/sshgen"
24 "github.com/cloudflare/cloudflared/token"
25 "github.com/cloudflare/cloudflared/validation"
26)
27
28const (
29 sshHostnameFlag = "hostname"
30 sshDestinationFlag = "destination"
31 sshURLFlag = "url"
32 sshHeaderFlag = "header"
33 sshTokenIDFlag = "service-token-id"
34 sshTokenSecretFlag = "service-token-secret"
35 sshGenCertFlag = "short-lived-cert"
36 sshConnectTo = "connect-to"
37 sshConfigTemplate = `
38Add to your {{.Home}}/.ssh/config:
39
40Host {{.Hostname}}
41{{- if .ShortLivedCerts}}
42 ProxyCommand bash -c '{{.Cloudflared}} access ssh-gen --hostname %h; ssh -tt %r@cfpipe-{{.Hostname}} >&2 <&1'
43
44Host cfpipe-{{.Hostname}}
45 HostName {{.Hostname}}
46 ProxyCommand {{.Cloudflared}} access ssh --hostname %h
47 IdentityFile ~/.cloudflared/{{.Hostname}}-cf_key
48 CertificateFile ~/.cloudflared/{{.Hostname}}-cf_key-cert.pub
49{{- else}}
50 ProxyCommand {{.Cloudflared}} access ssh --hostname %h
51{{end}}
52`
53)
54
55const sentryDSN = "https://56a9c9fa5c364ab28f34b14f35ea0f1b@sentry.io/189878"
56
57var (
58 shutdownC chan struct{}
59)
60
61// Init will initialize and store vars from the main program
62func Init(shutdown chan struct{}) {
63 shutdownC = shutdown
64}
65
66// Flags return the global flags for Access related commands (hopefully none)
67func Flags() []cli.Flag {
68 return []cli.Flag{} // no flags yet.
69}
70
71// Commands returns all the Access related subcommands
72func Commands() []*cli.Command {
73 return []*cli.Command{
74 {
75 Name: "access",
76 Aliases: []string{"forward"},
77 Category: "Access",
78 Usage: "access <subcommand>",
79 Description: `Cloudflare Access protects internal resources by securing, authenticating and monitoring access
80 per-user and by application. With Cloudflare Access, only authenticated users with the required permissions are
81 able to reach sensitive resources. The commands provided here allow you to interact with Access protected
82 applications from the command line.`,
83 Subcommands: []*cli.Command{
84 {
85 Name: "login",
86 Action: cliutil.Action(login),
87 Usage: "login <url of access application>",
88 Description: `The login subcommand initiates an authentication flow with your identity provider.
89 The subcommand will launch a browser. For headless systems, a url is provided.
90 Once authenticated with your identity provider, the login command will generate a JSON Web Token (JWT)
91 scoped to your identity, the application you intend to reach, and valid for a session duration set by your
92 administrator. cloudflared stores the token in local storage.`,
93 },
94 {
95 Name: "curl",
96 Action: cliutil.Action(curl),
97 Usage: "curl [--allow-request, -ar] <url> [<curl args>...]",
98 Description: `The curl subcommand wraps curl and automatically injects the JWT into a cf-access-token
99 header when using curl to reach an application behind Access.`,
100 ArgsUsage: "allow-request will allow the curl request to continue even if the jwt is not present.",
101 SkipFlagParsing: true,
102 },
103 {
104 Name: "token",
105 Action: cliutil.Action(generateToken),
106 Usage: "token -app=<url of access application>",
107 ArgsUsage: "url of Access application",
108 Description: `The token subcommand produces a JWT which can be used to authenticate requests.`,
109 Flags: []cli.Flag{
110 &cli.StringFlag{
111 Name: "app",
112 },
113 },
114 },
115 {
116 Name: "tcp",
117 Action: cliutil.Action(ssh),
118 Aliases: []string{"rdp", "ssh", "smb"},
119 Usage: "",
120 ArgsUsage: "",
121 Description: `The tcp subcommand sends data over a proxy to the Cloudflare edge.`,
122 Flags: []cli.Flag{
123 &cli.StringFlag{
124 Name: sshHostnameFlag,
125 Aliases: []string{"tunnel-host", "T"},
126 Usage: "specify the hostname of your application.",
127 },
128 &cli.StringFlag{
129 Name: sshDestinationFlag,
130 Usage: "specify the destination address of your SSH server.",
131 },
132 &cli.StringFlag{
133 Name: sshURLFlag,
134 Aliases: []string{"listener", "L"},
135 Usage: "specify the host:port to forward data to Cloudflare edge.",
136 },
137 &cli.StringSliceFlag{
138 Name: sshHeaderFlag,
139 Aliases: []string{"H"},
140 Usage: "specify additional headers you wish to send.",
141 },
142 &cli.StringFlag{
143 Name: sshTokenIDFlag,
144 Aliases: []string{"id"},
145 Usage: "specify an Access service token ID you wish to use.",
146 EnvVars: []string{"TUNNEL_SERVICE_TOKEN_ID"},
147 },
148 &cli.StringFlag{
149 Name: sshTokenSecretFlag,
150 Aliases: []string{"secret"},
151 Usage: "specify an Access service token secret you wish to use.",
152 EnvVars: []string{"TUNNEL_SERVICE_TOKEN_SECRET"},
153 },
154 &cli.StringFlag{
155 Name: logger.LogSSHDirectoryFlag,
156 Aliases: []string{"logfile"}, //added to match the tunnel side
157 Usage: "Save application log to this directory for reporting issues.",
158 },
159 &cli.StringFlag{
160 Name: logger.LogSSHLevelFlag,
161 Aliases: []string{"loglevel"}, //added to match the tunnel side
162 Usage: "Application logging level {debug, info, warn, error, fatal}. ",
163 },
164 &cli.StringFlag{
165 Name: sshConnectTo,
166 Hidden: true,
167 Usage: "Connect to alternate location for testing, value is host, host:port, or sni:port:host",
168 },
169 },
170 },
171 {
172 Name: "ssh-config",
173 Action: cliutil.Action(sshConfig),
174 Usage: "",
175 Description: `Prints an example configuration ~/.ssh/config`,
176 Flags: []cli.Flag{
177 &cli.StringFlag{
178 Name: sshHostnameFlag,
179 Usage: "specify the hostname of your application.",
180 },
181 &cli.BoolFlag{
182 Name: sshGenCertFlag,
183 Usage: "specify if you wish to generate short lived certs.",
184 },
185 },
186 },
187 {
188 Name: "ssh-gen",
189 Action: cliutil.Action(sshGen),
190 Usage: "",
191 Description: `Generates a short lived certificate for given hostname`,
192 Flags: []cli.Flag{
193 &cli.StringFlag{
194 Name: sshHostnameFlag,
195 Usage: "specify the hostname of your application.",
196 },
197 },
198 },
199 },
200 },
201 }
202}
203
204// login pops up the browser window to do the actual login and JWT generation
205func login(c *cli.Context) error {
206 if err := raven.SetDSN(sentryDSN); err != nil {
207 return err
208 }
209
210 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
211
212 args := c.Args()
213 rawURL := ensureURLScheme(args.First())
214 appURL, err := url.Parse(rawURL)
215 if args.Len() < 1 || err != nil {
216 log.Error().Msg("Please provide the url of the Access application")
217 return err
218 }
219
220 appInfo, err := token.GetAppInfo(appURL)
221 if err != nil {
222 return err
223 }
224
225 if err := verifyTokenAtEdge(appURL, appInfo, c, log); err != nil {
226 log.Err(err).Msg("Could not verify token")
227 return err
228 }
229
230 cfdToken, err := token.GetAppTokenIfExists(appInfo)
231 if err != nil {
232 fmt.Fprintln(os.Stderr, "Unable to find token for provided application.")
233 return err
234 } else if cfdToken == "" {
235 fmt.Fprintln(os.Stderr, "token for provided application was empty.")
236 return errors.New("empty application token")
237 }
238 fmt.Fprintf(os.Stdout, "Successfully fetched your token:\n\n%s\n\n", cfdToken)
239
240 return nil
241}
242
243// ensureURLScheme prepends a URL with https:// if it doesn't have a scheme. http:// URLs will not be converted.
244func ensureURLScheme(url string) string {
245 url = strings.Replace(strings.ToLower(url), "http://", "https://", 1)
246 if !strings.HasPrefix(url, "https://") {
247 url = fmt.Sprintf("https://%s", url)
248
249 }
250 return url
251}
252
253// curl provides a wrapper around curl, passing Access JWT along in request
254func curl(c *cli.Context) error {
255 if err := raven.SetDSN(sentryDSN); err != nil {
256 return err
257 }
258 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
259
260 args := c.Args()
261 if args.Len() < 1 {
262 log.Error().Msg("Please provide the access app and command you wish to run.")
263 return errors.New("incorrect args")
264 }
265
266 cmdArgs, allowRequest := parseAllowRequest(args.Slice())
267 appURL, err := getAppURL(cmdArgs, log)
268 if err != nil {
269 return err
270 }
271
272 appInfo, err := token.GetAppInfo(appURL)
273 if err != nil {
274 return err
275 }
276 tok, err := token.GetAppTokenIfExists(appInfo)
277 if err != nil || tok == "" {
278 if allowRequest {
279 log.Info().Msg("You don't have an Access token set. Please run access token <access application> to fetch one.")
280 return run("curl", cmdArgs...)
281 }
282 tok, err = token.FetchToken(appURL, appInfo, log)
283 if err != nil {
284 log.Err(err).Msg("Failed to refresh token")
285 return err
286 }
287 }
288
289 cmdArgs = append(cmdArgs, "-H")
290 cmdArgs = append(cmdArgs, fmt.Sprintf("%s: %s", carrier.CFAccessTokenHeader, tok))
291 return run("curl", cmdArgs...)
292}
293
294// run kicks off a shell task and pipe the results to the respective std pipes
295func run(cmd string, args ...string) error {
296 c := exec.Command(cmd, args...)
297 stderr, err := c.StderrPipe()
298 if err != nil {
299 return err
300 }
301 go func() {
302 io.Copy(os.Stderr, stderr)
303 }()
304
305 stdout, err := c.StdoutPipe()
306 if err != nil {
307 return err
308 }
309 go func() {
310 io.Copy(os.Stdout, stdout)
311 }()
312 return c.Run()
313}
314
315// token dumps provided token to stdout
316func generateToken(c *cli.Context) error {
317 if err := raven.SetDSN(sentryDSN); err != nil {
318 return err
319 }
320 appURL, err := url.Parse(ensureURLScheme(c.String("app")))
321 if err != nil || c.NumFlags() < 1 {
322 fmt.Fprintln(os.Stderr, "Please provide a url.")
323 return err
324 }
325
326 appInfo, err := token.GetAppInfo(appURL)
327 if err != nil {
328 return err
329 }
330 tok, err := token.GetAppTokenIfExists(appInfo)
331 if err != nil || tok == "" {
332 fmt.Fprintln(os.Stderr, "Unable to find token for provided application. Please run login command to generate token.")
333 return err
334 }
335
336 if _, err := fmt.Fprint(os.Stdout, tok); err != nil {
337 fmt.Fprintln(os.Stderr, "Failed to write token to stdout.")
338 return err
339 }
340 return nil
341}
342
343// sshConfig prints an example SSH config to stdout
344func sshConfig(c *cli.Context) error {
345 genCertBool := c.Bool(sshGenCertFlag)
346 hostname := c.String(sshHostnameFlag)
347 if hostname == "" {
348 hostname = "[your hostname]"
349 }
350
351 type config struct {
352 Home string
353 ShortLivedCerts bool
354 Hostname string
355 Cloudflared string
356 }
357
358 t := template.Must(template.New("sshConfig").Parse(sshConfigTemplate))
359 return t.Execute(os.Stdout, config{Home: os.Getenv("HOME"), ShortLivedCerts: genCertBool, Hostname: hostname, Cloudflared: cloudflaredPath()})
360}
361
362// sshGen generates a short lived certificate for provided hostname
363func sshGen(c *cli.Context) error {
364 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
365
366 // get the hostname from the cmdline and error out if its not provided
367 rawHostName := c.String(sshHostnameFlag)
368 hostname, err := validation.ValidateHostname(rawHostName)
369 if err != nil || rawHostName == "" {
370 return cli.ShowCommandHelp(c, "ssh-gen")
371 }
372
373 originURL, err := url.Parse(ensureURLScheme(hostname))
374 if err != nil {
375 return err
376 }
377
378 // this fetchToken function mutates the appURL param. We should refactor that
379 fetchTokenURL := &url.URL{}
380 *fetchTokenURL = *originURL
381
382 appInfo, err := token.GetAppInfo(fetchTokenURL)
383 if err != nil {
384 return err
385 }
386 cfdToken, err := token.FetchTokenWithRedirect(fetchTokenURL, appInfo, log)
387 if err != nil {
388 return err
389 }
390
391 if err := sshgen.GenerateShortLivedCertificate(originURL, cfdToken); err != nil {
392 return err
393 }
394
395 return nil
396}
397
398// getAppURL will pull the request URL needed for fetching a user's Access token
399func getAppURL(cmdArgs []string, log *zerolog.Logger) (*url.URL, error) {
400 if len(cmdArgs) < 1 {
401 log.Error().Msg("Please provide a valid URL as the first argument to curl.")
402 return nil, errors.New("not a valid url")
403 }
404
405 u, err := processURL(cmdArgs[0])
406 if err != nil {
407 log.Error().Msg("Please provide a valid URL as the first argument to curl.")
408 return nil, err
409 }
410
411 return u, err
412}
413
414// parseAllowRequest will parse cmdArgs and return a copy of the args and result
415// of the allow request was present
416func parseAllowRequest(cmdArgs []string) ([]string, bool) {
417 if len(cmdArgs) > 1 {
418 if cmdArgs[0] == "--allow-request" || cmdArgs[0] == "-ar" {
419 return cmdArgs[1:], true
420 }
421 }
422
423 return cmdArgs, false
424}
425
426// processURL will preprocess the string (parse to a url, convert to punycode, etc).
427func processURL(s string) (*url.URL, error) {
428 u, err := url.ParseRequestURI(s)
429 if err != nil {
430 return nil, err
431 }
432
433 if u.Host == "" {
434 return nil, errors.New("not a valid host")
435 }
436
437 host, err := idna.ToASCII(u.Hostname())
438 if err != nil { // we fail to convert to punycode, just return the url we parsed.
439 return u, nil
440 }
441 if u.Port() != "" {
442 u.Host = fmt.Sprintf("%s:%s", host, u.Port())
443 } else {
444 u.Host = host
445 }
446
447 return u, nil
448}
449
450// cloudflaredPath pulls the full path of cloudflared on disk
451func cloudflaredPath() string {
452 for _, p := range strings.Split(os.Getenv("PATH"), ":") {
453 path := fmt.Sprintf("%s/%s", p, "cloudflared")
454 if isFileThere(path) {
455 return path
456 }
457 }
458 return "cloudflared"
459}
460
461// isFileThere will check for the presence of candidate path
462func isFileThere(candidate string) bool {
463 fi, err := os.Stat(candidate)
464 if err != nil || fi.IsDir() || !fi.Mode().IsRegular() {
465 return false
466 }
467 return true
468}
469
470// verifyTokenAtEdge checks for a token on disk, or generates a new one.
471// Then makes a request to to the origin with the token to ensure it is valid.
472// Returns nil if token is valid.
473func verifyTokenAtEdge(appUrl *url.URL, appInfo *token.AppInfo, c *cli.Context, log *zerolog.Logger) error {
474 headers := buildRequestHeaders(c.StringSlice(sshHeaderFlag))
475 if c.IsSet(sshTokenIDFlag) {
476 headers.Add(cfAccessClientIDHeader, c.String(sshTokenIDFlag))
477 }
478 if c.IsSet(sshTokenSecretFlag) {
479 headers.Add(cfAccessClientSecretHeader, c.String(sshTokenSecretFlag))
480 }
481 options := &carrier.StartOptions{AppInfo: appInfo, OriginURL: appUrl.String(), Headers: headers}
482
483 if valid, err := isTokenValid(options, log); err != nil {
484 return err
485 } else if valid {
486 return nil
487 }
488
489 if err := token.RemoveTokenIfExists(appInfo); err != nil {
490 return err
491 }
492
493 if valid, err := isTokenValid(options, log); err != nil {
494 return err
495 } else if !valid {
496 return errors.New("failed to verify token")
497 }
498
499 return nil
500}
501
502// isTokenValid makes a request to the origin and returns true if the response was not a 302.
503func isTokenValid(options *carrier.StartOptions, log *zerolog.Logger) (bool, error) {
504 req, err := carrier.BuildAccessRequest(options, log)
505 if err != nil {
506 return false, errors.Wrap(err, "Could not create access request")
507 }
508
509 // Do not follow redirects
510 client := &http.Client{
511 CheckRedirect: func(req *http.Request, via []*http.Request) error {
512 return http.ErrUseLastResponse
513 },
514 Timeout: time.Second * 5,
515 }
516 resp, err := client.Do(req)
517 if err != nil {
518 return false, err
519 }
520 defer resp.Body.Close()
521
522 // A redirect to login means the token was invalid.
523 return !carrier.IsAccessResponse(resp), nil
524}
525