From d312871d8982c13eec026cae704b0c804f83864e Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Sat, 14 Dec 2024 10:25:15 -0800 Subject: [PATCH] feat: allows filters to be set at user level (#3456) --- docs/guide/agent.md | 18 +++++++++++++ docs/guide/authentication.md | 32 ++++++++++++++++++++--- docs/guide/filters.md | 13 ++++++++++ internal/auth/proxy.go | 23 +++++++++++------ internal/auth/simple.go | 2 +- internal/auth/users.go | 30 ++++++++++++++------- internal/docker/types.go | 24 +++++++++++++++++ internal/support/cli/args.go | 46 +++++++++++++++++---------------- internal/web/auth_proxy_test.go | 4 +-- internal/web/events.go | 13 ++++++++-- internal/web/logs.go | 11 +++++++- main.go | 3 ++- 12 files changed, 170 insertions(+), 49 deletions(-) diff --git a/docs/guide/agent.md b/docs/guide/agent.md index 734527f81ec2..5d20ab5192a7 100644 --- a/docs/guide/agent.md +++ b/docs/guide/agent.md @@ -117,6 +117,23 @@ services: This will change the agent's name to `my-special-name` and will be reflected on the UI when connecting to the agent. +## Setting Up Filters + +You can set up filters for the agent to limit the containers it can access. These filters are passed directly to Docker, restricting what Dozzle can view. + +```yaml +services: + dozzle-agent: + image: amir20/dozzle:latest + command: agent + environment: + - DOZZLE_FILTER=label=color + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro +``` + +This will restrict the agent to displaying only containers with the label `color`. Keep in mind that these filters are combined with the UI filters to narrow down the containers. To learn more about the different types of filters, read the [filters documentation](/guide/filters#ui-agents-and-user-filters). + ## Custom Certificates By default, Dozzle uses self-signed certificates for communication between agents. This is a private certificate which is only valid to other Dozzle instances. This is secure and recommended for most use cases. However, if Dozzle is exposed externally and an attacker knows exactly which port the agent is running on, then they can set up their own Dozzle instance and connect to the agent. To prevent this, you can provide your own certificates. @@ -169,5 +186,6 @@ Agents are similar to remote connections, but they have some advantages. General | Permissions | Full access to Docker | Can be controlled with a proxy | | Reconnect | Automatically reconnects | Requires UI restart | | Healthcheck | Built-in healthcheck | No healthcheck | +| Filters | Supports filters | No support for filters | If you do plan to use remote connections, make sure to secure the connection using Docker TLS or a reverse proxy. diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index eb1ef2766c4c..5fb35723d6fa 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -2,7 +2,7 @@ title: Authentication --- -# Setting Up Authentication +# Setting Up Authentication Dozzle supports two configurations for authentication. In the first configuration, you bring your own authentication method by protecting Dozzle through a proxy. Dozzle can read appropriate headers out of the box. @@ -22,6 +22,7 @@ users: name: Admin # Generate with docker run amir20/dozzle generate --name Admin --email me@email.net --password secret admin password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK + filter: ``` Dozzle uses `email` to generate avatars using [Gravatar](https://gravatar.com/). It is optional. The password is hashed using `bcrypt` which can be generated using `docker run amir20/dozzle generate`. @@ -90,15 +91,39 @@ services: Note that only duration is supported. You can only use `s`, `m`, `h` for seconds, minutes and hours respectively. +### Setting specific filters for users + +Dozzle supports setting filters for users. Filters are used to restrict the containers that a user can see. Filters are set in the `users.yml` file. Here is an example: + +```yaml +users: + admin: + email: + name: Admin + password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK + filter: + + guest: + email: + name: Guest + password: $2a$11$9ho4vY2LdJ/WBopFcsAS0uORC0x2vuFHQgT/yBqZyzclhHsoaIkzK + filter: "label=com.example.app" +``` + +In this example, the `admin` user has no filter, so they can see all containers. The `guest` user can only see containers with the label `com.example.app`. This is useful for restricting access to specific containers. + +> [!NOTE] +> Filters can also be set [globally](/guide/filters) with the `--filter` flag. This flag is applied to all users. If a user has a filter set, it will override the global filter. + ## Generating users.yml Dozzle has a built-in `generate` command to generate `users.yml`. Here is an example: ```sh -docker run amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" > users.yml +docker run amir20/dozzle generate admin --password password --email test@email.net --name "John Doe" --user-filter name=foo > users.yml ``` -In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options. +In this example, `admin` is the username. Email and name are optional but recommended to display accurate avatars. `docker run amir20/dozzle generate --help` displays all options. The `--user-filter` flag is a comma-separated list of filters. ## Forward Proxy @@ -129,6 +154,7 @@ In this mode, Dozzle expects the following headers: - `Remote-User` to map to the username e.g. `johndoe` - `Remote-Email` to map to the user's email address. This email is also used to find the right [Gravatar](https://gravatar.com/) for the user. - `Remote-Name` to be a display name like `John Doe` +- `Remote-Filter` to be a comma-separated list of filters allowed for user. ### Setting up Dozzle with Authelia diff --git a/docs/guide/filters.md b/docs/guide/filters.md index e2cedc5397ee..e445f154fa92 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -27,3 +27,16 @@ services: ::: Common filters are `name` or `label` to limit Dozzle's access to containers. + +## UI, Agents, and User Filters + +Dozzle supports multiple filters to limit the containers it can see. Filters can be set at the UI, agent, or user level. + +1. **UI Filters**: These filters are applied to the Dozzle UI instance and sent to Docker to restrict the visible containers. They affect all agents and users who do not have their own filters. +2. **Agent Filters**: These filters are set at the agent level and sent to Docker to limit the containers exposed by that agent. Agent filters and UI filters work together to restrict the containers. +3. **User Filters**: These filters are set at the user level and determine which containers the user can see. If user filters are not defined, Dozzle defaults to using the UI filters. + +For more information on setting filters for specific users, see [user filters](/guide/authentication#setting-specific-filters-for-users). For details on setting filters for agents, see [agent filters](/guide/agent#setting-up-filters). + +> [!WARNING] +> It is important to understand that multiple filters are combined to limit the containers. For example, if you set `--filter label=color` at the UI level and `--filter label=type` at the agent level, Dozzle will only display containers that have both the `color` and `type` labels. diff --git a/internal/auth/proxy.go b/internal/auth/proxy.go index 336180acf309..2769d4445191 100644 --- a/internal/auth/proxy.go +++ b/internal/auth/proxy.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/amir20/dozzle/internal/docker" "github.com/rs/zerolog/log" ) @@ -15,9 +16,10 @@ type contextKey string const remoteUser contextKey = "remoteUser" type proxyAuthContext struct { - headerUser string - headerEmail string - headerName string + headerUser string + headerEmail string + headerName string + headerFilter string } func hashEmail(email string) string { @@ -28,18 +30,23 @@ func hashEmail(email string) string { return hex.EncodeToString(hash[:]) } -func NewForwardProxyAuth(user, email, name string) *proxyAuthContext { +func NewForwardProxyAuth(userHeader, emailHeader, nameHeader, filterHeader string) *proxyAuthContext { return &proxyAuthContext{ - headerUser: user, - headerEmail: email, - headerName: name, + headerUser: userHeader, + headerEmail: emailHeader, + headerName: nameHeader, + headerFilter: filterHeader, } } func (p *proxyAuthContext) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get(p.headerUser) != "" { - user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName)) + containerFilter, err := docker.ParseContainerFilter(r.Header.Get(p.headerFilter)) + if err != nil { + log.Fatal().Str("filter", r.Header.Get(p.headerFilter)).Msg("Failed to parse container filter") + } + user := newUser(r.Header.Get(p.headerUser), r.Header.Get(p.headerEmail), r.Header.Get(p.headerName), containerFilter) ctx := context.WithValue(r.Context(), remoteUser, user) next.ServeHTTP(w, r.WithContext(ctx)) } else { diff --git a/internal/auth/simple.go b/internal/auth/simple.go index 31c0c9ee3e27..84152f71ed20 100644 --- a/internal/auth/simple.go +++ b/internal/auth/simple.go @@ -38,7 +38,7 @@ func (a *simpleAuthContext) CreateToken(username, password string) (string, erro return "", ErrInvalidCredentials } - claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name} + claims := map[string]interface{}{"username": user.Username, "email": user.Email, "name": user.Name, "filter": user.Filter} jwtauth.SetIssuedNow(claims) if a.ttl > 0 { diff --git a/internal/auth/users.go b/internal/auth/users.go index 5826e3d31c17..77bf79f20ffd 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -11,6 +11,7 @@ import ( "os" "time" + "github.com/amir20/dozzle/internal/docker" "github.com/go-chi/jwtauth/v5" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" @@ -18,10 +19,12 @@ import ( ) type User struct { - Username string `json:"username" yaml:"-"` - Email string `json:"email" yaml:"email"` - Name string `json:"name" yaml:"name"` - Password string `json:"-" yaml:"password"` + Username string `json:"username" yaml:"-"` + Email string `json:"email" yaml:"email"` + Name string `json:"name" yaml:"name"` + Password string `json:"-" yaml:"password"` + Filter string `json:"-" yaml:"filter"` + ContainerFilter docker.ContainerFilter `json:"-" yaml:"-"` } func (u User) AvatarURL() string { @@ -32,11 +35,12 @@ func (u User) AvatarURL() string { return fmt.Sprintf("https://gravatar.com/avatar/%s?d=https%%3A%%2F%%2Fui-avatars.com%%2Fapi%%2F/%s/128", hashEmail(u.Email), url.QueryEscape(name)) } -func newUser(username, email, name string) User { +func newUser(username, email, name string, filter docker.ContainerFilter) User { return User{ - Username: username, - Email: email, - Name: name, + Username: username, + Email: email, + Name: name, + ContainerFilter: filter, } } @@ -193,7 +197,15 @@ func UserFromContext(ctx context.Context) *User { } email := claims["email"].(string) name := claims["name"].(string) - user := newUser(username, email, name) + containerFilter := docker.ContainerFilter{} + if filter, ok := claims["filter"].(string); ok { + containerFilter, err = docker.ParseContainerFilter(filter) + if err != nil { + log.Fatal().Err(err).Str("filter", filter).Msg("Failed to parse container filter") + } + } + + user := newUser(username, email, name, containerFilter) return &user } return nil diff --git a/internal/docker/types.go b/internal/docker/types.go index 69367c72f454..345729563c3e 100644 --- a/internal/docker/types.go +++ b/internal/docker/types.go @@ -3,6 +3,7 @@ package docker import ( "fmt" "math" + "strings" "time" "github.com/amir20/dozzle/internal/utils" @@ -44,6 +45,29 @@ type ContainerEvent struct { type ContainerFilter map[string][]string +func ParseContainerFilter(commaValues string) (ContainerFilter, error) { + filter := make(ContainerFilter) + if commaValues == "" { + return filter, nil + } + + for _, val := range strings.Split(commaValues, ",") { + pos := strings.Index(val, "=") + if pos == -1 { + return nil, fmt.Errorf("invalid filter: %s", filter) + } + key := val[:pos] + val := val[pos+1:] + filter[key] = append(filter[key], val) + } + + return filter, nil +} + +func (f ContainerFilter) Exists() bool { + return len(f) > 0 +} + func (f ContainerFilter) asArgs() filters.Args { filterArgs := filters.NewArgs() for key, values := range f { diff --git a/internal/support/cli/args.go b/internal/support/cli/args.go index 88943af2447d..79dc99b6ba82 100644 --- a/internal/support/cli/args.go +++ b/internal/support/cli/args.go @@ -10,28 +10,29 @@ import ( var Version = "head" type Args struct { - Addr string `arg:"env:DOZZLE_ADDR" default:":8080" help:"sets host:port to bind for server. This is rarely needed inside a docker container."` - Base string `arg:"env:DOZZLE_BASE" default:"/" help:"sets the base for http router."` - Hostname string `arg:"env:DOZZLE_HOSTNAME" help:"sets the hostname for display. This is useful with multiple Dozzle instances."` - Level string `arg:"env:DOZZLE_LEVEL" default:"info" help:"set Dozzle log level. Use debug for more logging."` - AuthProvider string `arg:"--auth-provider,env:DOZZLE_AUTH_PROVIDER" default:"none" help:"sets the auth provider to use. Currently only forward-proxy is supported."` - AuthTTL string `arg:"--auth-ttl,env:DOZZLE_AUTH_TTL" default:"session" help:"sets the TTL for the auth token. Accepts duration values like 12h. Valid time units are s, m, h"` - AuthHeaderUser string `arg:"--auth-header-user,env:DOZZLE_AUTH_HEADER_USER" default:"Remote-User" help:"sets the HTTP Header to use for username in Forward Proxy configuration."` - AuthHeaderEmail string `arg:"--auth-header-email,env:DOZZLE_AUTH_HEADER_EMAIL" default:"Remote-Email" help:"sets the HTTP Header to use for email in Forward Proxy configuration."` - AuthHeaderName string `arg:"--auth-header-name,env:DOZZLE_AUTH_HEADER_NAME" default:"Remote-Name" help:"sets the HTTP Header to use for name in Forward Proxy configuration."` - EnableActions bool `arg:"--enable-actions,env:DOZZLE_ENABLE_ACTIONS" default:"false" help:"enables essential actions on containers from the web interface."` - FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."` - Filter map[string][]string `arg:"-"` - RemoteHost []string `arg:"env:DOZZLE_REMOTE_HOST,--remote-host,separate" help:"list of hosts to connect remotely"` - RemoteAgent []string `arg:"env:DOZZLE_REMOTE_AGENT,--remote-agent,separate" help:"list of agents to connect remotely"` - NoAnalytics bool `arg:"--no-analytics,env:DOZZLE_NO_ANALYTICS" help:"disables anonymous analytics"` - Mode string `arg:"env:DOZZLE_MODE" default:"server" help:"sets the mode to run in (server, swarm)"` - TimeoutString string `arg:"--timeout,env:DOZZLE_TIMEOUT" default:"3s" help:"sets the timeout for docker client"` - Timeout time.Duration `arg:"-"` - Healthcheck *HealthcheckCmd `arg:"subcommand:healthcheck" help:"checks if the server is running"` - Generate *GenerateCmd `arg:"subcommand:generate" help:"generates a configuration file for simple auth"` - Agent *AgentCmd `arg:"subcommand:agent" help:"starts the agent"` - AgentTest *AgentTestCmd `arg:"subcommand:agent-test" help:"tests an agent"` + Addr string `arg:"env:DOZZLE_ADDR" default:":8080" help:"sets host:port to bind for server. This is rarely needed inside a docker container."` + Base string `arg:"env:DOZZLE_BASE" default:"/" help:"sets the base for http router."` + Hostname string `arg:"env:DOZZLE_HOSTNAME" help:"sets the hostname for display. This is useful with multiple Dozzle instances."` + Level string `arg:"env:DOZZLE_LEVEL" default:"info" help:"set Dozzle log level. Use debug for more logging."` + AuthProvider string `arg:"--auth-provider,env:DOZZLE_AUTH_PROVIDER" default:"none" help:"sets the auth provider to use. Currently only forward-proxy is supported."` + AuthTTL string `arg:"--auth-ttl,env:DOZZLE_AUTH_TTL" default:"session" help:"sets the TTL for the auth token. Accepts duration values like 12h. Valid time units are s, m, h"` + AuthHeaderUser string `arg:"--auth-header-user,env:DOZZLE_AUTH_HEADER_USER" default:"Remote-User" help:"sets the HTTP Header to use for username in Forward Proxy configuration."` + AuthHeaderEmail string `arg:"--auth-header-email,env:DOZZLE_AUTH_HEADER_EMAIL" default:"Remote-Email" help:"sets the HTTP Header to use for email in Forward Proxy configuration."` + AuthHeaderName string `arg:"--auth-header-name,env:DOZZLE_AUTH_HEADER_NAME" default:"Remote-Name" help:"sets the HTTP Header to use for name in Forward Proxy configuration."` + AuthHeaderFilter string `arg:"--auth-header-filter,env:DOZZLE_AUTH_HEADER_FILTER" default:"Remote-Filter" help:"sets the HTTP Header to use for filtering in Forward Proxy configuration."` + EnableActions bool `arg:"--enable-actions,env:DOZZLE_ENABLE_ACTIONS" default:"false" help:"enables essential actions on containers from the web interface."` + FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."` + Filter map[string][]string `arg:"-"` + RemoteHost []string `arg:"env:DOZZLE_REMOTE_HOST,--remote-host,separate" help:"list of hosts to connect remotely"` + RemoteAgent []string `arg:"env:DOZZLE_REMOTE_AGENT,--remote-agent,separate" help:"list of agents to connect remotely"` + NoAnalytics bool `arg:"--no-analytics,env:DOZZLE_NO_ANALYTICS" help:"disables anonymous analytics"` + Mode string `arg:"env:DOZZLE_MODE" default:"server" help:"sets the mode to run in (server, swarm)"` + TimeoutString string `arg:"--timeout,env:DOZZLE_TIMEOUT" default:"3s" help:"sets the timeout for docker client"` + Timeout time.Duration `arg:"-"` + Healthcheck *HealthcheckCmd `arg:"subcommand:healthcheck" help:"checks if the server is running"` + Generate *GenerateCmd `arg:"subcommand:generate" help:"generates a configuration file for simple auth"` + Agent *AgentCmd `arg:"subcommand:agent" help:"starts the agent"` + AgentTest *AgentTestCmd `arg:"subcommand:agent-test" help:"tests an agent"` } type HealthcheckCmd struct { @@ -50,6 +51,7 @@ type GenerateCmd struct { Password string `arg:"--password, -p" help:"sets the password for the user"` Name string `arg:"--name, -n" help:"sets the display name for the user"` Email string `arg:"--email, -e" help:"sets the email for the user"` + Filter string `arg:"--user-filter" help:"sets the filter for the user. This can be a comma separated list of filters."` } func (Args) Version() string { diff --git a/internal/web/auth_proxy_test.go b/internal/web/auth_proxy_test.go index a62f6c47cfae..cfe86b9a54a7 100644 --- a/internal/web/auth_proxy_test.go +++ b/internal/web/auth_proxy_test.go @@ -20,7 +20,7 @@ func Test_createRoutes_proxy_missing_headers(t *testing.T) { handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/", Authorization: Authorization{ Provider: FORWARD_PROXY, - Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name"), + Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"), }, }) req, err := http.NewRequest("GET", "/", nil) @@ -39,7 +39,7 @@ func Test_createRoutes_proxy_happy(t *testing.T) { handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/", Authorization: Authorization{ Provider: FORWARD_PROXY, - Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name"), + Authorizer: auth.NewForwardProxyAuth("Remote-User", "Remote-Email", "Remote-Name", "Remote-Filter"), }, }) req, err := http.NewRequest("GET", "/", nil) diff --git a/internal/web/events.go b/internal/web/events.go index 77377cc9638f..d888e996db29 100644 --- a/internal/web/events.go +++ b/internal/web/events.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/amir20/dozzle/internal/analytics" + "github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/docker" docker_support "github.com/amir20/dozzle/internal/support/docker" support_web "github.com/amir20/dozzle/internal/support/web" @@ -25,7 +26,15 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { h.multiHostService.SubscribeEventsAndStats(r.Context(), events, stats) h.multiHostService.SubscribeAvailableHosts(r.Context(), availableHosts) - allContainers, errors := h.multiHostService.ListAllContainers(h.config.Filter) + usersFilter := h.config.Filter + if h.config.Authorization.Provider != NONE { + user := auth.UserFromContext(r.Context()) + if user.ContainerFilter.Exists() { + usersFilter = user.ContainerFilter + } + } + + allContainers, errors := h.multiHostService.ListAllContainers(usersFilter) for _, err := range errors { log.Warn().Err(err).Msg("error listing containers") @@ -63,7 +72,7 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { if event.Name == "start" || event.Name == "rename" { log.Debug().Str("action", event.Name).Str("id", event.ActorID).Msg("container event") - if containers, err := h.multiHostService.ListContainersForHost(event.Host, docker.ContainerFilter{}); err == nil { + if containers, err := h.multiHostService.ListContainersForHost(event.Host, usersFilter); err == nil { if err := sseWriter.Event("containers-changed", containers); err != nil { log.Error().Err(err).Msg("error writing containers to event stream") return diff --git a/internal/web/logs.go b/internal/web/logs.go index f673876168aa..445731ee3bdf 100644 --- a/internal/web/logs.go +++ b/internal/web/logs.go @@ -18,6 +18,7 @@ import ( "time" + "github.com/amir20/dozzle/internal/auth" "github.com/amir20/dozzle/internal/docker" "github.com/amir20/dozzle/internal/support/search" support_web "github.com/amir20/dozzle/internal/support/web" @@ -288,7 +289,15 @@ func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request return } - existingContainers, errs := h.multiHostService.ListAllContainersFiltered(h.config.Filter, containerFilter) + usersFilter := h.config.Filter + if h.config.Authorization.Provider != NONE { + user := auth.UserFromContext(r.Context()) + if user.ContainerFilter.Exists() { + usersFilter = user.ContainerFilter + } + } + + existingContainers, errs := h.multiHostService.ListAllContainersFiltered(usersFilter, containerFilter) if len(errs) > 0 { log.Warn().Err(errs[0]).Msg("error while listing containers") } diff --git a/main.go b/main.go index c9ced77c4f00..3bf45f82ebe1 100644 --- a/main.go +++ b/main.go @@ -121,6 +121,7 @@ func main() { Password: args.Generate.Password, Name: args.Generate.Name, Email: args.Generate.Email, + Filter: args.Generate.Filter, }, true) if _, err := os.Stdout.Write(buffer.Bytes()); err != nil { @@ -230,7 +231,7 @@ func createServer(args cli.Args, multiHostService *docker_support.MultiHostServi if args.AuthProvider == "forward-proxy" { log.Debug().Msg("Using forward proxy authentication") provider = web.FORWARD_PROXY - authorizer = auth.NewForwardProxyAuth(args.AuthHeaderUser, args.AuthHeaderEmail, args.AuthHeaderName) + authorizer = auth.NewForwardProxyAuth(args.AuthHeaderUser, args.AuthHeaderEmail, args.AuthHeaderName, args.AuthHeaderFilter) } else if args.AuthProvider == "simple" { log.Debug().Msg("Using simple authentication") provider = web.SIMPLE