Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ Authentication parameters are passed as URI via `-auth` parameter. Scheme of URI
* `else` - optional URL specifying the next auth provider to chain to, if authentication failed.
* `lookup` - optional URL specifying another auth provider queried for session validity (typically `basicfile` or some Redis-backed password auth). Queries to this lookup provider ask for validity of session providing hexadecimal session ID as username and empty string as password.

`static` can also be used with `-direct-response` to respond to direct non-proxy HTTP requests. It accepts the same `code`, `body`, and `headers` parameters as `reject-static`. `reject-http`, `reject-https`, and `reject-static` can also be used with `-access-reject` to respond to requests denied by access filters.

## Scripting

With the dumbproxy, it is possible to modify request processing behaviour using simple scripts written in the JavaScript programming language.
Expand Down Expand Up @@ -537,6 +539,8 @@ Configuration format is [RFC 4180](https://www.rfc-editor.org/rfc/rfc4180.html)
```
$ ~/go/bin/dumbproxy -h
Usage of /home/user/go/bin/dumbproxy:
-access-reject string
reject response parameters for requests denied by access filters
-auth string
auth parameters (default "none://")
-autocert
Expand Down Expand Up @@ -589,6 +593,8 @@ Usage of /home/user/go/bin/dumbproxy:
colon-separated list of enabled key exchange curves
-deny-dst-addr value
comma-separated list of CIDR prefixes of forbidden IP addresses (default 127.0.0.0/8, 0.0.0.0/32, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1/128, ::/128, fe80::/10)
-direct-response string
response parameters for direct HTTP requests
-disable-http2
disable HTTP2
-dns-cache-neg-ttl duration
Expand Down
39 changes: 37 additions & 2 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,47 @@ func NewAuth(paramstr string, logger *clog.CondLogger) (Auth, error) {
case "none":
return NoAuth{}, nil
case "reject-http", "reject-https":
return NewRejectHTTPAuth(url, logger)
return newRejectAuthFromURL(url, logger)
case "reject-static":
return NewStaticRejectAuth(url, logger)
return newRejectAuthFromURL(url, logger)
case "tlscookie":
return NewTLSCookieAuth(url, logger)
default:
return nil, errors.New("Unknown auth scheme")
}
}

// NewRejectAuth constructs an auth provider which always responds and rejects.
func NewRejectAuth(paramstr string, logger *clog.CondLogger) (Auth, error) {
url, err := url.Parse(paramstr)
if err != nil {
return nil, err
}

return newRejectAuthFromURL(url, logger)
}

func newRejectAuthFromURL(url *url.URL, logger *clog.CondLogger) (Auth, error) {
switch strings.ToLower(url.Scheme) {
case "reject-http", "reject-https":
return NewRejectHTTPAuth(url, logger)
case "reject-static":
return NewStaticRejectAuth(url, logger)
default:
return nil, errors.New("Unknown reject scheme")
}
}

func NewResponse(paramstr string, logger *clog.CondLogger) (Auth, error) {
url, err := url.Parse(paramstr)
if err != nil {
return nil, err
}

switch strings.ToLower(url.Scheme) {
case "static":
return NewStaticResponse(url, logger)
default:
return nil, errors.New("Unknown response scheme")
}
}
4 changes: 4 additions & 0 deletions auth/rejectstatic.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ type StaticRejectAuth struct {
}

func NewStaticRejectAuth(u *url.URL, logger *clog.CondLogger) (*StaticRejectAuth, error) {
return NewStaticResponse(u, logger)
}

func NewStaticResponse(u *url.URL, logger *clog.CondLogger) (*StaticRejectAuth, error) {
values, err := url.ParseQuery(u.RawQuery)
if err != nil {
return nil, err
Expand Down
37 changes: 37 additions & 0 deletions auth/response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package auth

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestNewResponseStatic(t *testing.T) {
response, err := NewResponse("static://?code=204", nil)
if err != nil {
t.Fatalf("NewResponse returned error: %v", err)
}

rr := httptest.NewRecorder()
_, ok := response.Validate(context.Background(), rr, httptest.NewRequest(http.MethodGet, "/", nil))

if ok {
t.Fatalf("static response should not authorize requests")
}
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent)
}
}

func TestNewResponseRejectStaticIsInvalid(t *testing.T) {
if _, err := NewResponse("reject-static://?code=204", nil); err == nil {
t.Fatalf("NewResponse accepted reject-static scheme")
}
}

func TestNewRejectAuthStaticIsInvalid(t *testing.T) {
if _, err := NewRejectAuth("static://?code=403", nil); err == nil {
t.Fatalf("NewRejectAuth accepted static scheme")
}
}
6 changes: 6 additions & 0 deletions handler/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ type Config struct {
// Auth optionally specifies request validator used to verify users
// and return their username.
Auth auth.Auth
// DirectResponse optionally specifies a response for direct HTTP requests
// that do not use proxy request form.
DirectResponse auth.Auth
// AccessReject optionally specifies a response for proxy requests
// denied by an access filter.
AccessReject auth.Auth
// Logger specifies optional custom logger.
Logger *clog.CondLogger
// Forward optionally specifies custom connection pairing function
Expand Down
11 changes: 11 additions & 0 deletions handler/direct.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package handler

import "net/http"

func isDirectRequest(req *http.Request) bool {
if req == nil || req.URL == nil || req.Method == http.MethodConnect || req.Method == "GETRANDOM" {
return false
}

return req.URL.Scheme == "" && req.URL.Host == ""
}
134 changes: 134 additions & 0 deletions handler/direct_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package handler

import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"

derrors "github.com/SenseUnit/dumbproxy/dialer/errors"
)

type staticReject struct {
status int
body string
}

func (r staticReject) Validate(_ context.Context, wr http.ResponseWriter, _ *http.Request) (string, bool) {
wr.WriteHeader(r.status)
_, _ = wr.Write([]byte(r.body))
return "", false
}

func (staticReject) Close() error {
return nil
}

type deniedDialer struct{}

func (deniedDialer) DialContext(_ context.Context, _, _ string) (net.Conn, error) {
return nil, derrors.ErrAccessDenied{Err: errors.New("denied")}
}

func TestIsDirectRequest(t *testing.T) {
tests := []struct {
name string
req *http.Request
want bool
}{
{
name: "origin form get",
req: &http.Request{
Method: http.MethodGet,
URL: &url.URL{Path: "/"},
Host: "web.nacl.one",
},
want: true,
},
{
name: "absolute form get",
req: &http.Request{
Method: http.MethodGet,
URL: &url.URL{Scheme: "http", Host: "openrouter.ai", Path: "/"},
Host: "openrouter.ai",
},
want: false,
},
{
name: "connect",
req: &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Host: "openrouter.ai:443"},
Host: "openrouter.ai:443",
},
want: false,
},
{
name: "trust tunnel random",
req: &http.Request{
Method: "GETRANDOM",
URL: &url.URL{Path: "/32"},
Host: "web.nacl.one",
},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isDirectRequest(tt.req); got != tt.want {
t.Fatalf("isDirectRequest() = %v, want %v", got, tt.want)
}
})
}
}

func TestDirectResponse(t *testing.T) {
proxy := NewProxyHandler(&Config{
DirectResponse: staticReject{status: http.StatusOK, body: "direct response"},
})
rr := httptest.NewRecorder()
req := &http.Request{
Method: http.MethodGet,
URL: &url.URL{Path: "/"},
Host: "web.nacl.one",
RemoteAddr: "198.51.100.7:1234",
}

proxy.ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
if rr.Body.String() != "direct response" {
t.Fatalf("body = %q, want direct response", rr.Body.String())
}
}

func TestAccessReject(t *testing.T) {
proxy := NewProxyHandler(&Config{
Dialer: deniedDialer{},
AccessReject: staticReject{status: http.StatusTeapot, body: "access response"},
})
rr := httptest.NewRecorder()
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Host: "openrouter.ai:443"},
RequestURI: "openrouter.ai:443",
Host: "openrouter.ai:443",
RemoteAddr: "198.51.100.7:1234",
ProtoMajor: 1,
}

proxy.ServeHTTP(rr, req)

if rr.Code != http.StatusTeapot {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusTeapot)
}
if rr.Body.String() != "access response" {
t.Fatalf("body = %q, want access response", rr.Body.String())
}
}
56 changes: 39 additions & 17 deletions handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ type HandlerDialer interface {
type ForwardFunc = func(ctx context.Context, username string, incoming, outgoing io.ReadWriteCloser, network, address string) error

type ProxyHandler struct {
auth auth.Auth
logger *clog.CondLogger
dialer HandlerDialer
forward ForwardFunc
httptransport http.RoundTripper
outbound map[string]string
outboundMux sync.RWMutex
userIPHints bool
auth auth.Auth
directResponse auth.Auth
accessReject auth.Auth
logger *clog.CondLogger
dialer HandlerDialer
forward ForwardFunc
httptransport http.RoundTripper
outbound map[string]string
outboundMux sync.RWMutex
userIPHints bool
}

func NewProxyHandler(config *Config) *ProxyHandler {
Expand All @@ -64,13 +66,15 @@ func NewProxyHandler(config *Config) *ProxyHandler {
f = forward.PairConnections
}
return &ProxyHandler{
auth: a,
logger: l,
dialer: d,
forward: f,
httptransport: httptransport,
outbound: make(map[string]string),
userIPHints: config.UserIPHints,
auth: a,
directResponse: config.DirectResponse,
accessReject: config.AccessReject,
logger: l,
dialer: d,
forward: f,
httptransport: httptransport,
outbound: make(map[string]string),
userIPHints: config.UserIPHints,
}
}

Expand All @@ -80,7 +84,7 @@ func (s *ProxyHandler) HandleTunnel(wr http.ResponseWriter, req *http.Request, u
var accessErr derrors.ErrAccessDenied
if errors.As(err, &accessErr) {
s.logger.Warning("Access denied: %v", err)
http.Error(wr, "Access denied", http.StatusForbidden)
s.rejectAccess(wr, req)
return
}
s.logger.Error("Can't satisfy CONNECT request: %v", err)
Expand Down Expand Up @@ -179,7 +183,7 @@ func (s *ProxyHandler) HandleRequest(wr http.ResponseWriter, req *http.Request,
var accessErr derrors.ErrAccessDenied
if errors.As(err, &accessErr) {
s.logger.Warning("Access denied: %v", err)
http.Error(wr, "Access denied", http.StatusForbidden)
s.rejectAccess(wr, req)
return
}
s.logger.Error("HTTP fetch error: %v", err)
Expand Down Expand Up @@ -229,6 +233,11 @@ func (s *ProxyHandler) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
return
}

if s.directResponse != nil && isDirectRequest(req) {
s.reject(s.directResponse, wr, req)
return
}

ctx := req.Context()
username, ok := s.auth.Validate(ctx, wr, req)
localAddr := getLocalAddr(req.Context())
Expand Down Expand Up @@ -266,6 +275,19 @@ func (s *ProxyHandler) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
}
}

func (s *ProxyHandler) rejectAccess(wr http.ResponseWriter, req *http.Request) {
if s.accessReject == nil {
http.Error(wr, "Access denied", http.StatusForbidden)
return
}

s.reject(s.accessReject, wr, req)
}

func (s *ProxyHandler) reject(reject auth.Auth, wr http.ResponseWriter, req *http.Request) {
_, _ = reject.Validate(req.Context(), wr, req)
}

func trimAddrPort(addrPort string) string {
res, _, err := net.SplitHostPort(addrPort)
if err != nil {
Expand Down
Loading