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

Limit IPs #166

Merged
merged 3 commits into from
Nov 26, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -11,8 +11,9 @@ Reproxy is a simple edge HTTP(s) server / reverse proxy supporting various provi
- Dynamic, file-based proxy rules provider
- Docker provider with an automatic discovery
- Consul Catalog provider with discovery by service tags
- Support of multiple (virtual) hosts
- Support for multiple (virtual) hosts
- Optional traffic compression
- Optional IP-based access control
- User-defined size limits and timeouts
- Single binary distribution
- Docker container distribution
@@ -82,6 +83,7 @@ default: # the same as * (catch-all) server
route: "/api/svc3/xyz",
dest: "http://127.0.0.3:8080/blah3/xyz",
ping: "http://127.0.0.3:8080/ping",
remote: "192.168.1.0/24, 127.0.0.1" # optional, restrict access to the route
}
srv.example.com:
- { route: "^/api/svc2/(.*)", dest: "http://127.0.0.2:8080/blah2/$1/abc" }
@@ -101,6 +103,7 @@ This default can be changed with labels:
- `reproxy.dest` - destination path. Note: this is not full url, but just the path which will be appended to container's ip:port
- `reproxy.port` - destination port for the discovered container
- `reproxy.ping` - ping path for the destination container.
- `reproxy.remote` - restrict access to the route with a list of comma-separated subnets or ips
- `reproxy.assets` - set assets mapping as `web-root:location`, for example `reproxy.assets=/web:/var/www`
- `reproxy.enabled` - enable (`yes`, `true`, `1`) or disable (`no`, `false`, `0`) container from reproxy destinations.

@@ -142,6 +145,7 @@ This default can be changed with tags:
- `reproxy.route` - source route (location)
- `reproxy.dest` - destination path. Note: this is not full url, but just the path which will be appended to service's ip:port
- `reproxy.port` - destination port for the discovered service
- `reproxy.remote` - restrict access to the route with a list of comma-separated subnets or ips
- `reproxy.ping` - ping path for the destination service.
- `reproxy.enabled` - enable (`yes`, `true`, `1`) or disable (`any different value`) service from reproxy destinations.

@@ -249,14 +253,14 @@ supported codes:
In order to eliminate the need to pass custom params/environment, the default `--listen` is dynamic and trying to be reasonable and helpful for the typical cases:

- If anything set by users to `--listen` all the logic below ignored and host:port passed in and used directly.
- If nothing set by users to `--listen` and reproxy runs outside of the docker container, the default is `127.0.0.1:80` for http mode (`ssl.type=none`) and `127.0.0.1:443` for ssl mode (`ssl.type=auto` or `ssl.type=static`).
- If nothing set by users to `--listen` and reproxy runs outside the docker container, the default is `127.0.0.1:80` for http mode (`ssl.type=none`) and `127.0.0.1:443` for ssl mode (`ssl.type=auto` or `ssl.type=static`).
- If nothing set by users to `--listen` and reproxy runs inside the docker, the default is `0.0.0.0:8080` for http mode, and `0.0.0.0:8443` for ssl mode.

Another default set in the similar dynamic way is `--ssl.http-port`. For run inside of the docker container it set to `8080` and without to `80`.

## Ping, health checks and fail-over

reproxy provides 2 endpoints for this purpose:
reproxy provides two endpoints for this purpose:

- `/ping` responds with `pong` and indicates what reproxy up and running
- `/health` returns `200 OK` status if all destination servers responded to their ping request with `200` or `417 Expectation Failed` if any of servers responded with non-200 code. It also returns json body with details about passed/failed services.
@@ -298,7 +302,16 @@ username2:bcrypt(password2)
...
```

this can be generated with `htpasswd -nbB` command, i.e. `htpasswd -nbB test passwd`
## IP-based access control

Reproxy allows restricting access to the routes with a list of comma-separated subnets or ips. This is useful for the development and testing, before allowing unrestricted access to them. It also can be used to restrict access to the internal services. By default, all the routes are open for all the clients.

To restrict access to the routes, user should set appropriate keys for the routes, i.e. `reproxy.remote` for docker and consul, and `remote` for file provider. The value should be a list of comma-separated subnets or ips or subnets. For example `127.0.0.1, 192.168.1.0/24`. For more details see [docker provider](#docker-provider) and [consul catalog provider](#consul-catalog-provider) sections.

By default, reproxy will check the remote address from the client's request. However, in some cases, it won't work as expected, for example behind of other proxy, or with docker bridge network. This can be altered with `--remote-lookup-headers` parameter allowing check the value of the header `X-Real-IP` or `X-Forwarded-For` (in this order) and use it for the check. If the header is not set, the check will be performed against the remote address of the client.

Checking headers should be used with caution, as it is possible to fake them. However, in some cases, it is the only way to get the real remote address of the client. Generally, it is recommended to use this option only if user is completely controlling all the headers and can guarantee the headers are not faked.


## Plugins support

@@ -353,6 +366,7 @@ This is the list of all options supporting multiple elements:
--basic-htpasswd= htpasswd file for basic auth [$BASIC_HTPASSWD]
--lb-type=[random|failover] load balancer type (default: random) [$LB_TYPE]
--signature enable reproxy signature headers [$SIGNATURE]
--remote-lookup-headers enable remote lookup headers [$REMOTE_LOOKUP_HEADERS]
--dbg debug mode [$DEBUG]

ssl:
32 changes: 22 additions & 10 deletions app/discovery/discovery.go
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@ type URLMapper struct {
PingURL string
MatchType MatchType
RedirectType RedirectType
OnlyFromIPs []string

AssetsLocation string // local FS root location
AssetsWebRoot string // web root location
@@ -484,16 +485,6 @@ func (s *Service) mergeEvents(ctx context.Context, chs ...<-chan ProviderID) <-c
return out
}

// Contains checks if the input string (e) in the given slice
func Contains(e string, s []string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}

// IsAlive indicates whether mapper destination is alive
func (m URLMapper) IsAlive() bool {
return !m.dead
@@ -515,3 +506,24 @@ func (m URLMapper) ping() (string, error) {

return "", err
}

// Contains checks if the input string (e) in the given slice
func Contains(e string, s []string) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}

// ParseOnlyFrom parses comma separated list of IPs
func ParseOnlyFrom(s string) (res []string) {
if s == "" {
return []string{}
}
for _, v := range strings.Split(s, ",") {
res = append(res, strings.TrimSpace(v))
}
return res
}
49 changes: 45 additions & 4 deletions app/discovery/discovery_test.go
Original file line number Diff line number Diff line change
@@ -39,7 +39,7 @@ func TestService_Run(t *testing.T) {
ListFunc: func() ([]URLMapper, error) {
return []URLMapper{
{Server: "localhost", SrcMatch: *regexp.MustCompile("/api/svc3/xyz"),
Dst: "http://127.0.0.3:8080/blah3/xyz", ProviderID: PIDocker},
Dst: "http://127.0.0.3:8080/blah3/xyz", ProviderID: PIDocker, OnlyFromIPs: []string{"127.0.0.1"}},
}, nil
},
}
@@ -66,6 +66,7 @@ func TestService_Run(t *testing.T) {
assert.Equal(t, "localhost", mappers[0].Server)
assert.Equal(t, "/api/svc3/xyz", mappers[0].SrcMatch.String())
assert.Equal(t, "http://127.0.0.3:8080/blah3/xyz", mappers[0].Dst)
assert.Equal(t, []string{"127.0.0.1"}, mappers[0].OnlyFromIPs)

assert.Equal(t, 1, len(p1.EventsCalls()))
assert.Equal(t, 1, len(p2.EventsCalls()))
@@ -104,7 +105,8 @@ func TestService_Match(t *testing.T) {
},
ListFunc: func() ([]URLMapper, error) {
return []URLMapper{
{SrcMatch: *regexp.MustCompile("/api/svc3/xyz"), Dst: "http://127.0.0.3:8080/blah3/xyz", ProviderID: PIDocker},
{SrcMatch: *regexp.MustCompile("/api/svc3/xyz"), Dst: "http://127.0.0.3:8080/blah3/xyz",
OnlyFromIPs: []string{"127.0.0.1", "192.168.1.0/24"}, ProviderID: PIDocker},
{SrcMatch: *regexp.MustCompile("/web"), Dst: "/var/web", ProviderID: PIDocker, MatchType: MTStatic,
AssetsWebRoot: "/web", AssetsLocation: "/var/web"},
{SrcMatch: *regexp.MustCompile("/www/"), Dst: "/var/web", ProviderID: PIDocker, MatchType: MTStatic,
@@ -131,9 +133,11 @@ func TestService_Match(t *testing.T) {
res Matches
}{
{"example.com", "/api/svc3/xyz/something", Matches{MTProxy, []MatchedRoute{
{Destination: "http://127.0.0.3:8080/blah3/xyz/something", Alive: true}}}},
{Destination: "http://127.0.0.3:8080/blah3/xyz/something", Alive: true,
Mapper: URLMapper{OnlyFromIPs: []string{"127.0.0.1", "192.168.1.0/24"}}}}}},
{"example.com", "/api/svc3/xyz", Matches{MTProxy, []MatchedRoute{{
Destination: "http://127.0.0.3:8080/blah3/xyz", Alive: true}}}},
Destination: "http://127.0.0.3:8080/blah3/xyz", Alive: true,
Mapper: URLMapper{OnlyFromIPs: []string{"127.0.0.1", "192.168.1.0/24"}}}}}},
{"abc.example.com", "/api/svc1/1234", Matches{MTProxy, []MatchedRoute{
{Destination: "http://127.0.0.1:8080/blah1/1234", Alive: true}}}},
{"zzz.example.com", "/aaa/api/svc1/1234", Matches{MTProxy, nil}},
@@ -167,6 +171,7 @@ func TestService_Match(t *testing.T) {
for i := 0; i < len(res.Routes); i++ {
assert.Equal(t, tt.res.Routes[i].Alive, res.Routes[i].Alive)
assert.Equal(t, tt.res.Routes[i].Destination, res.Routes[i].Destination)
assert.Equal(t, tt.res.Routes[i].Mapper.OnlyFromIPs, res.Routes[i].Mapper.OnlyFromIPs)
}
assert.Equal(t, tt.res.MatchType, res.MatchType)
})
@@ -608,3 +613,39 @@ func TestCheckHealth(t *testing.T) {
assert.NoError(t, res[ts.URL])
assert.NoError(t, res[ts2.URL])
}

func TestParseOnlyFrom(t *testing.T) {
tbl := []struct {
name string
input string
expected []string
}{
{
name: "empty string",
input: "",
expected: []string{},
},
{
name: "single IP",
input: "192.168.1.1",
expected: []string{"192.168.1.1"},
},
{
name: "multiple IPs",
input: "192.168.1.1, 192.168.1.2, 192.168.1.3, 10.0.0.0/16",
expected: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3", "10.0.0.0/16"},
},
{
name: "multiple IPs with extra spaces",
input: " 192.168.1.1 , 192.168.1.2 , 192.168.1.3 ",
expected: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"},
},
}

for _, tt := range tbl {
t.Run(tt.name, func(t *testing.T) {
result := ParseOnlyFrom(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
10 changes: 8 additions & 2 deletions app/discovery/provider/consulcatalog/consulcatalog.go
Original file line number Diff line number Diff line change
@@ -3,12 +3,13 @@ package consulcatalog
import (
"context"
"fmt"
"github.com/umputun/reproxy/app/discovery"
"log"
"regexp"
"sort"
"strings"
"time"

"github.com/umputun/reproxy/app/discovery"
)

//go:generate moq -out consul_client_mock.go -skip-ensure -fmt goimports . ConsulClient
@@ -139,6 +140,7 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) {
destURL := fmt.Sprintf("http://%s:%d/$1", c.ServiceAddress, c.ServicePort)
pingURL := fmt.Sprintf("http://%s:%d/ping", c.ServiceAddress, c.ServicePort)
server := "*"
onlyFrom := []string{}

if v, ok := c.Labels["reproxy.enabled"]; ok && (v == "true" || v == "yes" || v == "1") {
enabled = true
@@ -159,6 +161,10 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) {
server = v
}

if v, ok := c.Labels["reproxy.remote"]; ok {
onlyFrom = discovery.ParseOnlyFrom(v)
}

if v, ok := c.Labels["reproxy.ping"]; ok {
enabled = true
pingURL = fmt.Sprintf("http://%s:%d%s", c.ServiceAddress, c.ServicePort, v)
@@ -177,7 +183,7 @@ func (cc *ConsulCatalog) List() ([]discovery.URLMapper, error) {
// server label may have multiple, comma separated servers
for _, srv := range strings.Split(server, ",") {
res = append(res, discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL,
PingURL: pingURL, ProviderID: discovery.PIConsulCatalog})
OnlyFromIPs: onlyFrom, PingURL: pingURL, ProviderID: discovery.PIConsulCatalog})
}
}

15 changes: 11 additions & 4 deletions app/discovery/provider/consulcatalog/consulcatalog_test.go
Original file line number Diff line number Diff line change
@@ -3,12 +3,14 @@ package consulcatalog
import (
"context"
"fmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/umputun/reproxy/app/discovery"
"sort"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/umputun/reproxy/app/discovery"
)

func TestNew(t *testing.T) {
@@ -62,7 +64,8 @@ func TestConsulCatalog_List(t *testing.T) {
ServiceAddress: "addr3",
ServicePort: 3000,
Labels: map[string]string{"reproxy.route": "^/api/123/(.*)", "reproxy.dest": "/blah/$1",
"reproxy.server": "example.com,domain.com", "reproxy.ping": "/ping", "reproxy.enabled": "yes"},
"reproxy.server": "example.com,domain.com", "reproxy.ping": "/ping",
"reproxy.enabled": "yes", "reproxy.remote": "127.0.0.1, 192.168.1.0/24"},
},
{
ServiceID: "id4",
@@ -91,21 +94,25 @@ func TestConsulCatalog_List(t *testing.T) {
assert.Equal(t, "http://addr3:3000/blah/$1", res[0].Dst)
assert.Equal(t, "example.com", res[0].Server)
assert.Equal(t, "http://addr3:3000/ping", res[0].PingURL)
assert.Equal(t, []string{"127.0.0.1", "192.168.1.0/24"}, res[0].OnlyFromIPs)

assert.Equal(t, "^/api/123/(.*)", res[1].SrcMatch.String())
assert.Equal(t, "http://addr3:3000/blah/$1", res[1].Dst)
assert.Equal(t, "domain.com", res[1].Server)
assert.Equal(t, "http://addr3:3000/ping", res[1].PingURL)
assert.Equal(t, []string{"127.0.0.1", "192.168.1.0/24"}, res[1].OnlyFromIPs)

assert.Equal(t, "^/(.*)", res[2].SrcMatch.String())
assert.Equal(t, "http://addr44:4000/$1", res[2].Dst)
assert.Equal(t, "http://addr44:4000/ping", res[2].PingURL)
assert.Equal(t, "*", res[2].Server)
assert.Equal(t, []string{}, res[2].OnlyFromIPs)

assert.Equal(t, "^/(.*)", res[3].SrcMatch.String())
assert.Equal(t, "http://addr2:2000/$1", res[3].Dst)
assert.Equal(t, "http://addr2:2000/ping", res[3].PingURL)
assert.Equal(t, "*", res[3].Server)
assert.Equal(t, []string{}, res[3].OnlyFromIPs)
}

func TestConsulCatalog_serviceListWasChanged(t *testing.T) {
7 changes: 6 additions & 1 deletion app/discovery/provider/docker.go
Original file line number Diff line number Diff line change
@@ -103,6 +103,7 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper)
// defaults
destURL, pingURL, server := fmt.Sprintf("http://%s:%d/$1", c.IP, port), fmt.Sprintf("http://%s:%d/ping", c.IP, port), "*"
assetsWebRoot, assetsLocation, assetsSPA := "", "", false
onlyFrom := []string{}

if d.AutoAPI && n == 0 {
enabled = true
@@ -133,6 +134,10 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper)
server = v
}

if v, ok := d.labelN(c.Labels, n, "remote"); ok {
onlyFrom = discovery.ParseOnlyFrom(v)
}

if v, ok := d.labelN(c.Labels, n, "ping"); ok {
enabled = true
if strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") {
@@ -171,7 +176,7 @@ func (d *Docker) parseContainerInfo(c containerInfo) (res []discovery.URLMapper)
// docker server label may have multiple, comma separated servers
for _, srv := range strings.Split(server, ",") {
mp := discovery.URLMapper{Server: strings.TrimSpace(srv), SrcMatch: *srcRegex, Dst: destURL,
PingURL: pingURL, ProviderID: discovery.PIDocker, MatchType: discovery.MTProxy}
PingURL: pingURL, OnlyFromIPs: onlyFrom, ProviderID: discovery.PIDocker, MatchType: discovery.MTProxy}

// for assets we add the second proxy mapping only if explicitly requested
if assetsWebRoot != "" && explicit {
6 changes: 5 additions & 1 deletion app/discovery/provider/docker_test.go
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ func TestDocker_List(t *testing.T) {
{
Name: "c1", State: "running", IP: "127.0.0.2", Ports: []int{12345},
Labels: map[string]string{"reproxy.route": "^/api/123/(.*)", "reproxy.dest": "/blah/$1",
"reproxy.server": "example.com", "reproxy.ping": "/ping"},
"reproxy.server": "example.com", "reproxy.ping": "/ping", "reproxy.remote": "192.168.1.0/24, 127.0.0.1"},
},
{
Name: "c1", State: "running", IP: "127.0.0.21", Ports: []int{12345},
@@ -64,21 +64,25 @@ func TestDocker_List(t *testing.T) {
assert.Equal(t, "http://127.0.0.2:12345/blah/$1", res[0].Dst)
assert.Equal(t, "example.com", res[0].Server)
assert.Equal(t, "http://127.0.0.2:12345/ping", res[0].PingURL)
assert.Equal(t, []string{"192.168.1.0/24", "127.0.0.1"}, res[0].OnlyFromIPs)

assert.Equal(t, "^/api/90/(.*)", res[1].SrcMatch.String())
assert.Equal(t, "http://example.com/blah/$1", res[1].Dst)
assert.Equal(t, "https://example.com//ping", res[1].PingURL)
assert.Equal(t, "example.com", res[1].Server)
assert.Equal(t, []string{}, res[1].OnlyFromIPs)

assert.Equal(t, "^/c2/(.*)", res[2].SrcMatch.String())
assert.Equal(t, "http://127.0.0.3:12346/$1", res[2].Dst)
assert.Equal(t, "http://127.0.0.3:12346/ping", res[2].PingURL)
assert.Equal(t, "*", res[2].Server)
assert.Equal(t, []string{}, res[2].OnlyFromIPs)

assert.Equal(t, "^/a/(.*)", res[3].SrcMatch.String())
assert.Equal(t, "http://127.0.0.2:12348/a/$1", res[3].Dst)
assert.Equal(t, "http://127.0.0.2:12348/ping", res[3].PingURL)
assert.Equal(t, "example.com", res[3].Server)
assert.Equal(t, []string{}, res[3].OnlyFromIPs)
}

func TestDocker_ListMulti(t *testing.T) {
Loading