all repos

mugit @ 99ee247

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
fix naming inconsistencies; use is for testsing, 4 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/gitservice"
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
	authorized := sess.Context().Value(authorizedKey).(bool)
71
72
	cmd := sess.Command()
73
	if len(cmd) < 2 {
74
		fmt.Fprintln(sess, "No command provided")
75
		sess.Exit(1)
76
		return
77
	}
78
79
	gitCmd := cmd[0]
80
	rawRepoPath := cmd[1]
81
	normalizedRepoName := normalizeRepoName(rawRepoPath)
82
	repoPath := repoNameToPath(normalizedRepoName)
83
84
	fullPath, err := securejoin.SecureJoin(s.c.Repo.Dir, repoPath)
85
	if err != nil {
86
		slog.Error("ssh: invalid path", "err", err)
87
		s.repoNotFound(sess)
88
		return
89
	}
90
91
	repo, err := git.Open(fullPath, "")
92
	if err != nil {
93
		slog.Error("ssh: failed to open repo", "err", err)
94
		s.repoNotFound(sess)
95
		return
96
	}
97
98
	switch gitCmd {
99
	case "git-upload-pack":
100
		isPrivate, err := repo.IsPrivate()
101
		if err != nil {
102
			s.error(sess, err)
103
			return
104
		}
105
106
		if isPrivate && !authorized {
107
			s.repoNotFound(sess)
108
			return
109
		}
110
111
		if err := gitservice.UploadPack(fullPath, false, sess, sess); err != nil {
112
			s.error(sess, err)
113
			return
114
		}
115
		sess.Exit(0)
116
	case "git-receive-pack":
117
		if !authorized {
118
			s.unauthorized(sess)
119
			return
120
		}
121
122
		if err := gitservice.ReceivePack(fullPath, sess, sess, sess.Stderr()); err != nil {
123
			s.error(sess, err)
124
			return
125
		}
126
		sess.Exit(0)
127
128
	default:
129
		slog.Error("ssh unsupported command", "cmd", cmd)
130
		gitservice.PackError(sess, "Unsupported command.")
131
		sess.Exit(1)
132
	}
133
}
134
135
func (s *Server) parseAuthKeys() error {
136
	parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys))
137
	for i, key := range s.c.SSH.Keys {
138
		pkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key))
139
		if err != nil {
140
			return err
141
		}
142
		parsedKeys[i] = pkey
143
	}
144
	s.authKeys = parsedKeys
145
	return nil
146
}
147
148
func (s *Server) repoNotFound(sess ssh.Session) {
149
	gitservice.PackError(sess, "Repository not found.")
150
	sess.Exit(1)
151
}
152
153
func (s *Server) unauthorized(sess ssh.Session) {
154
	gitservice.PackError(sess, "You are not authorized to push to this repository.")
155
	sess.Exit(1)
156
}
157
158
func (s *Server) error(sess ssh.Session, err error) {
159
	slog.Error("error on ssh side", "err", err)
160
	gitservice.PackError(sess, "Unexpected server error.")
161
	sess.Exit(1)
162
}
163
164
func repoNameToPath(name string) string { return name + ".git" }
165
func normalizeRepoName(name string) string {
166
	return strings.TrimSuffix(name, ".git")
167
}