this repo has no description
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 "github.com/bluesky-social/indigo/atproto/identity"
16 securejoin "github.com/cyphar/filepath-securejoin"
17 "github.com/urfave/cli/v3"
18 "tangled.org/core/idresolver"
19)
20
21func Command() *cli.Command {
22 return &cli.Command{
23 Name: "guard",
24 Usage: "role-based access control for git over ssh (not for manual use)",
25 Action: Run,
26 Flags: []cli.Flag{
27 &cli.StringFlag{
28 Name: "user",
29 Usage: "allowed git user",
30 Required: true,
31 },
32 &cli.StringFlag{
33 Name: "git-dir",
34 Usage: "base directory for git repos",
35 Value: "/home/git",
36 },
37 &cli.StringFlag{
38 Name: "log-path",
39 Usage: "path to log file",
40 Value: "/home/git/guard.log",
41 },
42 &cli.StringFlag{
43 Name: "internal-api",
44 Usage: "internal API endpoint",
45 Value: "http://localhost:5444",
46 },
47 &cli.StringFlag{
48 Name: "motd-file",
49 Usage: "path to message of the day file",
50 Value: "/home/git/motd",
51 },
52 },
53 }
54}
55
56func Run(ctx context.Context, cmd *cli.Command) error {
57 incomingUser := cmd.String("user")
58 gitDir := cmd.String("git-dir")
59 logPath := cmd.String("log-path")
60 endpoint := cmd.String("internal-api")
61 motdFile := cmd.String("motd-file")
62
63 stream := io.Discard
64 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
65 if err == nil {
66 stream = logFile
67 }
68
69 fileHandler := slog.NewJSONHandler(stream, &slog.HandlerOptions{Level: slog.LevelInfo})
70 slog.SetDefault(slog.New(fileHandler))
71
72 var clientIP string
73 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
74 parts := strings.Fields(connInfo)
75 if len(parts) > 0 {
76 clientIP = parts[0]
77 }
78 }
79
80 if incomingUser == "" {
81 slog.Error("access denied: no user specified")
82 fmt.Fprintln(os.Stderr, "access denied: no user specified")
83 os.Exit(-1)
84 }
85
86 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
87
88 slog.Info("connection attempt",
89 "user", incomingUser,
90 "command", sshCommand,
91 "client", clientIP)
92
93 if sshCommand == "" {
94 slog.Info("access denied: no interactive shells", "user", incomingUser)
95 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
96 os.Exit(-1)
97 }
98
99 cmdParts := strings.Fields(sshCommand)
100 if len(cmdParts) < 2 {
101 slog.Error("invalid command format", "command", sshCommand)
102 fmt.Fprintln(os.Stderr, "invalid command format")
103 os.Exit(-1)
104 }
105
106 gitCommand := cmdParts[0]
107
108 // did:foo/repo-name or
109 // handle/repo-name or
110 // any of the above with a leading slash (/)
111
112 components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
113 slog.Info("command components", "components", components)
114
115 if len(components) != 2 {
116 slog.Error("invalid repo format", "components", components)
117 fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
118 os.Exit(-1)
119 }
120
121 didOrHandle := components[0]
122 identity := resolveIdentity(ctx, didOrHandle)
123 did := identity.DID.String()
124 repoName := components[1]
125 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
126
127 validCommands := map[string]bool{
128 "git-receive-pack": true,
129 "git-upload-pack": true,
130 "git-upload-archive": true,
131 }
132 if !validCommands[gitCommand] {
133 slog.Error("access denied: invalid git command", "command", gitCommand)
134 fmt.Fprintln(os.Stderr, "access denied: invalid git command")
135 return fmt.Errorf("access denied: invalid git command")
136 }
137
138 if gitCommand != "git-upload-pack" {
139 if !isPushPermitted(incomingUser, qualifiedRepoName, endpoint) {
140 slog.Error("access denied: user not allowed",
141 "did", incomingUser,
142 "reponame", qualifiedRepoName)
143 fmt.Fprintln(os.Stderr, "access denied: user not allowed")
144 os.Exit(-1)
145 }
146 }
147
148 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
149
150 slog.Info("processing command",
151 "user", incomingUser,
152 "command", gitCommand,
153 "repo", repoName,
154 "fullPath", fullPath,
155 "client", clientIP)
156
157 var motdReader io.Reader
158 if reader, err := os.Open(motdFile); err != nil {
159 if !errors.Is(err, os.ErrNotExist) {
160 slog.Error("failed to read motd file", "error", err)
161 }
162 motdReader = strings.NewReader("Welcome to this knot!\n")
163 } else {
164 motdReader = reader
165 }
166 if gitCommand == "git-upload-pack" {
167 io.WriteString(os.Stderr, "\x02")
168 }
169 io.Copy(os.Stderr, motdReader)
170
171 gitCmd := exec.Command(gitCommand, fullPath)
172 gitCmd.Stdout = os.Stdout
173 gitCmd.Stderr = os.Stderr
174 gitCmd.Stdin = os.Stdin
175 gitCmd.Env = append(os.Environ(),
176 fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
177 fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
178 )
179
180 if err := gitCmd.Run(); err != nil {
181 slog.Error("command failed", "error", err)
182 fmt.Fprintf(os.Stderr, "command failed: %v\n", err)
183 return fmt.Errorf("command failed: %v", err)
184 }
185
186 slog.Info("command completed",
187 "user", incomingUser,
188 "command", gitCommand,
189 "repo", repoName,
190 "success", true)
191
192 return nil
193}
194
195func resolveIdentity(ctx context.Context, didOrHandle string) *identity.Identity {
196 resolver := idresolver.DefaultResolver()
197 ident, err := resolver.ResolveIdent(ctx, didOrHandle)
198 if err != nil {
199 slog.Error("Error resolving handle", "error", err, "handle", didOrHandle)
200 fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
201 os.Exit(1)
202 }
203 if ident.Handle.IsInvalidHandle() {
204 slog.Error("Error resolving handle", "invalid handle", didOrHandle)
205 fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
206 os.Exit(1)
207 }
208 return ident
209}
210
211func isPushPermitted(user, qualifiedRepoName, endpoint string) bool {
212 u, _ := url.Parse(endpoint + "/push-allowed")
213 q := u.Query()
214 q.Add("user", user)
215 q.Add("repo", qualifiedRepoName)
216 u.RawQuery = q.Encode()
217
218 req, err := http.Get(u.String())
219 if err != nil {
220 slog.Error("Error verifying permissions", "error", err)
221 fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
222 os.Exit(1)
223 }
224
225 slog.Info("checking push permission",
226 "url", u.String(),
227 "status", req.Status)
228
229 return req.StatusCode == http.StatusNoContent
230}