🏡 index : quine-global-labs/nugitea.git

// Package sshgit implements the SSH transport for git clone/push, mirroring
// the approach in Gitea's modules/ssh/ssh.go + cmd/serv.go: read the raw SSH
// command, validate the verb, and exec the matching git subcommand with
// stdio wired directly to the session. Unlike Gitea, nugitea execs git
// directly from the session handler instead of re-invoking its own binary,
// since there is no permission database to hop into.
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"
)

// Server serves git-upload-pack/git-receive-pack over SSH.
type Server struct {
	Store   *repo.Store
	Auth    auth.Authorizer
	Addr    string
	HostKey string // path to a PEM private key file, created if missing
}

// New returns a Server for the given repo store. If a is nil, AllowAll is used.
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}
}

// ListenAndServe starts the SSH server and blocks.
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,
		// No users/login: accept any offered key. This is a stub seam,
		// structured like auth.Authorizer, for real key-checking later.
		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)
}

// allowedVerbs are the only git-* commands nugitea will exec over SSH.
var allowedVerbs = map[string]bool{
	"git-upload-pack":  true,
	"git-receive-pack": true,
}

// parseGitCommand extracts the verb and repo name from a raw SSH command
// line such as `git-upload-pack '/demo.git'`.
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)
}

// shellSplit does minimal POSIX-ish word splitting for the raw SSH command
// git clients send (e.g. `git-upload-pack '/demo.git'`), just enough to
// strip a single layer of matching quotes around the repo path.
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
}