2 files changed,
103 insertions(+),
43 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-02-13 18:49:23 +0200
Authored at:
2026-02-13 18:46:03 +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 5 "log/slog" 6 6 "slices" 7 7 "strconv" 8 - "strings" 9 8 10 - securejoin "github.com/cyphar/filepath-securejoin" 11 9 "github.com/gliderlabs/ssh" 12 10 "olexsmir.xyz/mugit/internal/config" 13 11 "olexsmir.xyz/mugit/internal/git" 路路路 53 51 54 52 func (s *Server) authhandler(ctx ssh.Context, key ssh.PublicKey) bool { 55 53 fingerprint := gossh.FingerprintSHA256(key) 54 + 56 55 if ctx.User() != "git" { 57 56 slog.Info("non git ssh request", "user", ctx.User(), "fingerprint", fingerprint) 58 57 return false 59 58 } 60 59 61 60 slog.Info("ssh request", "fingerprint", fingerprint) 62 - authorized := slices.ContainsFunc(s.authKeys, func(i gossh.PublicKey) bool { 63 - return ssh.KeysEqual(key, i) 64 - }) 65 - ctx.SetValue(authorizedKey, authorized) 61 + 62 + ctx.SetValue(authorizedKey, s.isAuthorized(key)) 66 63 return true 67 64 } 68 65 69 66 func (s *Server) handler(sess ssh.Session) { 70 67 ctx := sess.Context() 71 - authorized := sess.Context().Value(authorizedKey).(bool) 68 + authorized := ctx.Value(authorizedKey).(bool) 72 69 73 70 cmd := sess.Command() 74 71 if len(cmd) < 2 { 75 - fmt.Fprintln(sess, "No command provided") 76 - sess.Exit(1) 72 + s.error(sess, badRequestErrMsg, nil) 77 73 return 78 74 } 79 75 80 76 gitCmd := cmd[0] 81 - rawRepoPath := cmd[1] 82 - normalizedRepoName := normalizeRepoName(rawRepoPath) 83 - repoPath := repoNameToPath(normalizedRepoName) 84 - 85 - fullPath, err := securejoin.SecureJoin(s.c.Repo.Dir, repoPath) 77 + userProvidedRepoName := cmd[1] 78 + repoPath, err := git.ResolvePath(s.c.Repo.Dir, git.ResolveName(userProvidedRepoName)) 86 79 if err != nil { 87 - slog.Error("ssh: invalid path", "err", err) 88 - s.repoNotFound(sess) 80 + s.error(sess, internalServerErrMsg, err) 89 81 return 90 82 } 91 83 92 - repo, err := git.Open(fullPath, "") 84 + repo, err := git.Open(repoPath, "") 93 85 if err != nil { 94 - slog.Error("ssh: failed to open repo", "err", err) 95 - s.repoNotFound(sess) 86 + s.gitError(sess, repoNotFoundErrMsg, err) 96 87 return 97 88 } 98 89 路路路 100 91 case "git-upload-pack": 101 92 isPrivate, err := repo.IsPrivate() 102 93 if err != nil { 103 - s.error(sess, err) 94 + s.gitError(sess, badRequestErrMsg, nil) 104 95 return 105 96 } 106 97 107 98 if isPrivate && !authorized { 108 - s.repoNotFound(sess) 99 + s.gitError(sess, badRequestErrMsg, nil) 109 100 return 110 101 } 111 102 112 - if err := gitx.UploadPack(ctx, fullPath, false, sess, sess); err != nil { 113 - s.error(sess, err) 103 + if err := gitx.UploadPack(ctx, repoPath, false, sess, sess); err != nil { 104 + s.gitError(sess, internalServerErrMsg, err) 114 105 return 115 106 } 107 + 116 108 sess.Exit(0) 109 + 117 110 case "git-receive-pack": 118 111 if !authorized { 119 - s.unauthorized(sess) 112 + s.gitError(sess, unauthorizedErrMsg, nil) 120 113 return 121 114 } 122 115 123 - if err := gitx.ReceivePack(ctx, fullPath, sess, sess, sess.Stderr()); err != nil { 124 - s.error(sess, err) 116 + if err := gitx.ReceivePack(ctx, repoPath, sess, sess, sess.Stderr()); err != nil { 117 + s.gitError(sess, internalServerErrMsg, err) 125 118 return 126 119 } 120 + 127 121 sess.Exit(0) 128 122 129 123 default: 130 - slog.Error("ssh unsupported command", "cmd", cmd) 131 - gitx.PackError(sess, "Unsupported command.") 132 - sess.Exit(1) 124 + s.error(sess, badRequestErrMsg, nil) 125 + return 133 126 } 134 127 } 135 128 129 +func (s *Server) isAuthorized(iden gossh.PublicKey) bool { 130 + return slices.ContainsFunc(s.authKeys, func(i gossh.PublicKey) bool { 131 + return ssh.KeysEqual(iden, i) 132 + }) 133 +} 134 + 136 135 func (s *Server) parseAuthKeys() error { 137 136 parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys)) 138 137 for i, key := range s.c.SSH.Keys { 路路路 146 145 return nil 147 146 } 148 147 149 -func (s *Server) repoNotFound(sess ssh.Session) { 150 - gitx.PackError(sess, "Repository not found.") 151 - sess.Exit(1) 152 -} 148 +const ( 149 + internalServerErrMsg = "internal server error\n" 150 + badRequestErrMsg = "bad request\n" 151 + unauthorizedErrMsg = "pushing only allowed to authorized users\n" 152 + repoNotFoundErrMsg = "repository not found\n" 153 +) 153 154 154 -func (s *Server) unauthorized(sess ssh.Session) { 155 - gitx.PackError(sess, "You are not authorized to push to this repository.") 155 +func (s *Server) error(sess ssh.Session, msg string, err error) { 156 + slog.Error("ssh error", "msg", msg, "err", err) 157 + fmt.Fprintf(sess.Stderr(), "%s", msg) 156 158 sess.Exit(1) 157 159 } 158 160 159 -func (s *Server) error(sess ssh.Session, err error) { 160 - slog.Error("error on ssh side", "err", err) 161 - gitx.PackError(sess, "Unexpected server error.") 161 +func (s *Server) gitError(sess ssh.Session, msg string, err error) { 162 + slog.Error("ssh git error", "msg", msg, "err", err) 163 + gitx.PackError(sess, msg) 164 + gitx.PackFlush(sess) 162 165 sess.Exit(1) 163 166 } 164 - 165 -func repoNameToPath(name string) string { return name + ".git" } 166 -func normalizeRepoName(name string) string { 167 - return strings.TrimSuffix(name, ".git") 168 -}
A
internal/ssh/ssh_test.go
路路路 1 +package ssh 2 + 3 +import ( 4 + "crypto/rand" 5 + "crypto/rsa" 6 + "testing" 7 + 8 + gossh "golang.org/x/crypto/ssh" 9 + "olexsmir.xyz/x/is" 10 +) 11 + 12 +func TestServer_isAuthorized(t *testing.T) { 13 + key1, err := rsa.GenerateKey(rand.Reader, 2048) 14 + is.Err(t, err, nil) 15 + pub1, err := gossh.NewPublicKey(&key1.PublicKey) 16 + is.Err(t, err, nil) 17 + 18 + key2, err := rsa.GenerateKey(rand.Reader, 2048) 19 + is.Err(t, err, nil) 20 + pub2, err := gossh.NewPublicKey(&key2.PublicKey) 21 + is.Err(t, err, nil) 22 + 23 + tests := []struct { 24 + name string 25 + authKeys []gossh.PublicKey 26 + checkKey gossh.PublicKey 27 + wantAuth bool 28 + }{ 29 + { 30 + name: "authorized key", 31 + wantAuth: true, 32 + authKeys: []gossh.PublicKey{pub1}, 33 + checkKey: pub1, 34 + }, 35 + { 36 + name: "unauthorized key", 37 + wantAuth: false, 38 + authKeys: []gossh.PublicKey{pub1}, 39 + checkKey: pub2, 40 + }, 41 + { 42 + name: "empty auth keys", 43 + wantAuth: false, 44 + authKeys: []gossh.PublicKey{}, 45 + checkKey: pub1, 46 + }, 47 + { 48 + name: "multiple auth keys - found", 49 + wantAuth: true, 50 + authKeys: []gossh.PublicKey{pub1, pub2}, 51 + checkKey: pub2, 52 + }, 53 + } 54 + 55 + for _, tt := range tests { 56 + t.Run(tt.name, func(t *testing.T) { 57 + s := &Server{authKeys: tt.authKeys} 58 + got := s.isAuthorized(tt.checkKey) 59 + is.Equal(t, tt.wantAuth, got) 60 + }) 61 + } 62 +}