13 files changed,
234 insertions(+),
280 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-03-27 22:25:15 +0200
Authored at:
2026-03-27 15:22:35 +0200
Change ID:
pyrspmtmooxksprwzqrumwqkyxypnsrp
Parent:
0c6e821
jump to
M
README.md
路路路 88 88 # ssh: push/clone over SSH 89 89 ssh: 90 90 enable: true 91 - port: 2222 # SSH port (default 2222) 92 91 user: "git" # user as which the app operates (default "git") 93 - host_key: /var/lib/mugit/host # path to SSH host key (generate with ssh-keygen) 94 92 # Only these public keys can access private repos and push to others. 95 93 keys: 96 94 - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA......
M
flake.nix
路路路 5 5 let 6 6 systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 7 7 forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); 8 - in 9 - { 8 + in { 10 9 packages = forAllSystems (pkgs: 11 10 let version = self.rev or "dev"; 12 11 in { 路路路 15 14 pname = "mugit"; 16 15 version = version; 17 16 src = ./.; 18 - vendorHash = "sha256-rnBcUcEN24Qul0Fljo7aQ9aholXDZuUgQhoyzhEC49E="; 17 + vendorHash = "sha256-eJ6L6o2cisJRZxoEDf9gtHL8T+xpnIDq9KPQr1vgLig="; 19 18 ldflags = [ "-s" "-w" "-X main.version=${version}" ]; 20 19 meta = with pkgs.lib; { 21 20 homepage = "https://git.olexsmir.xyz/mugit"; 路路路 30 29 let 31 30 cfg = config.services.mugit; 32 31 format = pkgs.formats.yaml { }; 33 - configFile = format.generate "config.yaml" cfg.config; 34 - in 35 - { 32 + sshUser = if cfg.config.ssh.user != "" then cfg.config.ssh.user else cfg.user; 33 + configFile = if cfg.configFile != null 34 + then cfg.configFile 35 + else format.generate "config.yaml" cfg.config; 36 + in { 36 37 options.services.mugit = { 37 38 enable = mkEnableOption "mugit service"; 38 39 路路路 57 58 58 59 user = mkOption { 59 60 type = types.str; 60 - default = "mugit"; 61 + default = "git"; 61 62 description = "User account under which mugit runs."; 62 63 }; 63 64 64 65 group = mkOption { 65 66 type = types.str; 66 - default = "mugit"; 67 + default = "git"; 67 68 description = "Group under which mugit runs."; 68 69 }; 69 70 路路路 134 135 enable = mkOption { 135 136 type = types.bool; 136 137 default = false; 137 - description = "Wharever to run ssh server"; 138 + description = "Whether to enable SSH git access"; 138 139 }; 139 140 user = mkOption { 140 141 type = types.str; 141 - default = "git"; 142 - description = "User used for git access"; 143 - }; 144 - port = mkOption { 145 - type = types.port; 146 - default = 2222; 147 - description = "Website port"; 142 + default = ""; 143 + description = "User used for git access. Defaults to the main user option."; 148 144 }; 149 145 host_key = mkOption { 150 146 type = types.str; 路路路 191 187 }; 192 188 193 189 config = mkIf cfg.enable { 190 + assertions = [ 191 + { 192 + assertion = !cfg.config.ssh.enable || (cfg.config.ssh.keys != []); 193 + message = "SSH is enabled but no SSH keys provided. Please add keys to services.mugit.config.ssh.keys"; 194 + } 195 + ]; 196 + 197 + users.groups.${cfg.group} = { }; 194 198 users.users.${cfg.user} = { 195 199 isSystemUser = true; 200 + useDefaultShell = true; 196 201 group = cfg.group; 197 202 home = cfg.config.repo.dir; 198 203 createHome = true; 199 204 description = "mugit service user"; 200 205 }; 201 206 202 - users.groups.${cfg.group} = { }; 207 + services.openssh = mkIf cfg.config.ssh.enable { 208 + enable = true; 209 + extraConfig = '' 210 + Match User ${sshUser} 211 + AuthorizedKeysCommand /etc/ssh/mugit_authorized_keys 212 + AuthorizedKeysCommandUser ${sshUser} 213 + ChallengeResponseAuthentication no 214 + PasswordAuthentication no 215 + AllowUsers ${sshUser} 216 + ''; 217 + }; 218 + 219 + environment.etc."ssh/mugit_authorized_keys" = mkIf cfg.config.ssh.enable { 220 + mode = "0555"; 221 + text = '' 222 + #!${pkgs.stdenv.shell} 223 + ${cfg.package}/bin/mugit --config ${configFile} shell keys "$1" 224 + ''; 225 + }; 203 226 204 227 environment.systemPackages = lib.mkIf cfg.exposeCli [ 205 228 (pkgs.runCommandLocal "mugit-completions" {} '' 路路路 216 239 mugit = { 217 240 source = 218 241 let 219 - resolvedConfig = if cfg.configFile != null then cfg.configFile else configFile; 220 242 mugitWrapped = pkgs.writeScriptBin "mugit" '' 221 243 #!${pkgs.bash}/bin/bash 222 - exec ${cfg.package}/bin/mugit --config ${resolvedConfig} "$@" 244 + exec ${cfg.package}/bin/mugit --config ${configFile} "$@" 223 245 ''; 224 246 in 225 247 "${mugitWrapped}/bin/mugit"; 路路路 234 256 systemd.services.mugit = { 235 257 description = "mugit service"; 236 258 wantedBy = [ "multi-user.target" ]; 237 - after = [ "network.target" ]; 259 + after = [ "network.target" ] ++ lib.optionals cfg.config.ssh.enable [ "sshd.service" ]; 238 260 path = [ pkgs.git ]; 239 261 serviceConfig = { 240 262 Type = "simple"; 路路路 253 275 ProtectKernelTunables = true; 254 276 ProtectKernelModules = true; 255 277 ProtectControlGroups = true; 256 - } // lib.optionalAttrs (cfg.config.ssh.enable && cfg.config.ssh.port < 1024) { 257 - AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; 258 278 }; 259 279 }; 260 280 };
M
go.mod
路路路 5 5 require ( 6 6 github.com/bluekeyes/go-gitdiff v0.8.1 7 7 github.com/cyphar/filepath-securejoin v0.6.1 8 - github.com/gliderlabs/ssh v0.3.8 9 8 github.com/go-git/go-git/v5 v5.17.0 10 9 github.com/urfave/cli/v3 v3.7.0 11 10 github.com/yuin/goldmark v1.7.16 路路路 21 20 dario.cat/mergo v1.0.2 // indirect 22 21 github.com/Microsoft/go-winio v0.6.2 // indirect 23 22 github.com/ProtonMail/go-crypto v1.4.0 // indirect 24 - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 25 23 github.com/cloudflare/circl v1.6.3 // indirect 26 24 github.com/emirpasic/gods v1.18.1 // indirect 27 25 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
M
internal/cli/cli.go
路路路 7 7 "github.com/urfave/cli/v3" 8 8 "olexsmir.xyz/mugit/internal/config" 9 9 "olexsmir.xyz/mugit/internal/git" 10 + "olexsmir.xyz/mugit/internal/ssh" 10 11 ) 11 12 12 13 type Cli struct { 13 14 cfg *config.Config 15 + ssh *ssh.Shell 14 16 version string 15 17 } 16 18 路路路 40 42 return ctx, err 41 43 } 42 44 c.cfg = loadedCfg 45 + 46 + if c.cfg.SSH.Enable { 47 + shell, err := ssh.NewShell(c.cfg) 48 + if err != nil { 49 + return ctx, err 50 + } 51 + c.ssh = shell 52 + } 53 + 43 54 return ctx, nil 44 55 }, 45 56 Commands: []*cli.Command{ 路路路 97 108 Arguments: []cli.Argument{ 98 109 &cli.StringArg{Name: "name"}, 99 110 }, 111 + }, 112 + }, 113 + }, 114 + { 115 + Name: "shell", 116 + Description: "sshd things", // TODO: update me 117 + Action: c.sshShellAction, 118 + Commands: []*cli.Command{ 119 + { 120 + Name: "keys", 121 + Action: c.sshAuthorizedKeysAction, 100 122 }, 101 123 }, 102 124 },
M
internal/cli/serve.go
路路路 13 13 "github.com/urfave/cli/v3" 14 14 "olexsmir.xyz/mugit/internal/handlers" 15 15 "olexsmir.xyz/mugit/internal/mirror" 16 - "olexsmir.xyz/mugit/internal/ssh" 17 16 ) 18 17 19 18 func (c *Cli) serveAction(ctx context.Context, cmd *cli.Command) error { 路路路 27 26 slog.Error("HTTP server error", "err", err) 28 27 } 29 28 }() 30 - 31 - if c.cfg.SSH.Enable { 32 - sshServer := ssh.NewServer(c.cfg) 33 - go func() { 34 - slog.Info("starting ssh server", "port", c.cfg.SSH.Port) 35 - if err := sshServer.Start(); err != nil { 36 - slog.Error("ssh server error", "err", err) 37 - } 38 - }() 39 - } 40 29 41 30 if c.cfg.Mirror.Enable { 42 31 mirrorer := mirror.NewWorker(c.cfg)
A
internal/cli/ssh_shell.go
路路路 1 +package cli 2 + 3 +import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "os" 8 + 9 + "github.com/urfave/cli/v3" 10 +) 11 + 12 +var errSSHDisabled = errors.New("ssh is disabled") 13 + 14 +func (c *Cli) sshShellAction(ctx context.Context, cmd *cli.Command) error { 15 + if !c.cfg.SSH.Enable { 16 + return errSSHDisabled 17 + } 18 + 19 + sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 20 + return c.ssh.HandleCommand(ctx, sshCommand, os.Stdin, os.Stdout, os.Stderr) 21 +} 22 + 23 +func (c *Cli) sshAuthorizedKeysAction(ctx context.Context, cmd *cli.Command) error { 24 + if !c.cfg.SSH.Enable { 25 + return errSSHDisabled 26 + } 27 + 28 + fingerprint := cmd.Args().First() 29 + if fingerprint == "" { 30 + return fmt.Errorf("fingerprint is required") 31 + } 32 + 33 + executablePath, err := os.Executable() 34 + if err != nil { 35 + return err 36 + } 37 + 38 + out := c.ssh.AuthorizedKeys(executablePath) 39 + fmt.Fprint(os.Stdout, out) 40 + 41 + return nil 42 +}
M
internal/config/config.go
路路路 37 37 type SSHConfig struct { 38 38 Enable bool `yaml:"enable"` 39 39 User string `yaml:"user"` 40 - Port int `yaml:"port"` 41 - HostKey string `yaml:"host_key"` 42 40 Keys []string `yaml:"keys"` 43 41 } 44 42 路路路 143 141 } 144 142 145 143 // ssh 146 - if c.SSH.Port == 0 { 147 - c.SSH.Port = 2222 148 - } 149 - 150 144 if c.SSH.User == "" { 151 145 c.SSH.User = "git" 152 146 }
M
internal/config/validate.go
路路路 29 29 } 30 30 31 31 if c.SSH.Enable { 32 - if err := checkPort(c.SSH.Port); err != nil { 33 - errs = append(errs, fmt.Errorf("ssh.port %w", err)) 34 - } 35 - 36 - if c.SSH.Port == c.Server.Port { 37 - errs = append(errs, fmt.Errorf("ssh.port must differ from server.port (both are %d)", c.Server.Port)) 38 - } 39 - 40 32 if !validUserNameRe.MatchString(c.SSH.User) { 41 33 errs = append(errs, fmt.Errorf("ssh.user must be correct linux user name(^[a-z_][a-z0-9_-]{0,31}$)")) 42 - } 43 - 44 - if !isFileExists(c.SSH.HostKey) { 45 - errs = append(errs, fmt.Errorf("ssh.host_key seems to be an invalid path")) 46 34 } 47 35 } 48 36
M
internal/config/validate_test.go
路路路 17 17 } 18 18 19 19 func TestConfig_Validate(t *testing.T) { 20 - hostKey := "testdata/hostkey" 21 20 tests := []struct { 22 21 name string 23 22 expected any 路路路 35 34 c: Config{ 36 35 Meta: MetaConfig{Host: "example.com"}, 37 36 Repo: RepoConfig{Dir: t.TempDir()}, 38 - SSH: SSHConfig{ 39 - Enable: true, 40 - HostKey: hostKey, 41 - }, 37 + SSH: SSHConfig{Enable: true}, 42 38 }, 43 39 }, 44 40 { 路路路 71 67 Meta: MetaConfig{Host: "example.com"}, 72 68 Repo: RepoConfig{Dir: t.TempDir()}, 73 69 Server: ServerConfig{Port: -1}, 74 - }, 75 - }, 76 - { 77 - name: "invalid ssh port", 78 - expected: "ssh.port", 79 - c: Config{ 80 - Meta: MetaConfig{Host: "example.com"}, 81 - Repo: RepoConfig{Dir: t.TempDir()}, 82 - SSH: SSHConfig{ 83 - Enable: true, 84 - HostKey: hostKey, 85 - Port: 100000, 86 - }, 87 - }, 88 - }, 89 - { 90 - name: "same ssh and http ports", 91 - expected: "ssh.port must differ", 92 - c: Config{ 93 - Meta: MetaConfig{Host: "example.com"}, 94 - Repo: RepoConfig{Dir: t.TempDir()}, 95 - SSH: SSHConfig{Enable: true, Port: 228}, 96 - Server: ServerConfig{Port: 228}, 97 - }, 98 - }, 99 - { 100 - name: "invalid ssh.host_key path", 101 - expected: "ssh.host_key", 102 - c: Config{ 103 - Meta: MetaConfig{Host: "example.com"}, 104 - Repo: RepoConfig{Dir: t.TempDir()}, 105 - SSH: SSHConfig{ 106 - Enable: true, 107 - HostKey: "/somewhere", 108 - }, 109 70 }, 110 71 }, 111 72 }
M
internal/ssh/ssh.go
路路路 1 1 package ssh 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 6 + "io" 5 7 "log/slog" 6 - "slices" 7 - "strconv" 8 + "strings" 8 9 9 - "github.com/gliderlabs/ssh" 10 10 "olexsmir.xyz/mugit/internal/config" 11 11 "olexsmir.xyz/mugit/internal/git" 12 12 13 13 gossh "golang.org/x/crypto/ssh" 14 14 ) 15 15 16 -type authorizedKeyType string 17 - 18 -const authorizedKey authorizedKeyType = "authorized" 16 +type Shell struct { 17 + cfg *config.Config 19 18 20 -type Server struct { 21 - c *config.Config 22 - authKeys []gossh.PublicKey 19 + keys []gossh.PublicKey 23 20 } 24 21 25 -func NewServer(cfg *config.Config) *Server { 26 - return &Server{ 27 - c: cfg, 28 - authKeys: []gossh.PublicKey{}, 29 - } 30 -} 31 - 32 -func (s *Server) Start() error { 33 - if err := s.parseAuthKeys(); err != nil { 34 - return err 35 - } 36 - 37 - srv := &ssh.Server{ 38 - Addr: ":" + strconv.Itoa(s.c.SSH.Port), 39 - Handler: s.handler, 40 - PublicKeyHandler: s.authhandler, 41 - } 42 - 43 - if err := srv.SetOption(ssh.HostKeyFile(s.c.SSH.HostKey)); err != nil { 44 - // TODO: validate `gossh.ParsePrivateKey` 45 - return err 46 - } 47 - 48 - return srv.ListenAndServe() 49 -} 50 - 51 -func (s *Server) authhandler(ctx ssh.Context, key ssh.PublicKey) bool { 52 - fingerprint := gossh.FingerprintSHA256(key) 53 - 54 - if ctx.User() != "git" { 55 - slog.Info("non git ssh request", "user", ctx.User(), "fingerprint", fingerprint) 56 - return false 22 +func NewShell(cfg *config.Config) (*Shell, error) { 23 + parsedKeys := make([]gossh.PublicKey, len(cfg.SSH.Keys)) 24 + for i, key := range cfg.SSH.Keys { 25 + pkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key)) 26 + if err != nil { 27 + return nil, err 28 + } 29 + parsedKeys[i] = pkey 57 30 } 58 31 59 - slog.Info("ssh request", "fingerprint", fingerprint) 60 - 61 - ctx.SetValue(authorizedKey, s.isAuthorized(key)) 62 - return true 32 + return &Shell{ 33 + cfg: cfg, 34 + keys: parsedKeys, 35 + }, nil 63 36 } 64 37 65 -func (s *Server) handler(sess ssh.Session) { 66 - ctx := sess.Context() 67 - authorized := ctx.Value(authorizedKey).(bool) 68 - 69 - cmd := sess.Command() 70 - if len(cmd) < 2 { 71 - s.error(sess, badRequestErrMsg, nil) 72 - return 38 +func (s *Shell) HandleCommand(ctx context.Context, cmd string, stdin io.Reader, stdout, stderr io.Writer) error { 39 + gitCmd, repoName, err := s.parseCommand(cmd) 40 + if err != nil { 41 + slog.Error("ssh invalid command", "error", err, "raw_cmd", cmd) 42 + return err 73 43 } 74 44 75 - gitCmd := cmd[0] 76 - userProvidedRepoName := cmd[1] 77 - repoPath, err := git.ResolvePath(s.c.Repo.Dir, git.ResolveName(userProvidedRepoName)) 45 + repoPath, err := git.ResolvePath(s.cfg.Repo.Dir, git.ResolveName(repoName)) 78 46 if err != nil { 79 - s.error(sess, internalServerErrMsg, err) 80 - return 47 + slog.Error("ssh access denied", "cmd", gitCmd, "repo", repoName, "error", err) 48 + return err 81 49 } 82 50 83 51 repo, err := git.Open(repoPath, "") 84 52 if err != nil { 85 - s.gitError(sess, repoNotFoundErrMsg, err) 86 - return 53 + slog.Error("ssh access denied", "cmd", gitCmd, "repo", repoName, "error", err) 54 + return err 87 55 } 88 56 89 57 switch gitCmd { 90 58 case "git-upload-pack": 91 - isPrivate, err := repo.IsPrivate() 92 - if err != nil { 93 - s.gitError(sess, badRequestErrMsg, nil) 94 - return 95 - } 96 - 97 - if isPrivate && !authorized { 98 - s.gitError(sess, badRequestErrMsg, nil) 99 - return 100 - } 101 - 102 - if err := repo.UploadPack(ctx, false, "", sess, sess); err != nil { 103 - s.gitError(sess, internalServerErrMsg, err) 104 - return 105 - } 106 - 107 - sess.Exit(0) 108 - 59 + err = repo.UploadPack(ctx, false, "", stdin, stdout) 109 60 case "git-upload-archive": 110 - isPrivate, err := repo.IsPrivate() 111 - if err != nil { 112 - s.gitError(sess, badRequestErrMsg, nil) 113 - return 114 - } 115 - 116 - if isPrivate && !authorized { 117 - s.gitError(sess, badRequestErrMsg, nil) 118 - return 119 - } 120 - 121 - if err := repo.UploadArchive(ctx, sess, sess); err != nil { 122 - s.gitError(sess, internalServerErrMsg, err) 123 - return 124 - } 125 - 126 - sess.Exit(0) 127 - 61 + err = repo.UploadArchive(ctx, stdin, stdout) 128 62 case "git-receive-pack": 129 - if !authorized { 130 - s.gitError(sess, unauthorizedErrMsg, nil) 131 - return 132 - } 133 - 134 - if err := repo.ReceivePack(ctx, sess, sess, sess.Stderr()); err != nil { 135 - s.gitError(sess, internalServerErrMsg, err) 136 - return 137 - } 138 - 139 - sess.Exit(0) 140 - 63 + err = repo.ReceivePack(ctx, stdin, stdout, stderr) 141 64 default: 142 - s.error(sess, badRequestErrMsg, nil) 143 - return 65 + err = fmt.Errorf("access denied: invalid git command %q", gitCmd) 144 66 } 145 -} 67 + 68 + if err != nil { 69 + slog.Error("ssh operation failed", "cmd", gitCmd, "repo", repoName, "error", err) 70 + } 146 71 147 -func (s *Server) isAuthorized(iden gossh.PublicKey) bool { 148 - return slices.ContainsFunc(s.authKeys, func(i gossh.PublicKey) bool { 149 - return ssh.KeysEqual(iden, i) 150 - }) 72 + return err 151 73 } 152 74 153 -func (s *Server) parseAuthKeys() error { 154 - parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys)) 155 - for i, key := range s.c.SSH.Keys { 156 - pkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key)) 157 - if err != nil { 158 - return err 159 - } 160 - parsedKeys[i] = pkey 75 +func (s *Shell) AuthorizedKeys(executablePath string) string { 76 + var out strings.Builder 77 + for _, key := range s.cfg.SSH.Keys { 78 + fmt.Fprintf(&out, `command="%s shell",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", 79 + executablePath, key) 161 80 } 162 - s.authKeys = parsedKeys 163 - return nil 81 + return out.String() 164 82 } 165 83 166 -const ( 167 - internalServerErrMsg = "internal server error\n" 168 - badRequestErrMsg = "bad request\n" 169 - unauthorizedErrMsg = "pushing only allowed to authorized users\n" 170 - repoNotFoundErrMsg = "repository not found\n" 171 -) 84 +func (s *Shell) parseCommand(cmd string) (gitCmd, repoName string, err error) { 85 + cmdParts := strings.Fields(cmd) 86 + if len(cmdParts) < 2 { 87 + return "", "", fmt.Errorf("invalid command: expected 'git-cmd repo', got %q", cmd) 88 + } 172 89 173 -func (s *Server) error(sess ssh.Session, msg string, err error) { 174 - slog.Error("ssh error", "msg", msg, "err", err) 175 - fmt.Fprintf(sess.Stderr(), "%s", msg) 176 - sess.Exit(1) 177 -} 90 + gitCmd = cmdParts[0] 91 + repoName = strings.Trim(cmdParts[1], "'\"") 92 + if repoName == "" { 93 + return "", "", fmt.Errorf("invalid command: empty repository name") 94 + } 178 95 179 -func (s *Server) gitError(sess ssh.Session, msg string, err error) { 180 - slog.Error("ssh git error", "msg", msg, "err", err) 181 - git.PackError(sess, msg) 182 - git.PackFlush(sess) 183 - sess.Exit(1) 96 + return gitCmd, repoName, nil 184 97 }
M
internal/ssh/ssh_test.go
路路路 1 1 package ssh 2 2 3 3 import ( 4 - "crypto/rand" 5 - "crypto/rsa" 4 + "strings" 6 5 "testing" 7 6 8 - gossh "golang.org/x/crypto/ssh" 7 + "olexsmir.xyz/mugit/internal/config" 9 8 "olexsmir.xyz/x/is" 10 9 ) 11 10 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) 11 +var validKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" 12 + 13 +func TestNewShell(t *testing.T) { 14 + tests := []struct { 15 + name string 16 + keys []string 17 + wantErr string 18 + }{ 19 + {"valid key", []string{validKey}, ""}, 20 + {"invalid key", []string{"invalid-key"}, "ssh: no key found"}, 21 + {"multiple keys", []string{validKey, validKey}, ""}, 22 + {"no keys", []string{}, ""}, 23 + } 17 24 18 - key2, err := rsa.GenerateKey(rand.Reader, 2048) 19 - is.Err(t, err, nil) 20 - pub2, err := gossh.NewPublicKey(&key2.PublicKey) 25 + for _, tt := range tests { 26 + t.Run(tt.name, func(t *testing.T) { 27 + cfg := &config.Config{SSH: config.SSHConfig{Keys: tt.keys}} 28 + shell, err := NewShell(cfg) 29 + if tt.wantErr == "" { 30 + is.Err(t, err, nil) 31 + is.Equal(t, len(shell.keys), len(cfg.SSH.Keys)) 32 + } else { 33 + is.Err(t, err, tt.wantErr) 34 + } 35 + }) 36 + } 37 +} 38 + 39 +func TestShellParseCommand(t *testing.T) { 40 + cfg := &config.Config{ 41 + SSH: config.SSHConfig{ 42 + Keys: []string{validKey}, 43 + }, 44 + } 45 + 46 + shell, err := NewShell(cfg) 21 47 is.Err(t, err, nil) 22 48 23 49 tests := []struct { 24 - name string 25 - authKeys []gossh.PublicKey 26 - checkKey gossh.PublicKey 27 - wantAuth bool 50 + cmd string 51 + wantGitCmd string 52 + wantRepo string 53 + wantErr string 28 54 }{ 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 - }, 55 + {"git-upload-pack 'myrepo'", "git-upload-pack", "myrepo", ""}, 56 + {"git-upload-pack \"myrepo\"", "git-upload-pack", "myrepo", ""}, 57 + {"git-upload-pack myrepo", "git-upload-pack", "myrepo", ""}, 58 + {"git-upload-archive 'archive-repo'", "git-upload-archive", "archive-repo", ""}, 59 + {"git-upload-pack", "", "", "invalid command"}, 60 + {"git-upload-pack ''", "", "", "empty repository name"}, 61 + {"", "", "", "invalid command"}, 53 62 } 54 63 55 64 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) 65 + t.Run(tt.cmd, func(t *testing.T) { 66 + gitCmd, repo, err := shell.parseCommand(tt.cmd) 67 + if tt.wantErr == "" { 68 + is.Err(t, err, nil) 69 + is.Equal(t, gitCmd, tt.wantGitCmd) 70 + is.Equal(t, repo, tt.wantRepo) 71 + } else { 72 + is.Err(t, err, tt.wantErr) 73 + } 60 74 }) 61 75 } 62 76 } 77 + 78 +func TestShellAuthorizedKeys(t *testing.T) { 79 + shell, err := NewShell(&config.Config{ 80 + SSH: config.SSHConfig{Keys: []string{validKey}}, 81 + }) 82 + is.Err(t, err, nil) 83 + 84 + result := shell.AuthorizedKeys("/usr/bin/mugit") 85 + if !strings.Contains(result, `command="/usr/bin/mugit shell",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty`) { 86 + t.Errorf("AuthorizedKeys() missing expected format\ngot: %s", result) 87 + } 88 + if !strings.Contains(result, validKey) { 89 + t.Errorf("AuthorizedKeys() missing SSH key\ngot: %s", result) 90 + } 91 +}