cloudflared-mirror/ingress/ingress.go

405 lines
13 KiB
Go

package ingress
import (
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"golang.org/x/net/idna"
"github.com/cloudflare/cloudflared/config"
"github.com/cloudflare/cloudflared/ingress/middleware"
"github.com/cloudflare/cloudflared/ipaccess"
)
var (
ErrNoIngressRules = errors.New("The config file doesn't contain any ingress rules")
ErrNoIngressRulesCLI = errors.New("No ingress rules were defined in provided config (if any) nor from the cli, cloudflared will return 503 for all incoming HTTP requests")
errLastRuleNotCatchAll = errors.New("The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)")
errBadWildcard = errors.New("Hostname patterns can have at most one wildcard character (\"*\") and it can only be used for subdomains, e.g. \"*.example.com\"")
errHostnameContainsPort = errors.New("Hostname cannot contain a port")
errHostnameContainsScheme = errors.New("Hostname cannot contain a scheme (e.g. http://, https://, etc.)")
ErrURLIncompatibleWithIngress = errors.New("You can't set the --url flag (or $TUNNEL_URL) when using multiple-origin ingress rules")
)
const (
ServiceBastion = "bastion"
ServiceSocksProxy = "socks-proxy"
ServiceWarpRouting = "warp-routing"
)
// FindMatchingRule returns the index of the Ingress Rule which matches the given
// hostname and path. This function assumes the last rule matches everything,
// which is the case if the rules were instantiated via the ingress#Validate method.
//
// Negative index rule signifies local cloudflared rules (not-user defined).
func (ing Ingress) FindMatchingRule(hostname, path string) (*Rule, int) {
// The hostname might contain port. We only want to compare the host part with the rule
host, _, err := net.SplitHostPort(hostname)
if err == nil {
hostname = host
}
for i, rule := range ing.InternalRules {
if rule.Matches(hostname, path) {
// Local rule matches return a negative rule index to distiguish local rules from user-defined rules in logs
// Full range would be [-1 .. )
return &rule, -1 - i
}
}
for i, rule := range ing.Rules {
if rule.Matches(hostname, path) {
return &rule, i
}
}
i := len(ing.Rules) - 1
return &ing.Rules[i], i
}
func matchHost(ruleHost, reqHost string) bool {
if ruleHost == reqHost {
return true
}
// Validate hostnames that use wildcards at the start
if strings.HasPrefix(ruleHost, "*.") {
toMatch := strings.TrimPrefix(ruleHost, "*")
return strings.HasSuffix(reqHost, toMatch)
}
return false
}
// Ingress maps eyeball requests to origins.
type Ingress struct {
// Set of ingress rules that are not added to remote config, e.g. management
InternalRules []Rule
// Rules that are provided by the user from remote or local configuration
Rules []Rule `json:"ingress"`
Defaults OriginRequestConfig `json:"originRequest"`
}
// ParseIngress parses ingress rules, but does not send HTTP requests to the origins.
func ParseIngress(conf *config.Configuration) (Ingress, error) {
if len(conf.Ingress) == 0 {
return Ingress{}, ErrNoIngressRules
}
return validateIngress(conf.Ingress, originRequestFromConfig(conf.OriginRequest))
}
// ParseIngressFromConfigAndCLI will parse the configuration rules from config files for ingress
// rules and then attempt to parse CLI for ingress rules.
// Will always return at least one valid ingress rule. If none are provided by the user, the default
// will be to return 503 status code for all incoming requests.
func ParseIngressFromConfigAndCLI(conf *config.Configuration, c *cli.Context, log *zerolog.Logger) (Ingress, error) {
// Attempt to parse ingress rules from configuration
ingressRules, err := ParseIngress(conf)
if err == nil && !ingressRules.IsEmpty() {
return ingressRules, nil
}
if err != ErrNoIngressRules {
return Ingress{}, err
}
// Attempt to parse ingress rules from CLI:
// --url or --unix-socket flag for a tunnel HTTP ingress
// --hello-world for a basic HTTP ingress self-served
// --bastion for ssh bastion service
ingressRules, err = parseCLIIngress(c, false)
if errors.Is(err, ErrNoIngressRulesCLI) {
// If no token is provided, the probability of NOT being a remotely managed tunnel is higher.
// So, we should warn the user that no ingress rules were found, because remote configuration will most likely not exist.
if !c.IsSet("token") {
log.Warn().Msgf(ErrNoIngressRulesCLI.Error())
}
return newDefaultOrigin(c, log), nil
}
if err != nil {
return Ingress{}, err
}
return ingressRules, nil
}
// parseCLIIngress constructs an Ingress set with only one rule constructed from
// CLI parameters: --url, --hello-world, --bastion, or --unix-socket
func parseCLIIngress(c *cli.Context, allowURLFromArgs bool) (Ingress, error) {
service, err := parseSingleOriginService(c, allowURLFromArgs)
if err != nil {
return Ingress{}, err
}
// Construct an Ingress with the single rule.
defaults := originRequestFromSingleRule(c)
ing := Ingress{
Rules: []Rule{
{
Service: service,
Config: setConfig(defaults, config.OriginRequestConfig{}),
},
},
Defaults: defaults,
}
return ing, err
}
// newDefaultOrigin always returns a 503 response code to help indicate that there are no ingress
// rules setup, but the tunnel is reachable.
func newDefaultOrigin(c *cli.Context, log *zerolog.Logger) Ingress {
defaultRule := GetDefaultIngressRules(log)
defaults := originRequestFromSingleRule(c)
ingress := Ingress{
Rules: defaultRule,
Defaults: defaults,
}
return ingress
}
// Get a single origin service from the CLI/config.
func parseSingleOriginService(c *cli.Context, allowURLFromArgs bool) (OriginService, error) {
if c.IsSet(HelloWorldFlag) {
return new(helloWorld), nil
}
if c.IsSet(config.BastionFlag) {
return newBastionService(), nil
}
if c.IsSet("url") {
originURL, err := config.ValidateUrl(c, allowURLFromArgs)
if err != nil {
return nil, errors.Wrap(err, "Error validating origin URL")
}
if isHTTPService(originURL) {
return &httpService{
url: originURL,
}, nil
}
return newTCPOverWSService(originURL), nil
}
if c.IsSet("unix-socket") {
path, err := config.ValidateUnixSocket(c)
if err != nil {
return nil, errors.Wrap(err, "Error validating --unix-socket")
}
return &unixSocketPath{path: path, scheme: "http"}, nil
}
return nil, ErrNoIngressRulesCLI
}
// IsEmpty checks if there are any ingress rules.
func (ing Ingress) IsEmpty() bool {
return len(ing.Rules) == 0
}
// IsSingleRule checks if the user only specified a single ingress rule.
func (ing Ingress) IsSingleRule() bool {
return len(ing.Rules) == 1
}
// StartOrigins will start any origin services managed by cloudflared, e.g. proxy servers or Hello World.
func (ing Ingress) StartOrigins(
log *zerolog.Logger,
shutdownC <-chan struct{},
) error {
for _, rule := range ing.Rules {
if err := rule.Service.start(log, shutdownC, rule.Config); err != nil {
return errors.Wrapf(err, "Error starting local service %s", rule.Service)
}
}
return nil
}
// CatchAll returns the catch-all rule (i.e. the last rule)
func (ing Ingress) CatchAll() *Rule {
return &ing.Rules[len(ing.Rules)-1]
}
// Gets the default ingress rule that will be return 503 status
// code for all incoming requests.
func GetDefaultIngressRules(log *zerolog.Logger) []Rule {
noRulesService := newDefaultStatusCode(log)
return []Rule{
{
Service: &noRulesService,
},
}
}
func validateAccessConfiguration(cfg *config.AccessConfig) error {
if !cfg.Required {
return nil
}
// we allow for an initial setup where user can force Access but not configure the rest of the keys.
// however, if the user specified audTags but forgot teamName, we should alert it.
if cfg.TeamName == "" && len(cfg.AudTag) > 0 {
return errors.New("access.TeamName cannot be blank when access.audTags are present")
}
return nil
}
func validateIngress(ingress []config.UnvalidatedIngressRule, defaults OriginRequestConfig) (Ingress, error) {
rules := make([]Rule, len(ingress))
for i, r := range ingress {
cfg := setConfig(defaults, r.OriginRequest)
var service OriginService
if prefix := "unix:"; strings.HasPrefix(r.Service, prefix) {
// No validation necessary for unix socket filepath services
path := strings.TrimPrefix(r.Service, prefix)
service = &unixSocketPath{path: path, scheme: "http"}
} else if prefix := "unix+tls:"; strings.HasPrefix(r.Service, prefix) {
path := strings.TrimPrefix(r.Service, prefix)
service = &unixSocketPath{path: path, scheme: "https"}
} else if prefix := "http_status:"; strings.HasPrefix(r.Service, prefix) {
statusCode, err := strconv.Atoi(strings.TrimPrefix(r.Service, prefix))
if err != nil {
return Ingress{}, errors.Wrap(err, "invalid HTTP status code")
}
if statusCode < 100 || statusCode > 999 {
return Ingress{}, fmt.Errorf("invalid HTTP status code: %d", statusCode)
}
srv := newStatusCode(statusCode)
service = &srv
} else if r.Service == HelloWorldFlag || r.Service == HelloWorldService {
service = new(helloWorld)
} else if r.Service == ServiceSocksProxy {
rules := make([]ipaccess.Rule, len(r.OriginRequest.IPRules))
for i, ipRule := range r.OriginRequest.IPRules {
rule, err := ipaccess.NewRuleByCIDR(ipRule.Prefix, ipRule.Ports, ipRule.Allow)
if err != nil {
return Ingress{}, fmt.Errorf("unable to create ip rule for %s: %s", r.Service, err)
}
rules[i] = rule
}
accessPolicy, err := ipaccess.NewPolicy(false, rules)
if err != nil {
return Ingress{}, fmt.Errorf("unable to create ip access policy for %s: %s", r.Service, err)
}
service = newSocksProxyOverWSService(accessPolicy)
} else if r.Service == ServiceBastion || cfg.BastionMode {
// Bastion mode will always start a Websocket proxy server, which will
// overwrite the localService.URL field when `start` is called. So,
// leave the URL field empty for now.
cfg.BastionMode = true
service = newBastionService()
} else {
// Validate URL services
u, err := url.Parse(r.Service)
if err != nil {
return Ingress{}, err
}
if u.Scheme == "" || u.Hostname() == "" {
return Ingress{}, fmt.Errorf("%s is an invalid address, please make sure it has a scheme and a hostname", r.Service)
}
if u.Path != "" {
return Ingress{}, fmt.Errorf("%s is an invalid address, ingress rules don't support proxying to a different path on the origin service. The path will be the same as the eyeball request's path", r.Service)
}
if isHTTPService(u) {
service = &httpService{url: u}
} else {
service = newTCPOverWSService(u)
}
}
var handlers []middleware.Handler
if access := r.OriginRequest.Access; access != nil {
if err := validateAccessConfiguration(access); err != nil {
return Ingress{}, err
}
if access.Required {
verifier := middleware.NewJWTValidator(access.TeamName, "", access.AudTag)
handlers = append(handlers, verifier)
}
}
if err := validateHostname(r, i, len(ingress)); err != nil {
return Ingress{}, err
}
isCatchAllRule := (r.Hostname == "" || r.Hostname == "*") && r.Path == ""
punycodeHostname := ""
if !isCatchAllRule {
punycode, err := idna.Lookup.ToASCII(r.Hostname)
// Don't provide the punycode hostname if it is the same as the original hostname
if err == nil && punycode != r.Hostname {
punycodeHostname = punycode
}
}
var pathRegexp *Regexp
if r.Path != "" {
var err error
regex, err := regexp.Compile(r.Path)
if err != nil {
return Ingress{}, errors.Wrapf(err, "Rule #%d has an invalid regex", i+1)
}
pathRegexp = &Regexp{Regexp: regex}
}
rules[i] = Rule{
Hostname: r.Hostname,
punycodeHostname: punycodeHostname,
Service: service,
Path: pathRegexp,
Handlers: handlers,
Config: cfg,
}
}
return Ingress{Rules: rules, Defaults: defaults}, nil
}
func validateHostname(r config.UnvalidatedIngressRule, ruleIndex, totalRules int) error {
// Ensure the hostname doesn't contain a scheme so a less confusing error is returned.
if u, e := url.Parse(r.Hostname); e == nil && isHTTPService(u) {
return errHostnameContainsScheme
}
// Ensure that the hostname doesn't contain port
_, _, err := net.SplitHostPort(r.Hostname)
if err == nil {
return errHostnameContainsPort
}
// Ensure that there are no wildcards anywhere except the first character
// of the hostname.
if strings.LastIndex(r.Hostname, "*") > 0 {
return errBadWildcard
}
// The last rule should catch all hostnames.
isCatchAllRule := (r.Hostname == "" || r.Hostname == "*") && r.Path == ""
isLastRule := ruleIndex == totalRules-1
if isLastRule && !isCatchAllRule {
return errLastRuleNotCatchAll
}
// ONLY the last rule should catch all hostnames.
if !isLastRule && isCatchAllRule {
return errRuleShouldNotBeCatchAll{index: ruleIndex, hostname: r.Hostname}
}
return nil
}
type errRuleShouldNotBeCatchAll struct {
index int
hostname string
}
func (e errRuleShouldNotBeCatchAll) Error() string {
return fmt.Sprintf("Rule #%d is matching the hostname '%s', but "+
"this will match every hostname, meaning the rules which follow it "+
"will never be triggered.", e.index+1, e.hostname)
}
func isHTTPService(url *url.URL) bool {
return url.Scheme == "http" || url.Scheme == "https" || url.Scheme == "ws" || url.Scheme == "wss"
}