all repos

mugit @ dc62a68

🐮 git server that your cow will love

mugit/internal/ssh/ssh.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
git: refactor gitserver again; and rename, 3 months ago
1
package ssh
2
3
import (
4
	"fmt"
5
	"log/slog"
6
	"slices"
7
	"strconv"
8
	"strings"
9
10
	securejoin "github.com/cyphar/filepath-securejoin"
11
	"github.com/gliderlabs/ssh"
12
	"olexsmir.xyz/mugit/internal/config"
13
	"olexsmir.xyz/mugit/internal/git"
14
	"olexsmir.xyz/mugit/internal/git/gitx"
15
16
	gossh "golang.org/x/crypto/ssh"
17
)
18
19
type authorizedKeyType string
20
21
const authorizedKey authorizedKeyType = "authorized"
22
23
type Server struct {
24
	c        *config.Config
25
	authKeys []gossh.PublicKey
26
}
27
28
func NewServer(cfg *config.Config) *Server {
29
	return &Server{
30
		c:        cfg,
31
		authKeys: []gossh.PublicKey{},
32
	}
33
}
34
35
func (s *Server) Start() error {
36
	if err := s.parseAuthKeys(); err != nil {
37
		return err
38
	}
39
40
	srv := &ssh.Server{
41
		Addr:             ":" + strconv.Itoa(s.c.SSH.Port),
42
		Handler:          s.handler,
43
		PublicKeyHandler: s.authhandler,
44
	}
45
46
	if err := srv.SetOption(ssh.HostKeyFile(s.c.SSH.HostKey)); err != nil {
47
		// TODO: validate `gossh.ParsePrivateKey`
48
		return err
49
	}
50
51
	return srv.ListenAndServe()
52
}
53
54
func (s *Server) authhandler(ctx ssh.Context, key ssh.PublicKey) bool {
55
	fingerprint := gossh.FingerprintSHA256(key)
56
	if ctx.User() != "git" {
57
		slog.Info("non git ssh request", "user", ctx.User(), "fingerprint", fingerprint)
58
		return false
59
	}
60
61
	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)
66
	return true
67
}
68
69
func (s *Server) handler(sess ssh.Session) {
70
	ctx := sess.Context()
71
	authorized := sess.Context().Value(authorizedKey).(bool)
72
73
	cmd := sess.Command()
74
	if len(cmd) < 2 {
75
		fmt.Fprintln(sess, "No command provided")
76
		sess.Exit(1)
77
		return
78
	}
79
80
	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)
86
	if err != nil {
87
		slog.Error("ssh: invalid path", "err", err)
88
		s.repoNotFound(sess)
89
		return
90
	}
91
92
	repo, err := git.Open(fullPath, "")
93
	if err != nil {
94
		slog.Error("ssh: failed to open repo", "err", err)
95
		s.repoNotFound(sess)
96
		return
97
	}
98
99
	switch gitCmd {
100
	case "git-upload-pack":
101
		isPrivate, err := repo.IsPrivate()
102
		if err != nil {
103
			s.error(sess, err)
104
			return
105
		}
106
107
		if isPrivate && !authorized {
108
			s.repoNotFound(sess)
109
			return
110
		}
111
112
		if err := gitx.UploadPack(ctx, fullPath, false, sess, sess); err != nil {
113
			s.error(sess, err)
114
			return
115
		}
116
		sess.Exit(0)
117
	case "git-receive-pack":
118
		if !authorized {
119
			s.unauthorized(sess)
120
			return
121
		}
122
123
		if err := gitx.ReceivePack(ctx, fullPath, sess, sess, sess.Stderr()); err != nil {
124
			s.error(sess, err)
125
			return
126
		}
127
		sess.Exit(0)
128
129
	default:
130
		slog.Error("ssh unsupported command", "cmd", cmd)
131
		gitx.PackError(sess, "Unsupported command.")
132
		sess.Exit(1)
133
	}
134
}
135
136
func (s *Server) parseAuthKeys() error {
137
	parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys))
138
	for i, key := range s.c.SSH.Keys {
139
		pkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key))
140
		if err != nil {
141
			return err
142
		}
143
		parsedKeys[i] = pkey
144
	}
145
	s.authKeys = parsedKeys
146
	return nil
147
}
148
149
func (s *Server) repoNotFound(sess ssh.Session) {
150
	gitx.PackError(sess, "Repository not found.")
151
	sess.Exit(1)
152
}
153
154
func (s *Server) unauthorized(sess ssh.Session) {
155
	gitx.PackError(sess, "You are not authorized to push to this repository.")
156
	sess.Exit(1)
157
}
158
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.")
162
	sess.Exit(1)
163
}
164
165
func repoNameToPath(name string) string { return name + ".git" }
166
func normalizeRepoName(name string) string {
167
	return strings.TrimSuffix(name, ".git")
168
}