Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

reverseproxy: restore allowing upstream to contain replaceable placeholders #3776

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions caddytest/integration/reverseproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,281 @@ func TestSRVWithDial(t *testing.T) {
`, "json", `upstream: specifying dial address is incompatible with lookup_srv: 0: {\"dial\": \"tcp/address.to.upstream:80\", \"lookup_srv\": \"srv.host.service.consul\"}`)
}

func TestDialWithPlaceholderActiveHealthcheck(t *testing.T) {
caddytest.AssertLoadError(t, `
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"health_checks": {
"active": {
"path": "/ok"
}
},
"upstreams": [
{
"dial": "{http.request.header.X-Caddy-Upstream-Dial}"
}
]
}
]
}
]
}
}
}
}
}
`, "json", `upstream: dial address with placeholders is incompatible with active health checks: 0: {\"dial\": \"{http.request.header.X-Caddy-Upstream-Dial}\"}`)
}

func TestDialWithPlaceholderUnix(t *testing.T) {

if runtime.GOOS == "windows" {
t.SkipNow()
}

f, err := ioutil.TempFile("", "*.sock")
if err != nil {
t.Errorf("failed to create TempFile: %s", err)
return
}
// a hack to get a file name within a valid path to use as socket
socketName := f.Name()
os.Remove(f.Name())

server := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("Hello, World!"))
}),
}

unixListener, err := net.Listen("unix", socketName)
if err != nil {
t.Errorf("failed to listen on the socket: %s", err)
return
}
go server.Serve(unixListener)
t.Cleanup(func() {
server.Close()
})
runtime.Gosched() // Allow other goroutines to run

tester := caddytest.NewTester(t)
tester.InitServer(`
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "unix/{http.request.header.X-Caddy-Upstream-Dial}"
}
]
}
]
}
]
}
}
}
}
}
`, "json")

req, err := http.NewRequest(http.MethodGet, "http://localhost:8080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", socketName)
tester.AssertResponse(req, 200, "Hello, World!")
}

func TestReverseProxyWithPlaceholderDialAddress(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "static_response",
"body": "Hello, World!"
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
},
"srv1": {
"listen": [
":9080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{

"handler": "reverse_proxy",
"upstreams": [
{
"dial": "{http.request.header.X-Caddy-Upstream-Dial}"
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
}
}
}
}
}
`, "json")

req, err := http.NewRequest(http.MethodGet, "http://localhost:9080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", "localhost:8080")
tester.AssertResponse(req, 200, "Hello, World!")
}

func TestReverseProxyWithPlaceholderTCPDialAddress(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "static_response",
"body": "Hello, World!"
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
},
"srv1": {
"listen": [
":9080"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{

"handler": "reverse_proxy",
"upstreams": [
{
"dial": "tcp/{http.request.header.X-Caddy-Upstream-Dial}:8080"
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"skip": [
"localhost"
]
}
}
}
}
}
}
`, "json")

req, err := http.NewRequest(http.MethodGet, "http://localhost:9080", nil)
if err != nil {
t.Fail()
return
}
req.Header.Set("X-Caddy-Upstream-Dial", "localhost")
tester.AssertResponse(req, 200, "Hello, World!")
}

func TestSRVWithActiveHealthcheck(t *testing.T) {
caddytest.AssertLoadError(t, `
{
Expand Down
11 changes: 10 additions & 1 deletion modules/caddyhttp/reverseproxy/caddyfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"net/http"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"

Expand Down Expand Up @@ -101,6 +102,7 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
// to write tests before making any more changes to it
upstreamDialAddress := func(upstreamAddr string) (string, error) {
var network, scheme, host, port string
placeholder := regexp.MustCompile(`\{[[:graph:]]+\}`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this logic/check have to be repeated in the Caddyfile adapter?

(If we keep it, this should be brought out into a package-level variable like var placeholderPattern = ... or something, then reused...)


if strings.Contains(upstreamAddr, "://") {
// we get a parsing error if a placeholder is specified
Expand Down Expand Up @@ -155,7 +157,8 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
if err != nil {
host = upstreamAddr
}
if port == "" {

if !placeholder.MatchString(host) && port == "" {
port = "80"
}
}
Expand All @@ -174,6 +177,12 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
if network != "" {
return caddy.JoinNetworkAddress(network, host, port), nil
}
// if both network and port are empty, host address contains
// placeholdler (e.g. "{http.request.header.X-Caddy-Upstream-Dial}") and doing net.JoinHostPort(host, port) will
mohammed90 marked this conversation as resolved.
Show resolved Hide resolved
// add an extreneous colon.
if port == "" {
return host, nil
}
return net.JoinHostPort(host, port), nil
}

Expand Down
29 changes: 17 additions & 12 deletions modules/caddyhttp/reverseproxy/reverseproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ func (h *Handler) Provision(ctx caddy.Context) error {
h.ctx = ctx
h.logger = ctx.Logger(h)

re := regexp.MustCompile(`\{[[:graph:]]+\}`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not gonna lie, I do hate this 😆 Let me think on this for a bit and see if we can come up with something more elegant...

This didn't used to be a problem, right? Did we add code that broke this use case? Can we not remove that code? Or at least change it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the code added to fix the healthcheck on alternate port requires parsing the network address, which errors out for not having a port when using placeholders. I've moved that part into later in the Provision method where it only sets up the health checks. Now if the upstream contains a placeholder dial address, it will not accept active healthcheck config because it's like SRV and owned by another entity.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think there's a way we can fix the original health checking bug without requiring host/port information up front? That would simplify the code, rather than complicate it.

// get validation out of the way
for i, v := range h.Upstreams {
if v.Dial != "" && re.MatchString(v.Dial) && h.HealthChecks != nil && h.HealthChecks.Active != nil {
return fmt.Errorf(`upstream: dial address with placeholders is incompatible with active health checks: %d: {"dial": %q}`, i, v.Dial)
}
// Having LookupSRV non-empty conflicts with 2 other config parameters: active health checks, and upstream dial address.
// Therefore if LookupSRV is empty, then there are no immediately apparent config conflicts, and the iteration can be skipped.
if v.LookupSRV == "" {
Expand Down Expand Up @@ -219,18 +223,6 @@ func (h *Handler) Provision(ctx caddy.Context) error {

// set up upstreams
for _, upstream := range h.Upstreams {
if upstream.LookupSRV == "" {
addr, err := caddy.ParseNetworkAddress(upstream.Dial)
if err != nil {
return err
}

if addr.PortRangeSize() != 1 {
return fmt.Errorf("multiple addresses (upstream must map to only one address): %v", addr)
}

upstream.networkAddress = addr
}
// create or get the host representation for this upstream
var host Host = new(upstreamHost)
existingHost, loaded := hosts.LoadOrStore(upstream.String(), host)
Expand Down Expand Up @@ -288,6 +280,19 @@ func (h *Handler) Provision(ctx caddy.Context) error {
}

for _, upstream := range h.Upstreams {
if upstream.LookupSRV == "" {
addr, err := caddy.ParseNetworkAddress(upstream.Dial)
if err != nil {
return err
}

if addr.PortRangeSize() != 1 {
return fmt.Errorf("multiple addresses (upstream must map to only one address): %v", addr)
}

upstream.networkAddress = addr
}

// if there's an alternative port for health-check provided in the config,
// then use it, otherwise use the port of upstream.
if h.HealthChecks.Active.Port != 0 {
Expand Down