forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}