1package guard
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "os"
10 "os/exec"
11 "strings"
12
13 "github.com/bluesky-social/indigo/atproto/identity"
14 securejoin "github.com/cyphar/filepath-securejoin"
15 "github.com/urfave/cli/v3"
16 "tangled.sh/tangled.sh/core/appview/idresolver"
17 "tangled.sh/tangled.sh/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 },
47 }
48}
49
50func Run(ctx context.Context, cmd *cli.Command) error {
51 l := log.FromContext(ctx)
52
53 incomingUser := cmd.String("user")
54 gitDir := cmd.String("git-dir")
55 logPath := cmd.String("log-path")
56 endpoint := cmd.String("internal-api")
57
58 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
59 if err != nil {
60 l.Error("failed to open log file", "error", err)
61 return err
62 } else {
63 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo})
64 l = slog.New(fileHandler)
65 }
66
67 var clientIP string
68 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
69 parts := strings.Fields(connInfo)
70 if len(parts) > 0 {
71 clientIP = parts[0]
72 }
73 }
74
75 if incomingUser == "" {
76 l.Error("access denied: no user specified")
77 fmt.Fprintln(os.Stderr, "access denied: no user specified")
78 os.Exit(-1)
79 }
80
81 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
82
83 l.Info("connection attempt",
84 "user", incomingUser,
85 "command", sshCommand,
86 "client", clientIP)
87
88 if sshCommand == "" {
89 l.Info("access denied: no interactive shells", "user", incomingUser)
90 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
91 os.Exit(-1)
92 }
93
94 cmdParts := strings.Fields(sshCommand)
95 if len(cmdParts) < 2 {
96 l.Error("invalid command format", "command", sshCommand)
97 fmt.Fprintln(os.Stderr, "invalid command format")
98 os.Exit(-1)
99 }
100
101 gitCommand := cmdParts[0]
102
103 // did:foo/repo-name or
104 // handle/repo-name or
105 // any of the above with a leading slash (/)
106
107 components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/")
108 l.Info("command components", "components", components)
109
110 if len(components) != 2 {
111 l.Error("invalid repo format", "components", components)
112 fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
113 os.Exit(-1)
114 }
115
116 didOrHandle := components[0]
117 identity := resolveIdentity(ctx, l, didOrHandle)
118 did := identity.DID.String()
119 repoName := components[1]
120 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
121
122 validCommands := map[string]bool{
123 "git-receive-pack": true,
124 "git-upload-pack": true,
125 "git-upload-archive": true,
126 }
127 if !validCommands[gitCommand] {
128 l.Error("access denied: invalid git command", "command", gitCommand)
129 fmt.Fprintln(os.Stderr, "access denied: invalid git command")
130 return fmt.Errorf("access denied: invalid git command")
131 }
132
133 if gitCommand != "git-upload-pack" {
134 if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) {
135 l.Error("access denied: user not allowed",
136 "did", incomingUser,
137 "reponame", qualifiedRepoName)
138 fmt.Fprintln(os.Stderr, "access denied: user not allowed")
139 os.Exit(-1)
140 }
141 }
142
143 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName)
144
145 l.Info("processing command",
146 "user", incomingUser,
147 "command", gitCommand,
148 "repo", repoName,
149 "fullPath", fullPath,
150 "client", clientIP)
151
152 if gitCommand == "git-upload-pack" {
153 fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
154 } else {
155 fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
156 }
157
158 gitCmd := exec.Command(gitCommand, fullPath)
159 gitCmd.Stdout = os.Stdout
160 gitCmd.Stderr = os.Stderr
161 gitCmd.Stdin = os.Stdin
162 gitCmd.Env = append(os.Environ(),
163 fmt.Sprintf("GIT_USER_DID=%s", identity.DID.String()),
164 fmt.Sprintf("GIT_USER_HANDLE=%s", identity.Handle.String()),
165 fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()),
166 )
167
168 if err := gitCmd.Run(); err != nil {
169 l.Error("command failed", "error", err)
170 fmt.Fprintf(os.Stderr, "command failed: %v\n", err)
171 return fmt.Errorf("command failed: %v", err)
172 }
173
174 l.Info("command completed",
175 "user", incomingUser,
176 "command", gitCommand,
177 "repo", repoName,
178 "success", true)
179
180 return nil
181}
182
183func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity {
184 resolver := idresolver.DefaultResolver()
185 ident, err := resolver.ResolveIdent(ctx, didOrHandle)
186 if err != nil {
187 l.Error("Error resolving handle", "error", err, "handle", didOrHandle)
188 fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err)
189 os.Exit(1)
190 }
191 if ident.Handle.IsInvalidHandle() {
192 l.Error("Error resolving handle", "invalid handle", didOrHandle)
193 fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n")
194 os.Exit(1)
195 }
196 return ident
197}
198
199func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool {
200 u, _ := url.Parse(endpoint + "/push-allowed")
201 q := u.Query()
202 q.Add("user", user)
203 q.Add("repo", qualifiedRepoName)
204 u.RawQuery = q.Encode()
205
206 req, err := http.Get(u.String())
207 if err != nil {
208 l.Error("Error verifying permissions", "error", err)
209 fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err)
210 os.Exit(1)
211 }
212
213 l.Info("Checking push permission",
214 "url", u.String(),
215 "status", req.Status)
216
217 return req.StatusCode == http.StatusNoContent
218}