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