all repos

mugit @ 6e946c7

馃惍 git server that your cow will love
13 files changed, 234 insertions(+), 280 deletions(-)
ssh!: switch to sshd
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
M CHANGELOG.md
路路路
        4
        4
         

      
        5
        5
         ### Breaking changes

      
        6
        6
         - Changed route for raw files. From `/repo/blob/ref/file_path?raw=true` to `/repo/raw/ref/file_path`.

      
        
        7
        +- Switch to sshd, instead of running it's own ssh server.

      
        7
        8
         

      
        8
        9
         ### Features:

      
        9
        10
         - Paginate log page (150 commits per page).

      
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
         	}

      
D internal/config/testdata/hostkey
路路路
        1
        
        -test host key content

      
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
        +}