1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

feat(settings): add settings management (#906)

This commit is contained in:
Anthony Lapenna 2017-06-01 10:14:55 +02:00 committed by GitHub
parent 5e74a3993b
commit c7e306841a
93 changed files with 1086 additions and 457 deletions

View file

@ -22,6 +22,7 @@ type Store struct {
EndpointService *EndpointService
ResourceControlService *ResourceControlService
VersionService *VersionService
SettingsService *SettingsService
db *bolt.DB
checkForDataMigration bool
@ -35,6 +36,7 @@ const (
teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints"
resourceControlBucketName = "resource_control"
settingsBucketName = "settings"
)
// NewStore initializes a new Store and the associated services
@ -47,6 +49,7 @@ func NewStore(storePath string) (*Store, error) {
EndpointService: &EndpointService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
SettingsService: &SettingsService{},
}
store.UserService.store = store
store.TeamService.store = store
@ -54,6 +57,7 @@ func NewStore(storePath string) (*Store, error) {
store.EndpointService.store = store
store.ResourceControlService.store = store
store.VersionService.store = store
store.SettingsService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) {
@ -100,6 +104,10 @@ func (store *Store) Open() error {
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists([]byte(settingsBucketName))
if err != nil {
return err
}
return nil
})
}

View file

@ -57,6 +57,16 @@ func UnmarshalResourceControl(data []byte, rc *portainer.ResourceControl) error
return json.Unmarshal(data, rc)
}
// MarshalSettings encodes a settings object to binary format.
func MarshalSettings(settings *portainer.Settings) ([]byte, error) {
return json.Marshal(settings)
}
// UnmarshalSettings decodes a settings object from a binary data.
func UnmarshalSettings(data []byte, settings *portainer.Settings) error {
return json.Unmarshal(data, settings)
}
// Itob returns an 8-byte big endian representation of v.
// This function is typically used for encoding integer IDs to byte slices
// so that they can be used as BoltDB keys.

View file

@ -0,0 +1,61 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// SettingsService represents a service to manage application settings.
type SettingsService struct {
store *Store
}
const (
dbSettingsKey = "SETTINGS"
)
// Settings retrieve the settings object.
func (service *SettingsService) Settings() (*portainer.Settings, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(settingsBucketName))
value := bucket.Get([]byte(dbSettingsKey))
if value == nil {
return portainer.ErrSettingsNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var settings portainer.Settings
err = internal.UnmarshalSettings(data, &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// StoreSettings persists a Settings object.
func (service *SettingsService) StoreSettings(settings *portainer.Settings) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(settingsBucketName))
data, err := internal.MarshalSettings(settings)
if err != nil {
return err
}
err = bucket.Put([]byte(dbSettingsKey), data)
if err != nil {
return err
}
return nil
})
}

View file

@ -1,6 +1,7 @@
package cli
import (
"log"
"time"
"github.com/portainer/portainer"
@ -29,14 +30,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags := &portainer.CLIFlags{
Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(),
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Default(defaultTemplatesURL).Short('t').String(),
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(),
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
@ -47,6 +45,10 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
// Deprecated flags
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
}
kingpin.Parse()
@ -79,6 +81,8 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
return errNoAuthExcludeAdminPassword
}
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
return nil
}
@ -122,3 +126,15 @@ func validateSyncInterval(syncInterval string) error {
}
return nil
}
func displayDeprecationWarnings(templates, logo string, labels []portainer.Pair) {
if templates != "" {
log.Println("Warning: the --templates / -t flag is deprecated and will be removed in future versions.")
}
if logo != "" {
log.Println("Warning: the --logo flag is deprecated and will be removed in future versions.")
}
if labels != nil {
log.Println("Warning: the --hide-label / -l flag is deprecated and will be removed in future versions.")
}
}

View file

@ -6,7 +6,6 @@ const (
defaultBindAddress = ":9000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "."
defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"

View file

@ -4,7 +4,6 @@ const (
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "."
defaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"

View file

@ -82,16 +82,43 @@ func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpo
return authorizeEndpointMgmt
}
func initSettings(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Settings {
return &portainer.Settings{
HiddenLabels: *flags.Labels,
Logo: *flags.Logo,
func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status {
return &portainer.Status{
Analytics: !*flags.NoAnalytics,
Authentication: !*flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
Version: portainer.APIVersion,
}
}
func initSettings(settingsService portainer.SettingsService, flags *portainer.CLIFlags) error {
_, err := settingsService.Settings()
if err == portainer.ErrSettingsNotFound {
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayExternalContributors: true,
}
if *flags.Templates != "" {
settings.TemplatesURL = *flags.Templates
} else {
settings.TemplatesURL = portainer.DefaultTemplatesURL
}
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
} else {
settings.BlackListedLabels = make([]portainer.Pair, 0)
}
return settingsService.StoreSettings(settings)
} else if err != nil {
return err
}
return nil
}
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
endpoints, err := endpointService.Endpoints()
if err != nil {
@ -114,7 +141,12 @@ func main() {
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
settings := initSettings(authorizeEndpointMgmt, flags)
err := initSettings(store.SettingsService, flags)
if err != nil {
log.Fatal(err)
}
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
if *flags.Endpoint != "" {
var endpoints []portainer.Endpoint
@ -156,10 +188,9 @@ func main() {
}
var server portainer.Server = &http.Server{
Status: applicationStatus,
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
Settings: settings,
TemplatesURL: *flags.Templates,
AuthDisabled: *flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
UserService: store.UserService,
@ -167,6 +198,7 @@ func main() {
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
@ -176,7 +208,7 @@ func main() {
}
log.Printf("Starting Portainer on %s", *flags.Addr)
err := server.Start()
err = server.Start()
if err != nil {
log.Fatal(err)
}

View file

@ -47,6 +47,11 @@ const (
ErrDBVersionNotFound = Error("DB version not found")
)
// Settings errors.
const (
ErrSettingsNotFound = Error("Settings not found")
)
// Crypto errors.
const (
ErrCryptoHashFailure = Error("Unable to hash data")

View file

@ -18,6 +18,7 @@ type Handler struct {
TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler
ResourceHandler *ResourceHandler
StatusHandler *StatusHandler
SettingsHandler *SettingsHandler
TemplatesHandler *TemplatesHandler
DockerHandler *DockerHandler
@ -53,6 +54,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/settings") {
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/status") {
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/templates") {
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/upload") {

View file

@ -1,6 +1,9 @@
package handler
import (
"encoding/json"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
@ -12,32 +15,69 @@ import (
"github.com/gorilla/mux"
)
// SettingsHandler represents an HTTP API handler for managing settings.
// SettingsHandler represents an HTTP API handler for managing Settings.
type SettingsHandler struct {
*mux.Router
Logger *log.Logger
settings *portainer.Settings
Logger *log.Logger
SettingsService portainer.SettingsService
}
// NewSettingsHandler returns a new instance of SettingsHandler.
func NewSettingsHandler(bouncer *security.RequestBouncer, settings *portainer.Settings) *SettingsHandler {
// NewSettingsHandler returns a new instance of OldSettingsHandler.
func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
h := &SettingsHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
settings: settings,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/settings",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings)))
bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
h.Handle("/settings",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut)
return h
}
// handleGetSettings handles GET requests on /settings
func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet})
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, handler.settings, handler.Logger)
encodeJSON(w, settings, handler.Logger)
return
}
// handlePutSettings handles PUT requests on /settings
func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var req putSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
settings := &portainer.Settings{
TemplatesURL: req.TemplatesURL,
LogoURL: req.LogoURL,
BlackListedLabels: req.BlackListedLabels,
DisplayExternalContributors: req.DisplayExternalContributors,
}
err = handler.SettingsService.StoreSettings(settings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
type putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
}

View file

@ -0,0 +1,38 @@
package handler
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// StatusHandler represents an HTTP API handler for managing Status.
type StatusHandler struct {
*mux.Router
Logger *log.Logger
Status *portainer.Status
}
// NewStatusHandler returns a new instance of StatusHandler.
func NewStatusHandler(bouncer *security.RequestBouncer, status *portainer.Status) *StatusHandler {
h := &StatusHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
Status: status,
}
h.Handle("/status",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetStatus))).Methods(http.MethodGet)
return h
}
// handleGetStatus handles GET requests on /status
func (handler *StatusHandler) handleGetStatus(w http.ResponseWriter, r *http.Request) {
encodeJSON(w, handler.Status, handler.Logger)
return
}

View file

@ -7,6 +7,7 @@ import (
"os"
"github.com/gorilla/mux"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
)
@ -14,8 +15,8 @@ import (
// TemplatesHandler represents an HTTP API handler for managing templates.
type TemplatesHandler struct {
*mux.Router
Logger *log.Logger
containerTemplatesURL string
Logger *log.Logger
SettingsService portainer.SettingsService
}
const (
@ -23,11 +24,10 @@ const (
)
// NewTemplatesHandler returns a new instance of TemplatesHandler.
func NewTemplatesHandler(bouncer *security.RequestBouncer, containerTemplatesURL string) *TemplatesHandler {
func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler {
h := &TemplatesHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
containerTemplatesURL: containerTemplatesURL,
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/templates",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates)))
@ -49,7 +49,12 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht
var templatesURL string
if key == "containers" {
templatesURL = handler.containerTemplatesURL
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
templatesURL = settings.TemplatesURL
} else if key == "linuxserver.io" {
templatesURL = containerTemplatesURLLinuxServerIo
} else {

View file

@ -15,7 +15,7 @@ const (
// containerListOperation extracts the response as a JSON object, loop through the containers array
// decorate and/or filter the containers based on resource controls before rewriting the response
func containerListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ContainerList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
@ -24,22 +24,30 @@ func containerListOperation(request *http.Request, response *http.Response, oper
return err
}
if operationContext.isAdmin {
responseArray, err = decorateContainerList(responseArray, operationContext.resourceControls)
if executor.operationContext.isAdmin {
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterContainerList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs)
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls,
executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
if executor.labelBlackList != nil {
responseArray, err = filterContainersWithBlackListedLabels(responseArray, executor.labelBlackList)
if err != nil {
return err
}
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// containerInspectOperation extracts the response as a JSON object, verify that the user
// has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated container.
func containerInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ContainerInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
responseObject, err := getResponseAsJSONOBject(response)
@ -52,9 +60,10 @@ func containerInspectOperation(request *http.Request, response *http.Response, o
}
containerID := responseObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, operationContext.resourceControls)
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls)
if resourceControl != nil {
if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
@ -64,9 +73,10 @@ func containerInspectOperation(request *http.Request, response *http.Response, o
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)

View file

@ -13,6 +13,7 @@ import (
type proxyFactory struct {
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
}
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
@ -37,6 +38,7 @@ func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newSocketTransport(path),
}
proxy.Transport = transport
@ -48,6 +50,7 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newHTTPTransport(),
}
proxy.Transport = transport

View file

@ -65,6 +65,27 @@ func filterContainerList(containerData []interface{}, resourceControls []portain
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
// any resource control giving access to the user (these services will be decorated).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList

View file

@ -15,12 +15,13 @@ type Manager struct {
}
// NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService) *Manager {
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
return &Manager{
proxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService,
TeamMembershipService: teamMembershipService,
SettingsService: settingsService,
},
}
}

View file

@ -14,7 +14,7 @@ const (
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
@ -23,10 +23,10 @@ func serviceListOperation(request *http.Request, response *http.Response, operat
return err
}
if operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, operationContext.resourceControls)
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs)
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
@ -38,7 +38,7 @@ func serviceListOperation(request *http.Request, response *http.Response, operat
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
@ -51,9 +51,9 @@ func serviceInspectOperation(request *http.Request, response *http.Response, ope
}
serviceID := responseObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, operationContext.resourceControls)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)

View file

@ -15,6 +15,7 @@ type (
dockerTransport *http.Transport
ResourceControlService portainer.ResourceControlService
TeamMembershipService portainer.TeamMembershipService
SettingsService portainer.SettingsService
}
restrictedOperationContext struct {
isAdmin bool
@ -22,7 +23,11 @@ type (
userTeamIDs []portainer.TeamID
resourceControls []portainer.ResourceControl
}
restrictedOperationRequest func(*http.Request, *http.Response, *restrictedOperationContext) error
operationExecutor struct {
operationContext *restrictedOperationContext
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
)
func newSocketTransport(socketPath string) *http.Transport {
@ -60,7 +65,6 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
}
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
// return p.executeDockerRequest(request)
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":
return p.executeDockerRequest(request)
@ -69,7 +73,7 @@ func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Res
return p.administratorOperation(request)
case "/containers/json":
return p.rewriteOperation(request, containerListOperation)
return p.rewriteOperationWithLabelFiltering(request, containerListOperation)
default:
// This section assumes /containers/**
@ -96,9 +100,6 @@ func (p *proxyTransport) proxyServiceRequest(request *http.Request) (*http.Respo
case "/services/create":
return p.executeDockerRequest(request)
case "/volumes/prune":
return p.administratorOperation(request)
case "/services":
return p.rewriteOperation(request, serviceListOperation)
@ -177,9 +178,69 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s
return p.executeDockerRequest(request)
}
// rewriteOperation will create a new operation context with data that will be used
// to decorate the original request's response as well as retrieve all the black listed labels
// to filter the resources.
func (p *proxyTransport) rewriteOperationWithLabelFiltering(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := p.createOperationContext(request)
if err != nil {
return nil, err
}
settings, err := p.SettingsService.Settings()
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
labelBlackList: settings.BlackListedLabels,
}
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
// rewriteOperation will create a new operation context with data that will be used
// to decorate the original request's response.
func (p *proxyTransport) rewriteOperation(request *http.Request, operation restrictedOperationRequest) (*http.Response, error) {
operationContext, err := p.createOperationContext(request)
if err != nil {
return nil, err
}
executor := &operationExecutor{
operationContext: operationContext,
}
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
response, err := p.executeDockerRequest(request)
if err != nil {
return response, err
}
err = operation(request, response, executor)
return response, err
}
// administratorOperation ensures that the user has administrator privileges
// before executing the original request.
func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
return writeAccessDeniedResponse()
}
return p.executeDockerRequest(request)
}
func (p *proxyTransport) createOperationContext(request *http.Request) (*restrictedOperationContext, error) {
var err error
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
@ -212,26 +273,5 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr
operationContext.userTeamIDs = userTeamIDs
}
response, err := p.executeDockerRequest(request)
if err != nil {
return response, err
}
err = operation(request, response, operationContext)
return response, err
}
// administratorOperation ensures that the user has administrator privileges
// before executing the original request.
func (p *proxyTransport) administratorOperation(request *http.Request) (*http.Response, error) {
tokenData, err := security.RetrieveTokenData(request)
if err != nil {
return nil, err
}
if tokenData.Role != portainer.AdministratorRole {
return writeAccessDeniedResponse()
}
return p.executeDockerRequest(request)
return operationContext, nil
}

View file

@ -15,3 +15,18 @@ func getResourceControlByResourceID(resourceID string, resourceControls []portai
}
return nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View file

@ -14,7 +14,7 @@ const (
// volumeListOperation extracts the response as a JSON object, loop through the volume array
// decorate and/or filter the volumes based on resource controls before rewriting the response
func volumeListOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// VolumeList response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
@ -28,10 +28,10 @@ func volumeListOperation(request *http.Request, response *http.Response, operati
if responseObject["Volumes"] != nil {
volumeData := responseObject["Volumes"].([]interface{})
if operationContext.isAdmin {
volumeData, err = decorateVolumeList(volumeData, operationContext.resourceControls)
if executor.operationContext.isAdmin {
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
} else {
volumeData, err = filterVolumeList(volumeData, operationContext.resourceControls, operationContext.userID, operationContext.userTeamIDs)
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
@ -47,7 +47,7 @@ func volumeListOperation(request *http.Request, response *http.Response, operati
// volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on resource control and either rewrite an access denied response
// or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, operationContext *restrictedOperationContext) error {
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
responseObject, err := getResponseAsJSONOBject(response)
@ -60,9 +60,9 @@ func volumeInspectOperation(request *http.Request, response *http.Response, oper
}
volumeID := responseObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, operationContext.resourceControls)
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls)
if resourceControl != nil {
if operationContext.isAdmin || canUserAccessResource(operationContext.userID, operationContext.userTeamIDs, resourceControl) {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)

View file

@ -15,16 +15,16 @@ type Server struct {
AssetsPath string
AuthDisabled bool
EndpointManagement bool
Status *portainer.Status
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
FileService portainer.FileService
Settings *portainer.Settings
TemplatesURL string
Handler *handler.Handler
SSL bool
SSLCert string
@ -34,7 +34,7 @@ type Server struct {
// Start starts the HTTP server
func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
authHandler.UserService = server.UserService
@ -51,8 +51,11 @@ func (server *Server) Start() error {
teamHandler.TeamMembershipService = server.TeamMembershipService
var teamMembershipHandler = handler.NewTeamMembershipHandler(requestBouncer)
teamMembershipHandler.TeamMembershipService = server.TeamMembershipService
var settingsHandler = handler.NewSettingsHandler(requestBouncer, server.Settings)
var templatesHandler = handler.NewTemplatesHandler(requestBouncer, server.TemplatesURL)
var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status)
var settingsHandler = handler.NewSettingsHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
var templatesHandler = handler.NewTemplatesHandler(requestBouncer)
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService
dockerHandler.TeamMembershipService = server.TeamMembershipService
@ -77,6 +80,7 @@ func (server *Server) Start() error {
EndpointHandler: endpointHandler,
ResourceHandler: resourceHandler,
SettingsHandler: settingsHandler,
StatusHandler: statusHandler,
TemplatesHandler: templatesHandler,
DockerHandler: dockerHandler,
WebSocketHandler: websocketHandler,

View file

@ -17,9 +17,6 @@ type (
ExternalEndpoints *string
SyncInterval *string
Endpoint *string
Labels *[]Pair
Logo *string
Templates *string
NoAuth *bool
NoAnalytics *bool
TLSVerify *bool
@ -30,15 +27,26 @@ type (
SSLCert *string
SSLKey *string
AdminPassword *string
// Deprecated fields
Logo *string
Templates *string
Labels *[]Pair
}
// Settings represents Portainer settings.
// Status represents the application status.
Status struct {
Authentication bool `json:"Authentication"`
EndpointManagement bool `json:"EndpointManagement"`
Analytics bool `json:"Analytics"`
Version string `json:"Version"`
}
// Settings represents the application settings.
Settings struct {
HiddenLabels []Pair `json:"hiddenLabels"`
Logo string `json:"logo"`
Authentication bool `json:"authentication"`
Analytics bool `json:"analytics"`
EndpointManagement bool `json:"endpointManagement"`
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
}
// User represents a user account.
@ -209,6 +217,12 @@ type (
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// SettingsService represents a service for managing application settings.
SettingsService interface {
Settings() (*Settings, error)
StoreSettings(settings *Settings) error
}
// VersionService represents a service for managing version data.
VersionService interface {
DBVersion() (int, error)
@ -255,6 +269,8 @@ const (
APIVersion = "1.13.1"
// DBVersion is the version number of the Portainer database.
DBVersion = 2
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)
const (