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