forked from tangled.org/core
this repo has no description
at test-ci 5.8 kB view raw
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 "github.com/bluesky-social/indigo/atproto/identity" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/urfave/cli/v3" 16 "tangled.sh/tangled.sh/core/appview/idresolver" 17 "tangled.sh/tangled.sh/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 }, 47 } 48} 49 50func Run(ctx context.Context, cmd *cli.Command) error { 51 l := log.FromContext(ctx) 52 53 incomingUser := cmd.String("user") 54 gitDir := cmd.String("git-dir") 55 logPath := cmd.String("log-path") 56 endpoint := cmd.String("internal-api") 57 58 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 if err != nil { 60 l.Error("failed to open log file", "error", err) 61 return err 62 } else { 63 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) 64 l = slog.New(fileHandler) 65 } 66 67 var clientIP string 68 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 69 parts := strings.Fields(connInfo) 70 if len(parts) > 0 { 71 clientIP = parts[0] 72 } 73 } 74 75 if incomingUser == "" { 76 l.Error("access denied: no user specified") 77 fmt.Fprintln(os.Stderr, "access denied: no user specified") 78 os.Exit(-1) 79 } 80 81 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 82 83 l.Info("connection attempt", 84 "user", incomingUser, 85 "command", sshCommand, 86 "client", clientIP) 87 88 if sshCommand == "" { 89 l.Info("access denied: no interactive shells", "user", incomingUser) 90 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) 91 os.Exit(-1) 92 } 93 94 cmdParts := strings.Fields(sshCommand) 95 if len(cmdParts) < 2 { 96 l.Error("invalid command format", "command", sshCommand) 97 fmt.Fprintln(os.Stderr, "invalid command format") 98 os.Exit(-1) 99 } 100 101 gitCommand := cmdParts[0] 102 103 // did:foo/repo-name or 104 // handle/repo-name or 105 // any of the above with a leading slash (/) 106 107 components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 108 l.Info("command components", "components", components) 109 110 if len(components) != 2 { 111 l.Error("invalid repo format", "components", components) 112 fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 113 os.Exit(-1) 114 } 115 116 didOrHandle := components[0] 117 identity := resolveIdentity(ctx, l, didOrHandle) 118 did := identity.DID.String() 119 repoName := components[1] 120 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 121 122 validCommands := map[string]bool{ 123 "git-receive-pack": true, 124 "git-upload-pack": true, 125 "git-upload-archive": true, 126 } 127 if !validCommands[gitCommand] { 128 l.Error("access denied: invalid git command", "command", gitCommand) 129 fmt.Fprintln(os.Stderr, "access denied: invalid git command") 130 return fmt.Errorf("access denied: invalid git command") 131 } 132 133 if gitCommand != "git-upload-pack" { 134 if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 135 l.Error("access denied: user not allowed", 136 "did", incomingUser, 137 "reponame", qualifiedRepoName) 138 fmt.Fprintln(os.Stderr, "access denied: user not allowed") 139 os.Exit(-1) 140 } 141 } 142 143 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 144 145 l.Info("processing command", 146 "user", incomingUser, 147 "command", gitCommand, 148 "repo", repoName, 149 "fullPath", fullPath, 150 "client", clientIP) 151 152 if gitCommand == "git-upload-pack" { 153 fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 154 } else { 155 fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 156 } 157 158 gitCmd := exec.Command(gitCommand, fullPath) 159 gitCmd.Stdout = os.Stdout 160 gitCmd.Stderr = os.Stderr 161 gitCmd.Stdin = os.Stdin 162 gitCmd.Env = append(os.Environ(), 163 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 164 fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 165 ) 166 167 if err := gitCmd.Run(); err != nil { 168 l.Error("command failed", "error", err) 169 fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 170 return fmt.Errorf("command failed: %v", err) 171 } 172 173 l.Info("command completed", 174 "user", incomingUser, 175 "command", gitCommand, 176 "repo", repoName, 177 "success", true) 178 179 return nil 180} 181 182func resolveIdentity(ctx context.Context, l *slog.Logger, didOrHandle string) *identity.Identity { 183 resolver := idresolver.DefaultResolver() 184 ident, err := resolver.ResolveIdent(ctx, didOrHandle) 185 if err != nil { 186 l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 187 fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 188 os.Exit(1) 189 } 190 if ident.Handle.IsInvalidHandle() { 191 l.Error("Error resolving handle", "invalid handle", didOrHandle) 192 fmt.Fprintf(os.Stderr, "error resolving handle: invalid handle\n") 193 os.Exit(1) 194 } 195 return ident 196} 197 198func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 199 u, _ := url.Parse(endpoint + "/push-allowed") 200 q := u.Query() 201 q.Add("user", user) 202 q.Add("repo", qualifiedRepoName) 203 u.RawQuery = q.Encode() 204 205 req, err := http.Get(u.String()) 206 if err != nil { 207 l.Error("Error verifying permissions", "error", err) 208 fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 209 os.Exit(1) 210 } 211 212 l.Info("Checking push permission", 213 "url", u.String(), 214 "status", req.Status) 215 216 return req.StatusCode == http.StatusNoContent 217}