cloudflare/cloudflared

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
7f1c890a82a84e4eeeac3d7d687674f9c8c91121

Branches

Tags

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

Clone

HTTPS

Download ZIP

cmd/cloudflared/access/cmd.go

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