diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go new file mode 100644 index 000000000..9c4f23e38 --- /dev/null +++ b/cmd/soft/migrate_config.go @@ -0,0 +1,431 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/backend/sqlite" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/utils" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + "gopkg.in/yaml.v3" +) + +var ( + migrateConfig = &cobra.Command{ + Use: "migrate-config", + Short: "Migrate config to new format", + RunE: func(cmd *cobra.Command, args []string) error { + keyPath := os.Getenv("SOFT_SERVE_KEY_PATH") + reposPath := os.Getenv("SOFT_SERVE_REPO_PATH") + bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS") + cfg := config.DefaultConfig() + sb, err := sqlite.NewSqliteBackend(cfg.DataPath) + if err != nil { + return fmt.Errorf("failed to create sqlite backend: %w", err) + } + + cfg = cfg.WithBackend(sb) + + // Set SSH listen address + log.Info("Setting SSH listen address...") + if bindAddr != "" { + cfg.SSH.ListenAddr = bindAddr + } + + // Copy SSH host key + log.Info("Copying SSH host key...") + if keyPath != "" { + if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), 0700); err != nil { + return fmt.Errorf("failed to create ssh directory: %w", err) + } + + if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil { + return fmt.Errorf("failed to copy ssh key: %w", err) + } + + cfg.SSH.KeyPath = filepath.Join("ssh", filepath.Base(keyPath)) + } + + // Read config + log.Info("Reading config repository...") + r, err := git.Open(filepath.Join(reposPath, "config")) + if err != nil { + return fmt.Errorf("failed to open config repo: %w", err) + } + + head, err := r.HEAD() + if err != nil { + return fmt.Errorf("failed to get head: %w", err) + } + + tree, err := r.TreePath(head, "") + if err != nil { + return fmt.Errorf("failed to get tree: %w", err) + } + + isJson := false + te, err := tree.TreeEntry("config.yaml") + if err != nil { + te, err = tree.TreeEntry("config.json") + if err != nil { + return fmt.Errorf("failed to get config file: %w", err) + } + isJson = true + } + + cc, err := te.Contents() + if err != nil { + return fmt.Errorf("failed to get config contents: %w", err) + } + + var ocfg Config + if isJson { + if err := json.Unmarshal(cc, &ocfg); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + } else { + if err := yaml.Unmarshal(cc, &ocfg); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + } + + // Set server name + cfg.Name = ocfg.Name + + // Set server public url + cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port) + + // Set server settings + log.Info("Setting server settings...") + if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil { + fmt.Fprintf(os.Stderr, "failed to set allow keyless\n") + } + anon := backend.ParseAccessLevel(ocfg.AnonAccess) + if anon >= 0 { + if err := sb.SetAnonAccess(anon); err != nil { + fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err) + } + } + + // Copy repos + if reposPath != "" { + log.Info("Copying repos...") + dirs, err := os.ReadDir(reposPath) + if err != nil { + return fmt.Errorf("failed to read repos directory: %w", err) + } + + for _, dir := range dirs { + if !dir.IsDir() { + continue + } + + if !isGitDir(filepath.Join(reposPath, dir.Name())) { + continue + } + + log.Infof(" Copying repo %s", dir.Name()) + if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), 0700); err != nil { + return fmt.Errorf("failed to create repos directory: %w", err) + } + + src := utils.SanitizeRepo(filepath.Join(reposPath, dir.Name())) + dst := utils.SanitizeRepo(filepath.Join(cfg.DataPath, "repos", dir.Name())) + ".git" + if err := copyDir(src, dst); err != nil { + return fmt.Errorf("failed to copy repo: %w", err) + } + + if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil { + fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err) + } + } + } + + // Set repos metadata & collabs + log.Info("Setting repos metadata & collabs...") + for _, repo := range ocfg.Repos { + if err := sb.SetProjectName(repo.Repo, repo.Name); err != nil { + log.Errorf("failed to set repo name to %s: %s", repo.Repo, err) + } + + if err := sb.SetDescription(repo.Repo, repo.Note); err != nil { + log.Errorf("failed to set repo description to %s: %s", repo.Repo, err) + } + + if err := sb.SetPrivate(repo.Repo, repo.Private); err != nil { + log.Errorf("failed to set repo private to %s: %s", repo.Repo, err) + } + + for _, collab := range repo.Collabs { + if err := sb.AddCollaborator(repo.Repo, collab); err != nil { + log.Errorf("failed to add repo collab to %s: %s", repo.Repo, err) + } + } + } + + // Create users & collabs + log.Info("Creating users & collabs...") + for _, user := range ocfg.Users { + keys := make(map[string]ssh.PublicKey) + for _, key := range user.PublicKeys { + pk, _, err := backend.ParseAuthorizedKey(key) + if err != nil { + continue + } + ak := backend.MarshalAuthorizedKey(pk) + keys[ak] = pk + } + + pubkeys := make([]ssh.PublicKey, 0) + for _, pk := range keys { + pubkeys = append(pubkeys, pk) + } + + username := strings.ToLower(user.Name) + username = strings.ReplaceAll(username, " ", "-") + log.Infof("Creating user %q", username) + if _, err := sb.CreateUser(username, backend.UserOptions{ + Admin: user.Admin, + PublicKeys: pubkeys, + }); err != nil { + log.Errorf("failed to create user: %s", err) + } + + for _, repo := range user.CollabRepos { + if err := sb.AddCollaborator(repo, username); err != nil { + log.Errorf("failed to add user collab to %s: %s\n", repo, err) + } + } + } + + log.Info("Writing config...") + defer log.Info("Done!") + return config.WriteConfig(filepath.Join(cfg.DataPath, "config.yaml"), cfg) + }, + } +) + +// Returns true if path is a directory containing an `objects` directory and a +// `HEAD` file. +func isGitDir(path string) bool { + stat, err := os.Stat(filepath.Join(path, "objects")) + if err != nil { + return false + } + if !stat.IsDir() { + return false + } + + stat, err = os.Stat(filepath.Join(path, "HEAD")) + if err != nil { + return false + } + if stat.IsDir() { + return false + } + + return true +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + var err error + var srcfd *os.File + var dstfd *os.File + var srcinfo os.FileInfo + + if srcfd, err = os.Open(src); err != nil { + return err + } + defer srcfd.Close() + + if dstfd, err = os.Create(dst); err != nil { + return err + } + defer dstfd.Close() + + if _, err = io.Copy(dstfd, srcfd); err != nil { + return err + } + if srcinfo, err = os.Stat(src); err != nil { + return err + } + return os.Chmod(dst, srcinfo.Mode()) +} + +// copyDir copies a whole directory recursively +func copyDir(src string, dst string) error { + var err error + var fds []os.DirEntry + var srcinfo os.FileInfo + + if srcinfo, err = os.Stat(src); err != nil { + return err + } + + if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil { + return err + } + + if fds, err = os.ReadDir(src); err != nil { + return err + } + for _, fd := range fds { + srcfp := filepath.Join(src, fd.Name()) + dstfp := filepath.Join(dst, fd.Name()) + + if fd.IsDir() { + if err = copyDir(srcfp, dstfp); err != nil { + fmt.Println(err) + } + } else { + if err = copyFile(srcfp, dstfp); err != nil { + fmt.Println(err) + } + } + } + return nil +} + +// func copyDir(src, dst string) error { +// entries, err := os.ReadDir(src) +// if err != nil { +// return err +// } +// for _, entry := range entries { +// sourcePath := filepath.Join(src, entry.Name()) +// destPath := filepath.Join(dst, entry.Name()) +// +// fileInfo, err := os.Stat(sourcePath) +// if err != nil { +// return err +// } +// +// stat, ok := fileInfo.Sys().(*syscall.Stat_t) +// if !ok { +// return fmt.Errorf("failed to get raw syscall.Stat_t data for '%s'", sourcePath) +// } +// +// switch fileInfo.Mode() & os.ModeType { +// case os.ModeDir: +// if err := createIfNotExists(destPath, 0755); err != nil { +// return err +// } +// if err := copyDir(sourcePath, destPath); err != nil { +// return err +// } +// case os.ModeSymlink: +// if err := copySymLink(sourcePath, destPath); err != nil { +// return err +// } +// default: +// if err := copyFile(sourcePath, destPath); err != nil { +// return err +// } +// } +// +// if err := os.Lchown(destPath, int(stat.Uid), int(stat.Gid)); err != nil { +// return err +// } +// +// fInfo, err := entry.Info() +// if err != nil { +// return err +// } +// +// isSymlink := fInfo.Mode()&os.ModeSymlink != 0 +// if !isSymlink { +// if err := os.Chmod(destPath, fInfo.Mode()); err != nil { +// return err +// } +// } +// } +// return nil +// } +// +// func copyFile(srcFile, dstFile string) error { +// out, err := os.Create(dstFile) +// if err != nil { +// return err +// } +// +// defer out.Close() +// +// in, err := os.Open(srcFile) +// defer in.Close() +// if err != nil { +// return err +// } +// +// _, err = io.Copy(out, in) +// if err != nil { +// return err +// } +// +// return nil +// } + +func exists(filePath string) bool { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return false + } + + return true +} + +func createIfNotExists(dir string, perm os.FileMode) error { + if exists(dir) { + return nil + } + + if err := os.MkdirAll(dir, perm); err != nil { + return fmt.Errorf("failed to create directory: '%s', error: '%s'", dir, err.Error()) + } + + return nil +} + +func copySymLink(source, dest string) error { + link, err := os.Readlink(source) + if err != nil { + return err + } + return os.Symlink(link, dest) +} + +type Config struct { + Name string `yaml:"name" json:"name"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + AnonAccess string `yaml:"anon-access" json:"anon-access"` + AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"` + Users []User `yaml:"users" json:"users"` + Repos []RepoConfig `yaml:"repos" json:"repos"` +} + +// User contains user-level configuration for a repository. +type User struct { + Name string `yaml:"name" json:"name"` + Admin bool `yaml:"admin" json:"admin"` + PublicKeys []string `yaml:"public-keys" json:"public-keys"` + CollabRepos []string `yaml:"collab-repos" json:"collab-repos"` +} + +// RepoConfig is a repository configuration. +type RepoConfig struct { + Name string `yaml:"name" json:"name"` + Repo string `yaml:"repo" json:"repo"` + Note string `yaml:"note" json:"note"` + Private bool `yaml:"private" json:"private"` + Readme string `yaml:"readme" json:"readme"` + Collabs []string `yaml:"collabs" json:"collabs"` +} diff --git a/cmd/soft/root.go b/cmd/soft/root.go index fe7cb6540..5fc9e4588 100644 --- a/cmd/soft/root.go +++ b/cmd/soft/root.go @@ -32,6 +32,7 @@ func init() { serveCmd, manCmd, hookCmd, + migrateConfig, ) rootCmd.CompletionOptions.HiddenDefaultCmd = true diff --git a/server/backend/backend.go b/server/backend/backend.go index e95e6b74a..ae243d1a1 100644 --- a/server/backend/backend.go +++ b/server/backend/backend.go @@ -3,7 +3,8 @@ package backend import ( "bytes" - "golang.org/x/crypto/ssh" + "github.com/charmbracelet/ssh" + gossh "golang.org/x/crypto/ssh" ) // Backend is an interface that handles repositories management and any @@ -18,8 +19,8 @@ type Backend interface { } // ParseAuthorizedKey parses an authorized key string into a public key. -func ParseAuthorizedKey(ak string) (ssh.PublicKey, string, error) { - pk, c, _, _, err := ssh.ParseAuthorizedKey([]byte(ak)) +func ParseAuthorizedKey(ak string) (gossh.PublicKey, string, error) { + pk, c, _, _, err := gossh.ParseAuthorizedKey([]byte(ak)) return pk, c, err } @@ -28,9 +29,14 @@ func ParseAuthorizedKey(ak string) (ssh.PublicKey, string, error) { // This is the inverse of ParseAuthorizedKey. // This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. // It returns an empty string if pk is nil. -func MarshalAuthorizedKey(pk ssh.PublicKey) string { +func MarshalAuthorizedKey(pk gossh.PublicKey) string { if pk == nil { return "" } - return string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(pk), []byte("\n"))) + return string(bytes.TrimSuffix(gossh.MarshalAuthorizedKey(pk), []byte("\n"))) +} + +// KeysEqual returns whether the two public keys are equal. +func KeysEqual(a, b gossh.PublicKey) bool { + return ssh.KeysEqual(a, b) } diff --git a/server/backend/sqlite/db.go b/server/backend/sqlite/db.go index f3ed49d74..a8fe46bb4 100644 --- a/server/backend/sqlite/db.go +++ b/server/backend/sqlite/db.go @@ -8,7 +8,6 @@ import ( "github.com/charmbracelet/soft-serve/server/backend" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" ) @@ -96,20 +95,3 @@ func rollback(tx *sqlx.Tx, err error) error { return err } - -func hashPassword(password string) (string, error) { - hash, err := bcrypt.GenerateFromPassword([]byte(password+"soft-serve-v1"), 14) - if err != nil { - return "", fmt.Errorf("failed to hash password: %w", err) - } - - return string(hash), nil -} - -func checkPassword(hash, password string) error { - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"soft-serve-v1")); err != nil { - return fmt.Errorf("failed to check password: %w", err) - } - - return nil -} diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go index 263885b49..eb7c3413c 100644 --- a/server/backend/sqlite/sqlite.go +++ b/server/backend/sqlite/sqlite.go @@ -38,6 +38,10 @@ func (d *SqliteBackend) reposPath() string { // NewSqliteBackend creates a new SqliteBackend. func NewSqliteBackend(dataPath string) (*SqliteBackend, error) { + if err := os.MkdirAll(dataPath, 0755); err != nil { + return nil, err + } + db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+ "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")) if err != nil { @@ -164,6 +168,7 @@ func (d *SqliteBackend) ImportRepository(name string, remote string, opts backen copts := git.CloneOptions{ Mirror: opts.Mirror, + Quiet: true, } if err := git.Clone(remote, rp, copts); err != nil { logger.Debug("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp) diff --git a/server/backend/sqlite/user.go b/server/backend/sqlite/user.go index d03410277..068c85a3a 100644 --- a/server/backend/sqlite/user.go +++ b/server/backend/sqlite/user.go @@ -168,13 +168,13 @@ func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (b var user *User if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error { - into := "INSERT INTO user (username, admin" - values := "VALUES (?, ?" - args := []interface{}{username, opts.Admin} - into += ", updated_at)" - values += ", CURRENT_TIMESTAMP)" + stmt, err := tx.Prepare("INSERT INTO user (username, admin, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);") + if err != nil { + return err + } - r, err := tx.Exec(into+" "+values, args...) + defer stmt.Close() // nolint: errcheck + r, err := stmt.Exec(username, opts.Admin) if err != nil { return err } @@ -182,12 +182,19 @@ func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (b if len(opts.PublicKeys) > 0 { userID, err := r.LastInsertId() if err != nil { + logger.Error("error getting last insert id") return err } for _, pk := range opts.PublicKeys { - if _, err := tx.Exec(`INSERT INTO public_key (user_id, public_key, updated_at) - VALUES (?, ?, CURRENT_TIMESTAMP);`, userID, backend.MarshalAuthorizedKey(pk)); err != nil { + stmt, err := tx.Prepare(`INSERT INTO public_key (user_id, public_key, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP);`) + if err != nil { + return err + } + + defer stmt.Close() // nolint: errcheck + if _, err := stmt.Exec(userID, backend.MarshalAuthorizedKey(pk)); err != nil { return err } }