TUN-4063: Cleanup dependencies between packages.

- Move packages the provide generic functionality (such as config) from `cmd` subtree to top level.
- Remove all dependencies on `cmd` subtree from top level packages.
- Consolidate all code dealing with token generation and transfer to a single cohesive package.
This commit is contained in:
Igor Postelnik
2021-03-08 10:46:23 -06:00
parent d83d6d54ed
commit 39065377b5
47 changed files with 246 additions and 236 deletions

176
token/encrypt.go Normal file
View File

@@ -0,0 +1,176 @@
// Package encrypter is suitable for encrypting messages you would like to securely share between two points.
// Useful for providing end to end encryption (E2EE). It uses Box (NaCl) for encrypting the messages.
// tldr is it uses Elliptic Curves (Curve25519) for the keys, XSalsa20 and Poly1305 for encryption.
// You can read more here https://godoc.org/golang.org/x/crypto/nacl/box.
//
// msg := []byte("super safe message.")
// alice, err := NewEncrypter("alice_priv_key.pem", "alice_pub_key.pem")
// if err != nil {
// log.Fatal(err)
// }
//
// bob, err := NewEncrypter("bob_priv_key.pem", "bob_pub_key.pem")
// if err != nil {
// log.Fatal(err)
// }
// encrypted, err := alice.Encrypt(msg, bob.PublicKey())
// if err != nil {
// log.Fatal(err)
// }
//
// data, err := bob.Decrypt(encrypted, alice.PublicKey())
// if err != nil {
// log.Fatal(err)
// }
// fmt.Println(string(data))
package token
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/pem"
"errors"
"io"
"os"
"golang.org/x/crypto/nacl/box"
)
// Encrypter represents a keypair value with auxiliary functions to make
// doing encryption and decryption easier
type Encrypter struct {
privateKey *[32]byte
publicKey *[32]byte
}
// NewEncrypter returns a new encrypter with initialized keypair
func NewEncrypter(privateKey, publicKey string) (*Encrypter, error) {
e := &Encrypter{}
pubKey, key, err := e.fetchOrGenerateKeys(privateKey, publicKey)
if err != nil {
return nil, err
}
e.privateKey, e.publicKey = key, pubKey
return e, nil
}
// PublicKey returns a base64 encoded public key. Useful for transport (like in HTTP requests)
func (e *Encrypter) PublicKey() string {
return base64.URLEncoding.EncodeToString(e.publicKey[:])
}
// Decrypt data that was encrypted using our publicKey. It will use our privateKey and the sender's publicKey to decrypt
// data is an encrypted buffer of data, mostly like from the Encrypt function. Messages contain the nonce data on the front
// of the message.
// senderPublicKey is a base64 encoded version of the sender's public key (most likely from the PublicKey function).
// The return value is the decrypted buffer or an error.
func (e *Encrypter) Decrypt(data []byte, senderPublicKey string) ([]byte, error) {
var decryptNonce [24]byte
copy(decryptNonce[:], data[:24]) // we pull the nonce from the front of the actual message.
pubKey, err := e.decodePublicKey(senderPublicKey)
if err != nil {
return nil, err
}
decrypted, ok := box.Open(nil, data[24:], &decryptNonce, pubKey, e.privateKey)
if !ok {
return nil, errors.New("failed to decrypt message")
}
return decrypted, nil
}
// Encrypt data using our privateKey and the recipient publicKey
// data is a buffer of data that we would like to encrypt. Messages will have the nonce added to front
// as they have to unique for each message shared.
// recipientPublicKey is a base64 encoded version of the sender's public key (most likely from the PublicKey function).
// The return value is the encrypted buffer or an error.
func (e *Encrypter) Encrypt(data []byte, recipientPublicKey string) ([]byte, error) {
var nonce [24]byte
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
return nil, err
}
pubKey, err := e.decodePublicKey(recipientPublicKey)
if err != nil {
return nil, err
}
// This encrypts msg and adds the nonce to the front of the message, since the nonce has to be
// the same for encrypting and decrypting
return box.Seal(nonce[:], data, &nonce, pubKey, e.privateKey), nil
}
// WriteKeys keys will take the currently initialized keypair and write them to provided filenames
func (e *Encrypter) WriteKeys(privateKey, publicKey string) error {
if err := e.writeKey(e.privateKey[:], "BOX PRIVATE KEY", privateKey); err != nil {
return err
}
return e.writeKey(e.publicKey[:], "PUBLIC KEY", publicKey)
}
// fetchOrGenerateKeys will either load or create a keypair if it doesn't exist
func (e *Encrypter) fetchOrGenerateKeys(privateKey, publicKey string) (*[32]byte, *[32]byte, error) {
key, err := e.fetchKey(privateKey)
if os.IsNotExist(err) {
return box.GenerateKey(rand.Reader)
} else if err != nil {
return nil, nil, err
}
pub, err := e.fetchKey(publicKey)
if os.IsNotExist(err) {
return box.GenerateKey(rand.Reader)
} else if err != nil {
return nil, nil, err
}
return pub, key, nil
}
// writeKey will write a key to disk in DER format (it's a standard pem key)
func (e *Encrypter) writeKey(key []byte, pemType, filename string) error {
data := pem.EncodeToMemory(&pem.Block{
Type: pemType,
Bytes: key,
})
f, err := os.Create(filename)
if err != nil {
return err
}
_, err = f.Write(data)
if err != nil {
return err
}
return nil
}
// fetchKey will load a a DER formatted key from disk
func (e *Encrypter) fetchKey(filename string) (*[32]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
buf := new(bytes.Buffer)
io.Copy(buf, f)
p, _ := pem.Decode(buf.Bytes())
if p == nil {
return nil, errors.New("Failed to decode key")
}
var newKey [32]byte
copy(newKey[:], p.Bytes)
return &newKey, nil
}
// decodePublicKey will base64 decode the provided key to the box representation
func (e *Encrypter) decodePublicKey(key string) (*[32]byte, error) {
pub, err := base64.URLEncoding.DecodeString(key)
if err != nil {
return nil, err
}
var newKey [32]byte
copy(newKey[:], pub)
return &newKey, nil
}

View File

@@ -0,0 +1,11 @@
//+build darwin
package token
import (
"os/exec"
)
func getBrowserCmd(url string) *exec.Cmd {
return exec.Command("open", url)
}

View File

@@ -0,0 +1,11 @@
//+build !windows,!darwin,!linux,!netbsd,!freebsd,!openbsd
package token
import (
"os/exec"
)
func getBrowserCmd(url string) *exec.Cmd {
return nil
}

View File

@@ -0,0 +1,11 @@
//+build linux freebsd openbsd netbsd
package token
import (
"os/exec"
)
func getBrowserCmd(url string) *exec.Cmd {
return exec.Command("xdg-open", url)
}

View File

@@ -0,0 +1,18 @@
//+build windows
package token
import (
"fmt"
"os/exec"
"syscall"
)
func getBrowserCmd(url string) *exec.Cmd {
cmd := exec.Command("cmd")
// CmdLine is only defined when compiling for windows.
// Empty string is the cmd proc "Title". Needs to be included because the start command will interpret the first
// quoted string as that field and we want to quote the URL.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: fmt.Sprintf(`/c start "" "%s"`, url)}
return cmd
}

46
token/path.go Normal file
View File

@@ -0,0 +1,46 @@
package token
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cloudflare/cloudflared/config"
)
// GenerateAppTokenFilePathFromURL will return a filepath for given Access org token
func GenerateAppTokenFilePathFromURL(url *url.URL, suffix string) (string, error) {
configPath, err := getConfigPath()
if err != nil {
return "", err
}
name := strings.Replace(fmt.Sprintf("%s%s-%s", url.Hostname(), url.EscapedPath(), suffix), "/", "-", -1)
return filepath.Join(configPath, name), nil
}
// generateOrgTokenFilePathFromURL will return a filepath for given Access application token
func generateOrgTokenFilePathFromURL(authDomain string) (string, error) {
configPath, err := getConfigPath()
if err != nil {
return "", err
}
name := strings.Replace(fmt.Sprintf("%s-org-token", authDomain), "/", "-", -1)
return filepath.Join(configPath, name), nil
}
func getConfigPath() (string, error) {
configPath, err := homedir.Expand(config.DefaultConfigSearchDirectories()[0])
if err != nil {
return "", err
}
ok, err := config.FileExists(configPath)
if !ok && err == nil {
// create config directory if doesn't already exist
err = os.Mkdir(configPath, 0700)
}
return configPath, err
}

7
token/shell.go Normal file
View File

@@ -0,0 +1,7 @@
package token
// OpenBrowser opens the specified URL in the default browser of the user
func OpenBrowser(url string) error {
return getBrowserCmd(url).Start()
}

386
token/token.go Normal file
View File

@@ -0,0 +1,386 @@
package token
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/cloudflare/cloudflared/config"
"github.com/cloudflare/cloudflared/origin"
"github.com/coreos/go-oidc/jose"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
const (
keyName = "token"
tokenHeader = "CF_Authorization"
)
type lock struct {
lockFilePath string
backoff *origin.BackoffHandler
sigHandler *signalHandler
}
type signalHandler struct {
sigChannel chan os.Signal
signals []os.Signal
}
type appJWTPayload struct {
Aud []string `json:"aud"`
Email string `json:"email"`
Exp int `json:"exp"`
Iat int `json:"iat"`
Nbf int `json:"nbf"`
Iss string `json:"iss"`
Type string `json:"type"`
Subt string `json:"sub"`
}
type orgJWTPayload struct {
appJWTPayload
Aud string `json:"aud"`
}
type transferServiceResponse struct {
AppToken string `json:"app_token"`
OrgToken string `json:"org_token"`
}
func (p appJWTPayload) isExpired() bool {
return int(time.Now().Unix()) > p.Exp
}
func (s *signalHandler) register(handler func()) {
s.sigChannel = make(chan os.Signal, 1)
signal.Notify(s.sigChannel, s.signals...)
go func(s *signalHandler) {
for range s.sigChannel {
handler()
}
}(s)
}
func (s *signalHandler) deregister() {
signal.Stop(s.sigChannel)
close(s.sigChannel)
}
func errDeleteTokenFailed(lockFilePath string) error {
return fmt.Errorf("failed to acquire a new Access token. Please try to delete %s", lockFilePath)
}
// newLock will get a new file lock
func newLock(path string) *lock {
lockPath := path + ".lock"
return &lock{
lockFilePath: lockPath,
backoff: &origin.BackoffHandler{MaxRetries: 7},
sigHandler: &signalHandler{
signals: []os.Signal{syscall.SIGINT, syscall.SIGTERM},
},
}
}
func (l *lock) Acquire() error {
// Intercept SIGINT and SIGTERM to release lock before exiting
l.sigHandler.register(func() {
_ = l.deleteLockFile()
os.Exit(0)
})
// Check for a lock file
// if the lock file exists; start polling
// if not, create the lock file and go through the normal flow.
// See AUTH-1736 for the reason why we do all this
for isTokenLocked(l.lockFilePath) {
if l.backoff.Backoff(context.Background()) {
continue
}
if err := l.deleteLockFile(); err != nil {
return err
}
}
// Create a lock file so other processes won't also try to get the token at
// the same time
if err := ioutil.WriteFile(l.lockFilePath, []byte{}, 0600); err != nil {
return err
}
return nil
}
func (l *lock) deleteLockFile() error {
if err := os.Remove(l.lockFilePath); err != nil && !os.IsNotExist(err) {
return errDeleteTokenFailed(l.lockFilePath)
}
return nil
}
func (l *lock) Release() error {
defer l.sigHandler.deregister()
return l.deleteLockFile()
}
// isTokenLocked checks to see if there is another process attempting to get the token already
func isTokenLocked(lockFilePath string) bool {
exists, err := config.FileExists(lockFilePath)
return exists && err == nil
}
// FetchTokenWithRedirect will either load a stored token or generate a new one
// it appends the full url as the redirect URL to the access cli request if opening the browser
func FetchTokenWithRedirect(appURL *url.URL, log *zerolog.Logger) (string, error) {
return getToken(appURL, false, log)
}
// FetchToken will either load a stored token or generate a new one
// it appends the host of the appURL as the redirect URL to the access cli request if opening the browser
func FetchToken(appURL *url.URL, log *zerolog.Logger) (string, error) {
return getToken(appURL, true, log)
}
// getToken will either load a stored token or generate a new one
func getToken(appURL *url.URL, useHostOnly bool, log *zerolog.Logger) (string, error) {
if token, err := GetAppTokenIfExists(appURL); token != "" && err == nil {
return token, nil
}
appTokenPath, err := GenerateAppTokenFilePathFromURL(appURL, keyName)
if err != nil {
return "", errors.Wrap(err, "failed to generate app token file path")
}
fileLockAppToken := newLock(appTokenPath)
if err = fileLockAppToken.Acquire(); err != nil {
return "", errors.Wrap(err, "failed to acquire app token lock")
}
defer fileLockAppToken.Release()
// check to see if another process has gotten a token while we waited for the lock
if token, err := GetAppTokenIfExists(appURL); token != "" && err == nil {
return token, nil
}
// If an app token couldnt be found on disk, check for an org token and attempt to exchange it for an app token.
var orgTokenPath string
// Get auth domain to format into org token file path
if authDomain, err := getAuthDomain(appURL); err != nil {
log.Error().Msgf("failed to get auth domain: %s", err)
} else {
orgToken, err := GetOrgTokenIfExists(authDomain)
if err != nil {
orgTokenPath, err = generateOrgTokenFilePathFromURL(authDomain)
if err != nil {
return "", errors.Wrap(err, "failed to generate org token file path")
}
fileLockOrgToken := newLock(orgTokenPath)
if err = fileLockOrgToken.Acquire(); err != nil {
return "", errors.Wrap(err, "failed to acquire org token lock")
}
defer fileLockOrgToken.Release()
// check if an org token has been created since the lock was acquired
orgToken, err = GetOrgTokenIfExists(authDomain)
}
if err == nil {
if appToken, err := exchangeOrgToken(appURL, orgToken); err != nil {
log.Debug().Msgf("failed to exchange org token for app token: %s", err)
} else {
if err := ioutil.WriteFile(appTokenPath, []byte(appToken), 0600); err != nil {
return "", errors.Wrap(err, "failed to write app token to disk")
}
return appToken, nil
}
}
}
return getTokensFromEdge(appURL, appTokenPath, orgTokenPath, useHostOnly, log)
}
// getTokensFromEdge will attempt to use the transfer service to retrieve an app and org token, save them to disk,
// and return the app token.
func getTokensFromEdge(appURL *url.URL, appTokenPath, orgTokenPath string, useHostOnly bool, log *zerolog.Logger) (string, error) {
// If no org token exists or if it couldnt be exchanged for an app token, then run the transfer service flow.
// this weird parameter is the resource name (token) and the key/value
// we want to send to the transfer service. the key is token and the value
// is blank (basically just the id generated in the transfer service)
resourceData, err := RunTransfer(appURL, keyName, keyName, "", true, useHostOnly, log)
if err != nil {
return "", errors.Wrap(err, "failed to run transfer service")
}
var resp transferServiceResponse
if err = json.Unmarshal(resourceData, &resp); err != nil {
return "", errors.Wrap(err, "failed to marshal transfer service response")
}
// If we were able to get the auth domain and generate an org token path, lets write it to disk.
if orgTokenPath != "" {
if err := ioutil.WriteFile(orgTokenPath, []byte(resp.OrgToken), 0600); err != nil {
return "", errors.Wrap(err, "failed to write org token to disk")
}
}
if err := ioutil.WriteFile(appTokenPath, []byte(resp.AppToken), 0600); err != nil {
return "", errors.Wrap(err, "failed to write app token to disk")
}
return resp.AppToken, nil
}
// getAuthDomain makes a request to the appURL and stops at the first redirect. The 302 location header will contain the
// auth domain
func getAuthDomain(appURL *url.URL) (string, error) {
client := &http.Client{
// do not follow redirects
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: time.Second * 7,
}
authDomainReq, err := http.NewRequest("HEAD", appURL.String(), nil)
if err != nil {
return "", errors.Wrap(err, "failed to create auth domain request")
}
resp, err := client.Do(authDomainReq)
if err != nil {
return "", errors.Wrap(err, "failed to get auth domain")
}
resp.Body.Close()
location, err := resp.Location()
if err != nil {
return "", fmt.Errorf("failed to get auth domain. Received status code %d from %s", resp.StatusCode, appURL.String())
}
return location.Hostname(), nil
}
// exchangeOrgToken attaches an org token to a request to the appURL and returns an app token. This uses the Access SSO
// flow to automatically generate and return an app token without the login page.
func exchangeOrgToken(appURL *url.URL, orgToken string) (string, error) {
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// attach org token to login request
if strings.Contains(req.URL.Path, "cdn-cgi/access/login") {
req.AddCookie(&http.Cookie{Name: tokenHeader, Value: orgToken})
}
// stop after hitting authorized endpoint since it will contain the app token
if strings.Contains(via[len(via)-1].URL.Path, "cdn-cgi/access/authorized") {
return http.ErrUseLastResponse
}
return nil
},
Timeout: time.Second * 7,
}
appTokenRequest, err := http.NewRequest("HEAD", appURL.String(), nil)
if err != nil {
return "", errors.Wrap(err, "failed to create app token request")
}
resp, err := client.Do(appTokenRequest)
if err != nil {
return "", errors.Wrap(err, "failed to get app token")
}
resp.Body.Close()
var appToken string
for _, c := range resp.Cookies() {
//if Org token revoked on exchange, getTokensFromEdge instead
validAppToken := c.Name == tokenHeader && time.Now().Before(c.Expires)
if validAppToken {
appToken = c.Value
break
}
}
if len(appToken) > 0 {
return appToken, nil
}
return "", fmt.Errorf("response from %s did not contain app token", resp.Request.URL.String())
}
func GetOrgTokenIfExists(authDomain string) (string, error) {
path, err := generateOrgTokenFilePathFromURL(authDomain)
if err != nil {
return "", err
}
token, err := getTokenIfExists(path)
if err != nil {
return "", err
}
var payload orgJWTPayload
err = json.Unmarshal(token.Payload, &payload)
if err != nil {
return "", err
}
if payload.isExpired() {
err := os.Remove(path)
return "", err
}
return token.Encode(), nil
}
func GetAppTokenIfExists(url *url.URL) (string, error) {
path, err := GenerateAppTokenFilePathFromURL(url, keyName)
if err != nil {
return "", err
}
token, err := getTokenIfExists(path)
if err != nil {
return "", err
}
var payload appJWTPayload
err = json.Unmarshal(token.Payload, &payload)
if err != nil {
return "", err
}
if payload.isExpired() {
err := os.Remove(path)
return "", err
}
return token.Encode(), nil
}
// GetTokenIfExists will return the token from local storage if it exists and not expired
func getTokenIfExists(path string) (*jose.JWT, error) {
content, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
token, err := jose.ParseJWT(string(content))
if err != nil {
return nil, err
}
return &token, nil
}
// RemoveTokenIfExists removes the a token from local storage if it exists
func RemoveTokenIfExists(url *url.URL) error {
path, err := GenerateAppTokenFilePathFromURL(url, keyName)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

54
token/token_test.go Normal file
View File

@@ -0,0 +1,54 @@
//+build linux
package token
import (
"os"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSignalHandler(t *testing.T) {
sigHandler := signalHandler{signals: []os.Signal{syscall.SIGUSR1}}
handlerRan := false
done := make(chan struct{})
timer := time.NewTimer(time.Second)
sigHandler.register(func(){
handlerRan = true
done <- struct{}{}
})
p, err := os.FindProcess(os.Getpid())
require.Nil(t, err)
p.Signal(syscall.SIGUSR1)
// Blocks for up to one second to make sure the handler callback runs before the assert.
select {
case <- done:
assert.True(t, handlerRan)
case <- timer.C:
t.Fail()
}
sigHandler.deregister()
}
func TestSignalHandlerClose(t *testing.T) {
sigHandler := signalHandler{signals: []os.Signal{syscall.SIGUSR1}}
done := make(chan struct{})
timer := time.NewTimer(time.Second)
sigHandler.register(func(){done <- struct{}{}})
sigHandler.deregister()
p, err := os.FindProcess(os.Getpid())
require.Nil(t, err)
p.Signal(syscall.SIGUSR1)
select {
case <- done:
t.Fail()
case <- timer.C:
}
}

157
token/transfer.go Normal file
View File

@@ -0,0 +1,157 @@
package token
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
"github.com/pkg/errors"
"github.com/rs/zerolog"
)
const (
baseStoreURL = "https://login.argotunnel.com/"
clientTimeout = time.Second * 60
)
// RunTransfer does the transfer "dance" with the end result downloading the supported resource.
// The expanded description is run is encapsulation of shared business logic needed
// to request a resource (token/cert/etc) from the transfer service (loginhelper).
// The "dance" we refer to is building a HTTP request, opening that in a browser waiting for
// the user to complete an action, while it long polls in the background waiting for an
// action to be completed to download the resource.
func RunTransfer(transferURL *url.URL, resourceName, key, value string, shouldEncrypt bool, useHostOnly bool, log *zerolog.Logger) ([]byte, error) {
encrypterClient, err := NewEncrypter("cloudflared_priv.pem", "cloudflared_pub.pem")
if err != nil {
return nil, err
}
requestURL, err := buildRequestURL(transferURL, key, value+encrypterClient.PublicKey(), shouldEncrypt, useHostOnly)
if err != nil {
return nil, err
}
// See AUTH-1423 for why we use stderr (the way git wraps ssh)
err = OpenBrowser(requestURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Please open the following URL and log in with your Cloudflare account:\n\n%s\n\nLeave cloudflared running to download the %s automatically.\n", requestURL, resourceName)
} else {
fmt.Fprintf(os.Stderr, "A browser window should have opened at the following URL:\n\n%s\n\nIf the browser failed to open, please visit the URL above directly in your browser.\n", requestURL)
}
var resourceData []byte
if shouldEncrypt {
buf, key, err := transferRequest(baseStoreURL+"transfer/"+encrypterClient.PublicKey(), log)
if err != nil {
return nil, err
}
decodedBuf, err := base64.StdEncoding.DecodeString(string(buf))
if err != nil {
return nil, err
}
decrypted, err := encrypterClient.Decrypt(decodedBuf, key)
if err != nil {
return nil, err
}
resourceData = decrypted
} else {
buf, _, err := transferRequest(baseStoreURL+encrypterClient.PublicKey(), log)
if err != nil {
return nil, err
}
resourceData = buf
}
return resourceData, nil
}
// BuildRequestURL creates a request suitable for a resource transfer.
// it will return a constructed url based off the base url and query key/value provided.
// cli will build a url for cli transfer request.
func buildRequestURL(baseURL *url.URL, key, value string, cli, useHostOnly bool) (string, error) {
q := baseURL.Query()
q.Set(key, value)
baseURL.RawQuery = q.Encode()
if useHostOnly {
baseURL.Path = ""
}
if !cli {
return baseURL.String(), nil
}
q.Set("redirect_url", baseURL.String()) // we add the token as a query param on both the redirect_url and the main url
q.Set("send_org_token", "true") // indicates that the cli endpoint should return both the org and app token
baseURL.RawQuery = q.Encode() // and this actual baseURL.
baseURL.Path = "cdn-cgi/access/cli"
return baseURL.String(), nil
}
// transferRequest downloads the requested resource from the request URL
func transferRequest(requestURL string, log *zerolog.Logger) ([]byte, string, error) {
client := &http.Client{Timeout: clientTimeout}
const pollAttempts = 10
// we do "long polling" on the endpoint to get the resource.
for i := 0; i < pollAttempts; i++ {
buf, key, err := poll(client, requestURL, log)
if err != nil {
return nil, "", err
} else if len(buf) > 0 {
if err := putSuccess(client, requestURL); err != nil {
log.Err(err).Msg("Failed to update resource success")
}
return buf, key, nil
}
}
return nil, "", errors.New("Failed to fetch resource")
}
// poll the endpoint for the request resource, waiting for the user interaction
func poll(client *http.Client, requestURL string, log *zerolog.Logger) ([]byte, string, error) {
resp, err := client.Get(requestURL)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
// ignore everything other than server errors as the resource
// may not exist until the user does the interaction
if resp.StatusCode >= 500 {
return nil, "", fmt.Errorf("error on request %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
log.Info().Msg("Waiting for login...")
return nil, "", nil
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, resp.Body); err != nil {
return nil, "", err
}
return buf.Bytes(), resp.Header.Get("service-public-key"), nil
}
// putSuccess tells the server we successfully downloaded the resource
func putSuccess(client *http.Client, requestURL string) error {
req, err := http.NewRequest("PUT", requestURL+"/ok", nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP Response Status Code: %d", resp.StatusCode)
}
return nil
}