A vibe coded tangled fork which supports pijul.
1package guard
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "os"
12 "os/exec"
13 "strings"
14
15 securejoin "github.com/cyphar/filepath-securejoin"
16 "github.com/urfave/cli/v3"
17 "tangled.org/core/log"
18)
19
20func Command() *cli.Command {
21 return &cli.Command{
22 Name: "guard",
23 Usage: "role-based access control for git over ssh (not for manual use)",
24 Action: Run,
25 Flags: []cli.Flag{
26 &cli.StringFlag{
27 Name: "user",
28 Usage: "allowed git user",
29 Required: true,
30 },
31 &cli.StringFlag{
32 Name: "git-dir",
33 Usage: "base directory for git repos",
34 Value: "/home/git",
35 },
36 &cli.StringFlag{
37 Name: "log-path",
38 Usage: "path to log file",
39 Value: "/home/git/guard.log",
40 },
41 &cli.StringFlag{
42 Name: "internal-api",
43 Usage: "internal API endpoint",
44 Value: "http://localhost:5444",
45 },
46 &cli.StringFlag{
47 Name: "motd-file",
48 Usage: "path to message of the day file",
49 Value: "/home/git/motd",
50 },
51 },
52 }
53}
54
55func Run(ctx context.Context, cmd *cli.Command) error {
56 l := log.FromContext(ctx)
57
58 incomingUser := cmd.String("user")
59 gitDir := cmd.String("git-dir")
60 logPath := cmd.String("log-path")
61 endpoint := cmd.String("internal-api")
62 motdFile := cmd.String("motd-file")
63
64 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
65 if err != nil {
66 l.Error("failed to open log file", "error", err)
67 return err
68 } else {
69 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo})
70 l = slog.New(fileHandler)
71 }
72
73 var clientIP string
74 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
75 parts := strings.Fields(connInfo)
76 if len(parts) > 0 {
77 clientIP = parts[0]
78 }
79 }
80
81 if incomingUser == "" {
82 l.Error("access denied: no user specified")
83 fmt.Fprintln(os.Stderr, "access denied: no user specified")
84 os.Exit(-1)
85 }
86
87 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
88
89 l.Info("connection attempt",
90 "user", incomingUser,
91 "command", sshCommand,
92 "client", clientIP)
93
94 // TODO: greet user with their resolved handle instead of did
95 if sshCommand == "" {
96 l.Info("access denied: no interactive shells", "user", incomingUser)
97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
98 os.Exit(-1)
99 }
100
101 cmdParts := strings.Fields(sshCommand)
102 if len(cmdParts) < 2 {
103 l.Error("invalid command format", "command", sshCommand)
104 fmt.Fprintln(os.Stderr, "invalid command format")
105 os.Exit(-1)
106 }
107
108 if cmdParts[0] == "pijul" {
109 if cmdParts[1] != "protocol" {
110 l.Error("access denied: invalid pijul command", "command", sshCommand)
111 fmt.Fprintln(os.Stderr, "access denied: invalid pijul command")
112 return fmt.Errorf("access denied: invalid pijul command")
113 }
114
115 repoPath, version, err := parsePijulProtocolArgs(cmdParts[2:])
116 if err != nil {
117 l.Error("invalid pijul protocol args", "command", sshCommand, "err", err)
118 fmt.Fprintln(os.Stderr, "invalid pijul protocol args")
119 return err
120 }
121 if version != "" && version != "3" {
122 l.Error("unsupported pijul protocol version", "version", version)
123 fmt.Fprintln(os.Stderr, "unsupported pijul protocol version")
124 return fmt.Errorf("unsupported pijul protocol version")
125 }
126
127 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, "pijul-protocol")
128 if err != nil {
129 l.Error("failed to run guard", "err", err)
130 fmt.Fprintln(os.Stderr, err)
131 os.Exit(1)
132 }
133
134 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
135 args := []string{"protocol", "--repository", fullPath}
136 if version != "" {
137 args = append(args, "--version", version)
138 }
139
140 l.Info("processing command",
141 "user", incomingUser,
142 "command", "pijul protocol",
143 "repo", repoPath,
144 "fullPath", fullPath,
145 "client", clientIP)
146
147 pijulCmd := exec.Command("pijul", args...)
148 pijulCmd.Stdout = os.Stdout
149 pijulCmd.Stderr = os.Stderr
150 pijulCmd.Stdin = os.Stdin
151
152 if err := pijulCmd.Run(); err != nil {
153 l.Error("command failed", "error", err)
154 fmt.Fprintf(os.Stderr, "command failed: %v\n", err)
155 return fmt.Errorf("command failed: %v", err)
156 }
157
158 l.Info("command completed",
159 "user", incomingUser,
160 "command", "pijul protocol",
161 "repo", repoPath,
162 "success", true)
163
164 return nil
165 }
166
167 gitCommand := cmdParts[0]
168 repoPath := cmdParts[1]
169
170 validCommands := map[string]bool{
171 "git-receive-pack": true,
172 "git-upload-pack": true,
173 "git-upload-archive": true,
174 }
175 if !validCommands[gitCommand] {
176 l.Error("access denied: invalid git command", "command", gitCommand)
177 fmt.Fprintln(os.Stderr, "access denied: invalid git command")
178 return fmt.Errorf("access denied: invalid git command")
179 }
180
181 // qualify repo path from internal server which holds the knot config
182 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
183 if err != nil {
184 l.Error("failed to run guard", "err", err)
185 fmt.Fprintln(os.Stderr, err)
186 os.Exit(1)
187 }
188
189 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
190
191 l.Info("processing command",
192 "user", incomingUser,
193 "command", gitCommand,
194 "repo", repoPath,
195 "fullPath", fullPath,
196 "client", clientIP)
197
198 var motdReader io.Reader
199 if reader, err := os.Open(motdFile); err != nil {
200 if !errors.Is(err, os.ErrNotExist) {
201 l.Error("failed to read motd file", "error", err)
202 }
203 motdReader = strings.NewReader("Welcome to this knot!\n")
204 } else {
205 motdReader = reader
206 }
207 if gitCommand == "git-upload-pack" {
208 io.WriteString(os.Stderr, "\x02")
209 }
210 io.Copy(os.Stderr, motdReader)
211
212 gitCmd := exec.Command(gitCommand, fullPath)
213 gitCmd.Stdout = os.Stdout
214 gitCmd.Stderr = os.Stderr
215 gitCmd.Stdin = os.Stdin
216 gitCmd.Env = append(os.Environ(),
217 fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
218 )
219
220 if err := gitCmd.Run(); err != nil {
221 l.Error("command failed", "error", err)
222 fmt.Fprintf(os.Stderr, "command failed: %v\n", err)
223 return fmt.Errorf("command failed: %v", err)
224 }
225
226 l.Info("command completed",
227 "user", incomingUser,
228 "command", gitCommand,
229 "repo", repoPath,
230 "success", true)
231
232 return nil
233}
234
235func parsePijulProtocolArgs(args []string) (string, string, error) {
236 var repo string
237 var version string
238 for i := 0; i < len(args); i++ {
239 arg := args[i]
240 switch {
241 case arg == "--repository" || arg == "-r":
242 if i+1 >= len(args) {
243 return "", "", fmt.Errorf("missing --repository value")
244 }
245 repo = args[i+1]
246 i++
247 case strings.HasPrefix(arg, "--repository="):
248 repo = strings.TrimPrefix(arg, "--repository=")
249 case arg == "--version":
250 if i+1 >= len(args) {
251 return "", "", fmt.Errorf("missing --version value")
252 }
253 version = args[i+1]
254 i++
255 case strings.HasPrefix(arg, "--version="):
256 version = strings.TrimPrefix(arg, "--version=")
257 }
258 }
259 if repo == "" {
260 return "", "", fmt.Errorf("missing --repository")
261 }
262 return repo, version, nil
263}
264
265// runs guardAndQualifyRepo logic
266func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
267 u, _ := url.Parse(endpoint + "/guard")
268 q := u.Query()
269 q.Add("user", incomingUser)
270 q.Add("repo", repo)
271 q.Add("gitCmd", gitCommand)
272 u.RawQuery = q.Encode()
273
274 resp, err := http.Get(u.String())
275 if err != nil {
276 return "", err
277 }
278 defer resp.Body.Close()
279
280 l.Info("Running guard", "url", u.String(), "status", resp.Status)
281
282 body, err := io.ReadAll(resp.Body)
283 if err != nil {
284 return "", err
285 }
286 text := string(body)
287
288 switch resp.StatusCode {
289 case http.StatusOK:
290 return text, nil
291 case http.StatusForbidden:
292 l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
293 return text, errors.New("access denied: user not allowed")
294 default:
295 return "", errors.New(text)
296 }
297}