Compare commits

...

13 Commits

Author SHA1 Message Date
Adrien PONSIN
59b95ea315
improve default regex 2025-04-17 21:46:41 +02:00
Adrien PONSIN
fcaafbf3f4
still working? 2025-04-17 21:16:07 +02:00
Adrien PONSIN
c55dec6bc4
use a sync.Pool to reuse connexions 2025-04-17 18:41:32 +02:00
Adrien PONSIN
17aa156fa5
try to improve 2025-04-17 17:56:39 +02:00
Adrien PONSIN
7bc9fa242e
add an error log for unauthorized container 2025-04-17 17:15:58 +02:00
Adrien PONSIN
3937b7cda5
try fixing logic 2025-04-17 17:11:00 +02:00
Adrien PONSIN
52b4d44b4b
remove comments and add the container name 2025-04-17 15:19:19 +02:00
Adrien PONSIN
6a1c6c5967
fix refactoring 2025-04-17 15:07:57 +02:00
Adrien PONSIN
ec887ccb93
remove useless return 2025-04-17 15:00:32 +02:00
Adrien PONSIN
3829a46f87
big refactoring 2025-04-17 14:55:34 +02:00
Adrien PONSIN
47538621c9
call ServeHTTP 2025-04-17 14:17:04 +02:00
Adrien PONSIN
8adf4be96e
return a 401 if the container is not authorized 2025-04-17 14:07:19 +02:00
Adrien PONSIN
90d442b611
improve flow 2025-04-17 14:00:11 +02:00
2 changed files with 102 additions and 141 deletions

View File

@ -6,15 +6,16 @@ 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"
)
@ -34,25 +35,7 @@ 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 {
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)
frp *fastrp.FastRP
}
// ServeCmd is the command name.
@ -92,8 +75,8 @@ const (
defaultHealthEndpoint = "/healthz"
// defaultHealthListenAddr is the proxy health endpoint default listen address and port.
defaultHealthListenAddr = "127.0.0.1:5732"
// defaultAllowedRequest is the proxy default allowed request.
defaultAllowedRequest = "^/(version|containers/.*|events.*)$"
// 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)$"
)
var s serve
@ -101,7 +84,7 @@ var s serve
var (
containerMethodRegex = map[string]methodRegex{
"*": {
http.MethodGet: regexp.MustCompile(defaultAllowedRequest),
http.MethodGet: regexp.MustCompile(defaultAllowedRequests),
},
}
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))*)?)\):(.*)$`)
@ -149,121 +132,74 @@ 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")
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).
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)
if mr, ok := containerMethodRegex["*"]; ok {
if code := ph.checkMethodAndRegex(mr, r, ""); code != http.StatusOK {
http.Error(w, http.StatusText(code), code)
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)
ph.frp.ServeHTTP(w, r)
return
}
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")
return
/*
if err := checkMethodPath(r, mr); err != nil {
handleError(w, err)
log.Err(err).Send()
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
}
*/
} else {
var (
containerName string
host, _, _ = net.SplitHostPort(r.RemoteAddr)
ip = net.ParseIP(host)
)
for containerName, mr = range containerMethodRegex {
resolvedIPs, err := net.LookupIP(containerName)
if err != nil {
// log.Warn().Err(err).Msg("this error may be transient due to the unavailability of one of the services")
continue
}
for _, resolvedIP := range resolvedIPs {
if resolvedIP.Equal(ip) {
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
}
/*
if err = checkMethodPath(r, mr); err != nil {
handleError(w, err)
log.Err(err).Send()
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)
ph.frp.ServeHTTP(w, r)
return
}
}
}
}
log.Warn().
Str("remote_addr", r.RemoteAddr).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("decision", "denied").
Msg("this error may be transient due to the unavailability of one of the services")
http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
return
logDeniedRequest(r, http.StatusUnauthorized, "this container is not on the list of authorized ones")
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
// checkMethodPath executes the regular expression on the path of the HTTP request if and only if
// the latter's HTTP method is actually present in the list of authorized HTTP methods.
func checkMethodPath(r *http.Request, mr methodRegex) error {
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) {
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 {
return ErrHTTPMethodNotAllowed{httpMethod: r.Method}
logDeniedRequest(r, http.StatusMethodNotAllowed, "this HTTP method is not in the list of those authorized for this container")
return http.StatusMethodNotAllowed
}
if !req.MatchString(r.URL.Path) {
return ErrNoMatch{path: r.URL.Path, httpMethod: r.Method}
logDeniedRequest(r, http.StatusForbidden, "this path does not match any regular expression for this HTTP method")
return http.StatusForbidden
}
return nil
logAuthorizedRequest(r, containerName, "incoming request matches a registered regular expression")
return http.StatusOK
}
// action is executed when the ServeCmd command is called.
@ -281,14 +217,10 @@ func (s serve) action(ctx context.Context, command *cli.Command) error {
return err
}
dummyURL, _ := url.Parse("http://dummy")
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},
Handler: &ProxyHandler{
frp: fastrp.NewRP("unix", command.String(dockerSocketPathFlagName), dummyURL),
},
}
s.group.Go(func() error {
if err = srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
@ -487,7 +419,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{"*:" + defaultAllowedRequest},
Value: []string{"*:" + defaultAllowedRequests},
Aliases: middleman.PluckAlias(ServeCmd, addRequestsFlagName),
Action: func(ctx context.Context, command *cli.Command, requests []string) error {
clear(containerMethodRegex)
@ -532,15 +464,3 @@ func registerMethodRegex(containerName, urlRegex string, httpMethods []string) e
}
return nil
}
func handleError(w http.ResponseWriter, err error) {
var methodNotAllowedErr ErrHTTPMethodNotAllowed
var noMatchErr ErrNoMatch
if errors.As(err, &methodNotAllowedErr) {
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
} else if errors.As(err, &noMatchErr) {
http.Error(w, err.Error(), http.StatusForbidden)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

41
pkg/fastrp/fastrp.go Normal file
View File

@ -0,0 +1,41 @@
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)
}