7 files changed,
205 insertions(+),
19 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-01-21 01:49:14 +0200
Change ID:
tmqrwyvzvpsvtryusmuqlrvsluvtkwrn
Parent:
012e7df
jump to
| M | config.yml |
| M | go.mod |
| M | go.sum |
| M | internal/config/config.go |
| M | internal/handlers/git.go |
| A | internal/ssh/server.go |
| M | main.go |
M
config.yml
@@ -3,9 +3,17 @@ host: 0.0.0.0
port: 8008 meta: - title: i like git description: hey kid, come get your free software + title: git.olexsmir.xyz host: git.olexsmir.xyz + +ssh: + enable: true + port: 2222 + host_key: /home/olex/.ssh/mugit + keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPLLJdkVYKZgsayw+sHanKPKZbI0RMS2CakqBCEi5Trz + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMPQ0Qz0DFB+rGrD8ScUqbUTZ1/O8FHrOBF5bIAGQgMj olex@rachlinux repo: dir: /home/olex/mugit-test/
M
go.mod
@@ -4,8 +4,10 @@ go 1.25.3
require ( github.com/bluekeyes/go-gitdiff v0.8.1 + github.com/gliderlabs/ssh v0.3.8 github.com/go-git/go-git/v5 v5.16.4 github.com/yuin/goldmark v1.7.16 + golang.org/x/crypto v0.47.0 gopkg.in/yaml.v2 v2.4.0 )@@ -13,6 +15,7 @@ require (
dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect@@ -25,8 +28,7 @@ github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect )
M
go.sum
@@ -72,27 +72,27 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
M
internal/config/config.go
@@ -18,6 +18,12 @@ Title string `yaml:"title"`
Description string `yaml:"description"` Host string `yaml:"host"` } `yaml:"meta"` + SSH struct { + Enable bool `yaml:"enable"` + Port int `yaml:"port"` + HostKey string `yaml:"host_key"` + Keys []string `yaml:"keys"` + } `yaml:"ssh"` Repo struct { Dir string `yaml:"dir"` Readmes []string `yaml:"readmes"`
M
internal/handlers/git.go
@@ -20,6 +20,7 @@ w.WriteHeader(http.StatusOK)
if err := gitservice.InfoRefs( filepath.Join(h.c.Repo.Dir, name), // FIXME: use securejoin + true, w, ); err != nil { slog.Error("git: info/refs", "err", err)@@ -50,6 +51,7 @@ }
if err := gitservice.UploadPack( filepath.Join(h.c.Repo.Dir, name), + true, reader, newFlushWriter(w), ); err != nil {
A
internal/ssh/server.go
@@ -0,0 +1,138 @@
+package ssh + +import ( + "fmt" + "log/slog" + "path/filepath" + "strconv" + + "github.com/gliderlabs/ssh" + "olexsmir.xyz/mugit/internal/config" + "olexsmir.xyz/mugit/internal/git" + "olexsmir.xyz/mugit/internal/git/gitservice" + + gossh "golang.org/x/crypto/ssh" +) + +type authorizedKeyType string + +const authorizedKey authorizedKeyType = "authorized" + +type Server struct { + c *config.Config + authKeys []gossh.PublicKey +} + +func NewServer(cfg *config.Config) *Server { + return &Server{ + c: cfg, + authKeys: []gossh.PublicKey{}, + } +} + +func (s *Server) Start() error { + if err := s.parseAuthKeys(); err != nil { + return err + } + + srv := &ssh.Server{ + Addr: ":" + strconv.Itoa(s.c.SSH.Port), + Handler: s.handler, + PublicKeyHandler: s.authhandler, + } + srv.SetOption(ssh.HostKeyFile(s.c.SSH.HostKey)) // TODO: validate `gossh.ParsePrivateKey` + return srv.ListenAndServe() +} + +func (s *Server) authhandler(ctx ssh.Context, key ssh.PublicKey) bool { + fingerprint := gossh.FingerprintSHA256(key) + if ctx.User() != "git" { + slog.Info("non git ssh request", "user", ctx.User(), "fingerprint", fingerprint) + return false + } + + slog.Info("ssh request", "fingerprint", fingerprint) + + authorized := false + for _, authKey := range s.authKeys { + if ssh.KeysEqual(key, authKey) { + authorized = true + break + } + } + + ctx.SetValue(authorizedKey, authorized) + return true +} + +func (s *Server) handler(sess ssh.Session) { + authorized := sess.Context().Value(authorizedKey).(bool) + + cmd := sess.Command() + if len(cmd) < 2 { + fmt.Fprintln(sess, "No command provided") + sess.Exit(1) + return + } + + gitCmd := cmd[0] + repoPath := cmd[1] + + repoPath = filepath.Join(s.c.Repo.Dir, filepath.Clean(repoPath)) + _, err := git.Open(repoPath, "") + if err != nil { + s.error(sess, err) + return + } + + fmt.Println(repoPath) + + switch gitCmd { + case "git-upload-pack": + if err := gitservice.UploadPack(repoPath, false, sess, sess); err != nil { + s.error(sess, err) + return + } + sess.Exit(0) + case "git-receive-pack": + if !authorized { + s.repoNotFound(sess) + return + } + + if err := gitservice.ReceivePack(repoPath, sess, sess); err != nil { + s.error(sess, err) + return + } + sess.Exit(0) + + default: + slog.Error("ssh unsupported command", "cmd", cmd) + fmt.Fprintln(sess, "Unsupported command") + sess.Exit(1) + } +} + +func (s *Server) parseAuthKeys() error { + parsedKeys := make([]gossh.PublicKey, len(s.c.SSH.Keys)) + for i, key := range s.c.SSH.Keys { + pkey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(key)) + if err != nil { + return err + } + parsedKeys[i] = pkey + } + s.authKeys = parsedKeys + return nil +} + +func (s *Server) repoNotFound(sess ssh.Session) { + fmt.Fprintln(sess, "Sorry but repo you're looking for is not found.") + sess.Exit(1) +} + +func (s *Server) error(sess ssh.Session, err error) { + fmt.Fprintln(sess, "unexpected server error") + sess.Exit(1) + slog.Error("error on ssh side", "err", err) +}
M
main.go
@@ -1,15 +1,19 @@
package main import ( + "context" "log" "log/slog" "net" "net/http" "os" + "os/signal" "strconv" + "syscall" "olexsmir.xyz/mugit/internal/config" "olexsmir.xyz/mugit/internal/handlers" + "olexsmir.xyz/mugit/internal/ssh" ) func main() {@@ -26,12 +30,38 @@ slog.Error("config error", "err", err)
return err } - mux := handlers.InitRoutes(cfg) + httpServer := &http.Server{ + Addr: net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)), + Handler: handlers.InitRoutes(cfg), + } - port := strconv.Itoa(cfg.Server.Port) - slog.Info("starting server", "host", cfg.Server.Host, "port", port) - if err = http.ListenAndServe(net.JoinHostPort(cfg.Server.Host, port), mux); err != nil { - slog.Error("server error", "err", err) + go func() { + slog.Info("starting http server", "host", cfg.Server.Host, "port", cfg.Server.Port) + + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("HTTP server error", "err", err) + } + }() + + sshServer := ssh.NewServer(cfg) + if cfg.SSH.Enable { + slog.Info("starting ssh server", "port", cfg.SSH.Port) + if err := sshServer.Start(); err != nil { + slog.Error("ssh server error", "err", err) + } + } + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + sig := <-sigChan + slog.Info("received signal, starting graceful shutdown", "signal", sig) + + if err := httpServer.Shutdown(context.TODO()); err != nil { + slog.Error("HTTP server shutdown error", "err", err) + } else { + slog.Info("HTTP server shutdown complete") } return nil