cloudflare/cloudflared

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
2019.11.2

Branches

Tags

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

Clone

HTTPS

Download ZIP

cmd/cloudflared/access/cmd.go

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