2018-05-01 23:45:06 +00:00
|
|
|
package origin
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
2019-01-10 20:55:44 +00:00
|
|
|
"context"
|
2018-05-01 23:45:06 +00:00
|
|
|
"crypto/tls"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"net"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
2019-02-19 17:40:49 +00:00
|
|
|
"sync"
|
2018-05-01 23:45:06 +00:00
|
|
|
"time"
|
|
|
|
|
2019-11-21 17:03:13 +00:00
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
|
2019-06-17 21:18:47 +00:00
|
|
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/buildinfo"
|
2019-11-21 18:10:44 +00:00
|
|
|
"github.com/cloudflare/cloudflared/connection"
|
2018-05-01 23:45:06 +00:00
|
|
|
"github.com/cloudflare/cloudflared/h2mux"
|
2019-03-04 19:48:56 +00:00
|
|
|
"github.com/cloudflare/cloudflared/signal"
|
2019-06-05 15:08:55 +00:00
|
|
|
"github.com/cloudflare/cloudflared/streamhandler"
|
2018-05-01 23:45:06 +00:00
|
|
|
"github.com/cloudflare/cloudflared/tunnelrpc"
|
|
|
|
tunnelpogs "github.com/cloudflare/cloudflared/tunnelrpc/pogs"
|
|
|
|
"github.com/cloudflare/cloudflared/validation"
|
|
|
|
"github.com/cloudflare/cloudflared/websocket"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
dialTimeout = 15 * time.Second
|
2019-04-02 23:12:09 +00:00
|
|
|
openStreamTimeout = 30 * time.Second
|
2019-12-04 17:22:08 +00:00
|
|
|
muxerTimeout = 5 * time.Second
|
2018-05-01 23:45:06 +00:00
|
|
|
lbProbeUserAgentPrefix = "Mozilla/5.0 (compatible; Cloudflare-Traffic-Manager/1.0; +https://www.cloudflare.com/traffic-manager/;"
|
|
|
|
TagHeaderNamePrefix = "Cf-Warp-Tag-"
|
|
|
|
DuplicateConnectionError = "EDUPCONN"
|
|
|
|
)
|
|
|
|
|
2020-01-28 16:43:37 +00:00
|
|
|
type registerRPCName string
|
|
|
|
|
|
|
|
const (
|
|
|
|
register registerRPCName = "register"
|
|
|
|
reconnect registerRPCName = "reconnect"
|
2020-01-28 17:29:33 +00:00
|
|
|
unknown registerRPCName = "unknown"
|
2020-01-28 16:43:37 +00:00
|
|
|
)
|
|
|
|
|
2018-05-01 23:45:06 +00:00
|
|
|
type TunnelConfig struct {
|
2019-06-17 21:18:47 +00:00
|
|
|
BuildInfo *buildinfo.BuildInfo
|
2019-04-04 22:04:55 +00:00
|
|
|
ClientID string
|
|
|
|
ClientTlsConfig *tls.Config
|
|
|
|
CloseConnOnce *sync.Once // Used to close connectedSignal no more than once
|
|
|
|
CompressionQuality uint64
|
|
|
|
EdgeAddrs []string
|
|
|
|
GracePeriod time.Duration
|
|
|
|
HAConnections int
|
|
|
|
HTTPTransport http.RoundTripper
|
|
|
|
HeartbeatInterval time.Duration
|
|
|
|
Hostname string
|
2019-08-30 03:55:54 +00:00
|
|
|
HTTPHostHeader string
|
2019-04-04 22:04:55 +00:00
|
|
|
IncidentLookup IncidentLookup
|
|
|
|
IsAutoupdated bool
|
|
|
|
IsFreeTunnel bool
|
|
|
|
LBPool string
|
|
|
|
Logger *log.Logger
|
|
|
|
MaxHeartbeats uint64
|
|
|
|
Metrics *TunnelMetrics
|
|
|
|
MetricsUpdateFreq time.Duration
|
|
|
|
NoChunkedEncoding bool
|
|
|
|
OriginCert []byte
|
|
|
|
ReportedVersion string
|
|
|
|
Retries uint
|
|
|
|
RunFromTerminal bool
|
|
|
|
Tags []tunnelpogs.Tag
|
|
|
|
TlsConfig *tls.Config
|
|
|
|
TransportLogger *log.Logger
|
|
|
|
UseDeclarativeTunnel bool
|
|
|
|
WSGI bool
|
2019-02-14 10:40:54 +00:00
|
|
|
// OriginUrl may not be used if a user specifies a unix socket.
|
|
|
|
OriginUrl string
|
2019-12-04 17:22:08 +00:00
|
|
|
|
|
|
|
// feature-flag to use new edge reconnect tokens
|
|
|
|
UseReconnectToken bool
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
2019-12-06 21:32:15 +00:00
|
|
|
// ReconnectTunnelCredentialManager is invoked by functions in this file to
|
|
|
|
// get/set parameters for ReconnectTunnel RPC calls.
|
|
|
|
type ReconnectTunnelCredentialManager interface {
|
|
|
|
ReconnectToken() ([]byte, error)
|
|
|
|
EventDigest() ([]byte, error)
|
|
|
|
SetEventDigest(eventDigest []byte)
|
|
|
|
}
|
|
|
|
|
2018-05-01 23:45:06 +00:00
|
|
|
type dupConnRegisterTunnelError struct{}
|
|
|
|
|
|
|
|
func (e dupConnRegisterTunnelError) Error() string {
|
|
|
|
return "already connected to this server"
|
|
|
|
}
|
|
|
|
|
|
|
|
type muxerShutdownError struct{}
|
|
|
|
|
|
|
|
func (e muxerShutdownError) Error() string {
|
|
|
|
return "muxer shutdown"
|
|
|
|
}
|
|
|
|
|
|
|
|
// RegisterTunnel error from server
|
|
|
|
type serverRegisterTunnelError struct {
|
|
|
|
cause error
|
|
|
|
permanent bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func (e serverRegisterTunnelError) Error() string {
|
|
|
|
return e.cause.Error()
|
|
|
|
}
|
|
|
|
|
|
|
|
// RegisterTunnel error from client
|
|
|
|
type clientRegisterTunnelError struct {
|
|
|
|
cause error
|
|
|
|
}
|
|
|
|
|
2020-01-28 17:29:33 +00:00
|
|
|
func newClientRegisterTunnelError(cause error, counter *prometheus.CounterVec, name registerRPCName) clientRegisterTunnelError {
|
|
|
|
counter.WithLabelValues(cause.Error(), string(name)).Inc()
|
2019-03-15 23:46:53 +00:00
|
|
|
return clientRegisterTunnelError{cause: cause}
|
|
|
|
}
|
|
|
|
|
2018-05-01 23:45:06 +00:00
|
|
|
func (e clientRegisterTunnelError) Error() string {
|
|
|
|
return e.cause.Error()
|
|
|
|
}
|
|
|
|
|
2019-12-04 17:22:08 +00:00
|
|
|
func (c *TunnelConfig) muxerConfig(handler h2mux.MuxedStreamHandler) h2mux.MuxerConfig {
|
|
|
|
return h2mux.MuxerConfig{
|
|
|
|
Timeout: muxerTimeout,
|
|
|
|
Handler: handler,
|
|
|
|
IsClient: true,
|
|
|
|
HeartbeatInterval: c.HeartbeatInterval,
|
|
|
|
MaxHeartbeats: c.MaxHeartbeats,
|
|
|
|
Logger: c.TransportLogger.WithFields(log.Fields{}),
|
|
|
|
CompressionQuality: h2mux.CompressionSetting(c.CompressionQuality),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-10-08 19:20:28 +00:00
|
|
|
func (c *TunnelConfig) RegistrationOptions(connectionID uint8, OriginLocalIP string, uuid uuid.UUID) *tunnelpogs.RegistrationOptions {
|
2018-05-01 23:45:06 +00:00
|
|
|
policy := tunnelrpc.ExistingTunnelPolicy_balance
|
|
|
|
if c.HAConnections <= 1 && c.LBPool == "" {
|
|
|
|
policy = tunnelrpc.ExistingTunnelPolicy_disconnect
|
|
|
|
}
|
|
|
|
return &tunnelpogs.RegistrationOptions{
|
|
|
|
ClientID: c.ClientID,
|
|
|
|
Version: c.ReportedVersion,
|
|
|
|
OS: fmt.Sprintf("%s_%s", c.BuildInfo.GoOS, c.BuildInfo.GoArch),
|
|
|
|
ExistingTunnelPolicy: policy,
|
|
|
|
PoolName: c.LBPool,
|
|
|
|
Tags: c.Tags,
|
|
|
|
ConnectionID: connectionID,
|
|
|
|
OriginLocalIP: OriginLocalIP,
|
|
|
|
IsAutoupdated: c.IsAutoupdated,
|
|
|
|
RunFromTerminal: c.RunFromTerminal,
|
|
|
|
CompressionQuality: c.CompressionQuality,
|
2018-10-08 19:20:28 +00:00
|
|
|
UUID: uuid.String(),
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-06-17 21:18:47 +00:00
|
|
|
func StartTunnelDaemon(ctx context.Context, config *TunnelConfig, connectedSignal *signal.Signal, cloudflaredID uuid.UUID) error {
|
2019-12-13 23:05:21 +00:00
|
|
|
s, err := NewSupervisor(config, cloudflaredID)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return s.Run(ctx, connectedSignal)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func ServeTunnelLoop(ctx context.Context,
|
2019-12-06 21:32:15 +00:00
|
|
|
credentialManager ReconnectTunnelCredentialManager,
|
2018-05-01 23:45:06 +00:00
|
|
|
config *TunnelConfig,
|
|
|
|
addr *net.TCPAddr,
|
|
|
|
connectionID uint8,
|
2019-03-04 19:48:56 +00:00
|
|
|
connectedSignal *signal.Signal,
|
2019-02-01 20:11:12 +00:00
|
|
|
u uuid.UUID,
|
2018-05-01 23:45:06 +00:00
|
|
|
) error {
|
2019-10-14 23:36:34 +00:00
|
|
|
connectionLogger := config.Logger.WithField("connectionID", connectionID)
|
2018-05-01 23:45:06 +00:00
|
|
|
config.Metrics.incrementHaConnections()
|
|
|
|
defer config.Metrics.decrementHaConnections()
|
|
|
|
backoff := BackoffHandler{MaxRetries: config.Retries}
|
|
|
|
connectedFuse := h2mux.NewBooleanFuse()
|
|
|
|
go func() {
|
|
|
|
if connectedFuse.Await() {
|
2019-03-04 19:48:56 +00:00
|
|
|
connectedSignal.Notify()
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
// Ensure the above goroutine will terminate if we return without connecting
|
|
|
|
defer connectedFuse.Fuse(false)
|
|
|
|
for {
|
2019-11-04 11:11:54 +00:00
|
|
|
err, recoverable := ServeTunnel(
|
|
|
|
ctx,
|
2019-12-06 21:32:15 +00:00
|
|
|
credentialManager,
|
2019-11-04 11:11:54 +00:00
|
|
|
config,
|
|
|
|
connectionLogger,
|
|
|
|
addr, connectionID,
|
|
|
|
connectedFuse,
|
|
|
|
&backoff,
|
|
|
|
u,
|
|
|
|
)
|
2018-05-01 23:45:06 +00:00
|
|
|
if recoverable {
|
|
|
|
if duration, ok := backoff.GetBackoffDuration(ctx); ok {
|
2019-10-14 23:36:34 +00:00
|
|
|
connectionLogger.Infof("Retrying in %s seconds", duration)
|
2018-05-01 23:45:06 +00:00
|
|
|
backoff.Backoff(ctx)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func ServeTunnel(
|
|
|
|
ctx context.Context,
|
2019-12-06 21:32:15 +00:00
|
|
|
credentialManager ReconnectTunnelCredentialManager,
|
2018-05-01 23:45:06 +00:00
|
|
|
config *TunnelConfig,
|
2019-10-14 23:36:34 +00:00
|
|
|
logger *log.Entry,
|
2018-05-01 23:45:06 +00:00
|
|
|
addr *net.TCPAddr,
|
|
|
|
connectionID uint8,
|
|
|
|
connectedFuse *h2mux.BooleanFuse,
|
|
|
|
backoff *BackoffHandler,
|
2019-02-01 20:11:12 +00:00
|
|
|
u uuid.UUID,
|
2018-05-01 23:45:06 +00:00
|
|
|
) (err error, recoverable bool) {
|
|
|
|
// Treat panics as recoverable errors
|
|
|
|
defer func() {
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
var ok bool
|
|
|
|
err, ok = r.(error)
|
|
|
|
if !ok {
|
|
|
|
err = fmt.Errorf("ServeTunnel: %v", r)
|
|
|
|
}
|
|
|
|
recoverable = true
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
connectionTag := uint8ToString(connectionID)
|
|
|
|
|
|
|
|
// additional tags to send other than hostname which is set in cloudflared main package
|
|
|
|
tags := make(map[string]string)
|
|
|
|
tags["ha"] = connectionTag
|
|
|
|
|
|
|
|
// Returns error from parsing the origin URL or handshake errors
|
2019-11-21 18:10:44 +00:00
|
|
|
handler, originLocalIP, err := NewTunnelHandler(ctx, config, addr, connectionID)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
2019-10-14 23:36:34 +00:00
|
|
|
errLog := logger.WithError(err)
|
2018-05-01 23:45:06 +00:00
|
|
|
switch err.(type) {
|
2019-11-21 18:10:44 +00:00
|
|
|
case connection.DialError:
|
2018-05-01 23:45:06 +00:00
|
|
|
errLog.Error("Unable to dial edge")
|
|
|
|
case h2mux.MuxerHandshakeError:
|
|
|
|
errLog.Error("Handshake failed with edge server")
|
|
|
|
default:
|
|
|
|
errLog.Error("Tunnel creation failure")
|
|
|
|
return err, false
|
|
|
|
}
|
|
|
|
return err, true
|
|
|
|
}
|
|
|
|
|
|
|
|
errGroup, serveCtx := errgroup.WithContext(ctx)
|
|
|
|
|
2019-12-06 21:32:15 +00:00
|
|
|
errGroup.Go(func() (err error) {
|
|
|
|
defer func() {
|
|
|
|
if err == nil {
|
|
|
|
connectedFuse.Fuse(true)
|
|
|
|
backoff.SetGracePeriod()
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
if config.UseReconnectToken && connectedFuse.Value() {
|
|
|
|
token, tokenErr := credentialManager.ReconnectToken()
|
|
|
|
eventDigest, eventDigestErr := credentialManager.EventDigest()
|
|
|
|
// if we have both credentials, we can reconnect
|
|
|
|
if tokenErr == nil && eventDigestErr == nil {
|
2020-01-28 22:16:56 +00:00
|
|
|
return ReconnectTunnel(serveCtx, token, eventDigest, handler.muxer, config, logger, connectionID, originLocalIP, u)
|
2019-12-06 21:32:15 +00:00
|
|
|
}
|
|
|
|
// log errors and proceed to RegisterTunnel
|
|
|
|
if tokenErr != nil {
|
|
|
|
logger.WithError(tokenErr).Error("Couldn't get reconnect token")
|
|
|
|
}
|
|
|
|
if eventDigestErr != nil {
|
|
|
|
logger.WithError(eventDigestErr).Error("Couldn't get event digest")
|
|
|
|
}
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-12-06 21:32:15 +00:00
|
|
|
return RegisterTunnel(serveCtx, credentialManager, handler.muxer, config, logger, connectionID, originLocalIP, u)
|
2018-05-01 23:45:06 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
errGroup.Go(func() error {
|
|
|
|
updateMetricsTickC := time.Tick(config.MetricsUpdateFreq)
|
|
|
|
for {
|
|
|
|
select {
|
|
|
|
case <-serveCtx.Done():
|
|
|
|
// UnregisterTunnel blocks until the RPC call returns
|
2019-01-28 20:11:56 +00:00
|
|
|
err := UnregisterTunnel(handler.muxer, config.GracePeriod, config.TransportLogger)
|
2018-05-01 23:45:06 +00:00
|
|
|
handler.muxer.Shutdown()
|
|
|
|
return err
|
|
|
|
case <-updateMetricsTickC:
|
|
|
|
handler.UpdateMetrics(connectionTag)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
errGroup.Go(func() error {
|
|
|
|
// All routines should stop when muxer finish serving. When muxer is shutdown
|
|
|
|
// gracefully, it doesn't return an error, so we need to return errMuxerShutdown
|
|
|
|
// here to notify other routines to stop
|
|
|
|
err := handler.muxer.Serve(serveCtx)
|
|
|
|
if err == nil {
|
|
|
|
return muxerShutdownError{}
|
|
|
|
}
|
|
|
|
return err
|
|
|
|
})
|
|
|
|
|
|
|
|
err = errGroup.Wait()
|
|
|
|
if err != nil {
|
2020-01-28 17:29:33 +00:00
|
|
|
_ = newClientRegisterTunnelError(err, config.Metrics.regFail, unknown)
|
2019-11-04 11:11:54 +00:00
|
|
|
|
2018-05-01 23:45:06 +00:00
|
|
|
switch castedErr := err.(type) {
|
|
|
|
case dupConnRegisterTunnelError:
|
|
|
|
logger.Info("Already connected to this server, selecting a different one")
|
|
|
|
return err, true
|
|
|
|
case serverRegisterTunnelError:
|
|
|
|
logger.WithError(castedErr.cause).Error("Register tunnel error from server side")
|
|
|
|
// Don't send registration error return from server to Sentry. They are
|
|
|
|
// logged on server side
|
2019-01-10 20:55:44 +00:00
|
|
|
if incidents := config.IncidentLookup.ActiveIncidents(); len(incidents) > 0 {
|
|
|
|
logger.Error(activeIncidentsMsg(incidents))
|
|
|
|
}
|
2018-05-01 23:45:06 +00:00
|
|
|
return castedErr.cause, !castedErr.permanent
|
|
|
|
case clientRegisterTunnelError:
|
|
|
|
logger.WithError(castedErr.cause).Error("Register tunnel error on client side")
|
|
|
|
return err, true
|
|
|
|
case muxerShutdownError:
|
|
|
|
logger.Infof("Muxer shutdown")
|
|
|
|
return err, true
|
|
|
|
default:
|
|
|
|
logger.WithError(err).Error("Serve tunnel error")
|
|
|
|
return err, true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil, true
|
|
|
|
}
|
|
|
|
|
2018-11-26 17:32:27 +00:00
|
|
|
func RegisterTunnel(
|
|
|
|
ctx context.Context,
|
2019-12-06 21:32:15 +00:00
|
|
|
credentialManager ReconnectTunnelCredentialManager,
|
2018-11-26 17:32:27 +00:00
|
|
|
muxer *h2mux.Muxer,
|
|
|
|
config *TunnelConfig,
|
2019-10-14 23:36:34 +00:00
|
|
|
logger *log.Entry,
|
2018-11-26 17:32:27 +00:00
|
|
|
connectionID uint8,
|
|
|
|
originLocalIP string,
|
2019-02-01 20:11:12 +00:00
|
|
|
uuid uuid.UUID,
|
2018-11-26 17:32:27 +00:00
|
|
|
) error {
|
2019-01-28 20:11:56 +00:00
|
|
|
config.TransportLogger.Debug("initiating RPC stream to register")
|
2019-11-21 17:03:13 +00:00
|
|
|
tunnelServer, err := connection.NewRPCClient(ctx, muxer, config.TransportLogger.WithField("subsystem", "rpc-register"), openStreamTimeout)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
|
|
|
// RPC stream open error
|
2020-01-28 17:29:33 +00:00
|
|
|
return newClientRegisterTunnelError(err, config.Metrics.rpcFail, register)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-11-21 17:03:13 +00:00
|
|
|
defer tunnelServer.Close()
|
2018-05-01 23:45:06 +00:00
|
|
|
// Request server info without blocking tunnel registration; must use capnp library directly.
|
2019-11-21 17:03:13 +00:00
|
|
|
serverInfoPromise := tunnelrpc.TunnelServer{Client: tunnelServer.Client}.GetServerInfo(ctx, func(tunnelrpc.TunnelServer_getServerInfo_Params) error {
|
2018-05-01 23:45:06 +00:00
|
|
|
return nil
|
|
|
|
})
|
2019-11-21 16:56:04 +00:00
|
|
|
LogServerInfo(serverInfoPromise.Result(), connectionID, config.Metrics, logger)
|
2019-11-21 17:03:13 +00:00
|
|
|
registration := tunnelServer.RegisterTunnel(
|
2018-05-01 23:45:06 +00:00
|
|
|
ctx,
|
|
|
|
config.OriginCert,
|
|
|
|
config.Hostname,
|
2018-10-08 19:20:28 +00:00
|
|
|
config.RegistrationOptions(connectionID, originLocalIP, uuid),
|
2018-05-01 23:45:06 +00:00
|
|
|
)
|
2019-11-21 16:56:04 +00:00
|
|
|
if registrationErr := registration.DeserializeError(); registrationErr != nil {
|
2018-05-01 23:45:06 +00:00
|
|
|
// RegisterTunnel RPC failure
|
2020-01-28 16:43:37 +00:00
|
|
|
return processRegisterTunnelError(registrationErr, config.Metrics, register)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-12-06 21:32:15 +00:00
|
|
|
credentialManager.SetEventDigest(registration.EventDigest)
|
2020-01-28 16:43:37 +00:00
|
|
|
return processRegistrationSuccess(config, logger, connectionID, registration, register)
|
2019-12-06 21:32:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func ReconnectTunnel(
|
|
|
|
ctx context.Context,
|
|
|
|
token []byte,
|
|
|
|
eventDigest []byte,
|
|
|
|
muxer *h2mux.Muxer,
|
|
|
|
config *TunnelConfig,
|
|
|
|
logger *log.Entry,
|
|
|
|
connectionID uint8,
|
|
|
|
originLocalIP string,
|
|
|
|
uuid uuid.UUID,
|
|
|
|
) error {
|
|
|
|
config.TransportLogger.Debug("initiating RPC stream to reconnect")
|
|
|
|
tunnelServer, err := connection.NewRPCClient(ctx, muxer, config.TransportLogger.WithField("subsystem", "rpc-reconnect"), openStreamTimeout)
|
|
|
|
if err != nil {
|
|
|
|
// RPC stream open error
|
2020-01-28 17:29:33 +00:00
|
|
|
return newClientRegisterTunnelError(err, config.Metrics.rpcFail, reconnect)
|
2019-12-06 21:32:15 +00:00
|
|
|
}
|
|
|
|
defer tunnelServer.Close()
|
|
|
|
// Request server info without blocking tunnel registration; must use capnp library directly.
|
|
|
|
serverInfoPromise := tunnelrpc.TunnelServer{Client: tunnelServer.Client}.GetServerInfo(ctx, func(tunnelrpc.TunnelServer_getServerInfo_Params) error {
|
|
|
|
return nil
|
|
|
|
})
|
|
|
|
LogServerInfo(serverInfoPromise.Result(), connectionID, config.Metrics, logger)
|
|
|
|
registration := tunnelServer.ReconnectTunnel(
|
|
|
|
ctx,
|
|
|
|
token,
|
|
|
|
eventDigest,
|
|
|
|
config.Hostname,
|
|
|
|
config.RegistrationOptions(connectionID, originLocalIP, uuid),
|
|
|
|
)
|
|
|
|
if registrationErr := registration.DeserializeError(); registrationErr != nil {
|
|
|
|
// ReconnectTunnel RPC failure
|
2020-01-28 16:43:37 +00:00
|
|
|
return processRegisterTunnelError(registrationErr, config.Metrics, reconnect)
|
2019-12-06 21:32:15 +00:00
|
|
|
}
|
2020-01-28 16:43:37 +00:00
|
|
|
return processRegistrationSuccess(config, logger, connectionID, registration, reconnect)
|
2019-12-06 21:32:15 +00:00
|
|
|
}
|
2019-11-21 16:56:04 +00:00
|
|
|
|
2020-01-28 16:43:37 +00:00
|
|
|
func processRegistrationSuccess(config *TunnelConfig, logger *log.Entry, connectionID uint8, registration *tunnelpogs.TunnelRegistration, name registerRPCName) error {
|
2018-05-01 23:45:06 +00:00
|
|
|
for _, logLine := range registration.LogLines {
|
2019-10-14 23:36:34 +00:00
|
|
|
logger.Info(logLine)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-02-19 23:01:30 +00:00
|
|
|
|
2018-10-08 19:20:28 +00:00
|
|
|
if registration.TunnelID != "" {
|
2018-11-26 17:32:27 +00:00
|
|
|
config.Metrics.tunnelsHA.AddTunnelID(connectionID, registration.TunnelID)
|
2019-10-14 23:36:34 +00:00
|
|
|
logger.Infof("Each HA connection's tunnel IDs: %v", config.Metrics.tunnelsHA.String())
|
2018-10-08 19:20:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Print out the user's trial zone URL in a nice box (if they requested and got one)
|
2018-10-09 00:13:08 +00:00
|
|
|
if isTrialTunnel := config.Hostname == ""; isTrialTunnel {
|
|
|
|
if url, err := url.Parse(registration.Url); err == nil {
|
|
|
|
for _, line := range asciiBox(trialZoneMsg(url.String()), 2) {
|
2019-10-14 23:36:34 +00:00
|
|
|
logger.Infoln(line)
|
2018-10-09 00:13:08 +00:00
|
|
|
}
|
|
|
|
} else {
|
2019-10-14 23:36:34 +00:00
|
|
|
logger.Errorln("Failed to connect tunnel, please try again.")
|
2018-10-09 00:13:08 +00:00
|
|
|
return fmt.Errorf("empty URL in response from Cloudflare edge")
|
2018-10-08 19:20:28 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-04-16 20:26:31 +00:00
|
|
|
config.Metrics.userHostnamesCounts.WithLabelValues(registration.Url).Inc()
|
|
|
|
|
2019-10-14 23:36:34 +00:00
|
|
|
logger.Infof("Route propagating, it may take up to 1 minute for your new route to become functional")
|
2020-01-28 16:43:37 +00:00
|
|
|
config.Metrics.regSuccess.WithLabelValues(string(name)).Inc()
|
2018-05-01 23:45:06 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-01-28 16:43:37 +00:00
|
|
|
func processRegisterTunnelError(err tunnelpogs.TunnelRegistrationError, metrics *TunnelMetrics, name registerRPCName) error {
|
2019-11-21 16:56:04 +00:00
|
|
|
if err.Error() == DuplicateConnectionError {
|
2020-01-28 16:43:37 +00:00
|
|
|
metrics.regFail.WithLabelValues("dup_edge_conn", string(name)).Inc()
|
2019-02-19 23:01:30 +00:00
|
|
|
return dupConnRegisterTunnelError{}
|
|
|
|
}
|
2020-01-28 16:43:37 +00:00
|
|
|
metrics.regFail.WithLabelValues("server_error", string(name)).Inc()
|
2019-02-19 23:01:30 +00:00
|
|
|
return serverRegisterTunnelError{
|
2019-11-21 16:56:04 +00:00
|
|
|
cause: fmt.Errorf("Server error: %s", err.Error()),
|
|
|
|
permanent: err.IsPermanent(),
|
2019-02-19 23:01:30 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-05-01 23:45:06 +00:00
|
|
|
func UnregisterTunnel(muxer *h2mux.Muxer, gracePeriod time.Duration, logger *log.Logger) error {
|
|
|
|
logger.Debug("initiating RPC stream to unregister")
|
2019-04-02 23:12:09 +00:00
|
|
|
ctx := context.Background()
|
2019-12-06 21:32:15 +00:00
|
|
|
tunnelServer, err := connection.NewRPCClient(ctx, muxer, logger.WithField("subsystem", "rpc-unregister"), openStreamTimeout)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
|
|
|
// RPC stream open error
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
// gracePeriod is encoded in int64 using capnproto
|
2019-12-06 21:32:15 +00:00
|
|
|
return tunnelServer.UnregisterTunnel(ctx, gracePeriod.Nanoseconds())
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func LogServerInfo(
|
|
|
|
promise tunnelrpc.ServerInfo_Promise,
|
|
|
|
connectionID uint8,
|
|
|
|
metrics *TunnelMetrics,
|
2019-10-14 23:36:34 +00:00
|
|
|
logger *log.Entry,
|
2018-05-01 23:45:06 +00:00
|
|
|
) {
|
|
|
|
serverInfoMessage, err := promise.Struct()
|
|
|
|
if err != nil {
|
|
|
|
logger.WithError(err).Warn("Failed to retrieve server information")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
serverInfo, err := tunnelpogs.UnmarshalServerInfo(serverInfoMessage)
|
|
|
|
if err != nil {
|
|
|
|
logger.WithError(err).Warn("Failed to retrieve server information")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
logger.Infof("Connected to %s", serverInfo.LocationName)
|
|
|
|
metrics.registerServerLocation(uint8ToString(connectionID), serverInfo.LocationName)
|
|
|
|
}
|
|
|
|
|
|
|
|
func H1ResponseToH2Response(h1 *http.Response) (h2 []h2mux.Header) {
|
|
|
|
h2 = []h2mux.Header{{Name: ":status", Value: fmt.Sprintf("%d", h1.StatusCode)}}
|
|
|
|
for headerName, headerValues := range h1.Header {
|
|
|
|
for _, headerValue := range headerValues {
|
|
|
|
h2 = append(h2, h2mux.Header{Name: strings.ToLower(headerName), Value: headerValue})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
type TunnelHandler struct {
|
2019-08-30 03:55:54 +00:00
|
|
|
originUrl string
|
|
|
|
httpHostHeader string
|
|
|
|
muxer *h2mux.Muxer
|
|
|
|
httpClient http.RoundTripper
|
|
|
|
tlsConfig *tls.Config
|
|
|
|
tags []tunnelpogs.Tag
|
|
|
|
metrics *TunnelMetrics
|
2018-05-01 23:45:06 +00:00
|
|
|
// connectionID is only used by metrics, and prometheus requires labels to be string
|
|
|
|
connectionID string
|
|
|
|
logger *log.Logger
|
|
|
|
noChunkedEncoding bool
|
|
|
|
}
|
|
|
|
|
2019-11-22 18:07:14 +00:00
|
|
|
var dialer = net.Dialer{}
|
2018-05-01 23:45:06 +00:00
|
|
|
|
|
|
|
// NewTunnelHandler returns a TunnelHandler, origin LAN IP and error
|
|
|
|
func NewTunnelHandler(ctx context.Context,
|
|
|
|
config *TunnelConfig,
|
2019-11-21 18:10:44 +00:00
|
|
|
addr *net.TCPAddr,
|
2018-05-01 23:45:06 +00:00
|
|
|
connectionID uint8,
|
|
|
|
) (*TunnelHandler, string, error) {
|
|
|
|
originURL, err := validation.ValidateUrl(config.OriginUrl)
|
|
|
|
if err != nil {
|
2018-10-08 19:20:28 +00:00
|
|
|
return nil, "", fmt.Errorf("unable to parse origin URL %#v", originURL)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
h := &TunnelHandler{
|
|
|
|
originUrl: originURL,
|
2019-08-30 03:55:54 +00:00
|
|
|
httpHostHeader: config.HTTPHostHeader,
|
2018-05-01 23:45:06 +00:00
|
|
|
httpClient: config.HTTPTransport,
|
|
|
|
tlsConfig: config.ClientTlsConfig,
|
|
|
|
tags: config.Tags,
|
|
|
|
metrics: config.Metrics,
|
|
|
|
connectionID: uint8ToString(connectionID),
|
|
|
|
logger: config.Logger,
|
|
|
|
noChunkedEncoding: config.NoChunkedEncoding,
|
|
|
|
}
|
|
|
|
if h.httpClient == nil {
|
|
|
|
h.httpClient = http.DefaultTransport
|
|
|
|
}
|
2019-11-21 18:10:44 +00:00
|
|
|
|
|
|
|
edgeConn, err := connection.DialEdge(ctx, dialTimeout, config.TlsConfig, addr)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
2019-11-21 18:10:44 +00:00
|
|
|
return nil, "", err
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
// Establish a muxed connection with the edge
|
|
|
|
// Client mux handshake with agent server
|
2019-12-04 17:22:08 +00:00
|
|
|
h.muxer, err = h2mux.Handshake(edgeConn, edgeConn, config.muxerConfig(h), h.metrics.activeStreams)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
2019-12-24 05:11:00 +00:00
|
|
|
return nil, "", errors.Wrap(err, "h2mux handshake with edge error")
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-11-21 18:10:44 +00:00
|
|
|
return h, edgeConn.LocalAddr().String(), nil
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) AppendTagHeaders(r *http.Request) {
|
|
|
|
for _, tag := range h.tags {
|
|
|
|
r.Header.Add(TagHeaderNamePrefix+tag.Name, tag.Value)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) ServeStream(stream *h2mux.MuxedStream) error {
|
|
|
|
h.metrics.incrementRequests(h.connectionID)
|
2019-04-25 23:13:06 +00:00
|
|
|
defer h.metrics.decrementConcurrentRequests(h.connectionID)
|
|
|
|
|
|
|
|
req, reqErr := h.createRequest(stream)
|
|
|
|
if reqErr != nil {
|
|
|
|
h.logError(stream, reqErr)
|
|
|
|
return reqErr
|
|
|
|
}
|
|
|
|
|
2019-06-05 15:08:55 +00:00
|
|
|
cfRay := streamhandler.FindCfRayHeader(req)
|
|
|
|
lbProbe := streamhandler.IsLBProbeRequest(req)
|
2019-04-25 23:13:06 +00:00
|
|
|
h.logRequest(req, cfRay, lbProbe)
|
|
|
|
|
|
|
|
var resp *http.Response
|
|
|
|
var respErr error
|
|
|
|
if websocket.IsWebSocketUpgrade(req) {
|
|
|
|
resp, respErr = h.serveWebsocket(stream, req)
|
|
|
|
} else {
|
|
|
|
resp, respErr = h.serveHTTP(stream, req)
|
|
|
|
}
|
|
|
|
if respErr != nil {
|
|
|
|
h.logError(stream, respErr)
|
|
|
|
return respErr
|
|
|
|
}
|
|
|
|
h.logResponseOk(resp, cfRay, lbProbe)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) createRequest(stream *h2mux.MuxedStream) (*http.Request, error) {
|
2018-05-01 23:45:06 +00:00
|
|
|
req, err := http.NewRequest("GET", h.originUrl, h2mux.MuxedStreamReader{MuxedStream: stream})
|
|
|
|
if err != nil {
|
2019-04-25 23:13:06 +00:00
|
|
|
return nil, errors.Wrap(err, "Unexpected error from http.NewRequest")
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-06-05 15:08:55 +00:00
|
|
|
err = streamhandler.H2RequestHeadersToH1Request(stream.Headers, req)
|
2018-05-01 23:45:06 +00:00
|
|
|
if err != nil {
|
2019-04-25 23:13:06 +00:00
|
|
|
return nil, errors.Wrap(err, "invalid request received")
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
h.AppendTagHeaders(req)
|
2019-04-25 23:13:06 +00:00
|
|
|
return req, nil
|
|
|
|
}
|
2018-05-01 23:45:06 +00:00
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
func (h *TunnelHandler) serveWebsocket(stream *h2mux.MuxedStream, req *http.Request) (*http.Response, error) {
|
2019-08-30 03:55:54 +00:00
|
|
|
if h.httpHostHeader != "" {
|
|
|
|
req.Header.Set("Host", h.httpHostHeader)
|
|
|
|
req.Host = h.httpHostHeader
|
|
|
|
}
|
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
conn, response, err := websocket.ClientConnect(req, h.tlsConfig)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
defer conn.Close()
|
|
|
|
err = stream.WriteHeaders(H1ResponseToH2Response(response))
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "Error writing response header")
|
|
|
|
}
|
|
|
|
// Copy to/from stream to the undelying connection. Use the underlying
|
|
|
|
// connection because cloudflared doesn't operate on the message themselves
|
|
|
|
websocket.Stream(conn.UnderlyingConn(), stream)
|
|
|
|
return response, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) serveHTTP(stream *h2mux.MuxedStream, req *http.Request) (*http.Response, error) {
|
|
|
|
// Support for WSGI Servers by switching transfer encoding from chunked to gzip/deflate
|
|
|
|
if h.noChunkedEncoding {
|
|
|
|
req.TransferEncoding = []string{"gzip", "deflate"}
|
|
|
|
cLength, err := strconv.Atoi(req.Header.Get("Content-Length"))
|
|
|
|
if err == nil {
|
|
|
|
req.ContentLength = int64(cLength)
|
|
|
|
}
|
|
|
|
}
|
2018-07-30 14:23:55 +00:00
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
// Request origin to keep connection alive to improve performance
|
|
|
|
req.Header.Set("Connection", "keep-alive")
|
2018-05-01 23:45:06 +00:00
|
|
|
|
2019-08-30 03:55:54 +00:00
|
|
|
if h.httpHostHeader != "" {
|
|
|
|
req.Header.Set("Host", h.httpHostHeader)
|
|
|
|
req.Host = h.httpHostHeader
|
|
|
|
}
|
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
response, err := h.httpClient.RoundTrip(req)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "Error proxying request to origin")
|
|
|
|
}
|
|
|
|
defer response.Body.Close()
|
2018-05-01 23:45:06 +00:00
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
err = stream.WriteHeaders(H1ResponseToH2Response(response))
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "Error writing response header")
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
2019-04-25 23:13:06 +00:00
|
|
|
if h.isEventStream(response) {
|
|
|
|
h.writeEventStream(stream, response.Body)
|
|
|
|
} else {
|
|
|
|
// Use CopyBuffer, because Copy only allocates a 32KiB buffer, and cross-stream
|
|
|
|
// compression generates dictionary on first write
|
|
|
|
io.CopyBuffer(stream, response.Body, make([]byte, 512*1024))
|
|
|
|
}
|
|
|
|
return response, nil
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) writeEventStream(stream *h2mux.MuxedStream, responseBody io.ReadCloser) {
|
|
|
|
reader := bufio.NewReader(responseBody)
|
|
|
|
for {
|
|
|
|
line, err := reader.ReadBytes('\n')
|
|
|
|
if err != nil {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
stream.Write(line)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) isEventStream(response *http.Response) bool {
|
|
|
|
if response.Header.Get("content-type") == "text/event-stream" {
|
|
|
|
h.logger.Debug("Detected Server-Side Events from Origin")
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) logError(stream *h2mux.MuxedStream, err error) {
|
|
|
|
h.logger.WithError(err).Error("HTTP request error")
|
|
|
|
stream.WriteHeaders([]h2mux.Header{{Name: ":status", Value: "502"}})
|
|
|
|
stream.Write([]byte("502 Bad Gateway"))
|
|
|
|
h.metrics.incrementResponses(h.connectionID, "502")
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) logRequest(req *http.Request, cfRay string, lbProbe bool) {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger := log.NewEntry(h.logger)
|
2018-05-01 23:45:06 +00:00
|
|
|
if cfRay != "" {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger = logger.WithField("CF-RAY", cfRay)
|
|
|
|
logger.Debugf("%s %s %s", req.Method, req.URL, req.Proto)
|
2018-05-01 23:45:06 +00:00
|
|
|
} else if lbProbe {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger.Debugf("Load Balancer health check %s %s %s", req.Method, req.URL, req.Proto)
|
2018-05-01 23:45:06 +00:00
|
|
|
} else {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger.Warnf("All requests should have a CF-RAY header. Please open a support ticket with Cloudflare. %s %s %s ", req.Method, req.URL, req.Proto)
|
|
|
|
}
|
|
|
|
logger.Debugf("Request Headers %+v", req.Header)
|
|
|
|
|
|
|
|
if contentLen := req.ContentLength; contentLen == -1 {
|
|
|
|
logger.Debugf("Request Content length unknown")
|
|
|
|
} else {
|
|
|
|
logger.Debugf("Request content length %d", contentLen)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-04-25 23:13:06 +00:00
|
|
|
func (h *TunnelHandler) logResponseOk(r *http.Response, cfRay string, lbProbe bool) {
|
|
|
|
h.metrics.incrementResponses(h.connectionID, "200")
|
2019-01-28 20:05:59 +00:00
|
|
|
logger := log.NewEntry(h.logger)
|
2018-05-01 23:45:06 +00:00
|
|
|
if cfRay != "" {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger = logger.WithField("CF-RAY", cfRay)
|
|
|
|
logger.Debugf("%s", r.Status)
|
2018-05-01 23:45:06 +00:00
|
|
|
} else if lbProbe {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger.Debugf("Response to Load Balancer health check %s", r.Status)
|
|
|
|
} else {
|
|
|
|
logger.Infof("%s", r.Status)
|
|
|
|
}
|
|
|
|
logger.Debugf("Response Headers %+v", r.Header)
|
|
|
|
|
|
|
|
if contentLen := r.ContentLength; contentLen == -1 {
|
|
|
|
logger.Debugf("Response content length unknown")
|
2018-05-01 23:45:06 +00:00
|
|
|
} else {
|
2019-01-28 20:05:59 +00:00
|
|
|
logger.Debugf("Response content length %d", contentLen)
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (h *TunnelHandler) UpdateMetrics(connectionID string) {
|
|
|
|
h.metrics.updateMuxerMetrics(connectionID, h.muxer.Metrics())
|
|
|
|
}
|
|
|
|
|
|
|
|
func uint8ToString(input uint8) string {
|
|
|
|
return strconv.FormatUint(uint64(input), 10)
|
|
|
|
}
|
|
|
|
|
2018-10-08 19:20:28 +00:00
|
|
|
// Print out the given lines in a nice ASCII box.
|
|
|
|
func asciiBox(lines []string, padding int) (box []string) {
|
|
|
|
maxLen := maxLen(lines)
|
|
|
|
spacer := strings.Repeat(" ", padding)
|
|
|
|
|
|
|
|
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
|
|
|
|
|
|
|
|
box = append(box, border)
|
|
|
|
for _, line := range lines {
|
|
|
|
box = append(box, "|"+spacer+line+strings.Repeat(" ", maxLen-len(line))+spacer+"|")
|
|
|
|
}
|
|
|
|
box = append(box, border)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func maxLen(lines []string) int {
|
|
|
|
max := 0
|
|
|
|
for _, line := range lines {
|
|
|
|
if len(line) > max {
|
|
|
|
max = len(line)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return max
|
|
|
|
}
|
|
|
|
|
|
|
|
func trialZoneMsg(url string) []string {
|
|
|
|
return []string{
|
|
|
|
"Your free tunnel has started! Visit it:",
|
|
|
|
" " + url,
|
|
|
|
}
|
|
|
|
}
|
2019-01-10 20:55:44 +00:00
|
|
|
|
|
|
|
func activeIncidentsMsg(incidents []Incident) string {
|
|
|
|
preamble := "There is an active Cloudflare incident that may be related:"
|
|
|
|
if len(incidents) > 1 {
|
|
|
|
preamble = "There are active Cloudflare incidents that may be related:"
|
|
|
|
}
|
|
|
|
incidentStrings := []string{}
|
|
|
|
for _, incident := range incidents {
|
|
|
|
incidentString := fmt.Sprintf("%s (%s)", incident.Name, incident.URL())
|
|
|
|
incidentStrings = append(incidentStrings, incidentString)
|
|
|
|
}
|
|
|
|
return preamble + " " + strings.Join(incidentStrings, "; ")
|
|
|
|
|
|
|
|
}
|