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