commit 6db32797dcd07a1f875835e66014ebfb63b39c8a Author: Adrien PONSIN Date: Tue Apr 8 09:03:00 2025 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e72be3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# middleman + +Securely mount the Docker socket: apply fine-grained access control to Docker socket HTTP requests. \ No newline at end of file diff --git a/alias.go b/alias.go new file mode 100644 index 0000000..14120f0 --- /dev/null +++ b/alias.go @@ -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 +} diff --git a/cmd/middleman/main.go b/cmd/middleman/main.go new file mode 100644 index 0000000..c3c4acd --- /dev/null +++ b/cmd/middleman/main.go @@ -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 ", + }, + UseShortOptionHandling: true, + } + group.Go(func() error { + return app.Run(ctx, os.Args) + }) + if err := group.Wait(); err != nil { + log.Err(err).Send() + } +} diff --git a/command/command.go b/command/command.go new file mode 100644 index 0000000..8aa44c0 --- /dev/null +++ b/command/command.go @@ -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 +} diff --git a/command/healthcheck.go b/command/healthcheck.go new file mode 100644 index 0000000..6c6c159 --- /dev/null +++ b/command/healthcheck.go @@ -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), + } +} diff --git a/command/serve.go b/command/serve.go new file mode 100644 index 0000000..1947531 --- /dev/null +++ b/command/serve.go @@ -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 +} diff --git a/env.go b/env.go new file mode 100644 index 0000000..dd247fc --- /dev/null +++ b/env.go @@ -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...) +} diff --git a/flag/flag.go b/flag/flag.go new file mode 100644 index 0000000..a2347f2 --- /dev/null +++ b/flag/flag.go @@ -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), + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1359ffd --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7a3198f --- /dev/null +++ b/go.sum @@ -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=