all repos

mugit @ d5b24c1

馃惍 git server that your cow will love
2 files changed, 103 insertions(+), 43 deletions(-)
ssh: some refactoring + test isAuthorized
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
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
        +}