2 files changed,
103 insertions(+),
43 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-13 18:49:23 +0200
Change ID:
nzqvopsusmryvxqtsqykopvwvklxwvsz
Parent:
831d5c6
jump to
| M | internal/ssh/ssh.go |
| A | internal/ssh/ssh_test.go |
M
internal/ssh/ssh.go
@@ -5,9 +5,7 @@ "fmt"
"log/slog" "slices" "strconv" - "strings" - securejoin "github.com/cyphar/filepath-securejoin" "github.com/gliderlabs/ssh" "olexsmir.xyz/mugit/internal/config" "olexsmir.xyz/mugit/internal/git"@@ -53,46 +51,39 @@ }
func (s *Server) authhandler(ctx ssh.Context, key ssh.PublicKey) bool { fingerprint := gossh.FingerprintSHA256(key) + if ctx.User() != "git" { slog.Info("non git ssh request", "user", ctx.User(), "fingerprint", fingerprint) return false } slog.Info("ssh request", "fingerprint", fingerprint) - authorized := slices.ContainsFunc(s.authKeys, func(i gossh.PublicKey) bool { - return ssh.KeysEqual(key, i) - }) - ctx.SetValue(authorizedKey, authorized) + + ctx.SetValue(authorizedKey, s.isAuthorized(key)) return true } func (s *Server) handler(sess ssh.Session) { ctx := sess.Context() - authorized := sess.Context().Value(authorizedKey).(bool) + authorized := ctx.Value(authorizedKey).(bool) cmd := sess.Command() if len(cmd) < 2 { - fmt.Fprintln(sess, "No command provided") - sess.Exit(1) + s.error(sess, badRequestErrMsg, nil) return } gitCmd := cmd[0] - rawRepoPath := cmd[1] - normalizedRepoName := normalizeRepoName(rawRepoPath) - repoPath := repoNameToPath(normalizedRepoName) - - fullPath, err := securejoin.SecureJoin(s.c.Repo.Dir, repoPath) + userProvidedRepoName := cmd[1] + repoPath, err := git.ResolvePath(s.c.Repo.Dir, git.ResolveName(userProvidedRepoName)) if err != nil { - slog.Error("ssh: invalid path", "err", err) - s.repoNotFound(sess) + s.error(sess, internalServerErrMsg, err) return } - repo, err := git.Open(fullPath, "") + repo, err := git.Open(repoPath, "") if err != nil { - slog.Error("ssh: failed to open repo", "err", err) - s.repoNotFound(sess) + s.gitError(sess, repoNotFoundErrMsg, err) return }@@ -100,39 +91,47 @@ switch gitCmd {
case "git-upload-pack": isPrivate, err := repo.IsPrivate() if err != nil { - s.error(sess, err) + s.gitError(sess, badRequestErrMsg, nil) return } if isPrivate && !authorized { - s.repoNotFound(sess) + s.gitError(sess, badRequestErrMsg, nil) return } - if err := gitx.UploadPack(ctx, fullPath, false, sess, sess); err != nil { - s.error(sess, err) + if err := gitx.UploadPack(ctx, repoPath, false, sess, sess); err != nil { + s.gitError(sess, internalServerErrMsg, err) return } + sess.Exit(0) + case "git-receive-pack": if !authorized { - s.unauthorized(sess) + s.gitError(sess, unauthorizedErrMsg, nil) return } - if err := gitx.ReceivePack(ctx, fullPath, sess, sess, sess.Stderr()); err != nil { - s.error(sess, err) + if err := gitx.ReceivePack(ctx, repoPath, sess, sess, sess.Stderr()); err != nil { + s.gitError(sess, internalServerErrMsg, err) return } + sess.Exit(0) default: - slog.Error("ssh unsupported command", "cmd", cmd) - gitx.PackError(sess, "Unsupported command.") - sess.Exit(1) + s.error(sess, badRequestErrMsg, nil) + return } } +func (s *Server) isAuthorized(iden gossh.PublicKey) bool { + return slices.ContainsFunc(s.authKeys, func(i gossh.PublicKey) bool { + return ssh.KeysEqual(iden, i) + }) +} + func (s *Server) parseAuthKeys() error { parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys)) for i, key := range s.c.SSH.Keys {@@ -146,23 +145,22 @@ s.authKeys = parsedKeys
return nil } -func (s *Server) repoNotFound(sess ssh.Session) { - gitx.PackError(sess, "Repository not found.") - sess.Exit(1) -} +const ( + internalServerErrMsg = "internal server error\n" + badRequestErrMsg = "bad request\n" + unauthorizedErrMsg = "pushing only allowed to authorized users\n" + repoNotFoundErrMsg = "repository not found\n" +) -func (s *Server) unauthorized(sess ssh.Session) { - gitx.PackError(sess, "You are not authorized to push to this repository.") +func (s *Server) error(sess ssh.Session, msg string, err error) { + slog.Error("ssh error", "msg", msg, "err", err) + fmt.Fprintf(sess.Stderr(), "%s", msg) sess.Exit(1) } -func (s *Server) error(sess ssh.Session, err error) { - slog.Error("error on ssh side", "err", err) - gitx.PackError(sess, "Unexpected server error.") +func (s *Server) gitError(sess ssh.Session, msg string, err error) { + slog.Error("ssh git error", "msg", msg, "err", err) + gitx.PackError(sess, msg) + gitx.PackFlush(sess) sess.Exit(1) } - -func repoNameToPath(name string) string { return name + ".git" } -func normalizeRepoName(name string) string { - return strings.TrimSuffix(name, ".git") -}
A
internal/ssh/ssh_test.go
@@ -0,0 +1,62 @@
+package ssh + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + + gossh "golang.org/x/crypto/ssh" + "olexsmir.xyz/x/is" +) + +func TestServer_isAuthorized(t *testing.T) { + key1, err := rsa.GenerateKey(rand.Reader, 2048) + is.Err(t, err, nil) + pub1, err := gossh.NewPublicKey(&key1.PublicKey) + is.Err(t, err, nil) + + key2, err := rsa.GenerateKey(rand.Reader, 2048) + is.Err(t, err, nil) + pub2, err := gossh.NewPublicKey(&key2.PublicKey) + is.Err(t, err, nil) + + tests := []struct { + name string + authKeys []gossh.PublicKey + checkKey gossh.PublicKey + wantAuth bool + }{ + { + name: "authorized key", + wantAuth: true, + authKeys: []gossh.PublicKey{pub1}, + checkKey: pub1, + }, + { + name: "unauthorized key", + wantAuth: false, + authKeys: []gossh.PublicKey{pub1}, + checkKey: pub2, + }, + { + name: "empty auth keys", + wantAuth: false, + authKeys: []gossh.PublicKey{}, + checkKey: pub1, + }, + { + name: "multiple auth keys - found", + wantAuth: true, + authKeys: []gossh.PublicKey{pub1, pub2}, + checkKey: pub2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Server{authKeys: tt.authKeys} + got := s.isAuthorized(tt.checkKey) + is.Equal(t, tt.wantAuth, got) + }) + } +}