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}