cloudflare/cloudflared

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
55bf90468978f1c04db74d09c498f8d059167a4d

Branches

Tags

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

Clone

HTTPS

Download ZIP

cmd/cloudflared/access/cmd.go

482lines · modecode

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