cloudflare/cloudflared

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
2023.10.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

cmd/cloudflared/access/cmd.go

546lines · 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/sentry-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 sshDebugStream = "debug-stream"
38 sshConfigTemplate = `
39Add to your {{.Home}}/.ssh/config:
40
41{{- if .ShortLivedCerts}}
42Match host {{.Hostname}} exec "{{.Cloudflared}} access ssh-gen --hostname %h"
43 ProxyCommand {{.Cloudflared}} access ssh --hostname %h
44 IdentityFile ~/.cloudflared/%h-cf_key
45 CertificateFile ~/.cloudflared/%h-cf_key-cert.pub
46{{- else}}
47Host {{.Hostname}}
48 ProxyCommand {{.Cloudflared}} access ssh --hostname %h
49{{end}}
50`
51)
52
53const sentryDSN = "https://56a9c9fa5c364ab28f34b14f35ea0f1b@sentry.io/189878"
54
55var (
56 shutdownC chan struct{}
57 userAgent = "DEV"
58)
59
60// Init will initialize and store vars from the main program
61func Init(shutdown chan struct{}, version string) {
62 shutdownC = shutdown
63 userAgent = fmt.Sprintf("cloudflared/%s", version)
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.LogFileFlag,
156 Usage: "Save application log to this file for reporting issues.",
157 },
158 &cli.StringFlag{
159 Name: logger.LogSSHDirectoryFlag,
160 Usage: "Save application log to this directory for reporting issues.",
161 },
162 &cli.StringFlag{
163 Name: logger.LogSSHLevelFlag,
164 Aliases: []string{"loglevel"}, //added to match the tunnel side
165 Usage: "Application logging level {debug, info, warn, error, fatal}. ",
166 },
167 &cli.StringFlag{
168 Name: sshConnectTo,
169 Hidden: true,
170 Usage: "Connect to alternate location for testing, value is host, host:port, or sni:port:host",
171 },
172 &cli.Uint64Flag{
173 Name: sshDebugStream,
174 Hidden: true,
175 Usage: "Writes up-to the max provided stream payloads to the logger as debug statements.",
176 },
177 },
178 },
179 {
180 Name: "ssh-config",
181 Action: cliutil.Action(sshConfig),
182 Usage: "",
183 Description: `Prints an example configuration ~/.ssh/config`,
184 Flags: []cli.Flag{
185 &cli.StringFlag{
186 Name: sshHostnameFlag,
187 Usage: "specify the hostname of your application.",
188 },
189 &cli.BoolFlag{
190 Name: sshGenCertFlag,
191 Usage: "specify if you wish to generate short lived certs.",
192 },
193 },
194 },
195 {
196 Name: "ssh-gen",
197 Action: cliutil.Action(sshGen),
198 Usage: "",
199 Description: `Generates a short lived certificate for given hostname`,
200 Flags: []cli.Flag{
201 &cli.StringFlag{
202 Name: sshHostnameFlag,
203 Usage: "specify the hostname of your application.",
204 },
205 },
206 },
207 },
208 },
209 }
210}
211
212// login pops up the browser window to do the actual login and JWT generation
213func login(c *cli.Context) error {
214 err := sentry.Init(sentry.ClientOptions{
215 Dsn: sentryDSN,
216 Release: c.App.Version,
217 })
218 if err != nil {
219 return err
220 }
221
222 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
223
224 args := c.Args()
225 appURL, err := parseURL(args.First())
226 if args.Len() < 1 || err != nil {
227 log.Error().Msg("Please provide the url of the Access application")
228 return err
229 }
230
231 appInfo, err := token.GetAppInfo(appURL)
232 if err != nil {
233 return err
234 }
235
236 if err := verifyTokenAtEdge(appURL, appInfo, c, log); err != nil {
237 log.Err(err).Msg("Could not verify token")
238 return err
239 }
240
241 cfdToken, err := token.GetAppTokenIfExists(appInfo)
242 if err != nil {
243 fmt.Fprintln(os.Stderr, "Unable to find token for provided application.")
244 return err
245 } else if cfdToken == "" {
246 fmt.Fprintln(os.Stderr, "token for provided application was empty.")
247 return errors.New("empty application token")
248 }
249 fmt.Fprintf(os.Stdout, "Successfully fetched your token:\n\n%s\n\n", cfdToken)
250
251 return nil
252}
253
254// curl provides a wrapper around curl, passing Access JWT along in request
255func curl(c *cli.Context) error {
256 err := sentry.Init(sentry.ClientOptions{
257 Dsn: sentryDSN,
258 Release: c.App.Version,
259 })
260 if err != nil {
261 return err
262 }
263 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
264
265 args := c.Args()
266 if args.Len() < 1 {
267 log.Error().Msg("Please provide the access app and command you wish to run.")
268 return errors.New("incorrect args")
269 }
270
271 cmdArgs, allowRequest := parseAllowRequest(args.Slice())
272 appURL, err := getAppURL(cmdArgs, log)
273 if err != nil {
274 return err
275 }
276
277 appInfo, err := token.GetAppInfo(appURL)
278 if err != nil {
279 return err
280 }
281
282 // Verify that the existing token is still good; if not fetch a new one
283 if err := verifyTokenAtEdge(appURL, appInfo, c, log); err != nil {
284 log.Err(err).Msg("Could not verify token")
285 return err
286 }
287
288 tok, err := token.GetAppTokenIfExists(appInfo)
289 if err != nil || tok == "" {
290 if allowRequest {
291 log.Info().Msg("You don't have an Access token set. Please run access token <access application> to fetch one.")
292 return run("curl", cmdArgs...)
293 }
294 tok, err = token.FetchToken(appURL, appInfo, log)
295 if err != nil {
296 log.Err(err).Msg("Failed to refresh token")
297 return err
298 }
299 }
300
301 cmdArgs = append(cmdArgs, "-H")
302 cmdArgs = append(cmdArgs, fmt.Sprintf("%s: %s", carrier.CFAccessTokenHeader, tok))
303 return run("curl", cmdArgs...)
304}
305
306// run kicks off a shell task and pipe the results to the respective std pipes
307func run(cmd string, args ...string) error {
308 c := exec.Command(cmd, args...)
309 c.Stdin = os.Stdin
310 stderr, err := c.StderrPipe()
311 if err != nil {
312 return err
313 }
314 go func() {
315 io.Copy(os.Stderr, stderr)
316 }()
317
318 stdout, err := c.StdoutPipe()
319 if err != nil {
320 return err
321 }
322 go func() {
323 io.Copy(os.Stdout, stdout)
324 }()
325 return c.Run()
326}
327
328// token dumps provided token to stdout
329func generateToken(c *cli.Context) error {
330 err := sentry.Init(sentry.ClientOptions{
331 Dsn: sentryDSN,
332 Release: c.App.Version,
333 })
334 if err != nil {
335 return err
336 }
337 appURL, err := parseURL(c.String("app"))
338 if err != nil || c.NumFlags() < 1 {
339 fmt.Fprintln(os.Stderr, "Please provide a url.")
340 return err
341 }
342
343 appInfo, err := token.GetAppInfo(appURL)
344 if err != nil {
345 return err
346 }
347 tok, err := token.GetAppTokenIfExists(appInfo)
348 if err != nil || tok == "" {
349 fmt.Fprintln(os.Stderr, "Unable to find token for provided application. Please run login command to generate token.")
350 return err
351 }
352
353 if _, err := fmt.Fprint(os.Stdout, tok); err != nil {
354 fmt.Fprintln(os.Stderr, "Failed to write token to stdout.")
355 return err
356 }
357 return nil
358}
359
360// sshConfig prints an example SSH config to stdout
361func sshConfig(c *cli.Context) error {
362 genCertBool := c.Bool(sshGenCertFlag)
363 hostname := c.String(sshHostnameFlag)
364 if hostname == "" {
365 hostname = "[your hostname]"
366 }
367
368 type config struct {
369 Home string
370 ShortLivedCerts bool
371 Hostname string
372 Cloudflared string
373 }
374
375 t := template.Must(template.New("sshConfig").Parse(sshConfigTemplate))
376 return t.Execute(os.Stdout, config{Home: os.Getenv("HOME"), ShortLivedCerts: genCertBool, Hostname: hostname, Cloudflared: cloudflaredPath()})
377}
378
379// sshGen generates a short lived certificate for provided hostname
380func sshGen(c *cli.Context) error {
381 log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)
382
383 // get the hostname from the cmdline and error out if its not provided
384 rawHostName := c.String(sshHostnameFlag)
385 hostname, err := validation.ValidateHostname(rawHostName)
386 if err != nil || rawHostName == "" {
387 return cli.ShowCommandHelp(c, "ssh-gen")
388 }
389
390 originURL, err := parseURL(hostname)
391 if err != nil {
392 return err
393 }
394
395 // this fetchToken function mutates the appURL param. We should refactor that
396 fetchTokenURL := &url.URL{}
397 *fetchTokenURL = *originURL
398
399 appInfo, err := token.GetAppInfo(fetchTokenURL)
400 if err != nil {
401 return err
402 }
403 cfdToken, err := token.FetchTokenWithRedirect(fetchTokenURL, appInfo, log)
404 if err != nil {
405 return err
406 }
407
408 if err := sshgen.GenerateShortLivedCertificate(originURL, cfdToken); err != nil {
409 return err
410 }
411
412 return nil
413}
414
415// getAppURL will pull the request URL needed for fetching a user's Access token
416func getAppURL(cmdArgs []string, log *zerolog.Logger) (*url.URL, error) {
417 if len(cmdArgs) < 1 {
418 log.Error().Msg("Please provide a valid URL as the first argument to curl.")
419 return nil, errors.New("not a valid url")
420 }
421
422 u, err := processURL(cmdArgs[0])
423 if err != nil {
424 log.Error().Msg("Please provide a valid URL as the first argument to curl.")
425 return nil, err
426 }
427
428 return u, err
429}
430
431// parseAllowRequest will parse cmdArgs and return a copy of the args and result
432// of the allow request was present
433func parseAllowRequest(cmdArgs []string) ([]string, bool) {
434 if len(cmdArgs) > 1 {
435 if cmdArgs[0] == "--allow-request" || cmdArgs[0] == "-ar" {
436 return cmdArgs[1:], true
437 }
438 }
439
440 return cmdArgs, false
441}
442
443// processURL will preprocess the string (parse to a url, convert to punycode, etc).
444func processURL(s string) (*url.URL, error) {
445 u, err := url.ParseRequestURI(s)
446 if err != nil {
447 return nil, err
448 }
449
450 if u.Host == "" {
451 return nil, errors.New("not a valid host")
452 }
453
454 host, err := idna.ToASCII(u.Hostname())
455 if err != nil { // we fail to convert to punycode, just return the url we parsed.
456 return u, nil
457 }
458 if u.Port() != "" {
459 u.Host = fmt.Sprintf("%s:%s", host, u.Port())
460 } else {
461 u.Host = host
462 }
463
464 return u, nil
465}
466
467// cloudflaredPath pulls the full path of cloudflared on disk
468func cloudflaredPath() string {
469 for _, p := range strings.Split(os.Getenv("PATH"), ":") {
470 path := fmt.Sprintf("%s/%s", p, "cloudflared")
471 if isFileThere(path) {
472 return path
473 }
474 }
475 return "cloudflared"
476}
477
478// isFileThere will check for the presence of candidate path
479func isFileThere(candidate string) bool {
480 fi, err := os.Stat(candidate)
481 if err != nil || fi.IsDir() || !fi.Mode().IsRegular() {
482 return false
483 }
484 return true
485}
486
487// verifyTokenAtEdge checks for a token on disk, or generates a new one.
488// Then makes a request to to the origin with the token to ensure it is valid.
489// Returns nil if token is valid.
490func verifyTokenAtEdge(appUrl *url.URL, appInfo *token.AppInfo, c *cli.Context, log *zerolog.Logger) error {
491 headers := parseRequestHeaders(c.StringSlice(sshHeaderFlag))
492 if c.IsSet(sshTokenIDFlag) {
493 headers.Add(cfAccessClientIDHeader, c.String(sshTokenIDFlag))
494 }
495 if c.IsSet(sshTokenSecretFlag) {
496 headers.Add(cfAccessClientSecretHeader, c.String(sshTokenSecretFlag))
497 }
498 options := &carrier.StartOptions{AppInfo: appInfo, OriginURL: appUrl.String(), Headers: headers}
499
500 if valid, err := isTokenValid(options, log); err != nil {
501 return err
502 } else if valid {
503 return nil
504 }
505
506 if err := token.RemoveTokenIfExists(appInfo); err != nil {
507 return err
508 }
509
510 if valid, err := isTokenValid(options, log); err != nil {
511 return err
512 } else if !valid {
513 return errors.New("failed to verify token")
514 }
515
516 return nil
517}
518
519// isTokenValid makes a request to the origin and returns true if the response was not a 302.
520func isTokenValid(options *carrier.StartOptions, log *zerolog.Logger) (bool, error) {
521 req, err := carrier.BuildAccessRequest(options, log)
522 if err != nil {
523 return false, errors.Wrap(err, "Could not create access request")
524 }
525 req.Header.Set("User-Agent", userAgent)
526
527 query := req.URL.Query()
528 query.Set("cloudflared_token_check", "true")
529 req.URL.RawQuery = query.Encode()
530
531 // Do not follow redirects
532 client := &http.Client{
533 CheckRedirect: func(req *http.Request, via []*http.Request) error {
534 return http.ErrUseLastResponse
535 },
536 Timeout: time.Second * 5,
537 }
538 resp, err := client.Do(req)
539 if err != nil {
540 return false, err
541 }
542 defer resp.Body.Close()
543
544 // A redirect to login means the token was invalid.
545 return !carrier.IsAccessResponse(resp), nil
546}
547