1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

feat(backup): Add backup/restore to the server

This commit is contained in:
Dmitry Salakhov 2021-04-06 22:08:43 +12:00
parent c04bbb5775
commit a3ec2f8e85
65 changed files with 2394 additions and 564 deletions

View file

@ -0,0 +1,53 @@
package backup
import (
"fmt"
"net/http"
"os"
"path/filepath"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
operations "github.com/portainer/portainer/api/backup"
)
type (
backupPayload struct {
Password string
}
)
func (p *backupPayload) Validate(r *http.Request) error {
return nil
}
// @id Backup
// @summary Creates an archive with a system data snapshot that could be used to restore the system.
// @description Creates an archive with a system data snapshot that could be used to restore the system.
// @description **Access policy**: admin
// @tags backup
// @security jwt
// @produce octet-stream
// @param Password body string false "Password to encrypt the backup with"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /backup [post]
func (h *Handler) backup(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload backupPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
archivePath, err := operations.CreateBackupArchive(payload.Password, h.gate, h.dataStore, h.filestorePath)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to create backup", Err: err}
}
defer os.RemoveAll(filepath.Dir(archivePath))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fmt.Sprintf("portainer-backup_%s", filepath.Base(archivePath))))
http.ServeFile(w, r, archivePath)
return nil
}

View file

@ -0,0 +1,121 @@
package backup
import (
"bytes"
"context"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"testing"
"time"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/offlinegate"
i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func listFiles(dir string) []string {
items := make([]string, 0)
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if path == dir {
return nil
}
items = append(items, path)
return nil
})
return items
}
func contains(t *testing.T, list []string, path string) {
assert.Contains(t, list, path)
copyContent, _ := ioutil.ReadFile(path)
assert.Equal(t, "content\n", string(copyContent))
}
func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"password":""}`))
w := httptest.NewRecorder()
gate := offlinegate.NewOfflineGate()
adminMonitor := adminmonitor.New(time.Hour, nil, context.Background())
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()
body, _ := io.ReadAll(response.Body)
tmpdir, _ := os.MkdirTemp("", "backup")
defer os.RemoveAll(tmpdir)
archivePath := filepath.Join(tmpdir, "archive.tar.gz")
err := ioutil.WriteFile(archivePath, body, 0600)
if err != nil {
t.Fatal("Failed to save downloaded .tar.gz archive: ", err)
}
cmd := exec.Command("tar", "-xzf", archivePath, "-C", tmpdir)
err = cmd.Run()
if err != nil {
t.Fatal("Failed to extract archive: ", err)
}
createdFiles := listFiles(tmpdir)
contains(t, createdFiles, path.Join(tmpdir, "portainer.key"))
contains(t, createdFiles, path.Join(tmpdir, "portainer.pub"))
contains(t, createdFiles, path.Join(tmpdir, "tls", "file1"))
contains(t, createdFiles, path.Join(tmpdir, "tls", "file2"))
assert.NotContains(t, createdFiles, path.Join(tmpdir, "extra_file"))
assert.NotContains(t, createdFiles, path.Join(tmpdir, "extra_folder", "file1"))
}
func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"password":"secret"}`))
w := httptest.NewRecorder()
gate := offlinegate.NewOfflineGate()
adminMonitor := adminmonitor.New(time.Hour, nil, nil)
handlerErr := NewHandler(nil, i.NewDatastore(), gate, "./test_assets/handler_test", func() {}, adminMonitor).backup(w, r)
assert.Nil(t, handlerErr, "Handler should not fail")
response := w.Result()
body, _ := io.ReadAll(response.Body)
tmpdir, _ := os.MkdirTemp("", "backup")
defer os.RemoveAll(tmpdir)
dr, err := crypto.AesDecrypt(bytes.NewReader(body), []byte("secret"))
if err != nil {
t.Fatal("Failed to decrypt archive")
}
archivePath := filepath.Join(tmpdir, "archive.tag.gz")
archive, _ := os.Create(archivePath)
defer archive.Close()
io.Copy(archive, dr)
cmd := exec.Command("tar", "-xzf", archivePath, "-C", tmpdir)
err = cmd.Run()
if err != nil {
t.Fatal("Failed to extract archive: ", err)
}
createdFiles := listFiles(tmpdir)
contains(t, createdFiles, path.Join(tmpdir, "portainer.key"))
contains(t, createdFiles, path.Join(tmpdir, "portainer.pub"))
contains(t, createdFiles, path.Join(tmpdir, "tls", "file1"))
contains(t, createdFiles, path.Join(tmpdir, "tls", "file2"))
assert.NotContains(t, createdFiles, path.Join(tmpdir, "extra_file"))
assert.NotContains(t, createdFiles, path.Join(tmpdir, "extra_folder", "file1"))
}

View file

@ -0,0 +1,65 @@
package backup
import (
"context"
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/http/security"
)
// Handler is an http handler responsible for backup and restore portainer state
type Handler struct {
*mux.Router
bouncer *security.RequestBouncer
dataStore portainer.DataStore
gate *offlinegate.OfflineGate
filestorePath string
shutdownTrigger context.CancelFunc
adminMonitor *adminmonitor.Monitor
}
// NewHandler creates an new instance of backup handler
func NewHandler(bouncer *security.RequestBouncer, dataStore portainer.DataStore, gate *offlinegate.OfflineGate, filestorePath string, shutdownTrigger context.CancelFunc, adminMonitor *adminmonitor.Monitor) *Handler {
h := &Handler{
Router: mux.NewRouter(),
bouncer: bouncer,
dataStore: dataStore,
gate: gate,
filestorePath: filestorePath,
shutdownTrigger: shutdownTrigger,
adminMonitor: adminMonitor,
}
h.Handle("/backup", bouncer.RestrictedAccess(adminAccess(httperror.LoggerHandler(h.backup)))).Methods(http.MethodPost)
h.Handle("/restore", bouncer.PublicAccess(httperror.LoggerHandler(h.restore))).Methods(http.MethodPost)
return h
}
func adminAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve user info from request context", err)
}
if !securityContext.IsAdmin {
httperror.WriteError(w, http.StatusUnauthorized, "User is not authorized to perfom the action", nil)
}
next.ServeHTTP(w, r)
})
}
func systemWasInitialized(dataStore portainer.DataStore) (bool, error) {
users, err := dataStore.User().UsersByRole(portainer.AdministratorRole)
if err != nil {
return false, err
}
return len(users) > 0, nil
}

View file

@ -0,0 +1,69 @@
package backup
import (
"bytes"
"io"
"net/http"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
operations "github.com/portainer/portainer/api/backup"
)
type restorePayload struct {
FileContent []byte
FileName string
Password string
}
// @id Restore
// @summary Triggers a system restore using provided backup file
// @description Triggers a system restore using provided backup file
// @description **Access policy**: public
// @tags backup
// @param FileContent body []byte true "Content of the backup"
// @param FileName body string true "File name"
// @param Password body string false "Password to decrypt the backup with"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /restore [post]
func (h *Handler) restore(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
initialized, err := h.adminMonitor.WasInitialized()
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to check system initialization", Err: err}
}
if initialized {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot restore already initialized instance", Err: errors.New("system already initialized")}
}
h.adminMonitor.Stop()
defer h.adminMonitor.Start()
var payload restorePayload
err = decodeForm(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
var archiveReader io.Reader = bytes.NewReader(payload.FileContent)
err = operations.RestoreArchive(archiveReader, payload.Password, h.filestorePath, h.gate, h.dataStore, h.shutdownTrigger)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to restore the backup", Err: err}
}
return nil
}
func decodeForm(r *http.Request, p *restorePayload) error {
content, name, err := request.RetrieveMultiPartFormFile(r, "file")
if err != nil {
return err
}
p.FileContent = content
p.FileName = name
password, _ := request.RetrieveMultiPartFormValue(r, "password", true)
p.Password = password
return nil
}

View file

@ -0,0 +1,123 @@
package backup
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/http/offlinegate"
i "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func Test_restoreArchive_usingCombinationOfPasswords(t *testing.T) {
tests := []struct {
name string
backupPassword string
restorePassword string
fails bool
}{
{
name: "empty password to both encrypt and decrypt",
backupPassword: "",
restorePassword: "",
fails: false,
},
{
name: "same password to encrypt and decrypt",
backupPassword: "secret",
restorePassword: "secret",
fails: false,
},
{
name: "different passwords to encrypt and decrypt",
backupPassword: "secret",
restorePassword: "terces",
fails: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}), i.WithEdgeJobs([]portainer.EdgeJob{}))
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
//backup
archive := backup(t, h, test.backupPassword)
//restore
w := httptest.NewRecorder()
r, err := prepareMultipartRequest(test.restorePassword, archive)
assert.Nil(t, err, "Shouldn't fail to write multipart form")
restoreErr := h.restore(w, r)
assert.Equal(t, test.fails, restoreErr != nil, "Didn't meet expectation of failing restore handler")
})
}
}
func Test_restoreArchive_shouldFailIfSystemWasAlreadyInitialized(t *testing.T) {
admin := portainer.User{
Role: portainer.AdministratorRole,
}
datastore := i.NewDatastore(i.WithUsers([]portainer.User{admin}), i.WithEdgeJobs([]portainer.EdgeJob{}))
adminMonitor := adminmonitor.New(time.Hour, datastore, context.Background())
h := NewHandler(nil, datastore, offlinegate.NewOfflineGate(), "./test_assets/handler_test", func() {}, adminMonitor)
//backup
archive := backup(t, h, "password")
//restore
w := httptest.NewRecorder()
r, err := prepareMultipartRequest("password", archive)
assert.Nil(t, err, "Shouldn't fail to write multipart form")
restoreErr := h.restore(w, r)
assert.NotNil(t, restoreErr, "Should fail, because system it already initialized")
assert.Equal(t, "Cannot restore already initialized instance", restoreErr.Message, "Should fail with certain error")
}
func backup(t *testing.T, h *Handler, password string) []byte {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"password":"%s"}`, password)))
w := httptest.NewRecorder()
backupErr := h.backup(w, r)
assert.Nil(t, backupErr, "Backup should not fail")
response := w.Result()
archive, _ := io.ReadAll(response.Body)
return archive
}
func prepareMultipartRequest(password string, file []byte) (*http.Request, error) {
var body bytes.Buffer
w := multipart.NewWriter(&body)
err := w.WriteField("password", password)
if err != nil {
return nil, err
}
fw, err := w.CreateFormFile("file", "filename")
if err != nil {
return nil, err
}
io.Copy(fw, bytes.NewReader(file))
r := httptest.NewRequest(http.MethodPost, "http://localhost/", &body)
r.Header.Set("Content-Type", w.FormDataContentType())
w.Close()
return r, nil
}

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1 @@
content

View file

@ -0,0 +1 @@
content

View file

@ -5,6 +5,7 @@ import (
"strings"
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
"github.com/portainer/portainer/api/http/handler/dockerhub"
"github.com/portainer/portainer/api/http/handler/edgegroups"
@ -36,6 +37,7 @@ import (
// Handler is a collection of all the service handlers.
type Handler struct {
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHubHandler *dockerhub.Handler
EdgeGroupsHandler *edgegroups.Handler
@ -140,6 +142,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/backup"):
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/restore"):
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):

View file

@ -0,0 +1,71 @@
package offlinegate
import (
"log"
"net/http"
"sync"
"time"
httperror "github.com/portainer/libhttp/error"
)
// OfflineGate is a entity that works similar to a mutex with a signaling
// Only the caller that have Locked an gate can unlock it, otherw will be blocked with a call to Lock.
// Gate provides a passthrough http middleware that will wait for a locked gate to be unlocked.
// For a safety reasons, middleware will timeout
type OfflineGate struct {
lock *sync.Mutex
signalingCh chan interface{}
}
// NewOfflineGate creates a new gate
func NewOfflineGate() *OfflineGate {
return &OfflineGate{
lock: &sync.Mutex{},
}
}
// Lock locks readonly gate and returns a function to unlock
func (o *OfflineGate) Lock() func() {
o.lock.Lock()
o.signalingCh = make(chan interface{})
return o.unlock
}
func (o *OfflineGate) unlock() {
if o.signalingCh == nil {
return
}
close(o.signalingCh)
o.signalingCh = nil
o.lock.Unlock()
}
// Watch returns a signaling channel.
// Unless channel is nil, client needs to watch for a signal on a channel to know when gate is unlocked.
// Signal channel is disposable: onced signaled, has to be disposed and acquired again.
func (o *OfflineGate) Watch() chan interface{} {
return o.signalingCh
}
// WaitingMiddleware returns an http handler that waits for the gate to be unlocked before continuing
func (o *OfflineGate) WaitingMiddleware(timeout time.Duration, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
signalingCh := o.Watch()
if signalingCh != nil {
if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" {
select {
case <-signalingCh:
case <-time.After(timeout):
log.Println("error: Timeout waiting for the offline gate to signal")
httperror.WriteError(w, http.StatusRequestTimeout, "Timeout waiting for the offline gate to signal", http.ErrHandlerTimeout)
}
}
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,217 @@
package offlinegate
import (
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func Test_canLockAndUnlock(t *testing.T) {
o := NewOfflineGate()
unlock := o.Lock()
unlock()
}
func Test_hasToBeUnlockedToLockAgain(t *testing.T) {
// scenario:
// 1. first routine starts and locks the gate
// 2. first routine starts a second and wait for the second to start
// 3. second start but waits for the gate to be released
// 4. first continues and unlocks the gate, when done
// 5. second be able to continue
// 6. second lock the gate, does the job and unlocks it
o := NewOfflineGate()
wg := sync.WaitGroup{}
wg.Add(2)
result := make([]string, 0, 2)
go func() {
unlock := o.Lock()
defer unlock()
waitForSecondToStart := sync.WaitGroup{}
waitForSecondToStart.Add(1)
go func() {
waitForSecondToStart.Done()
unlock := o.Lock()
defer unlock()
result = append(result, "second")
wg.Done()
}()
waitForSecondToStart.Wait()
result = append(result, "first")
wg.Done()
}()
wg.Wait()
if len(result) != 2 || result[0] != "first" || result[1] != "second" {
t.Error("Second call have disresregarded a raised lock")
}
}
func Test_waitChannelWillBeEmpty_ifGateIsUnlocked(t *testing.T) {
o := NewOfflineGate()
signalingCh := o.Watch()
if signalingCh != nil {
t.Error("Signaling channel should be empty")
}
}
func Test_startWaitingForSignal_beforeGateGetsUnlocked(t *testing.T) {
// scenario:
// 1. main routing locks the gate and waits for a consumer to start up
// 2. consumer starts up, notifies main and begins waiting for the gate to be unlocked
// 3. main unlocks the gate
// 4. consumer be able to continue
o := NewOfflineGate()
unlock := o.Lock()
signalingCh := o.Watch()
wg := sync.WaitGroup{}
wg.Add(1)
readerIsReady := sync.WaitGroup{}
readerIsReady.Add(1)
go func(t *testing.T) {
readerIsReady.Done()
// either wait for a signal or timeout
select {
case <-signalingCh:
case <-time.After(10 * time.Second):
t.Error("Failed to wait for a signal, exit by timeout")
}
wg.Done()
}(t)
readerIsReady.Wait()
unlock()
wg.Wait()
}
func Test_startWaitingForSignal_afterGateGetsUnlocked(t *testing.T) {
// scenario:
// 1. main routing locks, gets waiting channel and unlocks
// 2. consumer starts up and begins waiting for the gate to be unlocked
// 3. consumer gets signal immediately and continues
o := NewOfflineGate()
unlock := o.Lock()
signalingCh := o.Watch()
unlock()
wg := sync.WaitGroup{}
wg.Add(1)
go func(t *testing.T) {
// either wait for a signal or timeout
select {
case <-signalingCh:
case <-time.After(10 * time.Second):
t.Error("Failed to wait for a signal, exit by timeout")
}
wg.Done()
}(t)
wg.Wait()
}
func Test_waitingMiddleware_executesImmediately_whenNotLocked(t *testing.T) {
// scenario:
// 1. create an gate
// 2. kick off a waiting middleware that will release immediately as gate wasn't locked
// 3. middleware shouldn't timeout
o := NewOfflineGate()
request := httptest.NewRequest(http.MethodPost, "/", nil)
response := httptest.NewRecorder()
timeout := 2 * time.Second
start := time.Now()
o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
elapsed := time.Since(start)
if elapsed >= timeout {
t.Error("WaitingMiddleware had likely timeout, when it shouldn't")
}
w.Write([]byte("success"))
})).ServeHTTP(response, request)
body, _ := io.ReadAll(response.Body)
if string(body) != "success" {
t.Error("Didn't receive expected result from the hanlder")
}
}
func Test_waitingMiddleware_waitsForTheLockToBeReleased(t *testing.T) {
// scenario:
// 1. create an gate and lock it
// 2. kick off a routing that will unlock the gate after 1 second
// 3. kick off a waiting middleware that will wait for lock to be eventually released
// 4. middleware shouldn't timeout
o := NewOfflineGate()
unlock := o.Lock()
request := httptest.NewRequest(http.MethodPost, "/", nil)
response := httptest.NewRecorder()
go func() {
time.Sleep(1 * time.Second)
unlock()
}()
timeout := 10 * time.Second
start := time.Now()
o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
elapsed := time.Since(start)
if elapsed >= timeout {
t.Error("WaitingMiddleware had likely timeout, when it shouldn't")
}
w.Write([]byte("success"))
})).ServeHTTP(response, request)
body, _ := io.ReadAll(response.Body)
if string(body) != "success" {
t.Error("Didn't receive expected result from the hanlder")
}
}
func Test_waitingMiddleware_mayTimeout_whenLockedForTooLong(t *testing.T) {
/*
scenario:
1. create an gate and lock it
2. kick off a waiting middleware that will wait for lock to be eventually released
3. because we never unlocked the gate, middleware suppose to timeout
*/
o := NewOfflineGate()
o.Lock()
request := httptest.NewRequest(http.MethodPost, "/", nil)
response := httptest.NewRecorder()
timeout := 1 * time.Second
start := time.Now()
o.WaitingMiddleware(timeout, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
elapsed := time.Since(start)
if elapsed < timeout {
t.Error("WaitingMiddleware suppose to timeout, but it didnt")
}
w.Write([]byte("success"))
})).ServeHTTP(response, request)
assert.Equal(t, http.StatusRequestTimeout, response.Result().StatusCode, "Request support to timeout waiting for the gate")
}

View file

@ -6,7 +6,7 @@ import (
"strings"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
)
@ -153,6 +153,9 @@ func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portain
return nil
}
// handlers are applied backwards to the incoming request:
// - add secure handlers to the response
// - parse the JWT token and put it into the http context.
func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler {
h = bouncer.mwCheckAuthentication(h)
h = mwSecureHeaders(h)
@ -216,6 +219,8 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
}
// mwCheckAuthentication provides Authentication middleware for handlers
//
// It parses the JWT token and adds the parsed token data to the http context
func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenData *portainer.TokenData
@ -269,30 +274,31 @@ func mwSecureHeaders(next http.Handler) http.Handler {
}
func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.UserID, userRole portainer.UserRole) (*RestrictedRequestContext, error) {
requestContext := &RestrictedRequestContext{
IsAdmin: true,
UserID: userID,
if userRole == portainer.AdministratorRole {
return &RestrictedRequestContext{
IsAdmin: true,
UserID: userID,
}, nil
}
if userRole != portainer.AdministratorRole {
requestContext.IsAdmin = false
memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return nil, err
}
isTeamLeader := false
for _, membership := range memberships {
if membership.Role == portainer.TeamLeader {
isTeamLeader = true
}
}
requestContext.IsTeamLeader = isTeamLeader
requestContext.UserMemberships = memberships
memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return nil, err
}
return requestContext, nil
isTeamLeader := false
for _, membership := range memberships {
if membership.Role == portainer.TeamLeader {
isTeamLeader = true
}
}
return &RestrictedRequestContext{
IsAdmin: false,
UserID: userID,
IsTeamLeader: isTeamLeader,
UserMemberships: memberships,
}, nil
}
// EdgeComputeOperation defines a restriced edge compute operation.

View file

@ -1,15 +1,20 @@
package http
import (
"context"
"fmt"
"log"
"net/http"
"path/filepath"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/adminmonitor"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/handler"
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
"github.com/portainer/portainer/api/http/handler/dockerhub"
"github.com/portainer/portainer/api/http/handler/edgegroups"
@ -36,10 +41,10 @@ import (
"github.com/portainer/portainer/api/http/handler/users"
"github.com/portainer/portainer/api/http/handler/webhooks"
"github.com/portainer/portainer/api/http/handler/websocket"
"github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
)
@ -69,6 +74,8 @@ type Server struct {
DockerClientFactory *docker.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc
}
// Start starts the HTTP server
@ -78,6 +85,7 @@ func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService)
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
offlineGate := offlinegate.NewOfflineGate()
var authHandler = auth.NewHandler(requestBouncer, rateLimiter)
authHandler.DataStore = server.DataStore
@ -88,6 +96,11 @@ func (server *Server) Start() error {
authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager
authHandler.OAuthService = server.OAuthService
adminMonitor := adminmonitor.New(5*time.Minute, server.DataStore, server.ShutdownCtx)
adminMonitor.Start()
var backupHandler = backup.NewHandler(requestBouncer, server.DataStore, offlineGate, server.FileService.GetDatastorePath(), server.ShutdownTrigger, adminMonitor)
var roleHandler = roles.NewHandler(requestBouncer)
roleHandler.DataStore = server.DataStore
@ -200,6 +213,7 @@ func (server *Server) Start() error {
server.Handler = &handler.Handler{
RoleHandler: roleHandler,
AuthHandler: authHandler,
BackupHandler: backupHandler,
CustomTemplatesHandler: customTemplatesHandler,
DockerHubHandler: dockerHubHandler,
EdgeGroupsHandler: edgeGroupsHandler,
@ -231,10 +245,27 @@ func (server *Server) Start() error {
Addr: server.BindAddress,
Handler: server.Handler,
}
httpServer.Handler = offlineGate.WaitingMiddleware(time.Minute, httpServer.Handler)
if server.SSL {
httpServer.TLSConfig = crypto.CreateServerTLSConfiguration()
return httpServer.ListenAndServeTLS(server.SSLCert, server.SSLKey)
}
go server.shutdown(httpServer)
return httpServer.ListenAndServe()
}
func (server *Server) shutdown(httpServer *http.Server) {
<-server.ShutdownCtx.Done()
log.Println("[DEBUG] Shutting down http server")
shutdownTimeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := httpServer.Shutdown(shutdownTimeout)
if err != nil {
fmt.Printf("Failed shutdown http server: %s \n", err)
}
}