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