Compare commits

..

No commits in common. "main" and "v0.1.25" have entirely different histories.

2 changed files with 105 additions and 107 deletions

View File

@ -6,16 +6,15 @@ import (
"fmt"
"gitea.illuad.fr/adrien/middleman"
"gitea.illuad.fr/adrien/middleman/flag"
"gitea.illuad.fr/adrien/middleman/pkg/fastrp"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"golang.org/x/sync/errgroup"
"net"
"net/http"
"net/http/httputil"
"net/netip"
"net/url"
"regexp"
"slices"
"strings"
"time"
)
@ -35,7 +34,25 @@ type methodRegex = map[string]*regexp.Regexp
// ProxyHandler takes an incoming request and sends it to another server, proxying the response back to the client.
type ProxyHandler struct {
frp *fastrp.FastRP
rp *httputil.ReverseProxy
}
// ErrHTTPMethodNotAllowed is returned if the request's HTTP method is not allowed for this container.
type ErrHTTPMethodNotAllowed struct {
httpMethod string
}
// ErrNoMatch is returned if the request's URL path is not allowed for this container.
type ErrNoMatch struct {
path, httpMethod string
}
func (e ErrHTTPMethodNotAllowed) Error() string {
return fmt.Sprintf("%s is not in the list of HTTP methods allowed this container", e.httpMethod)
}
func (e ErrNoMatch) Error() string {
return fmt.Sprintf("%s does not match any registered regular expression for this HTTP method (%s)", e.path, e.httpMethod)
}
// ServeCmd is the command name.
@ -75,8 +92,8 @@ const (
defaultHealthEndpoint = "/healthz"
// defaultHealthListenAddr is the proxy health endpoint default listen address and port.
defaultHealthListenAddr = "127.0.0.1:5732"
// defaultAllowedRequests is the default allowed requests. Note that they should be sufficient to make Traefik work properly.
defaultAllowedRequests = "/v1.\\d{1,2}/(version|containers/(?:json|[a-zA-Z0-9]{64}/json)|events)$"
// defaultAllowedRequest is the proxy default allowed request.
defaultAllowedRequest = "^/(version|containers/.*|events.*)$"
)
var s serve
@ -84,7 +101,7 @@ var s serve
var (
containerMethodRegex = map[string]methodRegex{
"*": {
http.MethodGet: regexp.MustCompile(defaultAllowedRequests),
http.MethodGet: regexp.MustCompile(defaultAllowedRequest),
},
}
applicatorURLRegex = regexp.MustCompile(`^([a-zA-Z0-9][a-zA-Z0-9_.-]+)\(((?:(?:GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|TRACE|OPTIONS)(?:,(?:GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|TRACE|OPTIONS))*)?)\):(.*)$`)
@ -132,74 +149,92 @@ func Serve(group *errgroup.Group) *cli.Command {
func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log.Debug().Str("remote_addr", r.RemoteAddr).Str("method", r.Method).Str("path", r.URL.Path).Msg("incoming request")
if mr, ok := containerMethodRegex["*"]; ok {
if code := ph.checkMethodAndRegex(mr, r, ""); code != http.StatusOK {
http.Error(w, http.StatusText(code), code)
return
}
ph.frp.ServeHTTP(w, r)
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
for containerName, mr := range containerMethodRegex {
if ph.isContainerAuthorized(containerName, host) {
if code := ph.checkMethodAndRegex(mr, r, containerName); code != http.StatusOK {
http.Error(w, http.StatusText(code), code)
return
}
ph.frp.ServeHTTP(w, r)
return
}
}
logDeniedRequest(r, http.StatusUnauthorized, "this container is not on the list of authorized ones")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func (ph *ProxyHandler) isContainerAuthorized(containerName, host string) bool {
resolvedIPs, err := net.LookupIP(containerName)
if err != nil {
return false
}
for resolvedIP := range slices.Values(resolvedIPs) {
if resolvedIP.Equal(net.ParseIP(host)) {
return true
}
}
return false
}
func logDeniedRequest(r *http.Request, statusCode int, message string) {
mr, ok := containerMethodRegex["*"]
if ok {
var req *regexp.Regexp
req, ok = mr[r.Method]
if !ok {
log.Error().
Str("remote_addr", r.RemoteAddr).Str("method", r.Method).
Str("path", r.URL.Path).Int("status_code", statusCode).
Str("status_text", http.StatusText(statusCode)).Msg(message)
}
func logAuthorizedRequest(r *http.Request, containerName, message string) {
l := log.Info().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Int("status_code", http.StatusOK).
Str("status_text", http.StatusText(http.StatusOK))
if containerName != "" {
l.Str("container_name", containerName)
}
l.Msg(message)
}
func (ph *ProxyHandler) checkMethodAndRegex(mr methodRegex, r *http.Request, containerName string) int {
req, ok := mr[r.Method]
if !ok {
logDeniedRequest(r, http.StatusMethodNotAllowed, "this HTTP method is not in the list of those authorized for this container")
return http.StatusMethodNotAllowed
Str("decision", "denied").
Msg("this HTTP method is not in the list of those authorized for this container")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !req.MatchString(r.URL.Path) {
logDeniedRequest(r, http.StatusForbidden, "this path does not match any regular expression for this HTTP method")
return http.StatusForbidden
log.Error().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "denied").
Msg("this path does not match any regular expression for this HTTP method")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
logAuthorizedRequest(r, containerName, "incoming request matches a registered regular expression")
return http.StatusOK
log.Info().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "authorized").
Msg("incoming request matches a registered regular expression")
ph.rp.ServeHTTP(w, r)
return
}
var (
containerName string
host, _, _ = net.SplitHostPort(r.RemoteAddr)
)
for containerName, mr = range containerMethodRegex {
resolvedIPs, err := net.LookupIP(containerName)
if err != nil {
continue
}
for _, resolvedIP := range resolvedIPs {
if resolvedIP.Equal(net.ParseIP(host)) {
var req *regexp.Regexp
req, ok = mr[r.Method]
if !ok {
log.Error().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "denied").
Msg("this HTTP method is not in the list of those authorized for this container")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !req.MatchString(r.URL.Path) {
log.Error().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "denied").
Msg("this path does not match any regular expression for this HTTP method")
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
log.Info().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "authorized").
Str("from", containerName).
Msg("incoming request matches a registered regular expression")
ph.rp.ServeHTTP(w, r)
return
}
}
}
log.Error().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "denied").
Msg("this container is not on the list of authorized ones")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
// action is executed when the ServeCmd command is called.
@ -217,11 +252,15 @@ func (s serve) action(ctx context.Context, command *cli.Command) error {
return err
}
dummyURL, _ := url.Parse("http://dummy")
srv := &http.Server{ // #nosec: G112
Handler: &ProxyHandler{
frp: fastrp.NewRP("unix", command.String(dockerSocketPathFlagName), dummyURL),
rp := httputil.NewSingleHostReverseProxy(dummyURL)
rp.Transport = &http.Transport{
DialContext: func(_ context.Context, _ string, _ string) (net.Conn, error) {
return net.Dial("unix", command.String(dockerSocketPathFlagName))
},
}
srv := &http.Server{ // #nosec: G112
Handler: &ProxyHandler{rp: rp},
}
s.group.Go(func() error {
if err = srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
@ -419,7 +458,7 @@ func addRequests() cli.Flag {
Usage: "add requests",
Sources: middleman.PluckEnvVar(EnvVarPrefix, addRequestsFlagName),
// Local: true, // Required to trigger the Action when this flag is set via the environment variable, see https://github.com/urfave/cli/issues/2041.
Value: []string{"*:" + defaultAllowedRequests},
Value: []string{"*:" + defaultAllowedRequest},
Aliases: middleman.PluckAlias(ServeCmd, addRequestsFlagName),
Action: func(ctx context.Context, command *cli.Command, requests []string) error {
clear(containerMethodRegex)

View File

@ -1,41 +0,0 @@
package fastrp
import (
"context"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sync"
)
type FastRP struct {
rp *httputil.ReverseProxy
connPool *sync.Pool
}
func NewRP(network, address string, url *url.URL) *FastRP {
roundTripper := &http.Transport{
DialContext: func(_ context.Context, _ string, _ string) (net.Conn, error) {
return net.Dial(network, address)
},
MaxIdleConns: 10,
}
frp := &FastRP{
rp: httputil.NewSingleHostReverseProxy(url),
connPool: &sync.Pool{
New: func() any {
return roundTripper.Clone()
},
},
}
frp.rp.Transport = frp.connPool.Get().(http.RoundTripper)
return frp
}
func (frp *FastRP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
roundTripper := frp.connPool.Get().(http.RoundTripper)
defer frp.connPool.Put(roundTripper)
frp.rp.Transport = roundTripper
frp.rp.ServeHTTP(w, r)
}