diff --git a/proto/access.go b/proto/access.go index f41ea07e2176ab7cd44bbb10b4426b7653e1c0f2..264f7e849ac428d1b7f4b4ced0e237ccbed97c60 100644 --- a/proto/access.go +++ b/proto/access.go @@ -1,5 +1,12 @@ package proto +import ( + "fmt" + "strings" + + "github.com/gliderlabs/ssh" +) + // AccessLevel is the level of access allowed to a repo. type AccessLevel int @@ -32,3 +39,25 @@ func (a AccessLevel) String() string { return "" } } + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (a *AccessLevel) UnmarshalText(text []byte) error { + switch strings.ToLower(string(text)) { + case "no-access": + *a = NoAccess + case "read-only": + *a = ReadOnlyAccess + case "read-write": + *a = ReadWriteAccess + case "admin-access": + *a = AdminAccess + default: + return fmt.Errorf("invalid access level: %s", text) + } + return nil +} + +// Access is an interface that defines the access level for repositories. +type Access interface { + AuthRepo(repo string, pk ssh.PublicKey) AccessLevel +} diff --git a/proto/user.go b/proto/user.go new file mode 100644 index 0000000000000000000000000000000000000000..5e8abbf5f23fb7842d224b381aefa58626fae313 --- /dev/null +++ b/proto/user.go @@ -0,0 +1,17 @@ +package proto + +import ( + "net/mail" + + "golang.org/x/crypto/ssh" +) + +// User is a user. +type User interface { + Name() string + PublicKeys() []ssh.PublicKey + Login() *string + Email() *mail.Address + Password() *string + IsAdmin() bool +} diff --git a/server/cmd/middleware.go b/server/cmd/middleware.go index 058d2a5e15603272aeb8d6a2d50766e5d88bffc9..120bd1a04ef55ca1c6617be0ca4747896330a6bf 100644 --- a/server/cmd/middleware.go +++ b/server/cmd/middleware.go @@ -4,13 +4,13 @@ import ( "context" "fmt" - appCfg "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/wish" "github.com/gliderlabs/ssh" ) // Middleware is the Soft Serve middleware that handles SSH commands. -func Middleware(ac *appCfg.Config) wish.Middleware { +func Middleware(cfg *config.Config) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { func() { @@ -18,15 +18,15 @@ func Middleware(ac *appCfg.Config) wish.Middleware { if active { return } - ctx := context.WithValue(s.Context(), ConfigCtxKey, ac) + ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg) ctx = context.WithValue(ctx, SessionCtxKey, s) use := "ssh" - port := ac.Port + port := cfg.Port if port != 22 { use += fmt.Sprintf(" -p%d", port) } - use += fmt.Sprintf(" %s", ac.Host) + use += fmt.Sprintf(" %s", cfg.Host) cmd := RootCommand() cmd.Use = use cmd.CompletionOptions.DisableDefaultCmd = true diff --git a/server/cmd/middleware_test.go b/server/cmd/middleware_test.go index 3ccdad40151df5b04261c3d30b748bf84ba9546c..e5b1d349acc43e2dac38aa809913dde7e72e0a4a 100644 --- a/server/cmd/middleware_test.go +++ b/server/cmd/middleware_test.go @@ -4,11 +4,9 @@ import ( "os" "testing" - "github.com/charmbracelet/soft-serve/config" - sconfig "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/wish/testsession" "github.com/gliderlabs/ssh" - "github.com/matryer/is" ) var () @@ -17,17 +15,15 @@ func TestMiddleware(t *testing.T) { t.Cleanup(func() { os.RemoveAll("testmiddleware") }) - is := is.New(t) - appCfg, err := config.NewConfig(&sconfig.Config{ + cfg := &config.Config{ Host: "localhost", - SSH: sconfig.SSHConfig{ + SSH: config.SSHConfig{ Port: 22223, }, DataPath: "testmiddleware", - }) - is.NoErr(err) + } _ = testsession.New(t, &ssh.Server{ - Handler: Middleware(appCfg)(func(s ssh.Session) { + Handler: Middleware(cfg)(func(s ssh.Session) { t.Run("TestCatConfig", func(t *testing.T) { _, err := s.Write([]byte("cat config/config.json")) if err == nil { diff --git a/server/config/access.go b/server/config/access.go new file mode 100644 index 0000000000000000000000000000000000000000..2425e769d47fd297d96ae122a0c3e2a10deb77d3 --- /dev/null +++ b/server/config/access.go @@ -0,0 +1,119 @@ +package config + +import ( + "strings" + + "github.com/charmbracelet/soft-serve/proto" + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +var _ proto.Access = &Config{} + +// AuthRepo grants repo authorization to the given key. +func (c *Config) AuthRepo(repo string, pk ssh.PublicKey) proto.AccessLevel { + return c.accessForKey(repo, pk) +} + +// PasswordHandler returns whether or not password access is allowed. +func (c *Config) PasswordHandler(ctx ssh.Context, password string) bool { + return (c.AnonAccess != proto.NoAccess) && c.SSH.AllowKeyless && + c.SSH.AllowPassword && (c.SSH.Password == password) +} + +// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed. +func (c *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { + return (c.AnonAccess != proto.NoAccess) && c.SSH.AllowKeyless +} + +// PublicKeyHandler returns whether or not the given public key may access the +// repo. +func (c *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { + return c.accessForKey("", pk) != proto.NoAccess +} + +// accessForKey returns the access level for the given repo. +// +// If repo doesn't exist, then access is based on user's admin privileges, or +// config.AnonAccess. +// If repo exists, and private, then admins and collabs are allowed access. +// If repo exists, and not private, then access is based on config.AnonAccess. +func (c *Config) accessForKey(repo string, pk ssh.PublicKey) proto.AccessLevel { + anon := c.AnonAccess + private := c.isPrivate(repo) + // Find user + if pk != nil { + if u := c.findUser(pk); u != nil { + if u.IsAdmin() { + return proto.AdminAccess + } + if c.isCollab(repo, pk) { + if anon > proto.ReadWriteAccess { + return anon + } + return proto.ReadWriteAccess + } + if !private { + if anon > proto.ReadOnlyAccess { + return anon + } + return proto.ReadOnlyAccess + } + } + } + // Don't restrict access to private repos if no users are configured. + // Return anon access level. + if private && c.countUsers() > 0 { + return proto.NoAccess + } + return anon +} + +func (c *Config) countUsers() int { + count, err := c.db.CountUsers() + if err != nil { + return 0 + } + return count +} + +func (c *Config) findUser(pk ssh.PublicKey) proto.User { + k := strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk))) + u, err := c.DB().GetUserByPublicKey(k) + if err != nil { + return nil + } + ks, err := c.DB().GetUserPublicKeys(u) + if err != nil { + return nil + } + return &user{user: u, keys: ks} +} + +func (c *Config) findRepo(repo string) proto.Repository { + r, err := c.DB().Open(repo) + if err != nil { + return nil + } + return r +} + +func (c *Config) isPrivate(repo string) bool { + if r := c.findRepo(repo); r != nil { + return r.IsPrivate() + } + return false +} + +func (c *Config) isCollab(repo string, pk ssh.PublicKey) bool { + pks, err := c.DB().ListRepoPublicKeys(repo) + if err != nil { + return false + } + for _, k := range pks { + if ssh.KeysEqual(pk, k) { + return true + } + } + return false +} diff --git a/server/config/config.go b/server/config/config.go index 3deb57576b8eb864579d060ce093e2f79c6fa6ba..3eb7a05f4ce801473977e3c018d43cec6b02298c 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1,10 +1,17 @@ package config import ( + "fmt" "log" + "net" + "net/url" + "os" "path/filepath" "github.com/caarlos0/env/v6" + "github.com/charmbracelet/soft-serve/proto" + "github.com/charmbracelet/soft-serve/server/db" + "github.com/charmbracelet/soft-serve/server/db/sqlite" ) // Callbacks provides an interface that can be used to run callbacks on different events. @@ -16,7 +23,10 @@ type Callbacks interface { // SSHConfig is the SSH configuration for the server. type SSHConfig struct { - Port int `env:"PORT" envDefault:"23231"` + Port int `env:"PORT" envDefault:"23231"` + AllowKeyless bool `env:"ALLOW_KEYLESS" envDefault:"true"` + AllowPassword bool `env:"ALLOW_PASSWORD" envDefault:"false"` + Password string `env:"PASSWORD"` } // GitConfig is the Git protocol configuration for the server. @@ -28,13 +38,55 @@ type GitConfig struct { MaxConnections int `env:"SOFT_SERVE_GIT_MAX_CONNECTIONS" envDefault:"32"` } +// DBConfig is the database configuration for the server. +type DBConfig struct { + Driver string `env:"DRIVER" envDefault:"sqlite"` + User string `env:"USER"` + Password string `env:"PASSWORD"` + Host string `env:"HOST"` + Port string `env:"PORT"` + Name string `env:"NAME"` + SSLMode bool `env:"SSL_MODE" envDefault:"false"` +} + +// URL returns a database URL for the configuration. +func (d *DBConfig) URL() *url.URL { + switch d.Driver { + case "sqlite": + return &url.URL{ + Scheme: "sqlite", + Path: filepath.Join(d.Name), + } + default: + ssl := "disable" + if d.SSLMode { + ssl = "require" + } + var user *url.Userinfo + if d.User != "" && d.Password != "" { + user = url.UserPassword(d.User, d.Password) + } else if d.User != "" { + user = url.User(d.User) + } + return &url.URL{ + Scheme: d.Driver, + Host: net.JoinHostPort(d.Host, d.Port), + User: user, + Path: d.Name, + RawQuery: fmt.Sprintf("sslmode=%s", ssl), + } + } +} + // Config is the configuration for Soft Serve. type Config struct { Host string `env:"HOST" envDefault:"localhost"` SSH SSHConfig `env:"SSH" envPrefix:"SSH_"` Git GitConfig `env:"GIT" envPrefix:"GIT_"` + Db DBConfig `env:"DB" envPrefix:"DB_"` - DataPath string `env:"DATA_PATH" envDefault:"soft-serve"` + AnonAccess proto.AccessLevel `env:"ANON_ACCESS" envDefault:"read-only"` + DataPath string `env:"DATA_PATH" envDefault:"data"` // Deprecated: use SOFT_SERVE_SSH_PORT instead. Port int `env:"PORT"` @@ -46,14 +98,12 @@ type Config struct { InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"` Callbacks Callbacks ErrorLog *log.Logger + + db db.Store } // RepoPath returns the path to the repositories. func (c *Config) RepoPath() string { - if c.ReposPath != "" { - log.Printf("warning: SOFT_SERVE_REPO_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead") - return c.ReposPath - } return filepath.Join(c.DataPath, "repos") } @@ -64,27 +114,52 @@ func (c *Config) SSHPath() string { // PrivateKeyPath returns the path to the SSH key. func (c *Config) PrivateKeyPath() string { - if c.KeyPath != "" { - log.Printf("warning: SOFT_SERVE_KEY_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead") - return c.KeyPath - } - return filepath.Join(c.DataPath, "ssh", "soft_serve") + return filepath.Join(c.SSHPath(), "soft_serve") } // DefaultConfig returns a Config with the values populated with the defaults // or specified environment variables. func DefaultConfig() *Config { + var err error + var migrateWarn bool cfg := &Config{ErrorLog: log.Default()} - if err := env.Parse(cfg, env.Options{ + if err = env.Parse(cfg, env.Options{ Prefix: "SOFT_SERVE_", }); err != nil { log.Fatalln(err) } if cfg.Port != 0 { - log.Printf("warning: SOFT_SERVE_PORT is deprecated, use SOFT_SERVE_SSH_PORT instead") - cfg.SSH.Port = cfg.Port + log.Printf("warning: SOFT_SERVE_PORT is deprecated, use SOFT_SERVE_SSH_PORT instead.") + migrateWarn = true + } + if cfg.KeyPath != "" { + log.Printf("warning: SOFT_SERVE_KEY_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead.") + migrateWarn = true } - return cfg.WithCallbacks(nil) + if cfg.ReposPath != "" { + log.Printf("warning: SOFT_SERVE_REPO_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead.") + migrateWarn = true + } + if migrateWarn { + log.Printf("warning: please run `soft serve --migrate` to migrate your server and configuration.") + } + var db db.Store + switch cfg.Db.Driver { + case "sqlite": + if err := os.MkdirAll(filepath.Join(cfg.DataPath, "db"), 0755); err != nil { + log.Fatalln(err) + } + db, err = sqlite.New(filepath.Join(cfg.DataPath, "db", "soft-serve.db")) + if err != nil { + log.Fatalln(err) + } + } + return cfg.WithDB(db) +} + +// DB returns the database for the configuration. +func (c *Config) DB() db.Store { + return c.db } // WithCallbacks applies the given Callbacks to the configuration. @@ -98,3 +173,9 @@ func (c *Config) WithErrorLogger(logger *log.Logger) *Config { c.ErrorLog = logger return c } + +// WithDB sets the database for the configuration. +func (c *Config) WithDB(db db.Store) *Config { + c.db = db + return c +} diff --git a/server/config/user.go b/server/config/user.go new file mode 100644 index 0000000000000000000000000000000000000000..b1a3472cbb0c3ace5ddfb4d85056c41bdd45da08 --- /dev/null +++ b/server/config/user.go @@ -0,0 +1,41 @@ +package config + +import ( + "net/mail" + + "github.com/charmbracelet/soft-serve/server/db/types" + "golang.org/x/crypto/ssh" +) + +type user struct { + user *types.User + keys []*types.PublicKey +} + +func (u *user) Name() string { + return u.user.Name +} + +func (u *user) Email() *mail.Address { + return u.user.Address() +} + +func (u *user) Login() *string { + return u.user.Login +} + +func (u *user) Password() *string { + return u.user.Password +} + +func (u *user) IsAdmin() bool { + return u.user.Admin +} + +func (u *user) PublicKeys() []ssh.PublicKey { + ks := make([]ssh.PublicKey, len(u.keys)) + for i, k := range u.keys { + ks[i] = k + } + return ks +} diff --git a/server/db/db.go b/server/db/db.go index e2b261451ee53314f9ada94cb39aa0f96f75fbcc..ed99728c446159b83348bd5b6133efe06fe66a07 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -1,6 +1,7 @@ package db import ( + "github.com/charmbracelet/soft-serve/proto" "github.com/charmbracelet/soft-serve/server/db/types" ) @@ -29,6 +30,7 @@ type UserStore interface { SetUserEmail(*types.User, string) error SetUserPassword(*types.User, string) error SetUserAdmin(*types.User, bool) error + CountUsers() (int, error) } // PublicKeyStore is a public key database storage. @@ -53,13 +55,16 @@ type RepoStore interface { // CollabStore is a collaborator database storage. type CollabStore interface { // Collaborators - AddRepoCollab(*types.Repo, *types.User) error + AddRepoCollab(string, *types.User) error DeleteRepoCollab(int, int) error - ListRepoCollabs(*types.Repo) ([]*types.User, error) + ListRepoCollabs(string) ([]*types.User, error) + ListRepoPublicKeys(string) ([]*types.PublicKey, error) } -// DB is a database. -type DB interface { +// Store is a database. +type Store interface { + proto.Provider + ConfigStore UserStore PublicKeyStore diff --git a/server/db/sqlite/collabs.go b/server/db/sqlite/collabs.go deleted file mode 100644 index e373250b46c4a1acd0f3d092775ab42ef0fb2cd4..0000000000000000000000000000000000000000 --- a/server/db/sqlite/collabs.go +++ /dev/null @@ -1,132 +0,0 @@ -package sqlite - -import ( - "net/mail" - "strconv" - - "github.com/charmbracelet/soft-serve/proto" - "github.com/charmbracelet/soft-serve/server/db/types" - "golang.org/x/crypto/ssh" -) - -var _ proto.CollaboratorService = &Sqlite{} - -// AddCollaborator adds a collaborator to a repository. -func (d *Sqlite) AddCollaborator(repo string, collab proto.Collaborator) error { - r, err := d.GetRepo(repo) - if err != nil { - return err - } - switch c := collab.(type) { - } -} - -// RemoveCollaborator removes a collaborator from a repository. -func (d *Sqlite) RemoveCollaborator(repo string, collab proto.Collaborator) error { - return nil -} - -// ListCollaborators lists the collaborators of a repository. -func (d *Sqlite) ListCollaborators(repo string) ([]proto.Collaborator, error) { - return nil, nil -} - -type publicKey struct { - key *types.PublicKey -} - -// PublicKey returns the collaborator's public key. -func (k publicKey) PublicKey() ssh.PublicKey { - pk, err := ssh.ParsePublicKey([]byte(k.key.PublicKey)) - if err != nil { - return nil - } - return pk -} - -var _ proto.PublicKeyCollaborator = &collaborator{} -var _ proto.UserLoginCollaborator = &collaborator{} -var _ proto.EmailCollaborator = &collaborator{} - -type collaborator struct { - user *types.User - keys []*types.PublicKey - db *Sqlite -} - -func (c *collaborator) init() { - if c.keys != nil || len(c.keys) > 0 { - return - } - ks, err := c.db.GetUserPublicKeys(c.user) - if err != nil { - return - } - c.keys = ks -} - -// Identifier returns the collaborator's identifier. -func (c *collaborator) Identifier() string { - return strconv.Itoa(c.user.ID) -} - -// Name returns the collaborator's name. -func (c *collaborator) Name() string { - return c.user.Name -} - -// String returns the collaborator's username. -func (c *collaborator) String() string { - return c.user.Name -} - -// Marshal implements proto.PublicKeyCollaborator -func (c *collaborator) Marshal() []byte { - c.init() - pk := c.keys[0] - return pk.Marshal() -} - -// Type implements proto.PublicKeyCollaborator -func (c *collaborator) Type() string { - c.init() - pk := c.keys[0] - return pk.Type() -} - -// Verify implements proto.PublicKeyCollaborator -func (c *collaborator) Verify(data []byte, sig *ssh.Signature) error { - c.init() - pk := c.keys[0] - return pk.Verify(data, sig) -} - -// Login implements proto.UserLoginCollaborator -func (c *collaborator) Login() string { - var login string - if c.user.Login != nil { - login = *c.user.Login - } - return login -} - -// Address implements proto.EmailCollaborator -func (c *collaborator) Address() mail.Address { - var addr string - if c.user.Email != nil { - addr = *c.user.Email - } - return mail.Address{ - Name: c.user.Name, - Address: addr, - } -} - -// Password implements proto.UserLoginCollaborator -func (c *collaborator) Password() string { - var pwd string - if c.user.Password != nil { - pwd = *c.user.Password - } - return pwd -} diff --git a/server/db/sqlite/sql.go b/server/db/sqlite/sql.go index fd3d96f73e20243d6a6859e9341b77ac00afda92..6c415924e25faac625c5ab76d87e798bf5681540 100644 --- a/server/db/sqlite/sql.go +++ b/server/db/sqlite/sql.go @@ -10,8 +10,7 @@ var ( allow_keyless BOOLEAN NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL - ); - ` + );` sqlCreateUserTable = `CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -43,8 +42,8 @@ var ( project_name TEXT NOT NULL, description TEXT NOT NULL, private BOOLEAN NOT NULL, - create_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL );` sqlCreateCollabTable = `CREATE TABLE IF NOT EXISTS collab ( @@ -85,6 +84,7 @@ var ( sqlUpdateUserEmail = `UPDATE user SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;` sqlUpdateUserPassword = `UPDATE user SET password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;` sqlUpdateUserAdmin = `UPDATE user SET admin = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;` + sqlCountUsers = `SELECT COUNT(*) FROM user;` // Public Key. sqlInsertPublicKey = `INSERT INTO public_key (user_id, public_key, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);` @@ -101,7 +101,12 @@ var ( sqlUpdateRepoPrivateByName = `UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;` // Collab. - sqlInsertCollab = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);` - sqlDeleteCollab = `DELETE FROM collab WHERE user_id = ? AND repo_id;` - sqlSelectRepoCollabs = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = ?;` + sqlInsertCollab = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP);` + sqlInsertCollabByName = `INSERT INTO collab (user_id, repo_id, updated_at) VALUES (?, (SELECT id FROM repo WHERE name = ?), CURRENT_TIMESTAMP);` + sqlDeleteCollab = `DELETE FROM collab WHERE user_id = ? AND repo_id = ?;` + sqlDeleteCollabByName = `DELETE FROM collab WHERE user_id = ? AND repo_id = (SELECT id FROM repo WHERE name = ?);` + sqlSelectRepoCollabs = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = ?;` + sqlSelectRepoCollabsByName = `SELECT user.id, user.name, user.login, user.email, user.admin, user.created_at, user.updated_at FROM user INNER JOIN collab ON user.id = collab.user_id WHERE collab.repo_id = (SELECT id FROM repo WHERE name = ?);` + sqlSelectRepoPublicKeys = `SELECT public_key.id, public_key.user_id, public_key.public_key, public_key.created_at, public_key.updated_at FROM public_key INNER JOIN collab ON public_key.user_id = collab.user_id WHERE collab.repo_id = ?;` + sqlSelectRepoPublicKeysByName = `SELECT public_key.id, public_key.user_id, public_key.public_key, public_key.created_at, public_key.updated_at FROM public_key INNER JOIN collab ON public_key.user_id = collab.user_id WHERE collab.repo_id = (SELECT id FROM repo WHERE name = ?);` ) diff --git a/server/db/sqlite/sqlite.go b/server/db/sqlite/sqlite.go index d5b4e92fbb99a14fc4f4f201c6bff86fad7d80e5..0c7a573da2a10d51716a918abfcf3c21a8a9236b 100644 --- a/server/db/sqlite/sqlite.go +++ b/server/db/sqlite/sqlite.go @@ -3,6 +3,7 @@ package sqlite import ( "context" "database/sql" + "errors" "fmt" "log" "strings" @@ -14,7 +15,7 @@ import ( sqlitelib "modernc.org/sqlite/lib" ) -var _ db.DB = &Sqlite{} +var _ db.Store = &Sqlite{} // Sqlite is a SQLite database. type Sqlite struct { @@ -36,7 +37,7 @@ func New(path string) (*Sqlite, error) { path: path, } if err = d.CreateDB(); err != nil { - return nil, err + return nil, fmt.Errorf("failed to create db: %w", err) } return d, d.db.Ping() } @@ -49,7 +50,7 @@ func (d *Sqlite) Close() error { // CreateDB creates the database and tables. func (d *Sqlite) CreateDB() error { return d.wrapTransaction(func(tx *sql.Tx) error { - if _, err := tx.Exec(sqlInsertConfig); err != nil { + if _, err := tx.Exec(sqlCreateConfigTable); err != nil { return err } if _, err := tx.Exec(sqlCreateUserTable); err != nil { @@ -273,6 +274,21 @@ func (d *Sqlite) SetUserAdmin(user *types.User, admin bool) error { }) } +// CountUsers returns the number of users. +func (d *Sqlite) CountUsers() (int, error) { + var count int + if err := d.wrapTransaction(func(tx *sql.Tx) error { + r := tx.QueryRow(sqlCountUsers) + if err := r.Scan(&count); err != nil { + return err + } + return nil + }); err != nil { + return 0, err + } + return count, nil +} + // AddUserPublicKey adds a new user public key. func (d *Sqlite) AddUserPublicKey(user *types.User, key string) error { return d.wrapTransaction(func(tx *sql.Tx) error { @@ -381,9 +397,9 @@ func (d *Sqlite) SetRepoPrivate(name string, private bool) error { } // AddRepoCollab adds a new repo collaborator. -func (d *Sqlite) AddRepoCollab(repo *types.Repo, user *types.User) error { +func (d *Sqlite) AddRepoCollab(repo string, user *types.User) error { return d.wrapTransaction(func(tx *sql.Tx) error { - _, err := tx.Exec(sqlInsertCollab, repo.ID, user.ID) + _, err := tx.Exec(sqlInsertCollabByName, repo, user.ID) return err }) } @@ -397,10 +413,10 @@ func (d *Sqlite) DeleteRepoCollab(userID int, repoID int) error { } // ListRepoCollabs returns a list of repo collaborators. -func (d *Sqlite) ListRepoCollabs(repo *types.Repo) ([]*types.User, error) { +func (d *Sqlite) ListRepoCollabs(repo string) ([]*types.User, error) { collabs := make([]*types.User, 0) if err := d.wrapTransaction(func(tx *sql.Tx) error { - rows, err := tx.Query(sqlSelectRepoCollabs, repo.ID) + rows, err := tx.Query(sqlSelectRepoCollabsByName, repo) if err != nil { return err } @@ -422,6 +438,32 @@ func (d *Sqlite) ListRepoCollabs(repo *types.Repo) ([]*types.User, error) { return collabs, nil } +// ListRepoPublicKeys returns a list of repo public keys. +func (d *Sqlite) ListRepoPublicKeys(repo string) ([]*types.PublicKey, error) { + keys := make([]*types.PublicKey, 0) + if err := d.wrapTransaction(func(tx *sql.Tx) error { + rows, err := tx.Query(sqlSelectRepoPublicKeysByName, repo) + if err != nil { + return err + } + if err := rows.Err(); err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var k types.PublicKey + if err := rows.Scan(&k.ID, &k.UserID, &k.PublicKey, &k.CreatedAt, &k.UpdatedAt); err != nil { + return err + } + keys = append(keys, &k) + } + return nil + }); err != nil { + return nil, err + } + return keys, nil +} + // WrapTransaction runs the given function within a transaction. func (d *Sqlite) wrapTransaction(f func(tx *sql.Tx) error) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) @@ -433,12 +475,17 @@ func (d *Sqlite) wrapTransaction(f func(tx *sql.Tx) error) error { } for { err = f(tx) - if err != nil { + if err != nil && !errors.Is(err, sql.ErrNoRows) { serr, ok := err.(*sqlite.Error) - if ok && serr.Code() == sqlitelib.SQLITE_BUSY { - continue + if ok { + switch serr.Code() { + case sqlitelib.SQLITE_BUSY: + continue + } + log.Printf("error in transaction: %d: %s", serr.Code(), serr) + } else { + log.Printf("error in transaction: %s", err) } - log.Printf("error in transaction: %s", err) return err } err = tx.Commit() diff --git a/server/db/types/repo.go b/server/db/types/repo.go index f9034c6fc40be73d4712dfe475e13a3d63b609cb..321b2b1335e9998b8ee74b73d8342272ee31c454 100644 --- a/server/db/types/repo.go +++ b/server/db/types/repo.go @@ -1,6 +1,8 @@ package types -import "time" +import ( + "time" +) // Repo is a repository database model. type Repo struct { diff --git a/server/git/daemon/daemon.go b/server/git/daemon/daemon.go index eb61b9c8dfe1458212e27a2e18eb57a815264db4..4880ff730299457ec3aa555c64bc7de9c34227bb 100644 --- a/server/git/daemon/daemon.go +++ b/server/git/daemon/daemon.go @@ -23,7 +23,6 @@ var ErrServerClosed = errors.New("git: Server closed") // Daemon represents a Git daemon. type Daemon struct { - auth git.Hooks listener net.Listener addr string exit chan struct{} @@ -34,11 +33,10 @@ type Daemon struct { } // NewDaemon returns a new Git daemon. -func NewDaemon(cfg *config.Config, auth git.Hooks) (*Daemon, error) { +func NewDaemon(cfg *config.Config) (*Daemon, error) { addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port) d := &Daemon{ addr: addr, - auth: auth, exit: make(chan struct{}), cfg: cfg, conns: make(map[net.Conn]struct{}), @@ -159,7 +157,7 @@ func (d *Daemon) handleClient(c net.Conn) { log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo) defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo) repo = strings.TrimPrefix(repo, "/") - auth := d.auth.AuthRepo(strings.TrimSuffix(repo, ".git"), nil) + auth := d.cfg.AuthRepo(strings.TrimSuffix(repo, ".git"), nil) if auth < proto.ReadOnlyAccess { fatal(c, git.ErrNotAuthed) return diff --git a/server/git/daemon/daemon_test.go b/server/git/daemon/daemon_test.go index aa4ef2744c06d4803b5d209cb8d097c02017fb56..fbfddd1e2133a2f6bc4ff09ab1ec878afae362ad 100644 --- a/server/git/daemon/daemon_test.go +++ b/server/git/daemon/daemon_test.go @@ -8,7 +8,6 @@ import ( "os" "testing" - appCfg "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/server/git" "github.com/go-git/go-git/v5/plumbing/format/pktline" @@ -35,11 +34,7 @@ func TestMain(m *testing.M) { Port: 9418, }, } - ac, err := appCfg.NewConfig(cfg) - if err != nil { - log.Fatal(err) - } - d, err := NewDaemon(cfg, ac) + d, err := NewDaemon(cfg) if err != nil { log.Fatal(err) } diff --git a/server/git/ssh/ssh.go b/server/git/ssh/ssh.go index b2c0ecd7dad2e8d909da5490e149aea90341b29a..3aff78d4e931b091c5f9a23e3c1a7e693ca766b2 100644 --- a/server/git/ssh/ssh.go +++ b/server/git/ssh/ssh.go @@ -17,7 +17,7 @@ import ( // checked for access on a per repo basis for a ssh.Session public key. // Hooks.Push and Hooks.Fetch will be called on successful completion of // their commands. -func Middleware(repoDir string, gh git.Hooks) wish.Middleware { +func Middleware(repoDir string, auth proto.Access) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { func() { @@ -33,7 +33,7 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware { return } pk := s.PublicKey() - access := gh.AuthRepo(strings.TrimSuffix(repo, ".git"), pk) + access := auth.AuthRepo(strings.TrimSuffix(repo, ".git"), pk) // git bare repositories should end in ".git" // https://git-scm.com/docs/gitrepository-layout if !strings.HasSuffix(repo, ".git") { @@ -46,8 +46,6 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware { err := git.GitPack(s, s, s.Stderr(), gc, repoDir, repo) if err != nil { Fatal(s, git.ErrSystemMalfunction) - } else { - gh.Push(repo, pk) } default: Fatal(s, git.ErrNotAuthed) @@ -65,7 +63,6 @@ func Middleware(repoDir string, gh git.Hooks) wish.Middleware { case git.ErrInvalidRepo: Fatal(s, git.ErrInvalidRepo) case nil: - gh.Fetch(repo, pk) default: log.Printf("unknown git error: %s", err) Fatal(s, git.ErrSystemMalfunction) diff --git a/server/server.go b/server/server.go index a7ce7276ca67964bfc24f98ae9765d0e1c790ab9..b241f89fd3af611083ce317c0b36eeac8d00e8b6 100644 --- a/server/server.go +++ b/server/server.go @@ -33,6 +33,7 @@ type Server struct { // restricted to that key. If authKey is not provided, the server will be // publicly writable until configured otherwise by cloning the `config` repo. func NewServer(cfg *config.Config) *Server { + s := &Server{Config: cfg} ac, err := appCfg.NewConfig(cfg) if err != nil { log.Fatal(err) @@ -43,33 +44,37 @@ func NewServer(cfg *config.Config) *Server { // BubbleTea middleware. bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256), // Command middleware must come after the git middleware. - cm.Middleware(ac), + cm.Middleware(cfg), // Git middleware. gm.Middleware(cfg.RepoPath(), ac), // Logging middleware must be last to be executed first. lm.Middleware(), ), } - s, err := wish.NewServer( - ssh.PublicKeyAuth(ac.PublicKeyHandler), - ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler), + + opts := []ssh.Option{ssh.PublicKeyAuth(cfg.PublicKeyHandler)} + if cfg.SSH.AllowKeyless { + opts = append(opts, ssh.KeyboardInteractiveAuth(cfg.KeyboardInteractiveHandler)) + } + if cfg.SSH.AllowPassword { + opts = append(opts, ssh.PasswordAuth(cfg.PasswordHandler)) + } + opts = append(opts, wish.WithAddress(fmt.Sprintf("%s:%d", cfg.Host, cfg.SSH.Port)), wish.WithHostKeyPath(cfg.PrivateKeyPath()), wish.WithMiddleware(mw...), ) + sh, err := wish.NewServer(opts...) if err != nil { log.Fatalln(err) } - d, err := daemon.NewDaemon(cfg, ac) + s.SSHServer = sh + d, err := daemon.NewDaemon(cfg) if err != nil { log.Fatalln(err) } - return &Server{ - SSHServer: s, - GitServer: d, - Config: cfg, - config: ac, - } + s.GitServer = d + return s } // Reload reloads the server configuration.