added git ssh support and ablity to download repo via zip, tar.gz, and bundle
This commit is contained in:
@@ -3,6 +3,7 @@ package git
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -467,3 +468,43 @@ func parseUnifiedDiff(raw string) []FileDiff {
|
||||
commit()
|
||||
return files
|
||||
}
|
||||
|
||||
// ArchiveStream writes a git archive of ref in the requested format to w.
|
||||
// format must be one of "zip", "tar.gz", or "bundle".
|
||||
// Output is streamed directly to w without buffering.
|
||||
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
|
||||
clean := filepath.Clean(repoPath)
|
||||
if repoRoot != "" {
|
||||
root := repoRoot + string(filepath.Separator)
|
||||
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
|
||||
return ErrPathTraversal
|
||||
}
|
||||
}
|
||||
|
||||
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch format {
|
||||
case "zip", "tar.gz":
|
||||
cmd = exec.Command("git", "archive", "--format="+format, ref)
|
||||
case "bundle":
|
||||
cmd = exec.Command("git", "bundle", "create", "-", "--all")
|
||||
default:
|
||||
return fmt.Errorf("git archive: unsupported format %q", format)
|
||||
}
|
||||
|
||||
cmd.Dir = clean
|
||||
cmd.Env = baseEnv
|
||||
cmd.Stdout = w
|
||||
|
||||
var errBuf strings.Builder
|
||||
cmd.Stderr = &errBuf
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if errBuf.Len() > 0 {
|
||||
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
|
||||
}
|
||||
return fmt.Errorf("git archive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package sshserver
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of
|
||||
// the presented key (matching the format stored by the SSH key registration
|
||||
// handler) and looks it up in the database.
|
||||
func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||||
fp := fingerprintMD5(key)
|
||||
|
||||
var sshKey models.SSHKey
|
||||
if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found {
|
||||
return nil, fmt.Errorf("unknown key")
|
||||
}
|
||||
|
||||
// Resolve the username so the session handler can use it for permission checks.
|
||||
var user models.User
|
||||
if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
return &ssh.Permissions{
|
||||
Extensions: map[string]string{
|
||||
"username": user.Username,
|
||||
"user_id": fmt.Sprintf("%d", user.ID),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fingerprintMD5(pub ssh.PublicKey) string {
|
||||
hash := md5.Sum(pub.Marshal())
|
||||
parts := make([]string, len(hash))
|
||||
for i, b := range hash {
|
||||
parts[i] = fmt.Sprintf("%02x", b)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package sshserver implements an SSH server for git clone/push/pull operations.
|
||||
// It authenticates users via their stored SSH public keys and executes
|
||||
// git-upload-pack / git-receive-pack as subprocesses.
|
||||
package sshserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/config"
|
||||
)
|
||||
|
||||
// Server is the SSH git server.
|
||||
type Server struct {
|
||||
db *xorm.Engine
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(db *xorm.Engine, cfg *config.Config) *Server {
|
||||
return &Server{db: db, cfg: cfg}
|
||||
}
|
||||
|
||||
// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts
|
||||
// connections until ctx is cancelled. Returns nil when the context is done.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
hostKey, err := s.loadOrGenerateHostKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sshserver: host key: %w", err)
|
||||
}
|
||||
|
||||
srvConfig := &ssh.ServerConfig{
|
||||
PublicKeyCallback: s.lookupKey,
|
||||
}
|
||||
srvConfig.AddHostKey(hostKey)
|
||||
|
||||
addr := ":" + s.cfg.SSHPort
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("sshserver: listening on %s", addr)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
ln.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
log.Printf("sshserver: accept: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
go s.handleConn(conn, srvConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) {
|
||||
defer netConn.Close()
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sshConn.Close()
|
||||
|
||||
go ssh.DiscardRequests(reqs)
|
||||
|
||||
username, _ := sshConn.Permissions.Extensions["username"]
|
||||
|
||||
for newChan := range chans {
|
||||
if newChan.ChannelType() != "session" {
|
||||
newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck
|
||||
continue
|
||||
}
|
||||
ch, requests, err := newChan.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handleSession(ch, requests, username)
|
||||
}
|
||||
}
|
||||
|
||||
// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set,
|
||||
// otherwise generates an ephemeral RSA-4096 key (lost on restart).
|
||||
func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) {
|
||||
if s.cfg.SSHHostKeyPath != "" {
|
||||
data, err := os.ReadFile(s.cfg.SSHHostKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err)
|
||||
}
|
||||
return ssh.ParsePrivateKey(data)
|
||||
}
|
||||
|
||||
log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)")
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate host key: %w", err)
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
return ssh.ParsePrivateKey(keyPEM)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package sshserver
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
// handleSession processes a single SSH session channel: waits for an exec
|
||||
// request, dispatches to the appropriate git subcommand, then exits.
|
||||
func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) {
|
||||
defer ch.Close()
|
||||
|
||||
for req := range reqs {
|
||||
if req.Type != "exec" {
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil) //nolint:errcheck
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
cmdStr, err := parseExecPayload(req.Payload)
|
||||
if err != nil {
|
||||
req.Reply(false, nil) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
req.Reply(true, nil) //nolint:errcheck
|
||||
|
||||
exitCode := s.runGitCommand(ch, username, cmdStr)
|
||||
sendExitStatus(ch, uint32(exitCode))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// runGitCommand parses the SSH exec command string, validates it, resolves the
|
||||
// repo, checks permissions, and runs the git subprocess.
|
||||
func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int {
|
||||
gitCmd, repoArg, err := parseGitCommand(cmdStr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git")
|
||||
path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n")
|
||||
return 1
|
||||
}
|
||||
ownerName, repoName := parts[0], parts[1]
|
||||
|
||||
repo, err := s.resolveRepo(ownerName, repoName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(ch.Stderr(), "error: repository not found\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Check permissions.
|
||||
if gitCmd == "receive-pack" {
|
||||
if !s.hasPermission(repo, username, "write") {
|
||||
fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
// upload-pack: public repos are accessible to all; private repos require read.
|
||||
if repo.IsPrivate && !s.hasPermission(repo, username, "read") {
|
||||
fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Exec the git subcommand against the bare repo path on disk.
|
||||
// The disk path comes from the DB — never from user input.
|
||||
cmd := exec.Command("git", gitCmd, repo.DiskPath)
|
||||
cmd.Dir = filepath.Clean(repo.DiskPath)
|
||||
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||
|
||||
cmd.Stdin = ch
|
||||
cmd.Stdout = ch
|
||||
cmd.Stderr = ch.Stderr()
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveRepo looks up a repository by owner name (user or workspace) and repo name.
|
||||
func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) {
|
||||
var u models.User
|
||||
if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found {
|
||||
var repo models.Repository
|
||||
if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
|
||||
return &repo, nil
|
||||
}
|
||||
}
|
||||
|
||||
var ws models.Workspace
|
||||
if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found {
|
||||
var repo models.Repository
|
||||
if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
|
||||
return &repo, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
// hasPermission checks whether username has at least the required permission on repo.
|
||||
func (s *Server) hasPermission(repo *models.Repository, username, required string) bool {
|
||||
var u models.User
|
||||
if found, _ := s.db.Where("username = ?", username).Get(&u); !found {
|
||||
return false
|
||||
}
|
||||
if u.ID == repo.OwnerID {
|
||||
return true
|
||||
}
|
||||
var m models.RepoMember
|
||||
if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found {
|
||||
return false
|
||||
}
|
||||
rank := map[string]int{"read": 1, "write": 2, "admin": 3}
|
||||
return rank[m.Permission] >= rank[required]
|
||||
}
|
||||
|
||||
// parseGitCommand splits the SSH exec command string into the git subcommand
|
||||
// and the repo path argument. Only upload-pack and receive-pack are permitted.
|
||||
//
|
||||
// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms.
|
||||
func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) {
|
||||
cmdStr = strings.TrimSpace(cmdStr)
|
||||
|
||||
var candidate string
|
||||
var rest string
|
||||
|
||||
if strings.HasPrefix(cmdStr, "git-upload-pack") {
|
||||
candidate = "upload-pack"
|
||||
rest = strings.TrimPrefix(cmdStr, "git-upload-pack")
|
||||
} else if strings.HasPrefix(cmdStr, "git-receive-pack") {
|
||||
candidate = "receive-pack"
|
||||
rest = strings.TrimPrefix(cmdStr, "git-receive-pack")
|
||||
} else if strings.HasPrefix(cmdStr, "git upload-pack") {
|
||||
candidate = "upload-pack"
|
||||
rest = strings.TrimPrefix(cmdStr, "git upload-pack")
|
||||
} else if strings.HasPrefix(cmdStr, "git receive-pack") {
|
||||
candidate = "receive-pack"
|
||||
rest = strings.TrimPrefix(cmdStr, "git receive-pack")
|
||||
} else {
|
||||
return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed")
|
||||
}
|
||||
|
||||
// Strip surrounding whitespace and single quotes from the path argument.
|
||||
rest = strings.TrimSpace(rest)
|
||||
rest = strings.Trim(rest, "'\"")
|
||||
if rest == "" {
|
||||
return "", "", fmt.Errorf("missing repository path argument")
|
||||
}
|
||||
|
||||
return candidate, rest, nil
|
||||
}
|
||||
|
||||
// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian
|
||||
// length followed by the command string.
|
||||
func parseExecPayload(payload []byte) (string, error) {
|
||||
if len(payload) < 4 {
|
||||
return "", fmt.Errorf("exec payload too short")
|
||||
}
|
||||
length := binary.BigEndian.Uint32(payload[:4])
|
||||
if int(length) > len(payload)-4 {
|
||||
return "", fmt.Errorf("exec payload length mismatch")
|
||||
}
|
||||
return string(payload[4 : 4+length]), nil
|
||||
}
|
||||
|
||||
// sendExitStatus sends an SSH exit-status channel request.
|
||||
func sendExitStatus(ch ssh.Channel, code uint32) {
|
||||
msg := struct{ Status uint32 }{code}
|
||||
ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Stderr returns the stderr stream of an SSH channel.
|
||||
// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin;
|
||||
// Stderr() is defined on *ssh.channel but not the interface — use a type assertion.
|
||||
func init() {
|
||||
// Compile-time interface check: ssh.Channel must have Stderr() method.
|
||||
var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil)
|
||||
}
|
||||
Reference in New Issue
Block a user