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}