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 securejoin "github.com/cyphar/filepath-securejoin"
16 "github.com/urfave/cli/v3"
17 "tangled.org/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 &cli.StringFlag{
47 Name: "motd-file",
48 Usage: "path to message of the day file",
49 Value: "/home/git/motd",
50 },
51 },
52 }
53}
54
55func Run(ctx context.Context, cmd *cli.Command) error {
56 l := log.FromContext(ctx)
57
58 incomingUser := cmd.String("user")
59 gitDir := cmd.String("git-dir")
60 logPath := cmd.String("log-path")
61 endpoint := cmd.String("internal-api")
62 motdFile := cmd.String("motd-file")
63
64 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
65 if err != nil {
66 l.Error("failed to open log file", "error", err)
67 return err
68 } else {
69 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo})
70 l = slog.New(fileHandler)
71 }
72
73 var clientIP string
74 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
75 parts := strings.Fields(connInfo)
76 if len(parts) > 0 {
77 clientIP = parts[0]
78 }
79 }
80
81 if incomingUser == "" {
82 l.Error("access denied: no user specified")
83 fmt.Fprintln(os.Stderr, "access denied: no user specified")
84 os.Exit(-1)
85 }
86
87 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
88
89 l.Info("connection attempt",
90 "user", incomingUser,
91 "command", sshCommand,
92 "client", clientIP)
93
94 // TODO: greet user with their resolved handle instead of did
95 if sshCommand == "" {
96 l.Info("access denied: no interactive shells", "user", incomingUser)
97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser)
98 os.Exit(-1)
99 }
100
101 cmdParts := strings.Fields(sshCommand)
102 if len(cmdParts) < 2 {
103 l.Error("invalid command format", "command", sshCommand)
104 fmt.Fprintln(os.Stderr, "invalid command format")
105 os.Exit(-1)
106 }
107
108 gitCommand := cmdParts[0]
109 repoPath := cmdParts[1]
110
111 validCommands := map[string]bool{
112 "git-receive-pack": true,
113 "git-upload-pack": true,
114 "git-upload-archive": true,
115 }
116 if !validCommands[gitCommand] {
117 l.Error("access denied: invalid git command", "command", gitCommand)
118 fmt.Fprintln(os.Stderr, "access denied: invalid git command")
119 return fmt.Errorf("access denied: invalid git command")
120 }
121
122 // qualify repo path from internal server which holds the knot config
123 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand)
124 if err != nil {
125 l.Error("failed to run guard", "err", err)
126 fmt.Fprintln(os.Stderr, err)
127 os.Exit(1)
128 }
129
130 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath)
131
132 l.Info("processing command",
133 "user", incomingUser,
134 "command", gitCommand,
135 "repo", repoPath,
136 "fullPath", fullPath,
137 "client", clientIP)
138
139 var motdReader io.Reader
140 if reader, err := os.Open(motdFile); err != nil {
141 if !errors.Is(err, os.ErrNotExist) {
142 l.Error("failed to read motd file", "error", err)
143 }
144 motdReader = strings.NewReader("Welcome to this knot!\n")
145 } else {
146 motdReader = reader
147 }
148 if gitCommand == "git-upload-pack" {
149 io.WriteString(os.Stderr, "\x02")
150 }
151 io.Copy(os.Stderr, motdReader)
152
153 gitCmd := exec.Command(gitCommand, fullPath)
154 gitCmd.Stdout = os.Stdout
155 gitCmd.Stderr = os.Stderr
156 gitCmd.Stdin = os.Stdin
157 gitCmd.Env = append(os.Environ(),
158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser),
159 )
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", repoPath,
171 "success", true)
172
173 return nil
174}
175
176// runs guardAndQualifyRepo logic
177func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) {
178 u, _ := url.Parse(endpoint + "/guard")
179 q := u.Query()
180 q.Add("user", incomingUser)
181 q.Add("repo", repo)
182 q.Add("gitCmd", gitCommand)
183 u.RawQuery = q.Encode()
184
185 resp, err := http.Get(u.String())
186 if err != nil {
187 return "", err
188 }
189 defer resp.Body.Close()
190
191 l.Info("Running guard", "url", u.String(), "status", resp.Status)
192
193 body, err := io.ReadAll(resp.Body)
194 if err != nil {
195 return "", err
196 }
197 text := string(body)
198
199 switch resp.StatusCode {
200 case http.StatusOK:
201 return text, nil
202 case http.StatusForbidden:
203 l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text)
204 return text, errors.New("access denied: user not allowed")
205 default:
206 return "", errors.New(text)
207 }
208}