diff --git a/cmd/cloudflared/tunnel/ingress.go b/cmd/cloudflared/tunnel/ingress.go new file mode 100644 index 00000000..027339e2 --- /dev/null +++ b/cmd/cloudflared/tunnel/ingress.go @@ -0,0 +1,98 @@ +package tunnel + +import ( + "fmt" + "net/url" + "regexp" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +var ( + errNoIngressRules = errors.New("No ingress rules were specified in the config file") + errLastRuleNotCatchAll = errors.New("The last ingress rule must match all hostnames (i.e. it must be missing, or must be \"*\")") +) + +// Each rule route traffic from a hostname/path on the public +// internet to the service running on the given URL. +type rule struct { + // Requests for this hostname will be proxied to this rule's service. + Hostname string + + // Path is an optional regex that can specify path-driven ingress rules. + Path *regexp.Regexp + + // A (probably local) address. Requests for a hostname which matches this + // rule's hostname pattern will be proxied to the service running on this + // address. + Service *url.URL +} + +type unvalidatedRule struct { + Hostname string + Path string + Service string +} + +type ingress struct { + Ingress []unvalidatedRule +} + +func (ing ingress) validate() ([]rule, error) { + rules := make([]rule, len(ing.Ingress)) + for i, r := range ing.Ingress { + service, err := url.Parse(r.Service) + if err != nil { + return nil, err + } + + // The last rule should catch all hostnames. + isCatchAllRule := (r.Hostname == "" || r.Hostname == "*") && r.Path == "" + isLastRule := i == len(ing.Ingress)-1 + if isLastRule && !isCatchAllRule { + return nil, errLastRuleNotCatchAll + } + // ONLY the last rule should catch all hostnames. + if !isLastRule && isCatchAllRule { + return nil, errRuleShouldNotBeCatchAll{i: i, hostname: r.Hostname} + } + + var pathRegex *regexp.Regexp + if r.Path != "" { + pathRegex, err = regexp.Compile(r.Path) + if err != nil { + return nil, errors.Wrapf(err, "Rule #%d has an invalid regex", i+1) + } + } + + rules[i] = rule{ + Hostname: r.Hostname, + Service: service, + Path: pathRegex, + } + } + return rules, nil +} + +type errRuleShouldNotBeCatchAll struct { + i 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.i+1, e.hostname) +} + +func parseIngress(rawYAML []byte) ([]rule, error) { + var ing ingress + if err := yaml.Unmarshal(rawYAML, &ing); err != nil { + return nil, err + } + if len(ing.Ingress) == 0 { + return nil, errNoIngressRules + } + return ing.validate() +} diff --git a/cmd/cloudflared/tunnel/ingress_test.go b/cmd/cloudflared/tunnel/ingress_test.go new file mode 100644 index 00000000..d7132618 --- /dev/null +++ b/cmd/cloudflared/tunnel/ingress_test.go @@ -0,0 +1,145 @@ +package tunnel + +import ( + "net/url" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_parseIngress(t *testing.T) { + localhost8000, err := url.Parse("https://localhost:8000") + require.NoError(t, err) + localhost8001, err := url.Parse("https://localhost:8001") + require.NoError(t, err) + type args struct { + rawYAML string + } + tests := []struct { + name string + args args + want []rule + wantErr bool + }{ + { + name: "Empty file", + args: args{rawYAML: ""}, + wantErr: true, + }, + { + name: "Multiple rules", + args: args{rawYAML: ` +ingress: + - hostname: tunnel1.example.com + service: https://localhost:8000 + - hostname: "*" + service: https://localhost:8001 +`}, + want: []rule{ + { + Hostname: "tunnel1.example.com", + Service: localhost8000, + }, + { + Hostname: "*", + Service: localhost8001, + }, + }, + }, + { + name: "Extra keys", + args: args{rawYAML: ` +ingress: + - hostname: "*" + service: https://localhost:8000 +extraKey: extraValue +`}, + want: []rule{ + { + Hostname: "*", + Service: localhost8000, + }, + }, + }, + { + name: "Hostname can be omitted", + args: args{rawYAML: ` +ingress: + - service: https://localhost:8000 +`}, + want: []rule{ + { + Service: localhost8000, + }, + }, + }, + { + name: "Invalid service", + args: args{rawYAML: ` +ingress: + - hostname: "*" + service: https://local host:8000 +`}, + wantErr: true, + }, + { + name: "Invalid YAML", + args: args{rawYAML: ` +key: "value +`}, + wantErr: true, + }, + { + name: "Last rule isn't catchall", + args: args{rawYAML: ` +ingress: + - hostname: example.com + service: https://localhost:8000 +`}, + wantErr: true, + }, + { + name: "First rule is catchall", + args: args{rawYAML: ` +ingress: + - service: https://localhost:8000 + - hostname: example.com + service: https://localhost:8000 +`}, + wantErr: true, + }, + { + name: "Catch-all rule can't have a path", + args: args{rawYAML: ` +ingress: + - service: https://localhost:8001 + path: /subpath1/(.*)/subpath2 +`}, + wantErr: true, + }, + { + name: "Invalid regex", + args: args{rawYAML: ` +ingress: + - hostname: example.com + service: https://localhost:8000 + path: "*/subpath2" + - service: https://localhost:8001 +`}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseIngress([]byte(tt.args.rawYAML)) + if (err != nil) != tt.wantErr { + t.Errorf("parseIngress() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseIngress() = %v, want %v", got, tt.want) + } + }) + } +}