Lindenii Project Forge
Login
Commit info
ID3a178ebb7ea7270181abbdef95871f98f03c61d1
AuthorRunxi Yu<me@runxiyu.org>
Author dateMon, 17 Feb 2025 23:46:32 +0800
CommitterRunxi Yu<me@runxiyu.org>
Committer dateMon, 17 Feb 2025 23:46:32 +0800
Actions
Get patch
hooks, etc.: Restructure concurrency and data flow

Previously we accepted handler connections at hooks_handle and used a
mess of channels and concurrent maps to let receive_pack handle the
session. This doesn't work well because there are conditions where a
push occurs but the hook is not called, e.g. when the destination branch
is up to date. There is no reliable way of checking whether the
subprocess is going to call the hook or not; it's technically possible
to parse stderr but that interface is not guaranteed to be stable and
IIRC has changed in the past). So receive_pack would be waiting on the
channel to receive a hooks connection to handle but it'll never receive
one, causing a deadlock. This entire thing was overengineered and was
very prone to error.

Here we let receive_pack put the cookie into the map, then start and
wait for the subprocess to finish. When the hook actually runs and
connects to its UNIX domain socket, the handler would check its cookie
within the map. If the hook doesn't run, then nothing happens. The
git-receive-pack subprocess blocks the execution of the SSH handler,
and when git-receive-pack exists, the SSH handler (using a defer)
deletes the cookie from the map.

There may be caveats in signal handling or other cases that cause the
cookie to be deleted from the map prematurely.
package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"fmt"
	"net"
	"os"
	"path/filepath"
	"syscall"
)

var (
	err_get_fd       = errors.New("Unable to get file descriptor")
	err_get_ucred    = errors.New("Failed getsockopt")
	err_get_fd    = errors.New("Unable to get file descriptor")
	err_get_ucred = errors.New("Failed getsockopt")
)

func hooks_handle_connection(conn net.Conn) {
	defer conn.Close()

	ucred, err := get_ucred(conn)
	if err != nil {
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "Unable to get peer credentials:", err.Error())
		return
	}
	if ucred.Uid != uint32(os.Getuid()) {
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "UID mismatch")
		return
	}

	cookie := make([]byte, 64)
	_, err = conn.Read(cookie)
	if err != nil {
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "Failed to read cookie:", err.Error())
		return
	}

	deployer_chan, ok := hooks_cookie_deployer.Load(string(cookie))
	pack_to_hook, ok := pack_to_hook_by_cookie.Load(string(cookie))
	if !ok {
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "Invalid cookie")
		fmt.Fprintln(conn, "Invalid handler cookie")
		return
	}

	var argc64 uint64
	err = binary.Read(conn, binary.NativeEndian, &argc64)
	if err != nil {
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "Failed to read argc:", err.Error())
		return
	}
	var args []string
	for i := uint64(0); i < argc64; i++ {
		var arg bytes.Buffer
		for {
			b := make([]byte, 1)
			n, err := conn.Read(b)
			if err != nil || n != 1 {
				conn.Write([]byte{1})
				fmt.Fprintln(conn, "Failed to read arg:", err.Error())
				return
			}
			if b[0] == 0 {
				break
			}
			arg.WriteByte(b[0])
		}
		args = append(args, arg.String())
	}

	callback := make(chan struct{})

	deployer_chan <- hooks_cookie_deployer_return{
		args:     args,
		callback: callback,
		conn:     conn,
	switch filepath.Base(args[0]) {
	case "pre-receive":
		if pack_to_hook.direct_access {
			conn.Write([]byte{0})
		} else {
			conn.Write([]byte{1})
			fmt.Fprintln(conn, "Non-maintainer push access not implemented yet")
		}
	default:
		conn.Write([]byte{1})
		fmt.Fprintln(conn, "Invalid hook:", args[0])
	}
	<-callback
}

func serve_git_hooks(listener net.Listener) error {
	for {
		conn, err := listener.Accept()
		if err != nil {
			return err
		}
		go hooks_handle_connection(conn)
	}
}

func get_ucred(conn net.Conn) (*syscall.Ucred, error) {
	unix_conn := conn.(*net.UnixConn)
	fd, err := unix_conn.File()
	if err != nil {
		return nil, err_get_fd
	}
	defer fd.Close()

	ucred, err := syscall.GetsockoptUcred(int(fd.Fd()), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
	if err != nil {
		return nil, err_get_ucred
	}
	return ucred, nil
}
package main

import (
	"crypto/rand"
	"errors"
	"fmt"
	"net"
	"os"
	"os/exec"

	glider_ssh "github.com/gliderlabs/ssh"
	"go.lindenii.runxiyu.org/lindenii-common/cmap"
)

var err_unauthorized_push = errors.New("You are not authorized to push to this repository")

type hooks_cookie_deployer_return struct {
	args     []string
	callback chan struct{}
	conn     net.Conn
type pack_to_hook_t struct {
	session       *glider_ssh.Session
	pubkey        string
	direct_access bool
	repo_path     string
}

var hooks_cookie_deployer = cmap.ComparableMap[string, chan hooks_cookie_deployer_return]{}
var pack_to_hook_by_cookie = cmap.Map[string, pack_to_hook_t]{}

func ssh_handle_receive_pack(session glider_ssh.Session, pubkey string, repo_identifier string) (err error) {
	repo_path, access, err := get_repo_path_perms_from_ssh_path_pubkey(session.Context(), repo_identifier, pubkey)
	if err != nil {
		return err
	}

	cookie, err := random_urlsafe_string(16)
	if err != nil {
		fmt.Fprintln(session.Stderr(), "Error while generating cookie:", err)
	}

	deployer_channel := make(chan hooks_cookie_deployer_return)
	hooks_cookie_deployer.Store(cookie, deployer_channel)
	defer hooks_cookie_deployer.Delete(cookie)
	pack_to_hook_by_cookie.Store(cookie, pack_to_hook_t{
		session:       &session,
		pubkey:        pubkey,
		direct_access: access,
		repo_path:     repo_path,
	})
	defer pack_to_hook_by_cookie.Delete(cookie)

	proc := exec.CommandContext(session.Context(), "git-receive-pack", repo_path)
	proc.Env = append(os.Environ(),
		"LINDENII_FORGE_HOOKS_SOCKET_PATH="+config.Hooks.Socket,
		"LINDENII_FORGE_HOOKS_COOKIE="+cookie,
	)
	proc.Stdin = session
	proc.Stdout = session
	proc.Stderr = session.Stderr()

	err = proc.Start()
	if err != nil {
		fmt.Fprintln(session.Stderr(), "Error while starting process:", err)
		return err
	}

	deployer := <-deployer_channel

	if access {
		deployer.conn.Write([]byte{0})
	} else {
		deployer.conn.Write([]byte{1})
		fmt.Fprintln(deployer.conn, "Hi! We don't support pushing from non-authorized users yet. This will be implemented soon.")
	}

	deployer.callback <- struct{}{}

	err = proc.Wait()
	if exitError, ok := err.(*exec.ExitError); ok {
		fmt.Fprintln(session.Stderr(), "Process exited with error", exitError.ExitCode())
	} else if err != nil {
		fmt.Fprintln(session.Stderr(), "Error while waiting for process:", err)
	}

	return err
}

func random_string(sz int) (string, error) {
	r := make([]byte, sz)
	_, err := rand.Read(r)
	return string(r), err
}