package sshgit
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"fmt"
"log"
"os"
"path/filepath"
"strings"
gliderssh "github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
"nugitea/internal/auth"
"nugitea/internal/gitcmd"
"nugitea/internal/repo"
)
type Server struct {
Store *repo.Store
Auth auth.Authorizer
Addr string
HostKey string
}
func New(store *repo.Store, a auth.Authorizer, addr, hostKeyPath string) *Server {
if a == nil {
a = auth.AllowAll{}
}
return &Server{Store: store, Auth: a, Addr: addr, HostKey: hostKeyPath}
}
func (s *Server) ListenAndServe() error {
signer, err := loadOrCreateHostKey(s.HostKey)
if err != nil {
return fmt.Errorf("ssh host key: %w", err)
}
srv := &gliderssh.Server{
Addr: s.Addr,
PublicKeyHandler: func(ctx gliderssh.Context, key gliderssh.PublicKey) bool { return true },
Handler: s.handleSession,
}
srv.AddHostKey(signer)
log.Printf("ssh: listening on %s", s.Addr)
return srv.ListenAndServe()
}
func (s *Server) handleSession(session gliderssh.Session) {
cmdLine := session.RawCommand()
verb, repoName, err := parseGitCommand(cmdLine)
if err != nil {
fmt.Fprintf(session.Stderr(), "nugitea: %v\n", err)
session.Exit(1)
return
}
if err := repo.ValidateName(repoName); err != nil {
fmt.Fprintf(session.Stderr(), "nugitea: %v\n", err)
session.Exit(1)
return
}
if !s.Store.Exists(repoName) {
fmt.Fprintf(session.Stderr(), "nugitea: repository %q not found\n", repoName)
session.Exit(1)
return
}
repoPath, err := s.Store.Path(repoName)
if err != nil {
fmt.Fprintf(session.Stderr(), "nugitea: %v\n", err)
session.Exit(1)
return
}
var allowed bool
var gitArg string
switch verb {
case "git-upload-pack":
allowed = s.Auth.AllowPull(repoName)
gitArg = "upload-pack"
case "git-receive-pack":
allowed = s.Auth.AllowPush(repoName)
gitArg = "receive-pack"
}
if !allowed {
fmt.Fprintf(session.Stderr(), "nugitea: forbidden\n")
session.Exit(1)
return
}
ctx := session.Context()
cmd := gitcmd.New(gitArg, repoPath).WithStdio(session, session, session.Stderr())
if err := cmd.Run(ctx); err != nil {
log.Printf("ssh %s %s: %v", repoName, verb, err)
session.Exit(1)
return
}
session.Exit(0)
}
var allowedVerbs = map[string]bool{
"git-upload-pack": true,
"git-receive-pack": true,
}
func parseGitCommand(cmdLine string) (verb, repoName string, err error) {
fields, err := shellSplit(cmdLine)
if err != nil || len(fields) != 2 {
return "", "", fmt.Errorf("unsupported command")
}
verb = fields[0]
if !allowedVerbs[verb] {
return "", "", fmt.Errorf("unsupported command %q", verb)
}
path := strings.Trim(fields[1], "'\"")
path = strings.TrimPrefix(path, "/")
path = strings.TrimSuffix(path, ".git")
return verb, path, nil
}
func loadOrCreateHostKey(path string) (gossh.Signer, error) {
if data, err := os.ReadFile(path); err == nil {
return gossh.ParsePrivateKey(data)
}
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
block, err := gossh.MarshalPrivateKey(priv, "nugitea host key")
if err != nil {
return nil, err
}
data := pem.EncodeToMemory(block)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return nil, err
}
return gossh.ParsePrivateKey(data)
}
func shellSplit(s string) ([]string, error) {
var fields []string
var cur strings.Builder
var quote rune
inField := false
for _, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
} else {
cur.WriteRune(r)
}
case r == '\'' || r == '"':
quote = r
inField = true
case r == ' ' || r == '\t':
if inField {
fields = append(fields, cur.String())
cur.Reset()
inField = false
}
default:
cur.WriteRune(r)
inField = true
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quote")
}
if inField {
fields = append(fields, cur.String())
}
return fields, nil
}