hermes/backend/api/backup.go

355 lines
9.7 KiB
Go

package main
import (
"compress/gzip"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"git.terah.dev/imterah/hermes/api/dbcore"
"github.com/charmbracelet/log"
"github.com/go-playground/validator/v10"
_ "github.com/jackc/pgx/v5"
"github.com/urfave/cli/v2"
"gorm.io/gorm"
)
// Data structures
type BackupBackend struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Description *string `json:"description"`
Backend string `json:"backend" validate:"required"`
BackendParameters string `json:"connectionDetails" validate:"required"`
}
type BackupProxy struct {
ID uint `json:"id" validate:"required"`
BackendID uint `json:"destProviderID" validate:"required"`
Name string `json:"name" validate:"required"`
Description *string `json:"description"`
Protocol string `json:"protocol" validate:"required"`
SourceIP string `json:"sourceIP" validate:"required"`
SourcePort uint16 `json:"sourcePort" validate:"required"`
DestinationPort uint16 `json:"destPort" validate:"required"`
AutoStart bool `json:"enabled" validate:"required"`
}
type BackupPermission struct {
ID uint `json:"id" validate:"required"`
PermissionNode string `json:"permission" validate:"required"`
HasPermission bool `json:"has" validate:"required"`
UserID uint `json:"userID" validate:"required"`
}
type BackupUser struct {
ID uint `json:"id" validate:"required"`
Email string `json:"email" validate:"required"`
Username *string `json:"username"`
Name string `json:"name" validate:"required"`
Password string `json:"password" validate:"required"`
IsBot *bool `json:"isRootServiceAccount"`
Token *string `json:"rootToken" validate:"required"`
}
type BackupData struct {
Backends []*BackupBackend `json:"destinationProviders" validate:"required"`
Proxies []*BackupProxy `json:"forwardRules" validate:"required"`
Permissions []*BackupPermission `json:"allPermissions" validate:"required"`
Users []*BackupUser `json:"users" validate:"required"`
}
// From https://stackoverflow.com/questions/54461423/efficient-way-to-remove-all-non-alphanumeric-characters-from-large-text
func stripAllAlphanumeric(s string) string {
var result strings.Builder
for i := 0; i < len(s); i++ {
b := s[i]
if ('a' <= b && b <= 'z') ||
('A' <= b && b <= 'Z') ||
('0' <= b && b <= '9') {
result.WriteByte(b)
}
}
return result.String()
}
func backupRestoreEntrypoint(cCtx *cli.Context) error {
log.Info("Decompressing backup...")
backupFile, err := os.Open(cCtx.String("backup-path"))
if err != nil {
return fmt.Errorf("failed to open backup: %s", err.Error())
}
reader, err := gzip.NewReader(backupFile)
backupDataBytes, err := io.ReadAll(reader)
if err != nil {
log.Fatal(err)
}
log.Info("Decompressed backup. Cleaning up...")
err = reader.Close()
if err != nil {
return fmt.Errorf("failed to close Gzip reader: %s", err.Error())
}
err = backupFile.Close()
if err != nil {
return fmt.Errorf("failed to close backup: %s", err.Error())
}
log.Info("Parsing backup into internal structures...")
backupData := &BackupData{}
err = json.Unmarshal(backupDataBytes, backupData)
if err != nil {
return fmt.Errorf("failed to parse backup: %s", err.Error())
}
if err := validator.New().Struct(backupData); err != nil {
return fmt.Errorf("failed to validate backup: %s", err.Error())
}
log.Warn("!! WARNING !!")
log.Warn("This will attempt to permanently wipe the old database. The backup will not be deleted, however, caution is still advised.")
log.Warn("Continuing in 5 seconds...")
time.Sleep(5 * time.Second)
log.Info("Wiping database...")
databaseBackend := os.Getenv("HERMES_DATABASE_BACKEND")
switch databaseBackend {
case "sqlite":
filePath := os.Getenv("HERMES_SQLITE_FILEPATH")
if filePath == "" {
return fmt.Errorf("sqlite database file not specified (missing HERMES_SQLITE_FILEPATH)")
}
err := os.Remove(filePath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to delete sqlite database: %s", err.Error())
}
case "postgresql":
// FIXME(imterah): Maybe make this not required?
postgresDB := os.Getenv("HERMES_MIGRATE_POSTGRES_DATABASE")
if postgresDB == "" {
return fmt.Errorf("postgres migration DB is not specified (we don't parse the DSN to save space) (missing HERMES_MIGRATE_POSTGRES_DATABASE)")
}
postgresDSN := os.Getenv("HERMES_POSTGRES_DSN")
if postgresDSN == "" {
return fmt.Errorf("postgres DSN not specified (missing HERMES_POSTGRES_DSN)")
}
log.Info("Connecting to database...")
db, err := sql.Open("postgres", postgresDSN)
if err != nil {
return fmt.Errorf("failed to connect to database: %s", err.Error())
}
log.Info("Dropping database...")
_, err = db.Query("DROP DATABASE ?", postgresDB)
if err != nil {
return fmt.Errorf("failed to drop database: %s", err.Error())
}
log.Info("Closing database connection...")
err = db.Close()
if err != nil {
return fmt.Errorf("failed to close database connection: %s", err.Error())
}
case "":
return fmt.Errorf("no database backend specified in environment variables (missing HERMES_DATABASE_BACKEND)")
default:
return fmt.Errorf("unknown database backend specified: %s", os.Getenv(databaseBackend))
}
log.Info("Reinitializing database and opening it...")
err = dbcore.InitializeDatabase(&gorm.Config{})
if err != nil {
log.Fatalf("Failed to initialize database: %s", err)
}
log.Info("Running database migrations...")
if err := dbcore.DoDatabaseMigrations(dbcore.DB); err != nil {
return fmt.Errorf("Failed to run database migrations: %s", err)
}
log.Info("Restoring database...")
bestEffortOwnerUIDFromBackup := -1
log.Info("Attempting to find user to use as owner of resources...")
for _, user := range backupData.Users {
foundUser := false
failedAdministrationCheck := false
for _, permission := range backupData.Permissions {
if permission.UserID != user.ID {
continue
}
foundUser = true
if !strings.HasPrefix(permission.PermissionNode, "routes.") && permission.PermissionNode != "permissions.see" && !permission.HasPermission {
log.Infof("User with email '%s' and ID of '%d' failed administration check (lacks all permissions required). Attempting to find better user", user.Email, user.ID)
failedAdministrationCheck = true
break
}
}
if !foundUser {
log.Warnf("User with email '%s' and ID of '%d' lacks any permissions!", user.Email, user.ID)
continue
}
if failedAdministrationCheck {
continue
}
log.Infof("Using user with email '%s', and ID of '%d'", user.Email, user.ID)
bestEffortOwnerUIDFromBackup = int(user.ID)
break
}
if bestEffortOwnerUIDFromBackup == -1 {
log.Warnf("Could not find Administrative level user to use as the owner of resources. Using user with email '%s', and ID of '%d'", backupData.Users[0].Email, backupData.Users[0].ID)
bestEffortOwnerUIDFromBackup = int(backupData.Users[0].ID)
}
var bestEffortOwnerUID uint
for _, user := range backupData.Users {
log.Debugf("Migrating user with email '%s' and ID of '%d'", user.Email, user.ID)
tokens := make([]dbcore.Token, 0)
permissions := make([]dbcore.Permission, 0)
if user.Token != nil {
tokens = append(tokens, dbcore.Token{
Token: *user.Token,
DisableExpiry: true,
CreationIPAddr: "127.0.0.1", // We don't know the creation IP address...
})
}
for _, permission := range backupData.Permissions {
if permission.UserID != user.ID {
continue
}
permissions = append(permissions, dbcore.Permission{
PermissionNode: permission.PermissionNode,
HasPermission: permission.HasPermission,
})
}
username := ""
if user.Username == nil {
username = strings.ToLower(stripAllAlphanumeric(user.Email))
log.Warnf("User with ID of '%d' doesn't have a username. Derived username from email is '%s' (email is '%s')", user.ID, username, user.Email)
} else {
username = *user.Username
}
userDatabase := &dbcore.User{
Email: user.Email,
Username: username,
Name: user.Name,
Password: user.Password,
IsBot: user.IsBot,
Tokens: tokens,
Permissions: permissions,
}
if err := dbcore.DB.Create(userDatabase).Error; err != nil {
log.Errorf("Failed to create user: %s", err.Error())
}
if uint(bestEffortOwnerUIDFromBackup) == user.ID {
bestEffortOwnerUID = userDatabase.ID
}
}
for _, backend := range backupData.Backends {
log.Debugf("Migrating backend ID '%d' with name '%s'", backend.ID, backend.Name)
backendDatabase := &dbcore.Backend{
UserID: bestEffortOwnerUID,
Name: backend.Name,
Description: backend.Description,
Backend: backend.Backend,
BackendParameters: backend.BackendParameters,
}
if err := dbcore.DB.Create(backendDatabase).Error; err != nil {
log.Errorf("Failed to create backend: %s", err.Error())
}
log.Debugf("Migrating proxies for backend ID '%d'", backend.ID)
for _, proxy := range backupData.Proxies {
if proxy.BackendID != backend.ID {
continue
}
log.Debugf("Migrating proxy ID '%d' with name '%s'", proxy.ID, proxy.Name)
proxyDatabase := &dbcore.Proxy{
BackendID: backendDatabase.ID,
UserID: bestEffortOwnerUID,
Name: proxy.Name,
Description: proxy.Description,
Protocol: proxy.Protocol,
SourceIP: proxy.SourceIP,
SourcePort: proxy.SourcePort,
DestinationPort: proxy.DestinationPort,
AutoStart: proxy.AutoStart,
}
if err := dbcore.DB.Create(proxyDatabase).Error; err != nil {
log.Errorf("Failed to create proxy: %s", err.Error())
}
}
}
log.Info("Successfully upgraded to Hermes from NextNet.")
return nil
}