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