hook.go

  1package main
  2
  3import (
  4	"bufio"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9
 10	"github.com/charmbracelet/keygen"
 11	"github.com/charmbracelet/soft-serve/server/config"
 12	"github.com/spf13/cobra"
 13	gossh "golang.org/x/crypto/ssh"
 14)
 15
 16var (
 17	configPath string
 18
 19	hookCmd = &cobra.Command{
 20		Use:    "hook",
 21		Short:  "Run git server hooks",
 22		Long:   "Handles git server hooks. This includes pre-receive, update, and post-receive.",
 23		Hidden: true,
 24	}
 25
 26	preReceiveCmd = &cobra.Command{
 27		Use:   "pre-receive",
 28		Short: "Run git pre-receive hook",
 29		RunE: func(cmd *cobra.Command, args []string) error {
 30			c, s, err := commonInit()
 31			if err != nil {
 32				return err
 33			}
 34			defer c.Close() //nolint:errcheck
 35			defer s.Close() //nolint:errcheck
 36			in, err := s.StdinPipe()
 37			if err != nil {
 38				return err
 39			}
 40			scanner := bufio.NewScanner(os.Stdin)
 41			for scanner.Scan() {
 42				in.Write([]byte(scanner.Text()))
 43				in.Write([]byte("\n"))
 44			}
 45			in.Close() //nolint:errcheck
 46			b, err := s.Output("hook pre-receive")
 47			if err != nil {
 48				return err
 49			}
 50			cmd.Print(string(b))
 51			return nil
 52		},
 53	}
 54
 55	updateCmd = &cobra.Command{
 56		Use:   "update",
 57		Short: "Run git update hook",
 58		Args:  cobra.ExactArgs(3),
 59		RunE: func(cmd *cobra.Command, args []string) error {
 60			refName := args[0]
 61			oldSha := args[1]
 62			newSha := args[2]
 63			c, s, err := commonInit()
 64			if err != nil {
 65				return err
 66			}
 67			defer c.Close() //nolint:errcheck
 68			defer s.Close() //nolint:errcheck
 69			b, err := s.Output(fmt.Sprintf("hook update %s %s %s", refName, oldSha, newSha))
 70			if err != nil {
 71				return err
 72			}
 73			cmd.Print(string(b))
 74			return nil
 75		},
 76	}
 77
 78	postReceiveCmd = &cobra.Command{
 79		Use:   "post-receive",
 80		Short: "Run git post-receive hook",
 81		RunE: func(cmd *cobra.Command, args []string) error {
 82			c, s, err := commonInit()
 83			if err != nil {
 84				return err
 85			}
 86			defer c.Close() //nolint:errcheck
 87			defer s.Close() //nolint:errcheck
 88			in, err := s.StdinPipe()
 89			if err != nil {
 90				return err
 91			}
 92			scanner := bufio.NewScanner(os.Stdin)
 93			for scanner.Scan() {
 94				in.Write([]byte(scanner.Text()))
 95				in.Write([]byte("\n"))
 96			}
 97			in.Close() //nolint:errcheck
 98			b, err := s.Output("hook post-receive")
 99			if err != nil {
100				return err
101			}
102			cmd.Print(string(b))
103			return nil
104		},
105	}
106
107	postUpdateCmd = &cobra.Command{
108		Use:   "post-update",
109		Short: "Run git post-update hook",
110		RunE: func(cmd *cobra.Command, args []string) error {
111			c, s, err := commonInit()
112			if err != nil {
113				return err
114			}
115			defer c.Close() //nolint:errcheck
116			defer s.Close() //nolint:errcheck
117			b, err := s.Output(fmt.Sprintf("hook post-update %s", strings.Join(args, " ")))
118			if err != nil {
119				return err
120			}
121			cmd.Print(string(b))
122			return nil
123		},
124	}
125)
126
127func init() {
128	hookCmd.AddCommand(
129		preReceiveCmd,
130		updateCmd,
131		postReceiveCmd,
132		postUpdateCmd,
133	)
134
135	hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
136}
137
138// TODO: use ssh controlmaster
139func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
140	cfg, err := config.ParseConfig(configPath)
141	if err != nil {
142		return
143	}
144
145	// Git runs the hook within the repository's directory.
146	// Get the working directory to determine the repository name.
147	wd, err := os.Getwd()
148	if err != nil {
149		return
150	}
151
152	rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos"))
153	if err != nil {
154		return
155	}
156
157	if !strings.HasPrefix(wd, rs) {
158		err = fmt.Errorf("hook must be run from within repository directory")
159		return
160	}
161	repoName := strings.TrimPrefix(wd, rs)
162	repoName = strings.TrimPrefix(repoName, string(os.PathSeparator))
163	c, err = newClient(cfg)
164	if err != nil {
165		return
166	}
167	s, err = newSession(c)
168	if err != nil {
169		return
170	}
171	s.Setenv("SOFT_SERVE_REPO_NAME", repoName)
172	return
173}
174
175func newClient(cfg *config.Config) (*gossh.Client, error) {
176	// Only accept the server's host key.
177	pk, err := keygen.New(cfg.Internal.KeyPath, keygen.WithKeyType(keygen.Ed25519))
178	if err != nil {
179		return nil, err
180	}
181	ik, err := keygen.New(cfg.Internal.InternalKeyPath, keygen.WithKeyType(keygen.Ed25519))
182	if err != nil {
183		return nil, err
184	}
185	cc := &gossh.ClientConfig{
186		User: "internal",
187		Auth: []gossh.AuthMethod{
188			gossh.PublicKeys(ik.Signer()),
189		},
190		HostKeyCallback: gossh.FixedHostKey(pk.PublicKey()),
191	}
192	c, err := gossh.Dial("tcp", cfg.Internal.ListenAddr, cc)
193	if err != nil {
194		return nil, err
195	}
196	return c, nil
197}
198
199func newSession(c *gossh.Client) (*gossh.Session, error) {
200	s, err := c.NewSession()
201	if err != nil {
202		return nil, err
203	}
204	return s, nil
205}