422 lines
14 KiB
Go
422 lines
14 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")
|
|
ErrURLIncompatibleWithIngress = errors.New("You can't set the --url flag (or $TUNNEL_URL) when using multiple-origin ingress rules")
|
|
)
|
|
|
|
const (
|
|
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, cfJumpDestinationHeader 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
|
|
}
|
|
derivedHostName := hostname
|
|
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 bastion mode is turned on and request is made as bastion, attempt
|
|
// to match a rule where jump destination header matches the hostname
|
|
if matchBastionDest(rule, cfJumpDestinationHeader) {
|
|
jumpDestinationUri, err := url.Parse(cfJumpDestinationHeader)
|
|
if err == nil {
|
|
derivedHostName = jumpDestinationUri.Hostname()
|
|
}
|
|
}
|
|
if rule.Matches(derivedHostName, path) {
|
|
return &rule, i
|
|
}
|
|
}
|
|
|
|
i := len(ing.Rules) - 1
|
|
return &ing.Rules[i], i
|
|
}
|
|
|
|
func matchBastionDest(rule Rule, cfJumpDestinationHeader string) bool {
|
|
return rule.Config.BastionMode && len(cfJumpDestinationHeader) > 0 && rule.Service != nil && rule.Service.String() != config.BastionFlag
|
|
}
|
|
|
|
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 conf == nil || 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 == config.BastionFlag || 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
|
|
|
|
if cfg.BastionMode && r.Service != config.BastionFlag {
|
|
u, err := url.Parse(r.Service)
|
|
if err != nil {
|
|
return Ingress{}, err
|
|
}
|
|
service = newBastionServiceWithDest(u)
|
|
} else {
|
|
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 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"
|
|
}
|