first commit
This commit is contained in:
commit
6db32797dc
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# middleman
|
||||
|
||||
Securely mount the Docker socket: apply fine-grained access control to Docker socket HTTP requests.
|
35
alias.go
Normal file
35
alias.go
Normal file
@ -0,0 +1,35 @@
|
||||
package middleman
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var aliases = make(map[string]map[string]struct{})
|
||||
|
||||
func PluckAlias(parent, flag string) []string {
|
||||
if _, ok := aliases[parent]; !ok {
|
||||
aliases[parent] = make(map[string]struct{})
|
||||
}
|
||||
var alias string
|
||||
for word := range strings.SplitAfterSeq(flag, "-") {
|
||||
alias = word[:1]
|
||||
if alias == "h" {
|
||||
alias = "H"
|
||||
if _, ok := aliases[parent][alias]; !ok {
|
||||
aliases[parent][alias] = struct{}{}
|
||||
return []string{alias}
|
||||
}
|
||||
}
|
||||
if _, ok := aliases[parent][alias]; !ok {
|
||||
aliases[parent][alias] = struct{}{}
|
||||
return []string{alias}
|
||||
} else {
|
||||
alias = strings.ToUpper(alias)
|
||||
if _, ok = aliases[parent][alias]; !ok {
|
||||
aliases[parent][alias] = struct{}{}
|
||||
return []string{alias}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
68
cmd/middleman/main.go
Normal file
68
cmd/middleman/main.go
Normal file
@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"gitea.illuad.fr/adrien/middleman/command"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type noCommit struct {
|
||||
message []byte
|
||||
}
|
||||
|
||||
func (nc noCommit) String() string {
|
||||
if nc.message == nil {
|
||||
nc.message = []byte("not tied to a commit") // 6344da387a21711b36a91c2fd55f48a026b2be8b5345cbbf32a31f5c089f4d75
|
||||
}
|
||||
sum := sha256.Sum256(nc.message)
|
||||
return string(sum[:])
|
||||
}
|
||||
|
||||
const appName = "middleman"
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = noCommit{}.String()
|
||||
)
|
||||
|
||||
func main() {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if ok {
|
||||
if info.Main.Version != "" {
|
||||
version = info.Main.Version
|
||||
commit = info.Main.Sum
|
||||
}
|
||||
}
|
||||
// Note: os.Kill/syscall.SIGKILL (SIGKILL) signal cannot be caught or ignored.
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
var group *errgroup.Group
|
||||
group, ctx = errgroup.WithContext(ctx)
|
||||
app := cli.Command{
|
||||
Name: appName,
|
||||
Usage: "Securely mount the Docker socket: apply fine-grained access control to Docker socket HTTP requests",
|
||||
Version: version + "-" + commit,
|
||||
DefaultCommand: command.ServeCmd,
|
||||
Commands: []*cli.Command{
|
||||
command.Serve(group),
|
||||
command.Healthcheck(group),
|
||||
},
|
||||
Authors: []any{
|
||||
"Adrien <contact@illuad.fr>",
|
||||
},
|
||||
UseShortOptionHandling: true,
|
||||
}
|
||||
group.Go(func() error {
|
||||
return app.Run(ctx, os.Args)
|
||||
})
|
||||
if err := group.Wait(); err != nil {
|
||||
log.Err(err).Send()
|
||||
}
|
||||
}
|
34
command/command.go
Normal file
34
command/command.go
Normal file
@ -0,0 +1,34 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gitea.illuad.fr/adrien/middleman/flag"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const EnvVarPrefix = "MIDDLEMAN"
|
||||
|
||||
// before is executed before any subcommands are run.
|
||||
func before(ctx context.Context, command *cli.Command) (context.Context, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
const humanFormatKeyword = "hf"
|
||||
if command.String(flag.LogFormatFlagName) == humanFormatKeyword {
|
||||
log.Logger = zerolog.New(zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
}).With().Timestamp().Logger()
|
||||
}
|
||||
logLevel := command.String(flag.LogLevelFlagName)
|
||||
level, err := zerolog.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
log.Err(flag.ErrInvalidLogLevel{LogLevel: logLevel}).Send()
|
||||
}
|
||||
zerolog.SetGlobalLevel(level)
|
||||
return ctx, nil
|
||||
}
|
86
command/healthcheck.go
Normal file
86
command/healthcheck.go
Normal file
@ -0,0 +1,86 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.illuad.fr/adrien/middleman"
|
||||
"gitea.illuad.fr/adrien/middleman/flag"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// healthcheck is the structure representing the command described below.
|
||||
type healthcheck struct {
|
||||
group *errgroup.Group
|
||||
}
|
||||
|
||||
// HealthcheckCmd is the command name.
|
||||
const HealthcheckCmd = "healthcheck"
|
||||
|
||||
const (
|
||||
// healthcheckURLFlagName is the flag name used to set the healthcheck URL.
|
||||
healthcheckURLFlagName = "healthcheck-url"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultHealthcheckURL is the default healthcheck URL.
|
||||
defaultHealthcheckURL = "http://127.0.0.1:5732/healthz"
|
||||
)
|
||||
|
||||
var h healthcheck
|
||||
|
||||
// Healthcheck describes the healthcheck command.
|
||||
func Healthcheck(group *errgroup.Group) *cli.Command {
|
||||
h.group = group
|
||||
return &cli.Command{
|
||||
Name: HealthcheckCmd,
|
||||
Aliases: middleman.PluckAlias(HealthcheckCmd, HealthcheckCmd),
|
||||
Usage: "Runs healthcheck mode",
|
||||
Description: "Perform HTTP HEAD request to the health endpoint",
|
||||
Flags: []cli.Flag{
|
||||
healthcheckURL(),
|
||||
flag.LogFormat(HealthcheckCmd),
|
||||
flag.LogLevel(HealthcheckCmd),
|
||||
},
|
||||
Before: before,
|
||||
Action: h.action,
|
||||
}
|
||||
}
|
||||
|
||||
// action is executed when the HealthcheckCmd command is called.
|
||||
func (h healthcheck) action(ctx context.Context, command *cli.Command) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, command.String(healthcheckURLFlagName), http.NoBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res *http.Response
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err = res.Body.Close(); err != nil {
|
||||
log.Err(err).Send()
|
||||
}
|
||||
}()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("healthcheck failed with the following HTTP code: %d (%s)", res.StatusCode, http.StatusText(res.StatusCode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func healthcheckURL() cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: healthcheckURLFlagName,
|
||||
Category: "monitoring",
|
||||
Usage: "full healthcheck URL (protocol, address and path)",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, healthcheckURLFlagName),
|
||||
Value: defaultHealthcheckURL,
|
||||
Aliases: middleman.PluckAlias(HealthcheckCmd, healthcheckURLFlagName),
|
||||
}
|
||||
}
|
466
command/serve.go
Normal file
466
command/serve.go
Normal file
@ -0,0 +1,466 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.illuad.fr/adrien/middleman"
|
||||
"gitea.illuad.fr/adrien/middleman/flag"
|
||||
"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"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// serve is the structure representing the command described below.
|
||||
type serve struct {
|
||||
group *errgroup.Group
|
||||
}
|
||||
|
||||
// addrPorts holds netip.AddrPort of different HTTP servers.
|
||||
type addrPorts struct {
|
||||
srvAddrPort, healthAddrPort netip.AddrPort
|
||||
}
|
||||
|
||||
// methodRegex maps HTTP methods (GET, POST, DELETE...) to a compiled regular expression.
|
||||
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)
|
||||
}
|
||||
|
||||
// ServeCmd is the command name.
|
||||
const ServeCmd = "serve"
|
||||
|
||||
const (
|
||||
// listenAddrFlagName is the flag name used to set the listen address and port.
|
||||
listenAddrFlagName = "listen-addr"
|
||||
// dockerSocketPathFlagName is the flag name used to set the Docker unix socket path.
|
||||
dockerSocketPathFlagName = "docker-socket-path"
|
||||
// shutdownTimeoutFlagName is the flag name used to set the server shutdown timeout.
|
||||
shutdownTimeoutFlagName = "shutdown-timeout"
|
||||
// noDockerSocketHealthcheckFlagName is the flag name used to disable the Docker unix socket healthcheck.
|
||||
noDockerSocketHealthcheckFlagName = "no-docker-socket-healthcheck"
|
||||
// noShutdownFlagName is the flag name used to disable the application shutdown in case of Docker unix socket healthcheck failure.
|
||||
noShutdownFlagName = "no-shutdown"
|
||||
// healthcheckRetryFlagName is the flag name used to set the number of dial attempts with the Docker unix socket before the application shutdown (if enabled).
|
||||
healthcheckRetryFlagName = "healthcheck-retry"
|
||||
// healthEndpointFlagName is the flag name used to set the health endpoint.
|
||||
healthEndpointFlagName = "health-endpoint"
|
||||
// healthListenAddrFlagName is the flag name used to set the health endpoint listen address and port.
|
||||
healthListenAddrFlagName = "health-listen-addr"
|
||||
// addRequestsFlagName is the flag name used to add allowed requests.
|
||||
addRequestsFlagName = "add-requests"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultListenAddr is the proxy default listen address and port.
|
||||
defaultListenAddr = "0.0.0.0:2375"
|
||||
// defaultDockerSocketPath is the default Docker socket path.
|
||||
defaultDockerSocketPath = "/var/run/docker.sock"
|
||||
// defaultShutdownTimeout is the default server shutdown timeout.
|
||||
defaultShutdownTimeout = 5 * time.Second
|
||||
// defaultHealthcheckRetry is the default number of healthcheck retry
|
||||
defaultHealthcheckRetry uint64 = 3
|
||||
// defaultHealthEndpoint is the default health endpoint.
|
||||
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.*)$"
|
||||
)
|
||||
|
||||
var s serve
|
||||
|
||||
var (
|
||||
containerMethodRegex = map[string]methodRegex{
|
||||
"*": {
|
||||
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))*)?)\):(.*)$`)
|
||||
validHTTPMethods = map[string]struct{}{
|
||||
http.MethodGet: {},
|
||||
http.MethodHead: {},
|
||||
http.MethodPost: {},
|
||||
http.MethodPut: {},
|
||||
http.MethodPatch: {},
|
||||
http.MethodDelete: {},
|
||||
http.MethodConnect: {},
|
||||
http.MethodTrace: {},
|
||||
http.MethodOptions: {},
|
||||
}
|
||||
containerNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+`)
|
||||
httpMethodsRegex = regexp.MustCompile(`(?:GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|TRACE|OPTIONS)(?:GET|HEAD|POST|PUT|PATCH|DELETE|CONNECT|TRACE|OPTIONS)*`)
|
||||
)
|
||||
|
||||
// Serve describes the serve command.
|
||||
func Serve(group *errgroup.Group) *cli.Command {
|
||||
s.group = group
|
||||
return &cli.Command{
|
||||
Name: ServeCmd,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, ServeCmd),
|
||||
Usage: "Runs serve mode",
|
||||
Description: "Proxy requests to the Docker socket using defined access control",
|
||||
Flags: []cli.Flag{
|
||||
listenAddr(),
|
||||
dockerSocketPath(),
|
||||
shutdownTimeout(),
|
||||
noDockerSocketHealthcheck(),
|
||||
noShutdown(),
|
||||
healthcheckRetry(),
|
||||
healthEndpoint(),
|
||||
healthListenAddr(),
|
||||
addRequests(),
|
||||
flag.LogFormat(ServeCmd),
|
||||
flag.LogLevel(ServeCmd),
|
||||
},
|
||||
Before: before,
|
||||
Action: s.action,
|
||||
DisableSliceFlagSeparator: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (ph *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug().Str("http_method", r.Method).Str("path", r.URL.Path).Msg("incoming request")
|
||||
mr, ok := containerMethodRegex["*"]
|
||||
if ok {
|
||||
if err := checkMethodPath(w, r, mr); err != nil {
|
||||
log.Err(err).Send()
|
||||
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 {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
for _, resolvedIP := range resolvedIPs {
|
||||
if resolvedIP.Equal(ip) {
|
||||
if err = checkMethodPath(w, r, mr); err != nil {
|
||||
log.Err(err).Send()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ph.rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// 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(w http.ResponseWriter, r *http.Request, mr methodRegex) error {
|
||||
req, ok := mr[r.Method]
|
||||
if !ok {
|
||||
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
return ErrHTTPMethodNotAllowed{httpMethod: r.Method}
|
||||
}
|
||||
if !req.MatchString(r.URL.Path) {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return ErrNoMatch{
|
||||
path: r.URL.Path,
|
||||
httpMethod: r.Method,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// action is executed when the ServeCmd command is called.
|
||||
func (s serve) action(ctx context.Context, command *cli.Command) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ap, err := parseAddrPorts(command.String(listenAddrFlagName), command.String(healthListenAddrFlagName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var l net.Listener
|
||||
l, err = net.Listen("tcp", ap.srvAddrPort.String())
|
||||
if err != nil {
|
||||
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},
|
||||
}
|
||||
s.group.Go(func() error {
|
||||
if err = srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
retry := command.Uint(healthcheckRetryFlagName)
|
||||
if !command.Bool(noDockerSocketHealthcheckFlagName) {
|
||||
ticker := time.Tick(2 * time.Second)
|
||||
s.group.Go(func() error {
|
||||
loop:
|
||||
for {
|
||||
if err = unixDial(command.String(dockerSocketPathFlagName)); err != nil {
|
||||
if !command.Bool(noShutdownFlagName) {
|
||||
if retry == 0 {
|
||||
return err
|
||||
}
|
||||
log.Err(err).Uint64("retry_remaining", retry).Send()
|
||||
retry--
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ticker:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
break loop
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(command.String(healthEndpointFlagName), func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Str("from", r.RemoteAddr).Msg("incoming healthcheck request")
|
||||
if r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err = unixDial(command.String(dockerSocketPathFlagName)); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
})
|
||||
healthSrv := http.Server{
|
||||
Addr: ap.healthAddrPort.String(),
|
||||
Handler: mux,
|
||||
ReadTimeout: 3 * time.Second,
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
}
|
||||
s.group.Go(func() error {
|
||||
if err = healthSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
log.Info().
|
||||
Stringer("listen_addr", ap.srvAddrPort).
|
||||
Str("docker_socket_path", command.String(dockerSocketPathFlagName)).
|
||||
Stringer("shutdown_timeout", command.Duration(shutdownTimeoutFlagName)).
|
||||
Bool("docker_socket_healthcheck_disabled", command.Bool(noDockerSocketHealthcheckFlagName)).
|
||||
Bool("shutdown_on_failure_disabled", command.Bool(noShutdownFlagName)).
|
||||
Uint64("number_of_healthcheck_retry", command.Uint(healthcheckRetryFlagName)).
|
||||
Str("health_endpoint", command.String(healthEndpointFlagName)).
|
||||
Stringer("health_endpoint_listen_addr", ap.healthAddrPort).
|
||||
Strs("requests", command.StringSlice(addRequestsFlagName)).
|
||||
Str("log_format", command.String(flag.LogFormatFlagName)).
|
||||
Str("log_level", command.String(flag.LogLevelFlagName)).
|
||||
Msg("middleman started")
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), command.Duration(shutdownTimeoutFlagName))
|
||||
defer cancel()
|
||||
if err = healthSrv.Close(); err != nil {
|
||||
log.Err(err).Send()
|
||||
}
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
func parseAddrPorts(srvAddrPort, healthAddrPort string) (addrPorts, error) {
|
||||
var ap addrPorts
|
||||
if err := parseAddrPort(srvAddrPort, &ap.srvAddrPort); err != nil {
|
||||
return ap, err
|
||||
}
|
||||
return ap, parseAddrPort(healthAddrPort, &ap.healthAddrPort)
|
||||
}
|
||||
|
||||
func parseAddrPort(addrPortStr string, addrPort *netip.AddrPort) error {
|
||||
ap, err := netip.ParseAddrPort(addrPortStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*addrPort = ap
|
||||
return nil
|
||||
}
|
||||
|
||||
func unixDial(socketPath string) error {
|
||||
conn, err := net.DialTimeout("unix", socketPath, 3*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
func listenAddr() cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: listenAddrFlagName,
|
||||
Category: "network",
|
||||
Usage: "proxy listen address",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, listenAddrFlagName),
|
||||
Value: defaultListenAddr,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, listenAddrFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func dockerSocketPath() cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: dockerSocketPathFlagName,
|
||||
Category: "docker",
|
||||
Usage: "Docker unix socket path",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, dockerSocketPathFlagName),
|
||||
Value: defaultDockerSocketPath,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, dockerSocketPathFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownTimeout() cli.Flag {
|
||||
return &cli.DurationFlag{
|
||||
Name: shutdownTimeoutFlagName,
|
||||
Category: "network",
|
||||
Usage: "server shutdown timeout",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, shutdownTimeoutFlagName),
|
||||
Value: defaultShutdownTimeout,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, shutdownTimeoutFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func noDockerSocketHealthcheck() cli.Flag {
|
||||
return &cli.BoolFlag{
|
||||
Name: noDockerSocketHealthcheckFlagName,
|
||||
Category: "docker",
|
||||
HideDefault: true,
|
||||
Usage: "disable Docker unix socket healthcheck",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, noDockerSocketHealthcheckFlagName),
|
||||
Aliases: middleman.PluckAlias(ServeCmd, noDockerSocketHealthcheckFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func noShutdown() cli.Flag {
|
||||
return &cli.BoolFlag{
|
||||
Name: noShutdownFlagName,
|
||||
Category: "internal monitoring behavior",
|
||||
HideDefault: true,
|
||||
Usage: "disable application shutdown in case of Docker unix socket healthcheck failure",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, noShutdownFlagName),
|
||||
Aliases: middleman.PluckAlias(ServeCmd, noShutdownFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func healthcheckRetry() cli.Flag {
|
||||
return &cli.UintFlag{
|
||||
Name: healthcheckRetryFlagName,
|
||||
Category: "internal monitoring behavior",
|
||||
Usage: "number of dial attempts with the Docker unix socket before the application shutdown (if enabled)",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, healthcheckRetryFlagName),
|
||||
Value: defaultHealthcheckRetry,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, healthcheckRetryFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func healthEndpoint() cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: healthEndpointFlagName,
|
||||
Category: "monitoring",
|
||||
Usage: "health endpoint",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, healthEndpointFlagName),
|
||||
Value: defaultHealthEndpoint,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, healthEndpointFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func healthListenAddr() cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: healthListenAddrFlagName,
|
||||
Category: "monitoring",
|
||||
Usage: "proxy health endpoint listen address",
|
||||
Sources: middleman.PluckEnvVar(EnvVarPrefix, healthListenAddrFlagName),
|
||||
Value: defaultHealthListenAddr,
|
||||
Aliases: middleman.PluckAlias(ServeCmd, healthListenAddrFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func addRequests() cli.Flag {
|
||||
return &cli.StringSliceFlag{
|
||||
Name: addRequestsFlagName,
|
||||
Category: "network",
|
||||
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},
|
||||
Aliases: middleman.PluckAlias(ServeCmd, addRequestsFlagName),
|
||||
Action: func(ctx context.Context, command *cli.Command, requests []string) error {
|
||||
clear(containerMethodRegex)
|
||||
for _, request := range requests {
|
||||
// An applicator is a container name/HTTP method pair e.g., nginx(GET).
|
||||
// The following regex extracts this applicator and the associated URL regex.
|
||||
aur := applicatorURLRegex.FindString(request)
|
||||
if aur == "" {
|
||||
// If we are here, it means that user set a wildcard (kinda) applicator e.g., GET,POST:URL_REGEX.
|
||||
// In its extended form, this applicator can be read as follows: *(GET,POST):URL_REGEX.
|
||||
methods, urlRegex, ok := strings.Cut(request, ":")
|
||||
if !ok { // || methods == ""?
|
||||
return errors.New("HTTP method(s) must be specified before ':'")
|
||||
}
|
||||
if err := registerMethodRegex("*", urlRegex, strings.Split(methods, ",")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
applicator, urlRegex, _ := strings.Cut(aur, ":")
|
||||
if err := registerMethodRegex(containerNameRegex.FindString(applicator), urlRegex, httpMethodsRegex.FindAllString(applicator, -1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func registerMethodRegex(containerName, urlRegex string, httpMethods []string) error {
|
||||
r, err := regexp.Compile(urlRegex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, httpMethod := range httpMethods {
|
||||
if _, ok := validHTTPMethods[httpMethod]; !ok {
|
||||
return fmt.Errorf("%s is not a valid HTTP method", httpMethod)
|
||||
}
|
||||
if containerMethodRegex[containerName] == nil {
|
||||
containerMethodRegex[containerName] = make(methodRegex)
|
||||
}
|
||||
containerMethodRegex[containerName][httpMethod] = r
|
||||
}
|
||||
return nil
|
||||
}
|
21
env.go
Normal file
21
env.go
Normal file
@ -0,0 +1,21 @@
|
||||
package middleman
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v3"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PluckEnvVar(prefix, flagName string, more ...string) cli.ValueSourceChain {
|
||||
prefix = strings.ToUpper(strings.TrimSpace(prefix))
|
||||
key := strings.ToUpper(strings.ReplaceAll(flagName, "-", "_"))
|
||||
if prefix != "" {
|
||||
key = prefix + "_" + key
|
||||
}
|
||||
n := len(more)
|
||||
keys := make([]string, 0, n+1)
|
||||
keys = append(keys, key)
|
||||
if n > 0 {
|
||||
keys = append(keys, more...)
|
||||
}
|
||||
return cli.EnvVars(keys...)
|
||||
}
|
49
flag/flag.go
Normal file
49
flag/flag.go
Normal file
@ -0,0 +1,49 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.illuad.fr/adrien/middleman"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
LogFormatFlagName = "log-format"
|
||||
LogLevelFlagName = "log-level"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogFormat = "json"
|
||||
defaultLogLevel = zerolog.ErrorLevel
|
||||
)
|
||||
|
||||
// ErrInvalidLogLevel is returned if the given log level is invalid.
|
||||
type ErrInvalidLogLevel struct {
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
func (e ErrInvalidLogLevel) Error() string {
|
||||
return fmt.Sprintf("%s: invalid log level, see help", e.LogLevel)
|
||||
}
|
||||
|
||||
func LogFormat(parent string) cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: LogFormatFlagName,
|
||||
Category: "log",
|
||||
Usage: "log format (hf, json)",
|
||||
Sources: middleman.PluckEnvVar("MIDDLEMAN", LogFormatFlagName),
|
||||
Value: defaultLogFormat,
|
||||
Aliases: middleman.PluckAlias(parent, LogFormatFlagName),
|
||||
}
|
||||
}
|
||||
|
||||
func LogLevel(parent string) cli.Flag {
|
||||
return &cli.StringFlag{
|
||||
Name: LogLevelFlagName,
|
||||
Category: "log",
|
||||
Usage: "log level (trace, debug, info, warn, error, fatal, panic)",
|
||||
Sources: middleman.PluckEnvVar("MIDDLEMAN", LogLevelFlagName),
|
||||
Value: defaultLogLevel.String(),
|
||||
Aliases: middleman.PluckAlias(parent, LogLevelFlagName),
|
||||
}
|
||||
}
|
15
go.mod
Normal file
15
go.mod
Normal file
@ -0,0 +1,15 @@
|
||||
module gitea.illuad.fr/adrien/middleman
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/urfave/cli/v3 v3.1.1
|
||||
golang.org/x/sync v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
)
|
38
go.sum
Normal file
38
go.sum
Normal file
@ -0,0 +1,38 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg=
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
|
||||
github.com/urfave/cli/v3 v3.1.1 h1:bNnl8pFI5dxPOjeONvFCDFoECLQsceDG4ejahs4Jtxk=
|
||||
github.com/urfave/cli/v3 v3.1.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
Loading…
x
Reference in New Issue
Block a user