From a3ec2f8e8587050d1398a5b21a5ebf23403a5830 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Tue, 6 Apr 2021 22:08:43 +1200 Subject: [PATCH 1/5] feat(backup): Add backup/restore to the server --- api/adminmonitor/admin_monitor.go | 69 +++++ api/adminmonitor/admin_monitor_test.go | 50 ++++ api/archive/targz.go | 119 ++++++++ api/archive/targz_test.go | 98 +++++++ api/backup/backup.go | 84 ++++++ api/backup/copy.go | 68 +++++ api/backup/copy_test.go | 104 +++++++ api/backup/restore.go | 68 +++++ api/backup/test_assets/copy_test/dir/.dotfile | 1 + api/backup/test_assets/copy_test/dir/inner | 1 + api/backup/test_assets/copy_test/outer | 1 + api/bolt/customtemplate/customtemplate.go | 22 +- api/bolt/datastore.go | 257 ++--------------- api/bolt/dockerhub/dockerhub.go | 16 +- api/bolt/edgegroup/edgegroup.go | 20 +- api/bolt/edgejob/edgejob.go | 22 +- api/bolt/edgestack/edgestack.go | 22 +- api/bolt/endpoint/endpoint.go | 24 +- api/bolt/endpointgroup/endpointgroup.go | 20 +- api/bolt/endpointrelation/endpointrelation.go | 16 +- api/bolt/extension/extension.go | 18 +- api/bolt/internal/db.go | 24 +- api/bolt/registry/registry.go | 20 +- api/bolt/resourcecontrol/resourcecontrol.go | 22 +- api/bolt/role/role.go | 18 +- api/bolt/schedule/schedule.go | 24 +- api/bolt/services.go | 263 ++++++++++++++++++ api/bolt/settings/settings.go | 16 +- api/bolt/stack/stack.go | 24 +- api/bolt/tag/tag.go | 20 +- api/bolt/team/team.go | 20 +- api/bolt/teammembership/teammembership.go | 28 +- api/bolt/tunnelserver/tunnelserver.go | 16 +- api/bolt/user/user.go | 22 +- api/bolt/version/version.go | 20 +- api/bolt/webhook/webhook.go | 22 +- api/chisel/service.go | 19 +- api/cmd/portainer/main.go | 75 ++--- api/crypto/aes.go | 70 +++++ api/crypto/aes_test.go | 131 +++++++++ api/filesystem/filesystem.go | 7 +- api/go.mod | 1 + api/go.sum | 2 + api/http/handler/backup/backup.go | 53 ++++ api/http/handler/backup/backup_test.go | 121 ++++++++ api/http/handler/backup/handler.go | 65 +++++ api/http/handler/backup/restore.go | 69 +++++ api/http/handler/backup/restore_test.go | 123 ++++++++ .../test_assets/handler_test/extra_file | 1 + .../handler_test/extra_folder/file1 | 1 + .../test_assets/handler_test/portainer.db | 0 .../test_assets/handler_test/portainer.key | 1 + .../test_assets/handler_test/portainer.pub | 1 + .../backup/test_assets/handler_test/tls/file1 | 1 + .../backup/test_assets/handler_test/tls/file2 | 1 + api/http/handler/handler.go | 6 + api/http/offlinegate/offlinegate.go | 71 +++++ api/http/offlinegate/offlinegate_test.go | 217 +++++++++++++++ api/http/security/bouncer.go | 48 ++-- api/http/server.go | 33 ++- api/internal/edge/edgejob.go | 19 ++ api/internal/snapshot/snapshot.go | 16 +- api/internal/testhelpers/datastore.go | 114 ++++++++ .../testhelpers/reverse_tunnel_service.go | 23 ++ api/portainer.go | 10 +- 65 files changed, 2394 insertions(+), 564 deletions(-) create mode 100644 api/adminmonitor/admin_monitor.go create mode 100644 api/adminmonitor/admin_monitor_test.go create mode 100644 api/archive/targz.go create mode 100644 api/archive/targz_test.go create mode 100644 api/backup/backup.go create mode 100644 api/backup/copy.go create mode 100644 api/backup/copy_test.go create mode 100644 api/backup/restore.go create mode 100644 api/backup/test_assets/copy_test/dir/.dotfile create mode 100644 api/backup/test_assets/copy_test/dir/inner create mode 100644 api/backup/test_assets/copy_test/outer create mode 100644 api/bolt/services.go create mode 100644 api/crypto/aes.go create mode 100644 api/crypto/aes_test.go create mode 100644 api/http/handler/backup/backup.go create mode 100644 api/http/handler/backup/backup_test.go create mode 100644 api/http/handler/backup/handler.go create mode 100644 api/http/handler/backup/restore.go create mode 100644 api/http/handler/backup/restore_test.go create mode 100644 api/http/handler/backup/test_assets/handler_test/extra_file create mode 100644 api/http/handler/backup/test_assets/handler_test/extra_folder/file1 create mode 100644 api/http/handler/backup/test_assets/handler_test/portainer.db create mode 100644 api/http/handler/backup/test_assets/handler_test/portainer.key create mode 100644 api/http/handler/backup/test_assets/handler_test/portainer.pub create mode 100644 api/http/handler/backup/test_assets/handler_test/tls/file1 create mode 100644 api/http/handler/backup/test_assets/handler_test/tls/file2 create mode 100644 api/http/offlinegate/offlinegate.go create mode 100644 api/http/offlinegate/offlinegate_test.go create mode 100644 api/internal/edge/edgejob.go create mode 100644 api/internal/testhelpers/datastore.go create mode 100644 api/internal/testhelpers/reverse_tunnel_service.go diff --git a/api/adminmonitor/admin_monitor.go b/api/adminmonitor/admin_monitor.go new file mode 100644 index 000000000..e22ae2c5b --- /dev/null +++ b/api/adminmonitor/admin_monitor.go @@ -0,0 +1,69 @@ +package adminmonitor + +import ( + "context" + "log" + "time" + + portainer "github.com/portainer/portainer/api" +) + +var logFatalf = log.Fatalf + +type Monitor struct { + timeout time.Duration + datastore portainer.DataStore + shutdownCtx context.Context + cancellationFunc context.CancelFunc +} + +// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized. +func New(timeout time.Duration, datastore portainer.DataStore, shutdownCtx context.Context) *Monitor { + return &Monitor{ + timeout: timeout, + datastore: datastore, + shutdownCtx: shutdownCtx, + } +} + +// Starts starts the monitor. Active monitor could be stopped or shuttted down by cancelling the shutdown context. +func (m *Monitor) Start() { + cancellationCtx, cancellationFunc := context.WithCancel(context.Background()) + m.cancellationFunc = cancellationFunc + + go func() { + log.Println("[DEBUG] [internal,init] [message: start initialization monitor ]") + select { + case <-time.After(m.timeout): + initialized, err := m.WasInitialized() + if err != nil { + logFatalf("%s", err) + } + if !initialized { + logFatalf("[FATAL] [internal,init] No administrator account was created in %f mins. Shutting down the Portainer instance for security reasons", m.timeout.Minutes()) + } + case <-cancellationCtx.Done(): + log.Println("[DEBUG] [internal,init] [message: canceling initialization monitor]") + case <-m.shutdownCtx.Done(): + log.Println("[DEBUG] [internal,init] [message: shutting down initialization monitor]") + } + }() +} + +// Stop stops monitor. Safe to call even if monitor wasn't started. +func (m *Monitor) Stop() { + if m.cancellationFunc == nil { + return + } + m.cancellationFunc() + m.cancellationFunc = nil +} + +// WasInitialized is a system initialization check +func (m *Monitor) WasInitialized() (bool, error) { + users, err := m.datastore.User().UsersByRole(portainer.AdministratorRole) + if err != nil { + return false, err + } + return len(users) > 0, nil +} diff --git a/api/adminmonitor/admin_monitor_test.go b/api/adminmonitor/admin_monitor_test.go new file mode 100644 index 000000000..1df983276 --- /dev/null +++ b/api/adminmonitor/admin_monitor_test.go @@ -0,0 +1,50 @@ +package adminmonitor + +import ( + "context" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + i "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_stopWithoutStarting(t *testing.T) { + monitor := New(1*time.Minute, nil, nil) + monitor.Stop() +} + +func Test_stopCouldBeCalledMultipleTimes(t *testing.T) { + monitor := New(1*time.Minute, nil, nil) + monitor.Stop() + monitor.Stop() +} + +func Test_canStopStartedMonitor(t *testing.T) { + monitor := New(1*time.Minute, nil, context.Background()) + monitor.Start() + assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor") + + monitor.Stop() + assert.Nil(t, monitor.cancellationFunc, "cancellation function should absent in stopped monitor") +} + +func Test_start_shouldFatalAfterTimeout_ifNotInitialized(t *testing.T) { + timeout := 10 * time.Millisecond + + datastore := i.NewDatastore(i.WithUsers([]portainer.User{})) + + var fataled bool + origLogFatalf := logFatalf + logFatalf = func(s string, v ...interface{}) { fataled = true } + defer func() { + logFatalf = origLogFatalf + }() + + monitor := New(timeout, datastore, context.Background()) + monitor.Start() + <-time.After(2 * timeout) + + assert.True(t, fataled, "monitor should been timeout and fatal") +} diff --git a/api/archive/targz.go b/api/archive/targz.go new file mode 100644 index 000000000..757854a23 --- /dev/null +++ b/api/archive/targz.go @@ -0,0 +1,119 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// TarGzDir creates a tar.gz archive and returns it's path. +// abosolutePath should be an absolute path to a directory. +// Archive name will be .tar.gz and will be placed next to the directory. +func TarGzDir(absolutePath string) (string, error) { + targzPath := filepath.Join(absolutePath, fmt.Sprintf("%s.tar.gz", filepath.Base(absolutePath))) + outFile, err := os.Create(targzPath) + if err != nil { + return "", err + } + defer outFile.Close() + + zipWriter := gzip.NewWriter(outFile) + defer zipWriter.Close() + tarWriter := tar.NewWriter(zipWriter) + defer tarWriter.Close() + + err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if path == targzPath { + return nil // skip archive file + } + + pathInArchive := filepath.Clean(strings.TrimPrefix(path, absolutePath)) + if pathInArchive == "" { + return nil // skip root dir + } + + return addToArchive(tarWriter, pathInArchive, path, info) + }) + + return targzPath, err +} + +func addToArchive(tarWriter *tar.Writer, pathInArchive string, path string, info os.FileInfo) error { + header, err := tar.FileInfoHeader(info, info.Name()) + if err != nil { + return err + } + + header.Name = pathInArchive // use relative paths in archive + + err = tarWriter.WriteHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + _, err = io.Copy(tarWriter, file) + return err +} + +// ExtractTarGz reads a .tar.gz archive from the reader and extracts it into outputDirPath directory +func ExtractTarGz(r io.Reader, outputDirPath string) error { + zipReader, err := gzip.NewReader(r) + if err != nil { + return err + } + defer zipReader.Close() + + tarReader := tar.NewReader(zipReader) + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + return err + } + + switch header.Typeflag { + case tar.TypeDir: + // skip, dir will be created with a file + case tar.TypeReg: + p := filepath.Clean(filepath.Join(outputDirPath, header.Name)) + if err := os.MkdirAll(filepath.Dir(p), 0744); err != nil { + return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p)) + } + outFile, err := os.Create(p) + if err != nil { + return fmt.Errorf("Failed to create file %s", header.Name) + } + if _, err := io.Copy(outFile, tarReader); err != nil { + return fmt.Errorf("Failed to extract file %s", header.Name) + } + outFile.Close() + default: + return fmt.Errorf("Tar: uknown type: %v in %s", + header.Typeflag, + header.Name) + } + } + + return nil +} diff --git a/api/archive/targz_test.go b/api/archive/targz_test.go new file mode 100644 index 000000000..f91482b81 --- /dev/null +++ b/api/archive/targz_test.go @@ -0,0 +1,98 @@ +package archive + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "testing" + + "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 Test_shouldCreateArhive(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "outer"), content, 0600) + os.MkdirAll(path.Join(tmpdir, "dir"), 0700) + ioutil.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600) + ioutil.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600) + + gzPath, err := TarGzDir(tmpdir) + assert.Nil(t, err) + assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath) + + extractionDir, _ := os.MkdirTemp("", "extract") + defer os.RemoveAll(extractionDir) + + cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir) + err = cmd.Run() + if err != nil { + t.Fatal("Failed to extract archive: ", err) + } + extractedFiles := listFiles(extractionDir) + + wasExtracted := func(p string) { + fullpath := path.Join(extractionDir, p) + assert.Contains(t, extractedFiles, fullpath) + copyContent, _ := ioutil.ReadFile(fullpath) + assert.Equal(t, content, copyContent) + } + + wasExtracted("outer") + wasExtracted("dir/inner") + wasExtracted("dir/.dotfile") +} + +func Test_shouldCreateArhiveXXXXX(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "outer"), content, 0600) + os.MkdirAll(path.Join(tmpdir, "dir"), 0700) + ioutil.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600) + ioutil.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600) + + gzPath, err := TarGzDir(tmpdir) + assert.Nil(t, err) + assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath) + + extractionDir, _ := os.MkdirTemp("", "extract") + defer os.RemoveAll(extractionDir) + + r, _ := os.Open(gzPath) + ExtractTarGz(r, extractionDir) + if err != nil { + t.Fatal("Failed to extract archive: ", err) + } + extractedFiles := listFiles(extractionDir) + + wasExtracted := func(p string) { + fullpath := path.Join(extractionDir, p) + assert.Contains(t, extractedFiles, fullpath) + copyContent, _ := ioutil.ReadFile(fullpath) + assert.Equal(t, content, copyContent) + } + + wasExtracted("outer") + wasExtracted("dir/inner") + wasExtracted("dir/.dotfile") +} diff --git a/api/backup/backup.go b/api/backup/backup.go new file mode 100644 index 000000000..8c31a76f9 --- /dev/null +++ b/api/backup/backup.go @@ -0,0 +1,84 @@ +package backup + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/offlinegate" +) + +const rwxr__r__ fs.FileMode = 0744 + +var filesToBackup = []string{"compose", "config.json", "custom_templates", "edge_jobs", "edge_stacks", "extensions", "portainer.key", "portainer.pub", "tls"} + +// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file. +func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) (string, error) { + unlock := gate.Lock() + defer unlock() + + backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05")) + if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil { + return "", errors.Wrap(err, "Failed to create backup dir") + } + + if err := backupDb(backupDirPath, datastore); err != nil { + return "", errors.Wrap(err, "Failed to backup database") + } + + for _, filename := range filesToBackup { + err := copyPath(filepath.Join(filestorePath, filename), backupDirPath) + if err != nil { + return "", errors.Wrap(err, "Failed to create backup file") + } + } + + archivePath, err := archive.TarGzDir(backupDirPath) + if err != nil { + return "", errors.Wrap(err, "Failed to make an archive") + } + + if password != "" { + archivePath, err = encrypt(archivePath, password) + if err != nil { + return "", errors.Wrap(err, "Failed to encrypt backup with the password") + } + } + + return archivePath, nil +} + +func backupDb(backupDirPath string, datastore portainer.DataStore) error { + backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db")) + if err != nil { + return err + } + if err = datastore.BackupTo(backupWriter); err != nil { + return err + } + return backupWriter.Close() +} + +func encrypt(path string, passphrase string) (string, error) { + in, err := os.Open(path) + if err != nil { + return "", err + } + defer in.Close() + + outFileName := fmt.Sprintf("%s.encrypted", path) + out, err := os.Create(outFileName) + if err != nil { + return "", err + } + + err = crypto.AesEncrypt(in, out, []byte(passphrase)) + + return outFileName, err +} diff --git a/api/backup/copy.go b/api/backup/copy.go new file mode 100644 index 000000000..6aaefd54c --- /dev/null +++ b/api/backup/copy.go @@ -0,0 +1,68 @@ +package backup + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" +) + +func copyPath(path string, toDir string) error { + info, err := os.Stat(path) + if err != nil && errors.Is(err, os.ErrNotExist) { + // skip copy if file does not exist + return nil + } + + if !info.IsDir() { + destination := filepath.Join(toDir, info.Name()) + return copyFile(path, destination) + } + + return copyDir(path, toDir) +} + +func copyDir(fromDir, toDir string) error { + cleanedSourcePath := filepath.Clean(fromDir) + parentDirectory := filepath.Dir(cleanedSourcePath) + err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + destination := filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory)) + if info.IsDir() { + return nil // skip directory creations + } + + if info.Mode()&os.ModeSymlink != 0 { // entry is a symlink + return nil // don't copy symlinks + } + + return copyFile(path, destination) + }) + + return err +} + +// copies regular a file from src to dst +func copyFile(src, dst string) error { + from, err := os.Open(src) + if err != nil { + return err + } + defer from.Close() + + // has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions + if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil { + return err + } + to, err := os.Create(dst) + if err != nil { + return err + } + defer to.Close() + + _, err = io.Copy(to, from) + return err +} diff --git a/api/backup/copy_test.go b/api/backup/copy_test.go new file mode 100644 index 000000000..171313181 --- /dev/null +++ b/api/backup/copy_test.go @@ -0,0 +1,104 @@ +package backup + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "testing" + + "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_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + err := copyFile("does-not-exist", tmpdir) + assert.NotNil(t, err) +} + +func Test_copyFile_shouldMakeAbackup(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600) + + err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy")) + assert.Nil(t, err) + + copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy")) + assert.Equal(t, content, copyContent) +} + +func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { + destination, _ := os.MkdirTemp("", "destination") + defer os.RemoveAll(destination) + err := copyDir("./test_assets/copy_test", destination) + assert.Nil(t, err) + + createdFiles := listFiles(destination) + + contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer")) + contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile")) + contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner")) +} + +func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + err := copyPath("does-not-exists", tmpdir) + assert.Nil(t, err) + + assert.Empty(t, listFiles(tmpdir)) +} + +func Test_backupPath_shouldCopyFile(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600) + + os.MkdirAll(path.Join(tmpdir, "backup"), 0700) + err := copyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup")) + assert.Nil(t, err) + + copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file")) + assert.Nil(t, err) + assert.Equal(t, content, copyContent) +} + +func Test_backupPath_shouldCopyDir(t *testing.T) { + destination, _ := os.MkdirTemp("", "destination") + defer os.RemoveAll(destination) + err := copyPath("./test_assets/copy_test", destination) + assert.Nil(t, err) + + createdFiles := listFiles(destination) + + contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer")) + contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile")) + contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner")) +} diff --git a/api/backup/restore.go b/api/backup/restore.go new file mode 100644 index 000000000..b0d7acee2 --- /dev/null +++ b/api/backup/restore.go @@ -0,0 +1,68 @@ +package backup + +import ( + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/archive" + "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/http/offlinegate" +) + +var filesToRestore = append(filesToBackup, "portainer.db") + +// Restores system state from backup archive, will trigger system shutdown, when finished. +func RestoreArchive(archive io.Reader, password string, filestorePath string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, shutdownTrigger context.CancelFunc) error { + var err error + if password != "" { + archive, err = decrypt(archive, password) + if err != nil { + return errors.Wrap(err, "failed to decrypt the archive") + } + } + + restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405")) + defer os.RemoveAll(filepath.Dir(restorePath)) + + err = extractArchive(archive, restorePath) + if err != nil { + return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again") + } + + unlock := gate.Lock() + defer unlock() + + if err = datastore.Close(); err != nil { + return errors.Wrap(err, "Failed to stop db") + } + + if err = restoreFiles(restorePath, filestorePath); err != nil { + return errors.Wrap(err, "failed to restore the system state") + } + + shutdownTrigger() + return nil +} + +func decrypt(r io.Reader, password string) (io.Reader, error) { + return crypto.AesDecrypt(r, []byte(password)) +} + +func extractArchive(r io.Reader, destinationDirPath string) error { + return archive.ExtractTarGz(r, destinationDirPath) +} + +func restoreFiles(srcDir string, destinationDir string) error { + for _, filename := range filesToRestore { + err := copyPath(filepath.Join(srcDir, filename), destinationDir) + if err != nil { + return err + } + } + return nil +} diff --git a/api/backup/test_assets/copy_test/dir/.dotfile b/api/backup/test_assets/copy_test/dir/.dotfile new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/backup/test_assets/copy_test/dir/.dotfile @@ -0,0 +1 @@ +content diff --git a/api/backup/test_assets/copy_test/dir/inner b/api/backup/test_assets/copy_test/dir/inner new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/backup/test_assets/copy_test/dir/inner @@ -0,0 +1 @@ +content diff --git a/api/backup/test_assets/copy_test/outer b/api/backup/test_assets/copy_test/outer new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/backup/test_assets/copy_test/outer @@ -0,0 +1 @@ +content diff --git a/api/bolt/customtemplate/customtemplate.go b/api/bolt/customtemplate/customtemplate.go index 316af170e..f48dc882f 100644 --- a/api/bolt/customtemplate/customtemplate.go +++ b/api/bolt/customtemplate/customtemplate.go @@ -2,7 +2,7 @@ package customtemplate import ( "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" ) @@ -13,18 +13,18 @@ const ( // Service represents a service for managing custom template data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -32,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) CustomTemplates() ([]portainer.CustomTemplate, error) { var customTemplates = make([]portainer.CustomTemplate, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -56,7 +56,7 @@ func (service *Service) CustomTemplate(ID portainer.CustomTemplateID) (*portaine var customTemplate portainer.CustomTemplate identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &customTemplate) + err := internal.GetObject(service.connection, BucketName, identifier, &customTemplate) if err != nil { return nil, err } @@ -67,18 +67,18 @@ func (service *Service) CustomTemplate(ID portainer.CustomTemplateID) (*portaine // UpdateCustomTemplate updates an custom template. func (service *Service) UpdateCustomTemplate(ID portainer.CustomTemplateID, customTemplate *portainer.CustomTemplate) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, customTemplate) + return internal.UpdateObject(service.connection, BucketName, identifier, customTemplate) } // DeleteCustomTemplate deletes an custom template. func (service *Service) DeleteCustomTemplate(ID portainer.CustomTemplateID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // CreateCustomTemplate assign an ID to a new custom template and saves it. func (service *Service) CreateCustomTemplate(customTemplate *portainer.CustomTemplate) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data, err := internal.MarshalObject(customTemplate) @@ -92,5 +92,5 @@ func (service *Service) CreateCustomTemplate(customTemplate *portainer.CustomTem // GetNextIdentifier returns the next identifier for a custom template. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 77e6adac1..b0904102c 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -1,6 +1,7 @@ package bolt import ( + "io" "log" "path" "time" @@ -17,6 +18,7 @@ import ( "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/extension" + "github.com/portainer/portainer/api/bolt/internal" "github.com/portainer/portainer/api/bolt/migrator" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" @@ -42,7 +44,7 @@ const ( // BoltDB as the storage system. type Store struct { path string - db *bolt.DB + connection *internal.DbConnection isNew bool fileService portainer.FileService CustomTemplateService *customtemplate.Service @@ -83,6 +85,7 @@ func NewStore(storePath string, fileService portainer.FileService) (*Store, erro path: storePath, fileService: fileService, isNew: true, + connection: &internal.DbConnection{}, } databasePath := path.Join(storePath, databaseFileName) @@ -105,15 +108,15 @@ func (store *Store) Open() error { if err != nil { return err } - store.db = db + store.connection.DB = db return store.initServices() } // Close closes the BoltDB database. func (store *Store) Close() error { - if store.db != nil { - return store.db.Close() + if store.connection.DB != nil { + return store.connection.Close() } return nil } @@ -134,8 +137,9 @@ func (store *Store) CheckCurrentEdition() error { // MigrateData automatically migrate the data based on the DBVersion. // This process is only triggered on an existing database, not if the database was just created. -func (store *Store) MigrateData() error { - if store.isNew { +// if force is true, then migrate regardless. +func (store *Store) MigrateData(force bool) error { + if store.isNew && !force { return store.VersionService.StoreDBVersion(portainer.DBVersion) } @@ -148,7 +152,7 @@ func (store *Store) MigrateData() error { if version < portainer.DBVersion { migratorParams := &migrator.Parameters{ - DB: store.db, + DB: store.connection.DB, DatabaseVersion: version, EndpointGroupService: store.EndpointGroupService, EndpointService: store.EndpointService, @@ -180,238 +184,11 @@ func (store *Store) MigrateData() error { return nil } -func (store *Store) initServices() error { - authorizationsetService, err := role.NewService(store.db) - if err != nil { +// BackupTo backs up db to a provided writer. +// It does hot backup and doesn't block other database reads and writes +func (store *Store) BackupTo(w io.Writer) error { + return store.connection.View(func(tx *bolt.Tx) error { + _, err := tx.WriteTo(w) return err - } - store.RoleService = authorizationsetService - - customTemplateService, err := customtemplate.NewService(store.db) - if err != nil { - return err - } - store.CustomTemplateService = customTemplateService - - dockerhubService, err := dockerhub.NewService(store.db) - if err != nil { - return err - } - store.DockerHubService = dockerhubService - - edgeStackService, err := edgestack.NewService(store.db) - if err != nil { - return err - } - store.EdgeStackService = edgeStackService - - edgeGroupService, err := edgegroup.NewService(store.db) - if err != nil { - return err - } - store.EdgeGroupService = edgeGroupService - - edgeJobService, err := edgejob.NewService(store.db) - if err != nil { - return err - } - store.EdgeJobService = edgeJobService - - endpointgroupService, err := endpointgroup.NewService(store.db) - if err != nil { - return err - } - store.EndpointGroupService = endpointgroupService - - endpointService, err := endpoint.NewService(store.db) - if err != nil { - return err - } - store.EndpointService = endpointService - - endpointRelationService, err := endpointrelation.NewService(store.db) - if err != nil { - return err - } - store.EndpointRelationService = endpointRelationService - - extensionService, err := extension.NewService(store.db) - if err != nil { - return err - } - store.ExtensionService = extensionService - - registryService, err := registry.NewService(store.db) - if err != nil { - return err - } - store.RegistryService = registryService - - resourcecontrolService, err := resourcecontrol.NewService(store.db) - if err != nil { - return err - } - store.ResourceControlService = resourcecontrolService - - settingsService, err := settings.NewService(store.db) - if err != nil { - return err - } - store.SettingsService = settingsService - - stackService, err := stack.NewService(store.db) - if err != nil { - return err - } - store.StackService = stackService - - tagService, err := tag.NewService(store.db) - if err != nil { - return err - } - store.TagService = tagService - - teammembershipService, err := teammembership.NewService(store.db) - if err != nil { - return err - } - store.TeamMembershipService = teammembershipService - - teamService, err := team.NewService(store.db) - if err != nil { - return err - } - store.TeamService = teamService - - tunnelServerService, err := tunnelserver.NewService(store.db) - if err != nil { - return err - } - store.TunnelServerService = tunnelServerService - - userService, err := user.NewService(store.db) - if err != nil { - return err - } - store.UserService = userService - - versionService, err := version.NewService(store.db) - if err != nil { - return err - } - store.VersionService = versionService - - webhookService, err := webhook.NewService(store.db) - if err != nil { - return err - } - store.WebhookService = webhookService - - scheduleService, err := schedule.NewService(store.db) - if err != nil { - return err - } - store.ScheduleService = scheduleService - - return nil -} - -// CustomTemplate gives access to the CustomTemplate data management layer -func (store *Store) CustomTemplate() portainer.CustomTemplateService { - return store.CustomTemplateService -} - -// DockerHub gives access to the DockerHub data management layer -func (store *Store) DockerHub() portainer.DockerHubService { - return store.DockerHubService -} - -// EdgeGroup gives access to the EdgeGroup data management layer -func (store *Store) EdgeGroup() portainer.EdgeGroupService { - return store.EdgeGroupService -} - -// EdgeJob gives access to the EdgeJob data management layer -func (store *Store) EdgeJob() portainer.EdgeJobService { - return store.EdgeJobService -} - -// EdgeStack gives access to the EdgeStack data management layer -func (store *Store) EdgeStack() portainer.EdgeStackService { - return store.EdgeStackService -} - -// Endpoint gives access to the Endpoint data management layer -func (store *Store) Endpoint() portainer.EndpointService { - return store.EndpointService -} - -// EndpointGroup gives access to the EndpointGroup data management layer -func (store *Store) EndpointGroup() portainer.EndpointGroupService { - return store.EndpointGroupService -} - -// EndpointRelation gives access to the EndpointRelation data management layer -func (store *Store) EndpointRelation() portainer.EndpointRelationService { - return store.EndpointRelationService -} - -// Registry gives access to the Registry data management layer -func (store *Store) Registry() portainer.RegistryService { - return store.RegistryService -} - -// ResourceControl gives access to the ResourceControl data management layer -func (store *Store) ResourceControl() portainer.ResourceControlService { - return store.ResourceControlService -} - -// Role gives access to the Role data management layer -func (store *Store) Role() portainer.RoleService { - return store.RoleService -} - -// Settings gives access to the Settings data management layer -func (store *Store) Settings() portainer.SettingsService { - return store.SettingsService -} - -// Stack gives access to the Stack data management layer -func (store *Store) Stack() portainer.StackService { - return store.StackService -} - -// Tag gives access to the Tag data management layer -func (store *Store) Tag() portainer.TagService { - return store.TagService -} - -// TeamMembership gives access to the TeamMembership data management layer -func (store *Store) TeamMembership() portainer.TeamMembershipService { - return store.TeamMembershipService -} - -// Team gives access to the Team data management layer -func (store *Store) Team() portainer.TeamService { - return store.TeamService -} - -// TunnelServer gives access to the TunnelServer data management layer -func (store *Store) TunnelServer() portainer.TunnelServerService { - return store.TunnelServerService -} - -// User gives access to the User data management layer -func (store *Store) User() portainer.UserService { - return store.UserService -} - -// Version gives access to the Version data management layer -func (store *Store) Version() portainer.VersionService { - return store.VersionService -} - -// Webhook gives access to the Webhook data management layer -func (store *Store) Webhook() portainer.WebhookService { - return store.WebhookService + }) } diff --git a/api/bolt/dockerhub/dockerhub.go b/api/bolt/dockerhub/dockerhub.go index a225be462..f39c32a8b 100644 --- a/api/bolt/dockerhub/dockerhub.go +++ b/api/bolt/dockerhub/dockerhub.go @@ -1,10 +1,8 @@ package dockerhub import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" - - "github.com/boltdb/bolt" ) const ( @@ -15,18 +13,18 @@ const ( // Service represents a service for managing Dockerhub data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) DockerHub() (*portainer.DockerHub, error) { var dockerhub portainer.DockerHub - err := internal.GetObject(service.db, BucketName, []byte(dockerHubKey), &dockerhub) + err := internal.GetObject(service.connection, BucketName, []byte(dockerHubKey), &dockerhub) if err != nil { return nil, err } @@ -44,5 +42,5 @@ func (service *Service) DockerHub() (*portainer.DockerHub, error) { // UpdateDockerHub updates a DockerHub object. func (service *Service) UpdateDockerHub(dockerhub *portainer.DockerHub) error { - return internal.UpdateObject(service.db, BucketName, []byte(dockerHubKey), dockerhub) + return internal.UpdateObject(service.connection, BucketName, []byte(dockerHubKey), dockerhub) } diff --git a/api/bolt/edgegroup/edgegroup.go b/api/bolt/edgegroup/edgegroup.go index 41909b437..d7dc2c60b 100644 --- a/api/bolt/edgegroup/edgegroup.go +++ b/api/bolt/edgegroup/edgegroup.go @@ -2,7 +2,7 @@ package edgegroup import ( "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" ) @@ -13,18 +13,18 @@ const ( // Service represents a service for managing Edge group data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -32,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) EdgeGroups() ([]portainer.EdgeGroup, error) { var groups = make([]portainer.EdgeGroup, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -56,7 +56,7 @@ func (service *Service) EdgeGroup(ID portainer.EdgeGroupID) (*portainer.EdgeGrou var group portainer.EdgeGroup identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &group) + err := internal.GetObject(service.connection, BucketName, identifier, &group) if err != nil { return nil, err } @@ -67,18 +67,18 @@ func (service *Service) EdgeGroup(ID portainer.EdgeGroupID) (*portainer.EdgeGrou // UpdateEdgeGroup updates an Edge group. func (service *Service) UpdateEdgeGroup(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, group) + return internal.UpdateObject(service.connection, BucketName, identifier, group) } // DeleteEdgeGroup deletes an Edge group. func (service *Service) DeleteEdgeGroup(ID portainer.EdgeGroupID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // CreateEdgeGroup assign an ID to a new Edge group and saves it. func (service *Service) CreateEdgeGroup(group *portainer.EdgeGroup) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() diff --git a/api/bolt/edgejob/edgejob.go b/api/bolt/edgejob/edgejob.go index f3354c7d8..216bdacec 100644 --- a/api/bolt/edgejob/edgejob.go +++ b/api/bolt/edgejob/edgejob.go @@ -2,7 +2,7 @@ package edgejob import ( "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" ) @@ -13,18 +13,18 @@ const ( // Service represents a service for managing edge jobs data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -32,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) EdgeJobs() ([]portainer.EdgeJob, error) { var edgeJobs = make([]portainer.EdgeJob, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -56,7 +56,7 @@ func (service *Service) EdgeJob(ID portainer.EdgeJobID) (*portainer.EdgeJob, err var edgeJob portainer.EdgeJob identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &edgeJob) + err := internal.GetObject(service.connection, BucketName, identifier, &edgeJob) if err != nil { return nil, err } @@ -66,7 +66,7 @@ func (service *Service) EdgeJob(ID portainer.EdgeJobID) (*portainer.EdgeJob, err // CreateEdgeJob creates a new Edge job func (service *Service) CreateEdgeJob(edgeJob *portainer.EdgeJob) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) if edgeJob.ID == 0 { @@ -86,16 +86,16 @@ func (service *Service) CreateEdgeJob(edgeJob *portainer.EdgeJob) error { // UpdateEdgeJob updates an Edge job by ID func (service *Service) UpdateEdgeJob(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, edgeJob) + return internal.UpdateObject(service.connection, BucketName, identifier, edgeJob) } // DeleteEdgeJob deletes an Edge job func (service *Service) DeleteEdgeJob(ID portainer.EdgeJobID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // GetNextIdentifier returns the next identifier for an endpoint. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/edgestack/edgestack.go b/api/bolt/edgestack/edgestack.go index 337bb6892..ff58c0dae 100644 --- a/api/bolt/edgestack/edgestack.go +++ b/api/bolt/edgestack/edgestack.go @@ -2,7 +2,7 @@ package edgestack import ( "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" ) @@ -13,18 +13,18 @@ const ( // Service represents a service for managing Edge stack data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -32,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) EdgeStacks() ([]portainer.EdgeStack, error) { var stacks = make([]portainer.EdgeStack, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -56,7 +56,7 @@ func (service *Service) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStac var stack portainer.EdgeStack identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &stack) + err := internal.GetObject(service.connection, BucketName, identifier, &stack) if err != nil { return nil, err } @@ -66,7 +66,7 @@ func (service *Service) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStac // CreateEdgeStack assign an ID to a new Edge stack and saves it. func (service *Service) CreateEdgeStack(edgeStack *portainer.EdgeStack) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) if edgeStack.ID == 0 { @@ -86,16 +86,16 @@ func (service *Service) CreateEdgeStack(edgeStack *portainer.EdgeStack) error { // UpdateEdgeStack updates an Edge stack. func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, edgeStack) + return internal.UpdateObject(service.connection, BucketName, identifier, edgeStack) } // DeleteEdgeStack deletes an Edge stack. func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // GetNextIdentifier returns the next identifier for an endpoint. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go index 69d9dc4ac..ebd162985 100644 --- a/api/bolt/endpoint/endpoint.go +++ b/api/bolt/endpoint/endpoint.go @@ -2,7 +2,7 @@ package endpoint import ( "github.com/boltdb/bolt" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" ) @@ -13,18 +13,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -33,7 +33,7 @@ func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, var endpoint portainer.Endpoint identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &endpoint) + err := internal.GetObject(service.connection, BucketName, identifier, &endpoint) if err != nil { return nil, err } @@ -44,20 +44,20 @@ func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, // UpdateEndpoint updates an endpoint. func (service *Service) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, endpoint) + return internal.UpdateObject(service.connection, BucketName, identifier, endpoint) } // DeleteEndpoint deletes an endpoint. func (service *Service) DeleteEndpoint(ID portainer.EndpointID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // Endpoints return an array containing all the endpoints. func (service *Service) Endpoints() ([]portainer.Endpoint, error) { var endpoints = make([]portainer.Endpoint, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -78,7 +78,7 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) { // CreateEndpoint assign an ID to a new endpoint and saves it. func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) // We manually manage sequences for endpoints @@ -98,12 +98,12 @@ func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { // GetNextIdentifier returns the next identifier for an endpoint. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } // Synchronize creates, updates and deletes endpoints inside a single transaction. func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) for _, endpoint := range toCreate { diff --git a/api/bolt/endpointgroup/endpointgroup.go b/api/bolt/endpointgroup/endpointgroup.go index 3311e88cb..02c0e3382 100644 --- a/api/bolt/endpointgroup/endpointgroup.go +++ b/api/bolt/endpointgroup/endpointgroup.go @@ -1,7 +1,7 @@ package endpointgroup import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer. var endpointGroup portainer.EndpointGroup identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &endpointGroup) + err := internal.GetObject(service.connection, BucketName, identifier, &endpointGroup) if err != nil { return nil, err } @@ -45,20 +45,20 @@ func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer. // UpdateEndpointGroup updates an endpoint group. func (service *Service) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, endpointGroup) + return internal.UpdateObject(service.connection, BucketName, identifier, endpointGroup) } // DeleteEndpointGroup deletes an endpoint group. func (service *Service) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // EndpointGroups return an array containing all the endpoint groups. func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { var endpointGroups = make([]portainer.EndpointGroup, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -79,7 +79,7 @@ func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { // CreateEndpointGroup assign an ID to a new endpoint group and saves it. func (service *Service) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() diff --git a/api/bolt/endpointrelation/endpointrelation.go b/api/bolt/endpointrelation/endpointrelation.go index 00dab3f4a..974913531 100644 --- a/api/bolt/endpointrelation/endpointrelation.go +++ b/api/bolt/endpointrelation/endpointrelation.go @@ -13,18 +13,18 @@ const ( // Service represents a service for managing endpoint relation data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -33,7 +33,7 @@ func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*port var endpointRelation portainer.EndpointRelation identifier := internal.Itob(int(endpointID)) - err := internal.GetObject(service.db, BucketName, identifier, &endpointRelation) + err := internal.GetObject(service.connection, BucketName, identifier, &endpointRelation) if err != nil { return nil, err } @@ -43,7 +43,7 @@ func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*port // CreateEndpointRelation saves endpointRelation func (service *Service) CreateEndpointRelation(endpointRelation *portainer.EndpointRelation) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data, err := internal.MarshalObject(endpointRelation) @@ -58,11 +58,11 @@ func (service *Service) CreateEndpointRelation(endpointRelation *portainer.Endpo // UpdateEndpointRelation updates an Endpoint relation object func (service *Service) UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { identifier := internal.Itob(int(EndpointID)) - return internal.UpdateObject(service.db, BucketName, identifier, endpointRelation) + return internal.UpdateObject(service.connection, BucketName, identifier, endpointRelation) } // DeleteEndpointRelation deletes an Endpoint relation object func (service *Service) DeleteEndpointRelation(EndpointID portainer.EndpointID) error { identifier := internal.Itob(int(EndpointID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/extension/extension.go b/api/bolt/extension/extension.go index 83e225414..15104af8f 100644 --- a/api/bolt/extension/extension.go +++ b/api/bolt/extension/extension.go @@ -1,7 +1,7 @@ package extension import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extensio var extension portainer.Extension identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &extension) + err := internal.GetObject(service.connection, BucketName, identifier, &extension) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extensio func (service *Service) Extensions() ([]portainer.Extension, error) { var extensions = make([]portainer.Extension, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -67,7 +67,7 @@ func (service *Service) Extensions() ([]portainer.Extension, error) { // Persist persists a extension inside the database. func (service *Service) Persist(extension *portainer.Extension) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data, err := internal.MarshalObject(extension) @@ -82,5 +82,5 @@ func (service *Service) Persist(extension *portainer.Extension) error { // DeleteExtension deletes a Extension. func (service *Service) DeleteExtension(ID portainer.ExtensionID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go index 101e1ff00..a90cf2adc 100644 --- a/api/bolt/internal/db.go +++ b/api/bolt/internal/db.go @@ -7,6 +7,10 @@ import ( "github.com/portainer/portainer/api/bolt/errors" ) +type DbConnection struct { + *bolt.DB +} + // 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. @@ -17,8 +21,8 @@ func Itob(v int) []byte { } // CreateBucket is a generic function used to create a bucket inside a bolt database. -func CreateBucket(db *bolt.DB, bucketName string) error { - return db.Update(func(tx *bolt.Tx) error { +func CreateBucket(connection *DbConnection, bucketName string) error { + return connection.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists([]byte(bucketName)) if err != nil { return err @@ -28,10 +32,10 @@ func CreateBucket(db *bolt.DB, bucketName string) error { } // GetObject is a generic function used to retrieve an unmarshalled object from a bolt database. -func GetObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { +func GetObject(connection *DbConnection, bucketName string, key []byte, object interface{}) error { var data []byte - err := db.View(func(tx *bolt.Tx) error { + err := connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) value := bucket.Get(key) @@ -52,8 +56,8 @@ func GetObject(db *bolt.DB, bucketName string, key []byte, object interface{}) e } // UpdateObject is a generic function used to update an object inside a bolt database. -func UpdateObject(db *bolt.DB, bucketName string, key []byte, object interface{}) error { - return db.Update(func(tx *bolt.Tx) error { +func UpdateObject(connection *DbConnection, bucketName string, key []byte, object interface{}) error { + return connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) data, err := MarshalObject(object) @@ -71,18 +75,18 @@ func UpdateObject(db *bolt.DB, bucketName string, key []byte, object interface{} } // DeleteObject is a generic function used to delete an object inside a bolt database. -func DeleteObject(db *bolt.DB, bucketName string, key []byte) error { - return db.Update(func(tx *bolt.Tx) error { +func DeleteObject(connection *DbConnection, bucketName string, key []byte) error { + return connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) return bucket.Delete(key) }) } // GetNextIdentifier is a generic function that returns the specified bucket identifier incremented by 1. -func GetNextIdentifier(db *bolt.DB, bucketName string) int { +func GetNextIdentifier(connection *DbConnection, bucketName string) int { var identifier int - db.Update(func(tx *bolt.Tx) error { + connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) id, err := bucket.NextSequence() if err != nil { diff --git a/api/bolt/registry/registry.go b/api/bolt/registry/registry.go index 428c6957e..dc741ae7b 100644 --- a/api/bolt/registry/registry.go +++ b/api/bolt/registry/registry.go @@ -1,7 +1,7 @@ package registry import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) Registry(ID portainer.RegistryID) (*portainer.Registry, var registry portainer.Registry identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, ®istry) + err := internal.GetObject(service.connection, BucketName, identifier, ®istry) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (service *Service) Registry(ID portainer.RegistryID) (*portainer.Registry, func (service *Service) Registries() ([]portainer.Registry, error) { var registries = make([]portainer.Registry, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -67,7 +67,7 @@ func (service *Service) Registries() ([]portainer.Registry, error) { // CreateRegistry creates a new registry. func (service *Service) CreateRegistry(registry *portainer.Registry) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -85,11 +85,11 @@ func (service *Service) CreateRegistry(registry *portainer.Registry) error { // UpdateRegistry updates an registry. func (service *Service) UpdateRegistry(ID portainer.RegistryID, registry *portainer.Registry) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, registry) + return internal.UpdateObject(service.connection, BucketName, identifier, registry) } // DeleteRegistry deletes an registry. func (service *Service) DeleteRegistry(ID portainer.RegistryID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/resourcecontrol/resourcecontrol.go b/api/bolt/resourcecontrol/resourcecontrol.go index ef07aff03..d0d1559fb 100644 --- a/api/bolt/resourcecontrol/resourcecontrol.go +++ b/api/bolt/resourcecontrol/resourcecontrol.go @@ -1,7 +1,7 @@ package resourcecontrol import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) ResourceControl(ID portainer.ResourceControlID) (*portai var resourceControl portainer.ResourceControl identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &resourceControl) + err := internal.GetObject(service.connection, BucketName, identifier, &resourceControl) if err != nil { return nil, err } @@ -48,7 +48,7 @@ func (service *Service) ResourceControl(ID portainer.ResourceControlID) (*portai func (service *Service) ResourceControlByResourceIDAndType(resourceID string, resourceType portainer.ResourceControlType) (*portainer.ResourceControl, error) { var resourceControl *portainer.ResourceControl - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -82,7 +82,7 @@ func (service *Service) ResourceControlByResourceIDAndType(resourceID string, re func (service *Service) ResourceControls() ([]portainer.ResourceControl, error) { var rcs = make([]portainer.ResourceControl, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -103,7 +103,7 @@ func (service *Service) ResourceControls() ([]portainer.ResourceControl, error) // CreateResourceControl creates a new ResourceControl object func (service *Service) CreateResourceControl(resourceControl *portainer.ResourceControl) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -121,11 +121,11 @@ func (service *Service) CreateResourceControl(resourceControl *portainer.Resourc // UpdateResourceControl saves a ResourceControl object. func (service *Service) UpdateResourceControl(ID portainer.ResourceControlID, resourceControl *portainer.ResourceControl) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, resourceControl) + return internal.UpdateObject(service.connection, BucketName, identifier, resourceControl) } // DeleteResourceControl deletes a ResourceControl object by ID func (service *Service) DeleteResourceControl(ID portainer.ResourceControlID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/role/role.go b/api/bolt/role/role.go index 36cd8e7d1..eff9d56f1 100644 --- a/api/bolt/role/role.go +++ b/api/bolt/role/role.go @@ -1,7 +1,7 @@ package role import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) Role(ID portainer.RoleID) (*portainer.Role, error) { var set portainer.Role identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &set) + err := internal.GetObject(service.connection, BucketName, identifier, &set) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (service *Service) Role(ID portainer.RoleID) (*portainer.Role, error) { func (service *Service) Roles() ([]portainer.Role, error) { var sets = make([]portainer.Role, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -67,7 +67,7 @@ func (service *Service) Roles() ([]portainer.Role, error) { // CreateRole creates a new Role. func (service *Service) CreateRole(role *portainer.Role) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -85,5 +85,5 @@ func (service *Service) CreateRole(role *portainer.Role) error { // UpdateRole updates a role. func (service *Service) UpdateRole(ID portainer.RoleID, role *portainer.Role) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, role) + return internal.UpdateObject(service.connection, BucketName, identifier, role) } diff --git a/api/bolt/schedule/schedule.go b/api/bolt/schedule/schedule.go index 25b996373..d919586d8 100644 --- a/api/bolt/schedule/schedule.go +++ b/api/bolt/schedule/schedule.go @@ -1,7 +1,7 @@ package schedule import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing schedule data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) Schedule(ID portainer.ScheduleID) (*portainer.Schedule, var schedule portainer.Schedule identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &schedule) + err := internal.GetObject(service.connection, BucketName, identifier, &schedule) if err != nil { return nil, err } @@ -45,20 +45,20 @@ func (service *Service) Schedule(ID portainer.ScheduleID) (*portainer.Schedule, // UpdateSchedule updates a schedule. func (service *Service) UpdateSchedule(ID portainer.ScheduleID, schedule *portainer.Schedule) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, schedule) + return internal.UpdateObject(service.connection, BucketName, identifier, schedule) } // DeleteSchedule deletes a schedule. func (service *Service) DeleteSchedule(ID portainer.ScheduleID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // Schedules return a array containing all the schedules. func (service *Service) Schedules() ([]portainer.Schedule, error) { var schedules = make([]portainer.Schedule, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -82,7 +82,7 @@ func (service *Service) Schedules() ([]portainer.Schedule, error) { func (service *Service) SchedulesByJobType(jobType portainer.JobType) ([]portainer.Schedule, error) { var schedules = make([]portainer.Schedule, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -105,7 +105,7 @@ func (service *Service) SchedulesByJobType(jobType portainer.JobType) ([]portain // CreateSchedule assign an ID to a new schedule and saves it. func (service *Service) CreateSchedule(schedule *portainer.Schedule) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) // We manually manage sequences for schedules @@ -125,5 +125,5 @@ func (service *Service) CreateSchedule(schedule *portainer.Schedule) error { // GetNextIdentifier returns the next identifier for a schedule. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/services.go b/api/bolt/services.go new file mode 100644 index 000000000..4cdc84069 --- /dev/null +++ b/api/bolt/services.go @@ -0,0 +1,263 @@ +package bolt + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/customtemplate" + "github.com/portainer/portainer/api/bolt/dockerhub" + "github.com/portainer/portainer/api/bolt/edgegroup" + "github.com/portainer/portainer/api/bolt/edgejob" + "github.com/portainer/portainer/api/bolt/edgestack" + "github.com/portainer/portainer/api/bolt/endpoint" + "github.com/portainer/portainer/api/bolt/endpointgroup" + "github.com/portainer/portainer/api/bolt/endpointrelation" + "github.com/portainer/portainer/api/bolt/extension" + "github.com/portainer/portainer/api/bolt/registry" + "github.com/portainer/portainer/api/bolt/resourcecontrol" + "github.com/portainer/portainer/api/bolt/role" + "github.com/portainer/portainer/api/bolt/schedule" + "github.com/portainer/portainer/api/bolt/settings" + "github.com/portainer/portainer/api/bolt/stack" + "github.com/portainer/portainer/api/bolt/tag" + "github.com/portainer/portainer/api/bolt/team" + "github.com/portainer/portainer/api/bolt/teammembership" + "github.com/portainer/portainer/api/bolt/tunnelserver" + "github.com/portainer/portainer/api/bolt/user" + "github.com/portainer/portainer/api/bolt/version" + "github.com/portainer/portainer/api/bolt/webhook" +) + +func (store *Store) initServices() error { + authorizationsetService, err := role.NewService(store.connection) + if err != nil { + return err + } + store.RoleService = authorizationsetService + + customTemplateService, err := customtemplate.NewService(store.connection) + if err != nil { + return err + } + store.CustomTemplateService = customTemplateService + + dockerhubService, err := dockerhub.NewService(store.connection) + if err != nil { + return err + } + store.DockerHubService = dockerhubService + + edgeStackService, err := edgestack.NewService(store.connection) + if err != nil { + return err + } + store.EdgeStackService = edgeStackService + + edgeGroupService, err := edgegroup.NewService(store.connection) + if err != nil { + return err + } + store.EdgeGroupService = edgeGroupService + + edgeJobService, err := edgejob.NewService(store.connection) + if err != nil { + return err + } + store.EdgeJobService = edgeJobService + + endpointgroupService, err := endpointgroup.NewService(store.connection) + if err != nil { + return err + } + store.EndpointGroupService = endpointgroupService + + endpointService, err := endpoint.NewService(store.connection) + if err != nil { + return err + } + store.EndpointService = endpointService + + endpointRelationService, err := endpointrelation.NewService(store.connection) + if err != nil { + return err + } + store.EndpointRelationService = endpointRelationService + + extensionService, err := extension.NewService(store.connection) + if err != nil { + return err + } + store.ExtensionService = extensionService + + registryService, err := registry.NewService(store.connection) + if err != nil { + return err + } + store.RegistryService = registryService + + resourcecontrolService, err := resourcecontrol.NewService(store.connection) + if err != nil { + return err + } + store.ResourceControlService = resourcecontrolService + + settingsService, err := settings.NewService(store.connection) + if err != nil { + return err + } + store.SettingsService = settingsService + + stackService, err := stack.NewService(store.connection) + if err != nil { + return err + } + store.StackService = stackService + + tagService, err := tag.NewService(store.connection) + if err != nil { + return err + } + store.TagService = tagService + + teammembershipService, err := teammembership.NewService(store.connection) + if err != nil { + return err + } + store.TeamMembershipService = teammembershipService + + teamService, err := team.NewService(store.connection) + if err != nil { + return err + } + store.TeamService = teamService + + tunnelServerService, err := tunnelserver.NewService(store.connection) + if err != nil { + return err + } + store.TunnelServerService = tunnelServerService + + userService, err := user.NewService(store.connection) + if err != nil { + return err + } + store.UserService = userService + + versionService, err := version.NewService(store.connection) + if err != nil { + return err + } + store.VersionService = versionService + + webhookService, err := webhook.NewService(store.connection) + if err != nil { + return err + } + store.WebhookService = webhookService + + scheduleService, err := schedule.NewService(store.connection) + if err != nil { + return err + } + store.ScheduleService = scheduleService + + return nil +} + +// CustomTemplate gives access to the CustomTemplate data management layer +func (store *Store) CustomTemplate() portainer.CustomTemplateService { + return store.CustomTemplateService +} + +// DockerHub gives access to the DockerHub data management layer +func (store *Store) DockerHub() portainer.DockerHubService { + return store.DockerHubService +} + +// EdgeGroup gives access to the EdgeGroup data management layer +func (store *Store) EdgeGroup() portainer.EdgeGroupService { + return store.EdgeGroupService +} + +// EdgeJob gives access to the EdgeJob data management layer +func (store *Store) EdgeJob() portainer.EdgeJobService { + return store.EdgeJobService +} + +// EdgeStack gives access to the EdgeStack data management layer +func (store *Store) EdgeStack() portainer.EdgeStackService { + return store.EdgeStackService +} + +// Endpoint gives access to the Endpoint data management layer +func (store *Store) Endpoint() portainer.EndpointService { + return store.EndpointService +} + +// EndpointGroup gives access to the EndpointGroup data management layer +func (store *Store) EndpointGroup() portainer.EndpointGroupService { + return store.EndpointGroupService +} + +// EndpointRelation gives access to the EndpointRelation data management layer +func (store *Store) EndpointRelation() portainer.EndpointRelationService { + return store.EndpointRelationService +} + +// Registry gives access to the Registry data management layer +func (store *Store) Registry() portainer.RegistryService { + return store.RegistryService +} + +// ResourceControl gives access to the ResourceControl data management layer +func (store *Store) ResourceControl() portainer.ResourceControlService { + return store.ResourceControlService +} + +// Role gives access to the Role data management layer +func (store *Store) Role() portainer.RoleService { + return store.RoleService +} + +// Settings gives access to the Settings data management layer +func (store *Store) Settings() portainer.SettingsService { + return store.SettingsService +} + +// Stack gives access to the Stack data management layer +func (store *Store) Stack() portainer.StackService { + return store.StackService +} + +// Tag gives access to the Tag data management layer +func (store *Store) Tag() portainer.TagService { + return store.TagService +} + +// TeamMembership gives access to the TeamMembership data management layer +func (store *Store) TeamMembership() portainer.TeamMembershipService { + return store.TeamMembershipService +} + +// Team gives access to the Team data management layer +func (store *Store) Team() portainer.TeamService { + return store.TeamService +} + +// TunnelServer gives access to the TunnelServer data management layer +func (store *Store) TunnelServer() portainer.TunnelServerService { + return store.TunnelServerService +} + +// User gives access to the User data management layer +func (store *Store) User() portainer.UserService { + return store.UserService +} + +// Version gives access to the Version data management layer +func (store *Store) Version() portainer.VersionService { + return store.VersionService +} + +// Webhook gives access to the Webhook data management layer +func (store *Store) Webhook() portainer.WebhookService { + return store.WebhookService +} diff --git a/api/bolt/settings/settings.go b/api/bolt/settings/settings.go index c14032b25..001bd6142 100644 --- a/api/bolt/settings/settings.go +++ b/api/bolt/settings/settings.go @@ -1,10 +1,8 @@ package settings import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" - - "github.com/boltdb/bolt" ) const ( @@ -15,18 +13,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) Settings() (*portainer.Settings, error) { var settings portainer.Settings - err := internal.GetObject(service.db, BucketName, []byte(settingsKey), &settings) + err := internal.GetObject(service.connection, BucketName, []byte(settingsKey), &settings) if err != nil { return nil, err } @@ -44,5 +42,5 @@ func (service *Service) Settings() (*portainer.Settings, error) { // UpdateSettings persists a Settings object. func (service *Service) UpdateSettings(settings *portainer.Settings) error { - return internal.UpdateObject(service.db, BucketName, []byte(settingsKey), settings) + return internal.UpdateObject(service.connection, BucketName, []byte(settingsKey), settings) } diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go index a5145ba35..f9cfafad7 100644 --- a/api/bolt/stack/stack.go +++ b/api/bolt/stack/stack.go @@ -1,7 +1,7 @@ package stack import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" @@ -15,18 +15,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -35,7 +35,7 @@ func (service *Service) Stack(ID portainer.StackID) (*portainer.Stack, error) { var stack portainer.Stack identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &stack) + err := internal.GetObject(service.connection, BucketName, identifier, &stack) if err != nil { return nil, err } @@ -47,7 +47,7 @@ func (service *Service) Stack(ID portainer.StackID) (*portainer.Stack, error) { func (service *Service) StackByName(name string) (*portainer.Stack, error) { var stack *portainer.Stack - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -78,7 +78,7 @@ func (service *Service) StackByName(name string) (*portainer.Stack, error) { func (service *Service) Stacks() ([]portainer.Stack, error) { var stacks = make([]portainer.Stack, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -99,12 +99,12 @@ func (service *Service) Stacks() ([]portainer.Stack, error) { // GetNextIdentifier returns the next identifier for a stack. func (service *Service) GetNextIdentifier() int { - return internal.GetNextIdentifier(service.db, BucketName) + return internal.GetNextIdentifier(service.connection, BucketName) } // CreateStack creates a new stack. func (service *Service) CreateStack(stack *portainer.Stack) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) // We manually manage sequences for stacks @@ -125,11 +125,11 @@ func (service *Service) CreateStack(stack *portainer.Stack) error { // UpdateStack updates a stack. func (service *Service) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, stack) + return internal.UpdateObject(service.connection, BucketName, identifier, stack) } // DeleteStack deletes a stack. func (service *Service) DeleteStack(ID portainer.StackID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go index ba0f44ba9..f10c64d35 100644 --- a/api/bolt/tag/tag.go +++ b/api/bolt/tag/tag.go @@ -1,7 +1,7 @@ package tag import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -33,7 +33,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) Tags() ([]portainer.Tag, error) { var tags = make([]portainer.Tag, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -57,7 +57,7 @@ func (service *Service) Tag(ID portainer.TagID) (*portainer.Tag, error) { var tag portainer.Tag identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &tag) + err := internal.GetObject(service.connection, BucketName, identifier, &tag) if err != nil { return nil, err } @@ -67,7 +67,7 @@ func (service *Service) Tag(ID portainer.TagID) (*portainer.Tag, error) { // CreateTag creates a new tag. func (service *Service) CreateTag(tag *portainer.Tag) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -85,11 +85,11 @@ func (service *Service) CreateTag(tag *portainer.Tag) error { // UpdateTag updates a tag. func (service *Service) UpdateTag(ID portainer.TagID, tag *portainer.Tag) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, tag) + return internal.UpdateObject(service.connection, BucketName, identifier, tag) } // DeleteTag deletes a tag. func (service *Service) DeleteTag(ID portainer.TagID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/team/team.go b/api/bolt/team/team.go index a503e8285..d710f05c1 100644 --- a/api/bolt/team/team.go +++ b/api/bolt/team/team.go @@ -17,18 +17,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -37,7 +37,7 @@ func (service *Service) Team(ID portainer.TeamID) (*portainer.Team, error) { var team portainer.Team identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &team) + err := internal.GetObject(service.connection, BucketName, identifier, &team) if err != nil { return nil, err } @@ -49,7 +49,7 @@ func (service *Service) Team(ID portainer.TeamID) (*portainer.Team, error) { func (service *Service) TeamByName(name string) (*portainer.Team, error) { var team *portainer.Team - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -80,7 +80,7 @@ func (service *Service) TeamByName(name string) (*portainer.Team, error) { func (service *Service) Teams() ([]portainer.Team, error) { var teams = make([]portainer.Team, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -102,12 +102,12 @@ func (service *Service) Teams() ([]portainer.Team, error) { // UpdateTeam saves a Team. func (service *Service) UpdateTeam(ID portainer.TeamID, team *portainer.Team) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, team) + return internal.UpdateObject(service.connection, BucketName, identifier, team) } // CreateTeam creates a new Team. func (service *Service) CreateTeam(team *portainer.Team) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -125,5 +125,5 @@ func (service *Service) CreateTeam(team *portainer.Team) error { // DeleteTeam deletes a Team. func (service *Service) DeleteTeam(ID portainer.TeamID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/teammembership/teammembership.go b/api/bolt/teammembership/teammembership.go index af60db0d2..752120ea1 100644 --- a/api/bolt/teammembership/teammembership.go +++ b/api/bolt/teammembership/teammembership.go @@ -1,7 +1,7 @@ package teammembership import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" @@ -14,18 +14,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func (service *Service) TeamMembership(ID portainer.TeamMembershipID) (*portaine var membership portainer.TeamMembership identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &membership) + err := internal.GetObject(service.connection, BucketName, identifier, &membership) if err != nil { return nil, err } @@ -46,7 +46,7 @@ func (service *Service) TeamMembership(ID portainer.TeamMembershipID) (*portaine func (service *Service) TeamMemberships() ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -69,7 +69,7 @@ func (service *Service) TeamMemberships() ([]portainer.TeamMembership, error) { func (service *Service) TeamMembershipsByUserID(userID portainer.UserID) ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -95,7 +95,7 @@ func (service *Service) TeamMembershipsByUserID(userID portainer.UserID) ([]port func (service *Service) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]portainer.TeamMembership, error) { var memberships = make([]portainer.TeamMembership, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -120,12 +120,12 @@ func (service *Service) TeamMembershipsByTeamID(teamID portainer.TeamID) ([]port // UpdateTeamMembership saves a TeamMembership object. func (service *Service) UpdateTeamMembership(ID portainer.TeamMembershipID, membership *portainer.TeamMembership) error { identifier := internal.Itob(int(ID)) - return internal.UpdateObject(service.db, BucketName, identifier, membership) + return internal.UpdateObject(service.connection, BucketName, identifier, membership) } // CreateTeamMembership creates a new TeamMembership object. func (service *Service) CreateTeamMembership(membership *portainer.TeamMembership) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -143,12 +143,12 @@ func (service *Service) CreateTeamMembership(membership *portainer.TeamMembershi // DeleteTeamMembership deletes a TeamMembership object. func (service *Service) DeleteTeamMembership(ID portainer.TeamMembershipID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // DeleteTeamMembershipByUserID deletes all the TeamMembership object associated to a UserID. func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -173,7 +173,7 @@ func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) er // DeleteTeamMembershipByTeamID deletes all the TeamMembership object associated to a TeamID. func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() diff --git a/api/bolt/tunnelserver/tunnelserver.go b/api/bolt/tunnelserver/tunnelserver.go index 52ba4c101..a85b098df 100644 --- a/api/bolt/tunnelserver/tunnelserver.go +++ b/api/bolt/tunnelserver/tunnelserver.go @@ -1,10 +1,8 @@ package tunnelserver import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/internal" - - "github.com/boltdb/bolt" ) const ( @@ -15,18 +13,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +32,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) Info() (*portainer.TunnelServerInfo, error) { var info portainer.TunnelServerInfo - err := internal.GetObject(service.db, BucketName, []byte(infoKey), &info) + err := internal.GetObject(service.connection, BucketName, []byte(infoKey), &info) if err != nil { return nil, err } @@ -44,5 +42,5 @@ func (service *Service) Info() (*portainer.TunnelServerInfo, error) { // UpdateInfo persists a TunnelServerInfo object. func (service *Service) UpdateInfo(settings *portainer.TunnelServerInfo) error { - return internal.UpdateObject(service.db, BucketName, []byte(infoKey), settings) + return internal.UpdateObject(service.connection, BucketName, []byte(infoKey), settings) } diff --git a/api/bolt/user/user.go b/api/bolt/user/user.go index de628e8d4..700d2f419 100644 --- a/api/bolt/user/user.go +++ b/api/bolt/user/user.go @@ -17,18 +17,18 @@ const ( // Service represents a service for managing endpoint data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -37,7 +37,7 @@ func (service *Service) User(ID portainer.UserID) (*portainer.User, error) { var user portainer.User identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &user) + err := internal.GetObject(service.connection, BucketName, identifier, &user) if err != nil { return nil, err } @@ -51,7 +51,7 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error) username = strings.ToLower(username) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -81,7 +81,7 @@ func (service *Service) UserByUsername(username string) (*portainer.User, error) func (service *Service) Users() ([]portainer.User, error) { var users = make([]portainer.User, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -103,7 +103,7 @@ func (service *Service) Users() ([]portainer.User, error) { // UsersByRole return an array containing all the users with the specified role. func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { var users = make([]portainer.User, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -128,12 +128,12 @@ func (service *Service) UsersByRole(role portainer.UserRole) ([]portainer.User, func (service *Service) UpdateUser(ID portainer.UserID, user *portainer.User) error { identifier := internal.Itob(int(ID)) user.Username = strings.ToLower(user.Username) - return internal.UpdateObject(service.db, BucketName, identifier, user) + return internal.UpdateObject(service.connection, BucketName, identifier, user) } // CreateUser creates a new user. func (service *Service) CreateUser(user *portainer.User) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() @@ -152,5 +152,5 @@ func (service *Service) CreateUser(user *portainer.User) error { // DeleteUser deletes a user. func (service *Service) DeleteUser(ID portainer.UserID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } diff --git a/api/bolt/version/version.go b/api/bolt/version/version.go index f879d2759..c697173ff 100644 --- a/api/bolt/version/version.go +++ b/api/bolt/version/version.go @@ -19,18 +19,18 @@ const ( // Service represents a service to manage stored versions. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -38,7 +38,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) DBVersion() (int, error) { var data []byte - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) value := bucket.Get([]byte(versionKey)) @@ -75,7 +75,7 @@ func (service *Service) Edition() (portainer.SoftwareEdition, error) { // StoreDBVersion store the database version. func (service *Service) StoreDBVersion(version int) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data := []byte(strconv.Itoa(version)) @@ -87,7 +87,7 @@ func (service *Service) StoreDBVersion(version int) error { func (service *Service) InstanceID() (string, error) { var data []byte - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) value := bucket.Get([]byte(instanceKey)) @@ -109,7 +109,7 @@ func (service *Service) InstanceID() (string, error) { // StoreInstanceID store the instance ID. func (service *Service) StoreInstanceID(ID string) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data := []byte(ID) @@ -120,7 +120,7 @@ func (service *Service) StoreInstanceID(ID string) error { func (service *Service) getKey(key string) ([]byte, error) { var data []byte - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) value := bucket.Get([]byte(key)) @@ -142,7 +142,7 @@ func (service *Service) getKey(key string) ([]byte, error) { } func (service *Service) setKey(key string, value string) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) data := []byte(value) diff --git a/api/bolt/webhook/webhook.go b/api/bolt/webhook/webhook.go index d18900de9..d7514f3e7 100644 --- a/api/bolt/webhook/webhook.go +++ b/api/bolt/webhook/webhook.go @@ -1,7 +1,7 @@ package webhook import ( - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" @@ -15,18 +15,18 @@ const ( // Service represents a service for managing webhook data. type Service struct { - db *bolt.DB + connection *internal.DbConnection } // NewService creates a new instance of a service. -func NewService(db *bolt.DB) (*Service, error) { - err := internal.CreateBucket(db, BucketName) +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) if err != nil { return nil, err } return &Service{ - db: db, + connection: connection, }, nil } @@ -34,7 +34,7 @@ func NewService(db *bolt.DB) (*Service, error) { func (service *Service) Webhooks() ([]portainer.Webhook, error) { var webhooks = make([]portainer.Webhook, 0) - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -58,7 +58,7 @@ func (service *Service) Webhook(ID portainer.WebhookID) (*portainer.Webhook, err var webhook portainer.Webhook identifier := internal.Itob(int(ID)) - err := internal.GetObject(service.db, BucketName, identifier, &webhook) + err := internal.GetObject(service.connection, BucketName, identifier, &webhook) if err != nil { return nil, err } @@ -70,7 +70,7 @@ func (service *Service) Webhook(ID portainer.WebhookID) (*portainer.Webhook, err func (service *Service) WebhookByResourceID(ID string) (*portainer.Webhook, error) { var webhook *portainer.Webhook - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -101,7 +101,7 @@ func (service *Service) WebhookByResourceID(ID string) (*portainer.Webhook, erro func (service *Service) WebhookByToken(token string) (*portainer.Webhook, error) { var webhook *portainer.Webhook - err := service.db.View(func(tx *bolt.Tx) error { + err := service.connection.View(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) cursor := bucket.Cursor() @@ -131,12 +131,12 @@ func (service *Service) WebhookByToken(token string) (*portainer.Webhook, error) // DeleteWebhook deletes a webhook. func (service *Service) DeleteWebhook(ID portainer.WebhookID) error { identifier := internal.Itob(int(ID)) - return internal.DeleteObject(service.db, BucketName, identifier) + return internal.DeleteObject(service.connection, BucketName, identifier) } // CreateWebhook assign an ID to a new webhook and saves it. func (service *Service) CreateWebhook(webhook *portainer.Webhook) error { - return service.db.Update(func(tx *bolt.Tx) error { + return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) id, _ := bucket.NextSequence() diff --git a/api/chisel/service.go b/api/chisel/service.go index e66983222..d5787d9e5 100644 --- a/api/chisel/service.go +++ b/api/chisel/service.go @@ -1,6 +1,7 @@ package chisel import ( + "context" "fmt" "log" "strconv" @@ -9,7 +10,7 @@ import ( "github.com/dchest/uniuri" chserver "github.com/jpillora/chisel/server" cmap "github.com/orcaman/concurrent-map" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" ) @@ -29,13 +30,15 @@ type Service struct { dataStore portainer.DataStore snapshotService portainer.SnapshotService chiselServer *chserver.Server + shutdownCtx context.Context } // NewService returns a pointer to a new instance of Service -func NewService(dataStore portainer.DataStore) *Service { +func NewService(dataStore portainer.DataStore, shutdownCtx context.Context) *Service { return &Service{ tunnelDetailsMap: cmap.New(), dataStore: dataStore, + shutdownCtx: shutdownCtx, } } @@ -83,6 +86,11 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por return nil } +// StopTunnelServer stops tunnel http server +func (service *Service) StopTunnelServer() error { + return service.chiselServer.Close() +} + func (service *Service) retrievePrivateKeySeed() (string, error) { var serverInfo *portainer.TunnelServerInfo @@ -108,13 +116,16 @@ func (service *Service) retrievePrivateKeySeed() (string, error) { func (service *Service) startTunnelVerificationLoop() { log.Printf("[DEBUG] [chisel, monitoring] [check_interval_seconds: %f] [message: starting tunnel management process]", tunnelCleanupInterval.Seconds()) ticker := time.NewTicker(tunnelCleanupInterval) - stopSignal := make(chan struct{}) for { select { case <-ticker.C: service.checkTunnels() - case <-stopSignal: + case <-service.shutdownCtx.Done(): + log.Println("[DEBUG] Shutting down tunnel service") + if err := service.StopTunnelServer(); err != nil { + log.Printf("Stopped tunnel service: %s", err) + } ticker.Stop() return } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index f10100c25..6ae3368d6 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -1,10 +1,10 @@ package main import ( + "context" "log" "os" "strings" - "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" @@ -20,6 +20,7 @@ import ( "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/proxy" kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/snapshot" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" @@ -67,7 +68,7 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port log.Fatalf("failed initializing data store: %v", err) } - err = store.MigrateData() + err = store.MigrateData(false) if err != nil { log.Fatalf("failed migration: %v", err) } @@ -136,11 +137,11 @@ func initKubernetesClientFactory(signatureService portainer.DigitalSignatureServ return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID) } -func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory) (portainer.SnapshotService, error) { +func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) { dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory) kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory) - snapshotService, err := snapshot.NewService(snapshotInterval, dataStore, dockerSnapshotter, kubernetesSnapshotter) + snapshotService, err := snapshot.NewService(snapshotInterval, dataStore, dockerSnapshotter, kubernetesSnapshotter, shutdownCtx) if err != nil { return nil, err } @@ -148,21 +149,6 @@ func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, return snapshotService, nil } -func loadEdgeJobsFromDatabase(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService) error { - edgeJobs, err := dataStore.EdgeJob().EdgeJobs() - if err != nil { - return err - } - - for _, edgeJob := range edgeJobs { - for endpointID := range edgeJob.Endpoints { - reverseTunnelService.AddEdgeJob(endpointID, &edgeJob) - } - } - - return nil -} - func initStatus(flags *portainer.CLIFlags) *portainer.Status { return &portainer.Status{ Version: portainer.APIVersion, @@ -353,28 +339,12 @@ func initEndpoint(flags *portainer.CLIFlags, dataStore portainer.DataStore, snap return createUnsecuredEndpoint(*flags.EndpointURL, dataStore, snapshotService) } -func terminateIfNoAdminCreated(dataStore portainer.DataStore) { - timer1 := time.NewTimer(5 * time.Minute) - <-timer1.C - - users, err := dataStore.User().UsersByRole(portainer.AdministratorRole) - if err != nil { - log.Fatalf("failed getting admin user: %v", err) - } - - if len(users) == 0 { - log.Fatal("No administrator account was created after 5 min. Shutting down the Portainer instance for security reasons.") - return - } -} - -func main() { - flags := initCLI() +func buildServer(flags *portainer.CLIFlags) portainer.Server { + shutdownCtx, shutdownTrigger := context.WithCancel(context.Background()) fileService := initFileService(*flags.Data) dataStore := initDataStore(*flags.Data, fileService) - defer dataStore.Close() if err := dataStore.CheckCurrentEdition(); err != nil { log.Fatal(err) @@ -400,7 +370,7 @@ func main() { log.Fatalf("failed initializing key pai: %v", err) } - reverseTunnelService := chisel.NewService(dataStore) + reverseTunnelService := chisel.NewService(dataStore, shutdownCtx) instanceID, err := dataStore.Version().InstanceID() if err != nil { @@ -410,7 +380,7 @@ func main() { dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService) kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID) - snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory) + snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx) if err != nil { log.Fatalf("failed initializing snapshot service: %v", err) } @@ -434,7 +404,7 @@ func main() { } } - err = loadEdgeJobsFromDatabase(dataStore, reverseTunnelService) + err = edge.LoadEdgeJobs(dataStore, reverseTunnelService) if err != nil { log.Fatalf("failed loading edge jobs from database: %v", err) } @@ -482,14 +452,12 @@ func main() { } } - go terminateIfNoAdminCreated(dataStore) - err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService) if err != nil { - log.Fatalf("failed starting tunnel server: %v", err) + log.Fatalf("failed starting license service: %s", err) } - var server portainer.Server = &http.Server{ + return &http.Server{ ReverseTunnelService: reverseTunnelService, Status: applicationStatus, BindAddress: *flags.Addr, @@ -513,11 +481,18 @@ func main() { SSLKey: *flags.SSLKey, DockerClientFactory: dockerClientFactory, KubernetesClientFactory: kubernetesClientFactory, - } - - log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) - err = server.Start() - if err != nil { - log.Fatalf("failed starting server: %v", err) + ShutdownCtx: shutdownCtx, + ShutdownTrigger: shutdownTrigger, + } +} + +func main() { + flags := initCLI() + + for { + server := buildServer(flags) + log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr) + err := server.Start() + log.Printf("Http server exited: %s\n", err) } } diff --git a/api/crypto/aes.go b/api/crypto/aes.go new file mode 100644 index 000000000..5a6f58fe7 --- /dev/null +++ b/api/crypto/aes.go @@ -0,0 +1,70 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "io" + + "golang.org/x/crypto/scrypt" +) + +// NOTE: has to go with what is considered to be a simplistic in that it omits any +// authentication of the encrypted data. +// Person with better knowledge is welcomed to improve it. +// sourced from https://golang.org/src/crypto/cipher/example_test.go + +var emptySalt []byte = make([]byte, 0, 0) + +// AesEncrypt reads from input, encrypts with AES-256 and writes to the output. +// passphrase is used to generate an encryption key. +func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { + // making a 32 bytes key that would correspond to AES-256 + // don't necessarily need a salt, so just kept in empty + key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32) + if err != nil { + return err + } + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + // If the key is unique for each ciphertext, then it's ok to use a zero + // IV. + var iv [aes.BlockSize]byte + stream := cipher.NewOFB(block, iv[:]) + + writer := &cipher.StreamWriter{S: stream, W: output} + // Copy the input to the output, encrypting as we go. + if _, err := io.Copy(writer, input); err != nil { + return err + } + + return nil +} + +// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from. +// passphrase is used to generate an encryption key. +func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) { + // making a 32 bytes key that would correspond to AES-256 + // don't necessarily need a salt, so just kept in empty + key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // If the key is unique for each ciphertext, then it's ok to use a zero + // IV. + var iv [aes.BlockSize]byte + stream := cipher.NewOFB(block, iv[:]) + + reader := &cipher.StreamReader{S: stream, R: input} + + return reader, nil +} diff --git a/api/crypto/aes_test.go b/api/crypto/aes_test.go new file mode 100644 index 000000000..d2c86f206 --- /dev/null +++ b/api/crypto/aes_test.go @@ -0,0 +1,131 @@ +package crypto + +import ( + "io" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "encrypt") + defer os.RemoveAll(tmpdir) + + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) + + content := []byte("content") + ioutil.WriteFile(originFilePath, content, 0600) + + originFile, _ := os.Open(originFilePath) + defer originFile.Close() + + encryptedFileWriter, _ := os.Create(encryptedFilePath) + defer encryptedFileWriter.Close() + + err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedContent, err := ioutil.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() + + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() + + decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase")) + assert.Nil(t, err, "Failed to decrypt file") + + io.Copy(decryptedFileWriter, decryptedReader) + + decryptedContent, _ := ioutil.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") +} + +func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "encrypt") + defer os.RemoveAll(tmpdir) + + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) + + content := []byte("content") + ioutil.WriteFile(originFilePath, content, 0600) + + originFile, _ := os.Open(originFilePath) + defer originFile.Close() + + encryptedFileWriter, _ := os.Create(encryptedFilePath) + defer encryptedFileWriter.Close() + + err := AesEncrypt(originFile, encryptedFileWriter, []byte("")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedContent, err := ioutil.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() + + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() + + decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("")) + assert.Nil(t, err, "Failed to decrypt file") + + io.Copy(decryptedFileWriter, decryptedReader) + + decryptedContent, _ := ioutil.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") +} + +func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) { + tmpdir, _ := os.MkdirTemp("", "encrypt") + defer os.RemoveAll(tmpdir) + + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) + + content := []byte("content") + ioutil.WriteFile(originFilePath, content, 0600) + + originFile, _ := os.Open(originFilePath) + defer originFile.Close() + + encryptedFileWriter, _ := os.Create(encryptedFilePath) + defer encryptedFileWriter.Close() + + err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedContent, err := ioutil.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() + + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() + + decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage")) + assert.Nil(t, err, "Should allow to decrypt with wrong passphrase") + + io.Copy(decryptedFileWriter, decryptedReader) + + decryptedContent, _ := ioutil.ReadFile(decryptedFilePath) + assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match") +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 5a766f7cc..62dac019d 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -9,7 +9,7 @@ import ( "io/ioutil" "github.com/gofrs/uuid" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "io" "os" @@ -505,3 +505,8 @@ func (service *Service) GetTemporaryPath() (string, error) { return path.Join(service.fileStorePath, TempPath, uid.String()), nil } + +// GetDataStorePath returns path to data folder +func (service *Service) GetDatastorePath() string { + return service.dataStorePath +} diff --git a/api/go.mod b/api/go.mod index f8cca5616..0b9a01bd7 100644 --- a/api/go.mod +++ b/api/go.mod @@ -25,6 +25,7 @@ require ( github.com/mattn/go-shellwords v1.0.6 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 + github.com/pkg/errors v0.9.1 github.com/portainer/libcompose v0.5.3 github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 diff --git a/api/go.sum b/api/go.sum index 237ef4cd2..6c69c89d4 100644 --- a/api/go.sum +++ b/api/go.sum @@ -223,6 +223,8 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/api/http/handler/backup/backup.go b/api/http/handler/backup/backup.go new file mode 100644 index 000000000..018dd2d34 --- /dev/null +++ b/api/http/handler/backup/backup.go @@ -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 +} diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go new file mode 100644 index 000000000..fa9ccc3d8 --- /dev/null +++ b/api/http/handler/backup/backup_test.go @@ -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")) +} diff --git a/api/http/handler/backup/handler.go b/api/http/handler/backup/handler.go new file mode 100644 index 000000000..489634675 --- /dev/null +++ b/api/http/handler/backup/handler.go @@ -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 +} diff --git a/api/http/handler/backup/restore.go b/api/http/handler/backup/restore.go new file mode 100644 index 000000000..db08e5335 --- /dev/null +++ b/api/http/handler/backup/restore.go @@ -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 +} diff --git a/api/http/handler/backup/restore_test.go b/api/http/handler/backup/restore_test.go new file mode 100644 index 000000000..bf9617248 --- /dev/null +++ b/api/http/handler/backup/restore_test.go @@ -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 +} diff --git a/api/http/handler/backup/test_assets/handler_test/extra_file b/api/http/handler/backup/test_assets/handler_test/extra_file new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/extra_file @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 b/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/extra_folder/file1 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/portainer.db b/api/http/handler/backup/test_assets/handler_test/portainer.db new file mode 100644 index 000000000..e69de29bb diff --git a/api/http/handler/backup/test_assets/handler_test/portainer.key b/api/http/handler/backup/test_assets/handler_test/portainer.key new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/portainer.key @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/portainer.pub b/api/http/handler/backup/test_assets/handler_test/portainer.pub new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/portainer.pub @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/tls/file1 b/api/http/handler/backup/test_assets/handler_test/tls/file1 new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/tls/file1 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/backup/test_assets/handler_test/tls/file2 b/api/http/handler/backup/test_assets/handler_test/tls/file2 new file mode 100644 index 000000000..d95f3ad14 --- /dev/null +++ b/api/http/handler/backup/test_assets/handler_test/tls/file2 @@ -0,0 +1 @@ +content diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 05f693aa3..2942c3a17 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -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"): diff --git a/api/http/offlinegate/offlinegate.go b/api/http/offlinegate/offlinegate.go new file mode 100644 index 000000000..e3614f451 --- /dev/null +++ b/api/http/offlinegate/offlinegate.go @@ -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) + + }) +} diff --git a/api/http/offlinegate/offlinegate_test.go b/api/http/offlinegate/offlinegate_test.go new file mode 100644 index 000000000..c63bf68f8 --- /dev/null +++ b/api/http/offlinegate/offlinegate_test.go @@ -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") +} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index ac55555d3..5c57314af 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -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. diff --git a/api/http/server.go b/api/http/server.go index ad7826087..4e0e8c775 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -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) + } +} diff --git a/api/internal/edge/edgejob.go b/api/internal/edge/edgejob.go new file mode 100644 index 000000000..bbd55b8e1 --- /dev/null +++ b/api/internal/edge/edgejob.go @@ -0,0 +1,19 @@ +package edge + +import portainer "github.com/portainer/portainer/api" + +// LoadEdgeJobs registers all edge jobs inside corresponding endpoint tunnel +func LoadEdgeJobs(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService) error { + edgeJobs, err := dataStore.EdgeJob().EdgeJobs() + if err != nil { + return err + } + + for _, edgeJob := range edgeJobs { + for endpointID := range edgeJob.Endpoints { + reverseTunnelService.AddEdgeJob(endpointID, &edgeJob) + } + } + + return nil +} diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go index 2c2e5ef34..31b17acda 100644 --- a/api/internal/snapshot/snapshot.go +++ b/api/internal/snapshot/snapshot.go @@ -1,6 +1,7 @@ package snapshot import ( + "context" "log" "time" @@ -16,10 +17,11 @@ type Service struct { snapshotIntervalInSeconds float64 dockerSnapshotter portainer.DockerSnapshotter kubernetesSnapshotter portainer.KubernetesSnapshotter + shutdownCtx context.Context } // NewService creates a new instance of a service -func NewService(snapshotInterval string, dataStore portainer.DataStore, dockerSnapshotter portainer.DockerSnapshotter, kubernetesSnapshotter portainer.KubernetesSnapshotter) (*Service, error) { +func NewService(snapshotInterval string, dataStore portainer.DataStore, dockerSnapshotter portainer.DockerSnapshotter, kubernetesSnapshotter portainer.KubernetesSnapshotter, shutdownCtx context.Context) (*Service, error) { snapshotFrequency, err := time.ParseDuration(snapshotInterval) if err != nil { return nil, err @@ -30,6 +32,7 @@ func NewService(snapshotInterval string, dataStore portainer.DataStore, dockerSn snapshotIntervalInSeconds: snapshotFrequency.Seconds(), dockerSnapshotter: dockerSnapshotter, kubernetesSnapshotter: kubernetesSnapshotter, + shutdownCtx: shutdownCtx, }, nil } @@ -43,7 +46,7 @@ func (service *Service) Start() { service.startSnapshotLoop() } -func (service *Service) stop() { +func (service *Service) Stop() { if service.refreshSignal == nil { return } @@ -55,7 +58,7 @@ func (service *Service) stop() { // SetSnapshotInterval sets the snapshot interval and resets the service func (service *Service) SetSnapshotInterval(snapshotInterval string) error { - service.stop() + service.Stop() snapshotFrequency, err := time.ParseDuration(snapshotInterval) if err != nil { @@ -132,9 +135,12 @@ func (service *Service) startSnapshotLoop() error { if err != nil { log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err) } - + case <-service.shutdownCtx.Done(): + log.Println("[DEBUG] [internal,snapshot] [message: shutting down snapshotting]") + ticker.Stop() + return case <-service.refreshSignal: - log.Println("[DEBUG] [internal,snapshot] [message: shutting down Snapshot service]") + log.Println("[DEBUG] [internal,snapshot] [message: shutting down snapshotting]") ticker.Stop() return } diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go new file mode 100644 index 000000000..a58da5c7c --- /dev/null +++ b/api/internal/testhelpers/datastore.go @@ -0,0 +1,114 @@ +package testhelpers + +import ( + "io" + + portainer "github.com/portainer/portainer/api" +) + +type datastore struct { + dockerHub portainer.DockerHubService + customTemplate portainer.CustomTemplateService + edgeGroup portainer.EdgeGroupService + edgeJob portainer.EdgeJobService + edgeStack portainer.EdgeStackService + endpoint portainer.EndpointService + endpointGroup portainer.EndpointGroupService + endpointRelation portainer.EndpointRelationService + registry portainer.RegistryService + resourceControl portainer.ResourceControlService + role portainer.RoleService + settings portainer.SettingsService + stack portainer.StackService + tag portainer.TagService + teamMembership portainer.TeamMembershipService + team portainer.TeamService + tunnelServer portainer.TunnelServerService + user portainer.UserService + version portainer.VersionService + webhook portainer.WebhookService +} + +func (d *datastore) BackupTo(io.Writer) error { return nil } +func (d *datastore) Open() error { return nil } +func (d *datastore) Init() error { return nil } +func (d *datastore) Close() error { return nil } +func (d *datastore) CheckCurrentEdition() error { return nil } +func (d *datastore) IsNew() bool { return false } +func (d *datastore) MigrateData(force bool) error { return nil } +func (d *datastore) RollbackToCE() error { return nil } +func (d *datastore) DockerHub() portainer.DockerHubService { return d.dockerHub } +func (d *datastore) CustomTemplate() portainer.CustomTemplateService { return d.customTemplate } +func (d *datastore) EdgeGroup() portainer.EdgeGroupService { return d.edgeGroup } +func (d *datastore) EdgeJob() portainer.EdgeJobService { return d.edgeJob } +func (d *datastore) EdgeStack() portainer.EdgeStackService { return d.edgeStack } +func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint } +func (d *datastore) EndpointGroup() portainer.EndpointGroupService { return d.endpointGroup } +func (d *datastore) EndpointRelation() portainer.EndpointRelationService { return d.endpointRelation } +func (d *datastore) Registry() portainer.RegistryService { return d.registry } +func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } +func (d *datastore) Role() portainer.RoleService { return d.role } +func (d *datastore) Settings() portainer.SettingsService { return d.settings } +func (d *datastore) Stack() portainer.StackService { return d.stack } +func (d *datastore) Tag() portainer.TagService { return d.tag } +func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } +func (d *datastore) Team() portainer.TeamService { return d.team } +func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } +func (d *datastore) User() portainer.UserService { return d.user } +func (d *datastore) Version() portainer.VersionService { return d.version } +func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } + +type datastoreOption = func(d *datastore) + +// NewDatastore creates new instance of datastore. +// Will apply options before returning, opts will be applied from left to right. +func NewDatastore(options ...datastoreOption) *datastore { + d := datastore{} + for _, o := range options { + o(&d) + } + return &d +} + +type stubUserService struct { + users []portainer.User +} + +func (s *stubUserService) User(ID portainer.UserID) (*portainer.User, error) { return nil, nil } +func (s *stubUserService) UserByUsername(username string) (*portainer.User, error) { return nil, nil } +func (s *stubUserService) Users() ([]portainer.User, error) { return s.users, nil } +func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { + return s.users, nil +} +func (s *stubUserService) CreateUser(user *portainer.User) error { return nil } +func (s *stubUserService) UpdateUser(ID portainer.UserID, user *portainer.User) error { return nil } +func (s *stubUserService) DeleteUser(ID portainer.UserID) error { return nil } + +// WithUsers datastore option that will instruct datastore to return provided users +func WithUsers(us []portainer.User) datastoreOption { + return func(d *datastore) { + d.user = &stubUserService{users: us} + } +} + +type stubEdgeJobService struct { + jobs []portainer.EdgeJob +} + +func (s *stubEdgeJobService) EdgeJobs() ([]portainer.EdgeJob, error) { return s.jobs, nil } +func (s *stubEdgeJobService) EdgeJob(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) { + return nil, nil +} +func (s *stubEdgeJobService) CreateEdgeJob(edgeJob *portainer.EdgeJob) error { return nil } +func (s *stubEdgeJobService) UpdateEdgeJob(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error { + return nil +} +func (s *stubEdgeJobService) DeleteEdgeJob(ID portainer.EdgeJobID) error { return nil } +func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 } + +// WithEdgeJobs option will instruct datastore to return provided jobs +func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { + return func(d *datastore) { + d.edgeJob = &stubEdgeJobService{jobs: js} + } +} diff --git a/api/internal/testhelpers/reverse_tunnel_service.go b/api/internal/testhelpers/reverse_tunnel_service.go new file mode 100644 index 000000000..0dbc19d19 --- /dev/null +++ b/api/internal/testhelpers/reverse_tunnel_service.go @@ -0,0 +1,23 @@ +package testhelpers + +import portainer "github.com/portainer/portainer/api" + +type ReverseTunnelService struct{} + +func (r ReverseTunnelService) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error { + return nil +} +func (r ReverseTunnelService) GenerateEdgeKey(url, host string, endpointIdentifier int) string { + return "nil" +} +func (r ReverseTunnelService) SetTunnelStatusToActive(endpointID portainer.EndpointID) {} +func (r ReverseTunnelService) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error { + return nil +} +func (r ReverseTunnelService) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {} +func (r ReverseTunnelService) GetTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails { + return nil +} +func (r ReverseTunnelService) AddEdgeJob(endpointID portainer.EndpointID, edgeJob *portainer.EdgeJob) { +} +func (r ReverseTunnelService) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {} diff --git a/api/portainer.go b/api/portainer.go index 1647e0846..c9248a688 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -381,8 +381,8 @@ type ( // QuayRegistryData represents data required for Quay registry to work QuayRegistryData struct { - UseOrganisation bool `json:"UseOrganisation"` - OrganisationName string `json:"OrganisationName"` + UseOrganisation bool `json:"UseOrganisation"` + OrganisationName string `json:"OrganisationName"` } // JobType represents a job type @@ -1000,8 +1000,9 @@ type ( Init() error Close() error IsNew() bool - MigrateData() error + MigrateData(force bool) error CheckCurrentEdition() error + BackupTo(w io.Writer) error DockerHub() DockerHubService CustomTemplate() CustomTemplateService @@ -1130,6 +1131,7 @@ type ( StoreCustomTemplateFileFromBytes(identifier, fileName string, data []byte) (string, error) GetCustomTemplateProjectPath(identifier string) string GetTemporaryPath() (string, error) + GetDatastorePath() string } // GitService represents a service for managing Git @@ -1196,6 +1198,7 @@ type ( // ReverseTunnelService represents a service used to manage reverse tunnel connections. ReverseTunnelService interface { StartTunnelServer(addr, port string, snapshotService SnapshotService) error + StopTunnelServer() error GenerateEdgeKey(url, host string, endpointIdentifier int) string SetTunnelStatusToActive(endpointID EndpointID) SetTunnelStatusToRequired(endpointID EndpointID) error @@ -1238,6 +1241,7 @@ type ( // SnapshotService represents a service for managing endpoint snapshots SnapshotService interface { Start() + Stop() SetSnapshotInterval(snapshotInterval string) error SnapshotEndpoint(endpoint *Endpoint) error } From 6d8f5e7479f7da05c4d688c640a4a23427724975 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Wed, 7 Apr 2021 12:12:19 +1200 Subject: [PATCH 2/5] go 1.13 compatibility --- api/archive/targz_test.go | 9 +++++---- api/backup/backup.go | 3 +-- api/backup/copy_test.go | 13 +++++++------ api/crypto/aes_test.go | 7 ++++--- api/http/handler/backup/backup_test.go | 5 +++-- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/api/archive/targz_test.go b/api/archive/targz_test.go index f91482b81..ed8a67543 100644 --- a/api/archive/targz_test.go +++ b/api/archive/targz_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/docker/docker/pkg/ioutils" "github.com/stretchr/testify/assert" ) @@ -26,7 +27,7 @@ func listFiles(dir string) []string { } func Test_shouldCreateArhive(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) content := []byte("content") @@ -39,7 +40,7 @@ func Test_shouldCreateArhive(t *testing.T) { assert.Nil(t, err) assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath) - extractionDir, _ := os.MkdirTemp("", "extract") + extractionDir, _ := ioutils.TempDir("", "extract") defer os.RemoveAll(extractionDir) cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir) @@ -62,7 +63,7 @@ func Test_shouldCreateArhive(t *testing.T) { } func Test_shouldCreateArhiveXXXXX(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) content := []byte("content") @@ -75,7 +76,7 @@ func Test_shouldCreateArhiveXXXXX(t *testing.T) { assert.Nil(t, err) assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath) - extractionDir, _ := os.MkdirTemp("", "extract") + extractionDir, _ := ioutils.TempDir("", "extract") defer os.RemoveAll(extractionDir) r, _ := os.Open(gzPath) diff --git a/api/backup/backup.go b/api/backup/backup.go index 8c31a76f9..3da032c2c 100644 --- a/api/backup/backup.go +++ b/api/backup/backup.go @@ -2,7 +2,6 @@ package backup import ( "fmt" - "io/fs" "os" "path/filepath" "time" @@ -14,7 +13,7 @@ import ( "github.com/portainer/portainer/api/http/offlinegate" ) -const rwxr__r__ fs.FileMode = 0744 +const rwxr__r__ os.FileMode = 0744 var filesToBackup = []string{"compose", "config.json", "custom_templates", "edge_jobs", "edge_stacks", "extensions", "portainer.key", "portainer.pub", "tls"} diff --git a/api/backup/copy_test.go b/api/backup/copy_test.go index 171313181..b9ceaeaab 100644 --- a/api/backup/copy_test.go +++ b/api/backup/copy_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/docker/docker/pkg/ioutils" "github.com/stretchr/testify/assert" ) @@ -30,7 +31,7 @@ func contains(t *testing.T, list []string, path string) { } func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) err := copyFile("does-not-exist", tmpdir) @@ -38,7 +39,7 @@ func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { } func Test_copyFile_shouldMakeAbackup(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) content := []byte("content") @@ -52,7 +53,7 @@ func Test_copyFile_shouldMakeAbackup(t *testing.T) { } func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { - destination, _ := os.MkdirTemp("", "destination") + destination, _ := ioutils.TempDir("", "destination") defer os.RemoveAll(destination) err := copyDir("./test_assets/copy_test", destination) assert.Nil(t, err) @@ -65,7 +66,7 @@ func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { } func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) err := copyPath("does-not-exists", tmpdir) @@ -75,7 +76,7 @@ func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) { } func Test_backupPath_shouldCopyFile(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) content := []byte("content") @@ -91,7 +92,7 @@ func Test_backupPath_shouldCopyFile(t *testing.T) { } func Test_backupPath_shouldCopyDir(t *testing.T) { - destination, _ := os.MkdirTemp("", "destination") + destination, _ := ioutils.TempDir("", "destination") defer os.RemoveAll(destination) err := copyPath("./test_assets/copy_test", destination) assert.Nil(t, err) diff --git a/api/crypto/aes_test.go b/api/crypto/aes_test.go index d2c86f206..1a2e377ac 100644 --- a/api/crypto/aes_test.go +++ b/api/crypto/aes_test.go @@ -7,11 +7,12 @@ import ( "path/filepath" "testing" + "github.com/docker/docker/pkg/ioutils" "github.com/stretchr/testify/assert" ) func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "encrypt") + tmpdir, _ := ioutils.TempDir("", "encrypt") defer os.RemoveAll(tmpdir) var ( @@ -51,7 +52,7 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { } func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "encrypt") + tmpdir, _ := ioutils.TempDir("", "encrypt") defer os.RemoveAll(tmpdir) var ( @@ -91,7 +92,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) { } func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) { - tmpdir, _ := os.MkdirTemp("", "encrypt") + tmpdir, _ := ioutils.TempDir("", "encrypt") defer os.RemoveAll(tmpdir) var ( diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go index fa9ccc3d8..40c7a01bc 100644 --- a/api/http/handler/backup/backup_test.go +++ b/api/http/handler/backup/backup_test.go @@ -15,6 +15,7 @@ import ( "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" @@ -54,7 +55,7 @@ func Test_backupHandlerWithoutPassword_shouldCreateATarballArchive(t *testing.T) response := w.Result() body, _ := io.ReadAll(response.Body) - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) archivePath := filepath.Join(tmpdir, "archive.tar.gz") @@ -91,7 +92,7 @@ func Test_backupHandlerWithPassword_shouldCreateEncryptedATarballArchive(t *test response := w.Result() body, _ := io.ReadAll(response.Body) - tmpdir, _ := os.MkdirTemp("", "backup") + tmpdir, _ := ioutils.TempDir("", "backup") defer os.RemoveAll(tmpdir) dr, err := crypto.AesDecrypt(bytes.NewReader(body), []byte("secret")) From fc9511dc9741a4f156cfb09862b4b049f622cab3 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Wed, 7 Apr 2021 13:21:58 +1200 Subject: [PATCH 3/5] UI --- app/constants.js | 1 + app/portainer/rest/backup.js | 27 +++++ app/portainer/services/api/backupService.js | 90 +++++++++++++++ app/portainer/services/fileUpload.js | 10 ++ app/portainer/views/init/admin/initAdmin.html | 104 +++++++++++++++++- .../views/init/admin/initAdminController.js | 49 +++++++-- app/portainer/views/settings/settings.html | 53 +++++++++ .../views/settings/settingsController.js | 31 +++++- 8 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 app/portainer/rest/backup.js create mode 100644 app/portainer/services/api/backupService.js diff --git a/app/constants.js b/app/constants.js index 547c7d518..cb0e8f17f 100644 --- a/app/constants.js +++ b/app/constants.js @@ -22,6 +22,7 @@ angular .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') .constant('API_ENDPOINT_WEBHOOKS', 'api/webhooks') + .constant('API_ENDPOINT_BACKUP', 'api/backup') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') .constant('PAGINATION_MAX_ITEMS', 10) .constant('APPLICATION_CACHE_VALIDITY', 3600) diff --git a/app/portainer/rest/backup.js b/app/portainer/rest/backup.js new file mode 100644 index 000000000..50526c5b0 --- /dev/null +++ b/app/portainer/rest/backup.js @@ -0,0 +1,27 @@ +angular.module('portainer.app').factory('Backup', [ + '$resource', + 'API_ENDPOINT_BACKUP', + function BackupFactory($resource, API_ENDPOINT_BACKUP) { + 'use strict'; + return $resource( + API_ENDPOINT_BACKUP + '/:subResource/:action', + {}, + { + download: { + method: 'POST', + responseType: 'blob', + ignoreLoadingBar: true, + transformResponse: (data, headersGetter) => ({ + file: data, + name: headersGetter('Content-Disposition').replace('attachment; filename=', ''), + }), + }, + getS3Settings: { method: 'GET', params: { subResource: 's3', action: 'settings' } }, + saveS3Settings: { method: 'POST', params: { subResource: 's3', action: 'settings' } }, + exportS3Backup: { method: 'POST', params: { subResource: 's3', action: 'execute' } }, + restoreS3Backup: { method: 'POST', params: { subResource: 's3', action: 'restore' } }, + getBackupStatus: { method: 'GET', params: { subResource: 's3', action: 'status' } }, + } + ); + }, +]); diff --git a/app/portainer/services/api/backupService.js b/app/portainer/services/api/backupService.js new file mode 100644 index 000000000..1ff04cda0 --- /dev/null +++ b/app/portainer/services/api/backupService.js @@ -0,0 +1,90 @@ +angular.module('portainer.app').factory('BackupService', [ + '$q', + '$async', + 'Backup', + 'FileUploadService', + function BackupServiceFactory($q, $async, Backup, FileUploadService) { + 'use strict'; + const service = {}; + + service.downloadBackup = function (payload) { + return Backup.download({}, payload).$promise; + }; + + service.uploadBackup = function (file, password) { + return FileUploadService.uploadBackup(file, password); + }; + + service.getS3Settings = function () { + var deferred = $q.defer(); + + Backup.getS3Settings() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.saveS3Settings = function (payload) { + var deferred = $q.defer(); + + Backup.saveS3Settings({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to save backup S3 settings', err: err }); + }); + + return deferred.promise; + }; + + service.exportBackup = function (payload) { + var deferred = $q.defer(); + + Backup.exportS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to export backup', err: err }); + }); + + return deferred.promise; + }; + + service.restoreFromS3 = function (payload) { + var deferred = $q.defer(); + + Backup.restoreS3Backup({}, payload) + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to restore backup from S3', err: err }); + }); + + return deferred.promise; + }; + + service.getBackupStatus = function () { + var deferred = $q.defer(); + + Backup.getBackupStatus() + .$promise.then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve backup status', err: err }); + }); + + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index a9df4606a..81340d623 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -61,6 +61,16 @@ angular.module('portainer.app').factory('FileUploadService', [ }); }; + service.uploadBackup = function (file, password) { + return Upload.upload({ + url: 'api/restore', + data: { + file, + password, + }, + }); + }; + service.createSwarmStack = function (stackName, swarmId, file, env, endpointId) { return Upload.upload({ url: 'api/stacks?method=file&type=1&endpointId=' + endpointId, diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index b3643ea58..72921d19e 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -11,8 +11,17 @@
+ + + + -
+
@@ -98,6 +107,99 @@
+ + +
+
+ + + + + + + +
+
+ + This will restore the Portainer metadata which contains information about the endpoints, stacks and applications, as well as the configured users. + +
+
+ + +
+
+ + You can upload a backup file from your computer. + +
+
+ + +
+
+ + + {{ formValues.BackupFile.name }} + + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + You are about to restore Portainer from this backup. + +
+
+ + After restoring has completed, please log in as a user that was known by the Portainer that was restored. + +
+
+ + +
+
+ +
+
+ + + +
+
+
diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 63bd27d02..c8aac975b 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -8,7 +8,9 @@ angular.module('portainer.app').controller('InitAdminController', [ 'SettingsService', 'UserService', 'EndpointService', - function ($async, $scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService) { + 'BackupService', + 'StatusService', + function ($async, $scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, EndpointService, BackupService, StatusService) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -20,6 +22,13 @@ angular.module('portainer.app').controller('InitAdminController', [ $scope.state = { actionInProgress: false, + showInitPassword: true, + showRestorePortainer: false, + }; + + $scope.togglePanel = function () { + $scope.state.showInitPassword = !$scope.state.showInitPassword; + $scope.state.showRestorePortainer = !$scope.state.showRestorePortainer; }; $scope.createAdminUser = function () { @@ -55,17 +64,35 @@ angular.module('portainer.app').controller('InitAdminController', [ }); }; - function createAdministratorFlow() { - UserService.administratorExists() - .then(function success(exists) { - if (exists) { - $state.go('portainer.home'); + async function waitPortainerRestart() { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 5 * 1000)); + try { + const status = await StatusService.status(); + if (status && status.Version) { + return; } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to verify administrator account existence'); - }); + } catch (e) {} + } + throw 'Timeout to wait for Portainer restarting'; } - createAdministratorFlow(); + + $scope.uploadBackup = async function () { + $scope.state.backupInProgress = true; + + const file = $scope.formValues.BackupFile; + const password = $scope.formValues.Password; + + try { + await BackupService.uploadBackup(file, password); + await waitPortainerRestart(); + Notifications.success('The backup has successfully been restored'); + $state.go('portainer.auth'); + } catch (err) { + Notifications.error('Failure', err, 'Unable to restore the backup'); + } finally { + $scope.state.backupInProgress = false; + } + }; }, ]); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index bad170142..0b6c023fc 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -191,3 +191,56 @@ + +
+
+ + + +
+ +
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 86b352ced..a5438ce10 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -4,7 +4,10 @@ angular.module('portainer.app').controller('SettingsController', [ 'Notifications', 'SettingsService', 'StateManager', - function ($scope, $state, Notifications, SettingsService, StateManager) { + 'BackupService', + 'FileSaver', + 'Blob', + function ($scope, $state, Notifications, SettingsService, StateManager, BackupService, FileSaver) { $scope.state = { actionInProgress: false, availableEdgeAgentCheckinOptions: [ @@ -21,6 +24,8 @@ angular.module('portainer.app').controller('SettingsController', [ value: 30, }, ], + + backupInProgress: false, }; $scope.formValues = { @@ -29,6 +34,8 @@ angular.module('portainer.app').controller('SettingsController', [ labelValue: '', enableEdgeComputeFeatures: false, enableTelemetry: false, + passwordProtect: false, + password: '', }; $scope.removeFilteredContainerLabel = function (index) { @@ -49,6 +56,28 @@ angular.module('portainer.app').controller('SettingsController', [ updateSettings(settings); }; + $scope.downloadBackup = function () { + const payload = {}; + if ($scope.formValues.passwordProtect) { + payload.password = $scope.formValues.password; + } + + $scope.state.backupInProgress = true; + + BackupService.downloadBackup(payload) + .then(function success(data) { + const downloadData = new Blob([data.file], { type: 'application/gzip' }); + FileSaver.saveAs(downloadData, data.name); + Notifications.success('Backup successfully downloaded'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to download backup'); + }) + .finally(function final() { + $scope.state.backupInProgress = false; + }); + }; + $scope.saveApplicationSettings = function () { var settings = $scope.settings; From 8bf662c13affe9ed738ea646b71d1a11cdef2064 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Wed, 7 Apr 2021 16:49:27 +1200 Subject: [PATCH 4/5] that shouldn't be removed --- .../views/init/admin/initAdminController.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index c8aac975b..8872bcfe1 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -64,6 +64,19 @@ angular.module('portainer.app').controller('InitAdminController', [ }); }; + function createAdministratorFlow() { + UserService.administratorExists() + .then(function success(exists) { + if (exists) { + $state.go('portainer.home'); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify administrator account existence'); + }); + } + createAdministratorFlow(); + async function waitPortainerRestart() { for (let i = 0; i < 10; i++) { await new Promise((resolve) => setTimeout(resolve, 5 * 1000)); From 0aec8fd4233a8c36a13568c6d64552ca86ab0bf1 Mon Sep 17 00:00:00 2001 From: fhanportainer <79428273+fhanportainer@users.noreply.github.com> Date: Thu, 8 Apr 2021 13:32:59 +1200 Subject: [PATCH 5/5] EE-379: add S3 stubs to CE (#4967) --- app/portainer/views/init/admin/initAdmin.html | 24 ++++++++++++++ app/portainer/views/settings/settings.html | 31 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index 72921d19e..5e286abfc 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -131,6 +131,30 @@ +
+
+
+ + +
+
+ + +
+
+
diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 0b6c023fc..c2ca0d340 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -198,6 +198,37 @@
+
+ Backup configuration +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ Security settings +