Detailed changes
@@ -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
+}
@@ -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
+}
@@ -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
@@ -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 {
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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
@@ -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
-}
@@ -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 = ?);`
)
@@ -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()
@@ -1,6 +1,8 @@
package types
-import "time"
+import (
+ "time"
+)
// Repo is a repository database model.
type Repo struct {
@@ -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
@@ -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)
}
@@ -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)
@@ -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.