Monorepo for Tangled
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 "syscall"
15
16 securejoin "github.com/cyphar/filepath-securejoin"
17 "github.com/urfave/cli/v3"
18 "tangled.org/core/knotserver/sandbox"
19 "tangled.org/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 &cli.BoolFlag{
54 Name: "secure-mode",
55 Usage: "isolate git subprocesses to their own repository directory",
56 },
57 },
58 }
59}
60
61func Run(ctx context.Context, cmd *cli.Command) error {
62 l := log.FromContext(ctx)
63
64 incomingUser := cmd.String("user")
65 gitDir := cmd.String("git-dir")
66 logPath := cmd.String("log-path")
67 endpoint := cmd.String("internal-api")
68 motdFile := cmd.String("motd-file")
69 secureMode := cmd.Bool("secure-mode")
70
71 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
72 if err != nil {
73 l.Error("failed to open log file", "error", err)
74 return err
75 } else {
76 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo})
77 l = slog.New(fileHandler)
78 }
79
80 var clientIP string
81 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
82 parts := strings.Fields(connInfo)
83 if len(parts) > 0 {
84 clientIP = parts[0]
85 }
86 }
87
88 if incomingUser == "" {
89 l.Error("access denied: no user specified")
90 fmt.Fprintln(os.Stderr, "access denied: no user specified")
91 os.Exit(-1)
92 }
93
94 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
95
96 l.Info("connection attempt",
97 "user", incomingUser,
98 "command", sshCommand,
99 "client", clientIP)
100
101 // TODO: greet user with their resolved handle instead of did
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 repoPath := cmdParts[1]
117
118 validCommands := map[string]bool{
119 "git-receive-pack": true,
120 "git-upload-pack": true,
121 "git-upload-archive": true,
122 }
123 if !validCommands[gitCommand] {
124 l.Error("access denied: invalid git command", "command", gitCommand)
125 fmt.Fprintln(os.Stderr, "access denied: invalid git command")
126 return fmt.Errorf("access denied: invalid git command")
127 }
128
129 // qualify repo path from internal server which holds the knot config
130 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
131 if err != nil {
132 l.Error("failed to run guard", "err", err)
133 fmt.Fprintln(os.Stderr, err)
134 os.Exit(1)
135 }
136
137 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
138
139 l.Info("processing command",
140 "user", incomingUser,
141 "command", gitCommand,
142 "repo", repoPath,
143 "fullPath", fullPath,
144 "client", clientIP)
145
146 var motdReader io.Reader
147 if reader, err := os.Open(motdFile); err != nil {
148 if !errors.Is(err, os.ErrNotExist) {
149 l.Error("failed to read motd file", "error", err)
150 }
151 motdReader = strings.NewReader("Welcome to this knot!\n")
152 } else {
153 motdReader = reader
154 }
155 if gitCommand == "git-upload-pack" {
156 io.WriteString(os.Stderr, "\x02")
157 }
158 io.Copy(os.Stderr, motdReader)
159
160 gitCmd := exec.Command(gitCommand, fullPath)
161 gitCmd.Stdout = os.Stdout
162 gitCmd.Stderr = os.Stderr
163 gitCmd.Stdin = os.Stdin
164 gitCmd.Env = append(os.Environ(),
165 fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
166 )
167
168 if secureMode {
169 sb, warn := sandbox.New(func(repoPath string) (uint32, uint32, error) {
170 return sandbox.LookupUIDForRepoPath(gitDir, repoPath)
171 })
172 if warn != "" {
173 l.Warn("secure-mode: sandbox degraded", "reason", warn)
174 } else {
175 l.Info("secure-mode: wrapping git command", "backend", sb.Name())
176 }
177 wrapped, wrapErr := sb.Wrap(fullPath, gitCmd)
178 if wrapErr != nil {
179 l.Error("sandbox wrap failed", "error", wrapErr)
180 } else {
181 gitCmd = wrapped
182 }
183 }
184
185 if gitCmd.SysProcAttr == nil {
186 gitCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
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", repoPath,
199 "success", true)
200
201 return nil
202}
203
204// runs guardAndQualifyRepo logic
205func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
206 u, _ := url.Parse(endpoint + "/guard")
207 q := u.Query()
208 q.Add("user", incomingUser)
209 q.Add("repo", repo)
210 q.Add("gitCmd", gitCommand)
211 u.RawQuery = q.Encode()
212
213 resp, err := http.Get(u.String())
214 if err != nil {
215 return "", err
216 }
217 defer resp.Body.Close()
218
219 l.Info("Running guard", "url", u.String(), "status", resp.Status)
220
221 body, err := io.ReadAll(resp.Body)
222 if err != nil {
223 return "", err
224 }
225 text := string(body)
226
227 switch resp.StatusCode {
228 case http.StatusOK:
229 return text, nil
230 case http.StatusForbidden:
231 l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
232 return text, errors.New("access denied: user not allowed")
233 default:
234 return "", errors.New(text)
235 }
236}