diff --git a/Dockerfile b/Dockerfile index 4062389..8e40c52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,15 @@ -FROM golang:1.21-alpine3.18 as toolset +FROM golang:1.21-alpine3.18 AS toolset RUN apk add gcc make git musl-dev -FROM node:16-alpine3.14 as nodejs +FROM node:16-alpine3.14 AS nodejs COPY ./frontend /app/ WORKDIR /app RUN npm install && npm run build -from toolset as gomodules +FROM toolset AS gomodules RUN apk add openssh-client COPY go.mod /build/ COPY .gitconfig /root/ @@ -17,7 +17,7 @@ WORKDIR /build RUN mkdir -p -m 0600 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts RUN --mount=type=ssh GOPRIVATE=github.com/vpnhouse go mod download -FROM gomodules as builder +FROM gomodules AS builder COPY . /build COPY --from=nodejs /app/dist /build/internal/frontend/dist/ diff --git a/cmd/tunnel/main.go b/cmd/tunnel/main.go index f694487..43e3304 100644 --- a/cmd/tunnel/main.go +++ b/cmd/tunnel/main.go @@ -19,6 +19,7 @@ import ( "github.com/vpnhouse/tunnel/internal/ipdiscover" "github.com/vpnhouse/tunnel/internal/iprose" "github.com/vpnhouse/tunnel/internal/manager" + "github.com/vpnhouse/tunnel/internal/proxy" "github.com/vpnhouse/tunnel/internal/runtime" "github.com/vpnhouse/tunnel/internal/settings" "github.com/vpnhouse/tunnel/internal/storage" @@ -152,6 +153,23 @@ func initServices(runtime *runtime.TunnelRuntime) error { } } + // Create proxy server + var proxyServer *proxy.Instance + if runtime.Features.WithProxy() && runtime.Settings.Proxy != nil { + proxyServer, err = proxy.New( + runtime.Settings.Proxy, + jwtAuthorizer, + append( + runtime.Settings.Domain.ExtraNames, + runtime.Settings.Domain.PrimaryName, + )) + if err != nil { + return err + } + + runtime.Services.RegisterService("proxy", proxyServer) + } + // Prepare tunneling HTTP API tunnelAPI := httpapi.NewTunnelHandlers(runtime, sessionManager, adminJWT, jwtAuthorizer, dataStorage, keyStore, ipv4am) @@ -163,7 +181,15 @@ func initServices(runtime *runtime.TunnelRuntime) error { if runtime.Settings.HTTP.CORS { xhttpOpts = append([]xhttp.Option{xhttp.WithCORS()}, xhttpOpts...) } + if proxyServer != nil { + xhttpOpts = append([]xhttp.Option{ + xhttp.WithMiddleware(proxyServer.ProxyHandler), + xhttp.WithDisableHTTPv2(), // see task 97304 (fix http over httpv2 proxy issue) + }, xhttpOpts...) + } + // assume that config validation does not pass + // the SSL enabled without the domain name configuration if runtime.Settings.SSL != nil { redirectOnly := xhttp.NewRedirectToSSL(runtime.Settings.Domain.PrimaryName) // we must start the redirect-only server before passing its Router diff --git a/go.mod b/go.mod index 14b0bda..2502a7e 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/miekg/dns v1.1.55 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/oschwald/maxminddb-golang v1.8.0 + github.com/posener/h2conn v0.0.0-20231204025407-3997deeca0f0 github.com/prometheus/client_golang v1.12.1 github.com/rubenv/sql-migrate v1.0.0 github.com/slok/go-http-metrics v0.10.0 @@ -34,8 +35,8 @@ require ( go.etcd.io/etcd/client/v3 v3.5.2 go.uber.org/multierr v1.10.0 go.uber.org/zap v1.25.0 - golang.org/x/net v0.2.0 - golang.org/x/sys v0.11.0 + golang.org/x/net v0.17.0 + golang.org/x/sys v0.13.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20211230205640-daad0b7ba671 google.golang.org/grpc v1.44.0 google.golang.org/protobuf v1.27.1 @@ -93,11 +94,12 @@ require ( github.com/ziutek/mymysql v1.5.4 // indirect go.etcd.io/etcd/api/v3 v3.5.2 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.2 // indirect - golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect - golang.org/x/mod v0.7.0 // indirect - golang.org/x/text v0.4.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/mod v0.8.0 // indirect + golang.org/x/sync v0.6.0 + golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect - golang.org/x/tools v0.3.0 // indirect + golang.org/x/tools v0.6.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.zx2c4.com/wireguard v0.0.0-20211129173154-2dd424e2d808 // indirect google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c // indirect diff --git a/go.sum b/go.sum index 4cc85d7..90835fa 100644 --- a/go.sum +++ b/go.sum @@ -679,6 +679,8 @@ github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/h2conn v0.0.0-20231204025407-3997deeca0f0 h1:zZg03nifrj6ayWNaDO8tNj57tqrOIKDwiBaLkhtK7Kk= +github.com/posener/h2conn v0.0.0-20231204025407-3997deeca0f0/go.mod h1:bblJa8QcHntareAJYfLJUzLj42sUFBKCBeTDK5LyUrw= github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= @@ -814,8 +816,6 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7Zo github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= github.com/vpnhouse/api v0.0.0-20240207081757-572ff9ef5b97 h1:FwO9Yeqikw7MxnUESAtb5Lpd/KEIMNy+fHRI7vYsj6o= github.com/vpnhouse/api v0.0.0-20240207081757-572ff9ef5b97/go.mod h1:qeAZBOFAiz7FiTG49UremjHQExCUPui2tTdn1NMDn1s= -github.com/vpnhouse/iprose-go v0.1.0-rc15 h1:kd1RFQv3F4cmaJLpEppBYCVMBGW4zJ54aq31tS2iFbA= -github.com/vpnhouse/iprose-go v0.1.0-rc15/go.mod h1:BYBBO1eTgM9Vl/mLD2cCL0JF/X2dzsDtSrwDaHSoIWc= github.com/vpnhouse/iprose-go v0.1.0-rc18 h1:GIujbCYM4FVTtcq72f1LeR2rrVcBZghh4Eh5CCp/TpY= github.com/vpnhouse/iprose-go v0.1.0-rc18/go.mod h1:JPDz6koxs2qX0X5vxA1r3Kv1RgpSgT1drek47C+jrWc= github.com/vultr/govultr/v2 v2.7.1/go.mod h1:BvOhVe6/ZpjwcoL6/unkdQshmbS9VGbowI4QT+3DGVU= @@ -899,8 +899,8 @@ golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -937,8 +937,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1001,8 +1001,8 @@ golang.org/x/net v0.0.0-20211111083644-e5c967477495/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211201190559-0a0e4e1bb54c/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211208012354-db4efeb81f4b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1024,8 +1024,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180622082034-63fc586f45fe/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1121,12 +1121,12 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211214234402-4825e8c3871d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1136,8 +1136,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1209,8 +1209,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/authorizer/entitlements.go b/internal/authorizer/entitlements.go index 755971e..0e1c50c 100644 --- a/internal/authorizer/entitlements.go +++ b/internal/authorizer/entitlements.go @@ -13,6 +13,7 @@ const ( Any EntitlementType = "" Wireguard EntitlementType = "wireguard" IPRose EntitlementType = "iprose" + Proxy EntitlementType = "proxy" ) type jwtAuthorizerEntitlement struct { diff --git a/internal/proxy/auth.go b/internal/proxy/auth.go new file mode 100644 index 0000000..36ef7b3 --- /dev/null +++ b/internal/proxy/auth.go @@ -0,0 +1,35 @@ +package proxy + +import ( + "encoding/base64" + "net/http" + "strings" + + "github.com/vpnhouse/tunnel/pkg/xhttp" + "go.uber.org/zap" +) + +const ( + headerProxyAuthorization = "Proxy-Authorization" + authTypeBasic = "basic" +) + +func extractProxyAuthToken(r *http.Request) (string, bool) { + authType, authInfo := xhttp.ExtractAuthorizationInfo(r, headerProxyAuthorization) + if authInfo == "" { + return "", false + } + + if strings.ToLower(authType) != authTypeBasic { + zap.L().Debug("Invalid authentication type") + return "", false + } + + userpass, err := base64.StdEncoding.DecodeString(authInfo) + if err != nil { + zap.L().Debug("Failed to extract authentication token", zap.Error(err)) + return "", false + } + + return string(userpass[:len(userpass)-1]), true +} diff --git a/internal/proxy/instance.go b/internal/proxy/instance.go new file mode 100644 index 0000000..cb8ae13 --- /dev/null +++ b/internal/proxy/instance.go @@ -0,0 +1,142 @@ +package proxy + +import ( + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/vpnhouse/tunnel/internal/authorizer" + "github.com/vpnhouse/tunnel/pkg/auth" + "github.com/vpnhouse/tunnel/pkg/xerror" + "github.com/vpnhouse/tunnel/pkg/xhttp" +) + +type Config struct { + ConnLimit int `yaml:"conn_limit"` + ConnTimeout time.Duration `yaml:"conn_timeout"` + MarkHeaderPrefix string `yaml:"mark_header_prefix"` + MarkHeaderRandomLength uint `yaml:"mark_header_random_length"` +} + +type Instance struct { + config *Config + authorizer authorizer.JWTAuthorizer + users *userStorage + myDomains map[string]struct{} + proxyMarkHeader string + terminated atomic.Bool +} + +func New(config *Config, jwtAuthorizer authorizer.JWTAuthorizer, myDomains []string) (*Instance, error) { + if config == nil { + return nil, xerror.EInternalError("No configuration", nil) + } + + domains := make(map[string]struct{}) + for _, domain := range myDomains { + domains[domain] = struct{}{} + } + + markHeaderLength := config.MarkHeaderRandomLength + if markHeaderLength == 0 { + markHeaderLength = 8 + } + + return &Instance{ + config: config, + authorizer: authorizer.WithEntitlement(jwtAuthorizer, authorizer.Proxy), + users: newUserStorage(config.ConnLimit), + myDomains: domains, + proxyMarkHeader: config.MarkHeaderPrefix + randomString(markHeaderLength), + }, nil +} + +func (instance *Instance) Shutdown() error { + if instance.terminated.Swap(true) { + return xerror.EInternalError("Double proxy shutdown", nil) + } + + return nil +} + +func (instance *Instance) Running() bool { + return instance.terminated.Load() +} + +func (instance *Instance) isMyRequest(r *http.Request) bool { + hostParts := strings.Split(r.Host, ":") + _, myDomain := instance.myDomains[hostParts[0]] + return myDomain +} + +func (instance *Instance) cycledProxy(r *http.Request) bool { + _, cycled := r.Header[instance.proxyMarkHeader] + return cycled +} + +func (instance *Instance) ProxyHandler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if (instance.isMyRequest(r) || instance.cycledProxy(r)) && (r.Method != http.MethodConnect) { + next.ServeHTTP(w, r) + } else { + instance.doProxy(w, r) + } + }) +} + +func (instance *Instance) doAuth(r *http.Request) (string, error) { + userToken, ok := extractProxyAuthToken(r) + if !ok { + return "", xerror.WAuthenticationFailed("proxy", "no auth token", nil) + } + + token, err := instance.authorizer.Authenticate(userToken, auth.AudienceTunnel) + if err != nil { + return "", err + } + + return token.UserId, nil +} + +func (instance *Instance) doProxy(w http.ResponseWriter, r *http.Request) { + userId, err := instance.doAuth(r) + if err != nil { + w.Header()["Proxy-Authenticate"] = []string{"Basic realm=\"proxy\""} + w.WriteHeader(http.StatusProxyAuthRequired) + w.Write([]byte("Proxy authentication required")) + return + } + + user, err := instance.users.acquire(r.Context(), userId) + if err != nil { + http.Error(w, "Limit exceeded", http.StatusTooManyRequests) + xhttp.WriteJsonError(w, err) + return + } + defer instance.users.release(userId, user) + + query := &ProxyQuery{ + userId: userId, + userInfo: user, + id: queryCounter.Add(1), + proxyInstance: instance, + } + + if r.Method == "CONNECT" { + if r.ProtoMajor == 1 { + query.handleV1Connect(w, r) + return + } + + if r.ProtoMajor == 2 { + query.handleV2Connect(w, r) + return + } + + http.Error(w, "Unsupported protocol version", http.StatusHTTPVersionNotSupported) + return + } else { + query.handleProxy(w, r) + } +} diff --git a/internal/proxy/query.go b/internal/proxy/query.go new file mode 100644 index 0000000..358a440 --- /dev/null +++ b/internal/proxy/query.go @@ -0,0 +1,149 @@ +package proxy + +import ( + "io" + "net" + "net/http" + "sync" + "sync/atomic" + + "github.com/posener/h2conn" + "go.uber.org/zap" +) + +var ( + queryCounter atomic.Int64 + customTransport = http.DefaultTransport +) + +type ProxyQuery struct { + id int64 + userId string + userInfo *userInfo + proxyInstance *Instance +} + +func (query *ProxyQuery) doPairedForward(wg *sync.WaitGroup, src, dst io.ReadWriteCloser) { + defer wg.Done() + defer dst.Close() + + for { + buffer := make([]byte, 4096) + len, err := src.Read(buffer) + if err != nil { + return + } + + // TODO: Handle length + _, err = dst.Write(buffer[:len]) + if err != nil { + return + } + } +} + +func (query *ProxyQuery) handleV1Connect(w http.ResponseWriter, r *http.Request) { + remoteConn, err := net.DialTimeout("tcp", remoteEndpoint(r), query.proxyInstance.config.ConnTimeout) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "Hijack not supported", http.StatusServiceUnavailable) + zap.L().Error("Hijacking is not supported", zap.Int64("id", query.id)) + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + if r.Body != nil { + defer r.Body.Close() + } + zap.L().Error("Hijack error", zap.Error(err), zap.Int64("id", query.id)) + return + } + + if _, err := clientConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")); err != nil { + clientConn.Close() + remoteConn.Close() + if !isConnectionClosed(err) { + zap.L().Error("Can't write 200 OK response", zap.Error(err), zap.Int64("id", query.id)) + } + return + } + + var wg sync.WaitGroup + wg.Add(2) + go query.doPairedForward(&wg, clientConn, remoteConn) + go query.doPairedForward(&wg, remoteConn, clientConn) + wg.Wait() +} + +func (query *ProxyQuery) handleV2Connect(w http.ResponseWriter, r *http.Request) { + remoteConn, err := net.DialTimeout("tcp", remoteEndpoint(r), query.proxyInstance.config.ConnTimeout) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + clientConn, err := h2conn.Accept(w, r) + if err != nil { + zap.L().Error("h2conn error", zap.Error(err), zap.Int64("id", query.id)) + remoteConn.Close() + return + } + + var wg sync.WaitGroup + wg.Add(2) + go query.doPairedForward(&wg, clientConn, remoteConn) + go query.doPairedForward(&wg, remoteConn, clientConn) + wg.Wait() +} + +func (query *ProxyQuery) handleProxy(w http.ResponseWriter, r *http.Request) { + if r.ProtoMajor == 2 { + http.Error(w, "Bad request", http.StatusHTTPVersionNotSupported) + } + + proxyReq, err := http.NewRequest(r.Method, r.URL.String(), r.Body) + if err != nil { + zap.L().Error("Error creating proxy request", zap.Error(err), zap.Int64("id", query.id)) + http.Error(w, "Error creating proxy request", http.StatusInternalServerError) + return + } + + r.Header.Del("Proxy-Connection") + r.Header.Del("Proxy-Authenticate") + r.Header.Del("Proxy-Authorization") + r.Header.Add(query.proxyInstance.proxyMarkHeader, randomString(8)) + + // Copy the headers from the original request to the proxy request + for name, values := range r.Header { + for _, value := range values { + proxyReq.Header.Add(name, value) + } + } + + // Send the proxy request using the custom transport + resp, err := customTransport.RoundTrip(proxyReq) + if err != nil { + http.Error(w, "Error sending proxy request", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + // Copy the headers from the proxy response to the original response + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + + // Set the status code of the original response to the status code of the proxy response + w.WriteHeader(resp.StatusCode) + + // Copy the body of the proxy response to the original response + io.Copy(w, resp.Body) +} diff --git a/internal/proxy/users.go b/internal/proxy/users.go new file mode 100644 index 0000000..435e576 --- /dev/null +++ b/internal/proxy/users.go @@ -0,0 +1,79 @@ +package proxy + +import ( + "context" + "sync" + + "github.com/vpnhouse/tunnel/pkg/xerror" + "go.uber.org/zap" + "golang.org/x/sync/semaphore" +) + +type userStorage struct { + lock sync.Mutex + maxConn int64 + users map[string]*userInfo +} + +type userInfo struct { + limit *semaphore.Weighted + usage int +} + +func newUserStorage(maxConn int) *userStorage { + return &userStorage{ + maxConn: int64(maxConn), + users: make(map[string]*userInfo), + } +} + +func (s *userStorage) take(id string) *userInfo { + s.lock.Lock() + defer s.lock.Unlock() + + user, loaded := s.users[id] + if !loaded { + user = &userInfo{ + limit: semaphore.NewWeighted(s.maxConn), + } + s.users[id] = user + } + + user.usage += 1 + return user + +} + +func (s *userStorage) put(id string) { + s.lock.Lock() + defer s.lock.Unlock() + + user, loaded := s.users[id] + if !loaded { + zap.L().Error("Can't put unknown user") + return + } + + user.usage -= 1 + if user.usage == 0 { + delete(s.users, id) + } +} + +// TODO: Recover user limits + +func (s *userStorage) acquire(ctx context.Context, id string) (*userInfo, error) { + user := s.take(id) + err := user.limit.Acquire(ctx, 1) + if err != nil { + s.put(id) + return nil, xerror.EUnavailable("unavailable", err) + } + + return user, nil +} + +func (s *userStorage) release(id string, user *userInfo) { + user.limit.Release(1) + s.put(id) +} diff --git a/internal/proxy/utils.go b/internal/proxy/utils.go new file mode 100644 index 0000000..4838b2e --- /dev/null +++ b/internal/proxy/utils.go @@ -0,0 +1,52 @@ +package proxy + +import ( + "errors" + "io" + "math/rand" + "net/http" + "os" + "regexp" + "syscall" +) + +var ( + hasPort = regexp.MustCompile(`:\d+$`) + randomLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +) + +func randomString(n uint) string { + b := make([]rune, n) + for i := range b { + b[i] = randomLetters[rand.Intn(len(randomLetters))] + } + return string(b) +} + +func remoteEndpoint(r *http.Request) string { + host := r.URL.Host + if !hasPort.MatchString(host) { + host += ":80" + } + + return host +} + +func isConnectionClosed(err error) bool { + if err == nil { + return false + } + + if err == io.EOF { + return true + } + + var syscallError *os.SyscallError + if errors.As(err, &syscallError) { + if syscallError.Err == syscall.EPIPE || syscallError.Err == syscall.ECONNRESET || syscallError.Err == syscall.EPROTOTYPE { + return true + } + } + + return false +} diff --git a/internal/runtime/features.go b/internal/runtime/features.go index e213318..22f6988 100644 --- a/internal/runtime/features.go +++ b/internal/runtime/features.go @@ -15,6 +15,7 @@ const ( featurePublicAPI = "public_api" featureGeoip = "geoip" featureIPRose = "iprose" + featureProxy = "proxy" ) type FeatureSet map[string]bool @@ -28,6 +29,7 @@ func NewFeatureSet() FeatureSet { featurePublicAPI: true, featureGeoip: true, featureIPRose: true, + featureProxy: true, } } @@ -38,6 +40,7 @@ func NewFeatureSet() FeatureSet { featurePublicAPI: false, featureGeoip: false, featureIPRose: false, + featureProxy: false, } } @@ -64,3 +67,7 @@ func (f FeatureSet) WithGeoip() bool { func (f FeatureSet) WithIPRose() bool { return f[featureIPRose] } + +func (f FeatureSet) WithProxy() bool { + return f[featureProxy] +} diff --git a/internal/settings/static.go b/internal/settings/static.go index 9ec09c2..e62dce8 100644 --- a/internal/settings/static.go +++ b/internal/settings/static.go @@ -19,6 +19,7 @@ import ( "github.com/vpnhouse/tunnel/internal/extstat" "github.com/vpnhouse/tunnel/internal/grpc" "github.com/vpnhouse/tunnel/internal/iprose" + "github.com/vpnhouse/tunnel/internal/proxy" "github.com/vpnhouse/tunnel/internal/wireguard" "github.com/vpnhouse/tunnel/pkg/human" "github.com/vpnhouse/tunnel/pkg/ipam" @@ -55,6 +56,7 @@ type Config struct { HTTP HttpConfig `yaml:"http"` // optional configuration + Proxy *proxy.Config `yaml:"proxy,omitempty"` ExternalStats *extstat.Config `yaml:"external_stats,omitempty"` NetworkPolicy *NetworkAccessPolicy `yaml:"network,omitempty"` SSL *xhttp.SSLConfig `yaml:"ssl,omitempty"` diff --git a/pkg/auth/jwt.go b/pkg/auth/jwt.go index 82139af..06e23ae 100644 --- a/pkg/auth/jwt.go +++ b/pkg/auth/jwt.go @@ -83,7 +83,6 @@ func (instance *JWTChecker) keyHelper(token *jwt.Token) (interface{}, error) { if !ok { return nil, xerror.EAuthenticationFailed("invalid token", nil) } - zap.L().Debug("Got key id", zap.Any("keyIdValue", keyIdValue)) keyID, ok := keyIdValue.(string) if !ok { diff --git a/pkg/xhttp/auth.go b/pkg/xhttp/auth.go index 4f1bb50..ad913b1 100644 --- a/pkg/xhttp/auth.go +++ b/pkg/xhttp/auth.go @@ -11,18 +11,40 @@ import ( "go.uber.org/zap" ) -func ExtractTokenFromRequest(r *http.Request) (string, bool) { - authHeader := r.Header.Get("Authorization") +const ( + headerAuthorization = "Authorization" + authTypeBearer = "bearer" +) + +func AuthIsBearer(authType string) bool { + return strings.ToLower(authType) == authTypeBearer +} + +func ExtractAuthorizationInfo(r *http.Request, header string) (authType string, authInfo string) { + authHeader := r.Header.Get(header) if authHeader == "" { - zap.L().Debug("no auth header was found") - return "", false // No error, just no token + return "", "" } authHeaderParts := strings.Fields(authHeader) - if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { - zap.L().Debug("bearer auth header was not found") + if len(authHeaderParts) != 2 { + return "", "" + } + + return authHeaderParts[0], authHeaderParts[1] +} + +func ExtractTokenFromRequest(r *http.Request) (string, bool) { + authType, authToken := ExtractAuthorizationInfo(r, headerAuthorization) + if authToken == "" { + zap.L().Debug("Authentication token was not found") + return "", false + } + + if !AuthIsBearer(authType) { + zap.L().Debug("Invalid authentication type") return "", false } - return authHeaderParts[1], true + return authToken, true } diff --git a/pkg/xhttp/server.go b/pkg/xhttp/server.go index 06056a1..d0204fc 100644 --- a/pkg/xhttp/server.go +++ b/pkg/xhttp/server.go @@ -74,6 +74,12 @@ func WithCORS() Option { } } +func WithDisableHTTPv2() Option { + return func(w *Server) { + w.disablev2 = true + } +} + func WithLogger() Option { return func(w *Server) { w.router.Use(requestLogger) @@ -90,14 +96,20 @@ type Server struct { srv *http.Server tlsConfig *tls.Config router chi.Router + disablev2 bool } // Run starts the http server asynchronously. func (w *Server) Run(addr string) error { w.srv = &http.Server{ - Handler: w.router, - Addr: addr, - TLSConfig: w.tlsConfig, + Handler: w.router, + Addr: addr, + TLSConfig: w.tlsConfig, + ReadTimeout: 10 * time.Second, + } + + if w.disablev2 { + w.srv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) } lis, err := net.Listen("tcp", addr)