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