mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
Merge pull request #4965 from portainer/feat(backup)-backup-restore-system
feat(backup): Add backup/restore to the server [EE-386] [EE-378] [CE-452]
This commit is contained in:
commit
335bfb81ba
73 changed files with 2807 additions and 567 deletions
53
api/http/handler/backup/backup.go
Normal file
53
api/http/handler/backup/backup.go
Normal 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
|
||||
}
|
122
api/http/handler/backup/backup_test.go
Normal file
122
api/http/handler/backup/backup_test.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"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, _ := ioutils.TempDir("", "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, _ := ioutils.TempDir("", "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"))
|
||||
}
|
65
api/http/handler/backup/handler.go
Normal file
65
api/http/handler/backup/handler.go
Normal 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
|
||||
}
|
69
api/http/handler/backup/restore.go
Normal file
69
api/http/handler/backup/restore.go
Normal 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
|
||||
}
|
123
api/http/handler/backup/restore_test.go
Normal file
123
api/http/handler/backup/restore_test.go
Normal 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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -0,0 +1 @@
|
|||
content
|
|
@ -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"):
|
||||
|
|
71
api/http/offlinegate/offlinegate.go
Normal file
71
api/http/offlinegate/offlinegate.go
Normal 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)
|
||||
|
||||
})
|
||||
}
|
217
api/http/offlinegate/offlinegate_test.go
Normal file
217
api/http/offlinegate/offlinegate_test.go
Normal 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")
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue