forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package guard 2 3import ( 4 "context" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "net/url" 9 "os" 10 "os/exec" 11 "strings" 12 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/urfave/cli/v3" 15 "tangled.sh/tangled.sh/core/appview/idresolver" 16 "tangled.sh/tangled.sh/core/log" 17) 18 19func Command() *cli.Command { 20 return &cli.Command{ 21 Name: "guard", 22 Usage: "role-based access control for git over ssh (not for manual use)", 23 Action: Run, 24 Flags: []cli.Flag{ 25 &cli.StringFlag{ 26 Name: "user", 27 Usage: "allowed git user", 28 Required: true, 29 }, 30 &cli.StringFlag{ 31 Name: "git-dir", 32 Usage: "base directory for git repos", 33 Value: "/home/git", 34 }, 35 &cli.StringFlag{ 36 Name: "log-path", 37 Usage: "path to log file", 38 Value: "/home/git/guard.log", 39 }, 40 &cli.StringFlag{ 41 Name: "internal-api", 42 Usage: "internal API endpoint", 43 Value: "http://localhost:5444", 44 }, 45 }, 46 } 47} 48 49func Run(ctx context.Context, cmd *cli.Command) error { 50 l := log.FromContext(ctx) 51 52 incomingUser := cmd.String("user") 53 gitDir := cmd.String("git-dir") 54 logPath := cmd.String("log-path") 55 endpoint := cmd.String("internal-api") 56 57 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 58 if err != nil { 59 l.Error("failed to open log file", "error", err) 60 return err 61 } else { 62 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) 63 l = slog.New(fileHandler) 64 } 65 66 var clientIP string 67 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 68 parts := strings.Fields(connInfo) 69 if len(parts) > 0 { 70 clientIP = parts[0] 71 } 72 } 73 74 if incomingUser == "" { 75 l.Error("access denied: no user specified") 76 fmt.Fprintln(os.Stderr, "access denied: no user specified") 77 return fmt.Errorf("access denied: no user specified") 78 } 79 80 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 81 82 l.Info("connection attempt", 83 "user", incomingUser, 84 "command", sshCommand, 85 "client", clientIP) 86 87 if sshCommand == "" { 88 l.Error("access denied: no interactive shells", "user", incomingUser) 89 fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)") 90 os.Exit(-1) 91 } 92 93 cmdParts := strings.Fields(sshCommand) 94 if len(cmdParts) < 2 { 95 l.Error("invalid command format", "command", sshCommand) 96 fmt.Fprintln(os.Stderr, "invalid command format") 97 return fmt.Errorf("invalid command format") 98 } 99 100 gitCommand := cmdParts[0] 101 102 // did:foo/repo-name or 103 // handle/repo-name or 104 // any of the above with a leading slash (/) 105 106 components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 107 l.Info("command components", "components", components) 108 109 if len(components) != 2 { 110 l.Error("invalid repo format", "components", components) 111 fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 112 return fmt.Errorf("invalid repo format, needs <user>/<repo> or /<user>/<repo>") 113 } 114 115 didOrHandle := components[0] 116 did := resolveToDid(ctx, l, didOrHandle) 117 repoName := components[1] 118 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 119 120 validCommands := map[string]bool{ 121 "git-receive-pack": true, 122 "git-upload-pack": true, 123 "git-upload-archive": true, 124 } 125 if !validCommands[gitCommand] { 126 l.Error("access denied: invalid git command", "command", gitCommand) 127 fmt.Fprintln(os.Stderr, "access denied: invalid git command") 128 return fmt.Errorf("access denied: invalid git command") 129 } 130 131 if gitCommand != "git-upload-pack" { 132 if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 133 l.Error("access denied: user not allowed", 134 "did", incomingUser, 135 "reponame", qualifiedRepoName) 136 fmt.Fprintln(os.Stderr, "access denied: user not allowed") 137 return fmt.Errorf("access denied: user not allowed") 138 } 139 } 140 141 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 142 143 l.Info("processing command", 144 "user", incomingUser, 145 "command", gitCommand, 146 "repo", repoName, 147 "fullPath", fullPath, 148 "client", clientIP) 149 150 if gitCommand == "git-upload-pack" { 151 fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 152 } else { 153 fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 154 } 155 156 gitCmd := exec.Command(gitCommand, fullPath) 157 gitCmd.Stdout = os.Stdout 158 gitCmd.Stderr = os.Stderr 159 gitCmd.Stdin = os.Stdin 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", repoName, 171 "success", true) 172 173 return nil 174} 175 176func resolveToDid(ctx context.Context, l *slog.Logger, didOrHandle string) string { 177 resolver := idresolver.DefaultResolver() 178 ident, err := resolver.ResolveIdent(ctx, didOrHandle) 179 if err != nil { 180 l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 181 fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 182 os.Exit(1) 183 } 184 185 // did:plc:foobarbaz/repo 186 return ident.DID.String() 187} 188 189func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 190 u, _ := url.Parse(endpoint + "/push-allowed") 191 q := u.Query() 192 q.Add("user", user) 193 q.Add("repo", qualifiedRepoName) 194 u.RawQuery = q.Encode() 195 196 req, err := http.Get(u.String()) 197 if err != nil { 198 l.Error("Error verifying permissions", "error", err) 199 fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 200 os.Exit(1) 201 } 202 203 l.Info("Checking push permission", 204 "url", u.String(), 205 "status", req.Status) 206 207 return req.StatusCode == http.StatusNoContent 208}