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 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/urfave/cli/v3" 17 "tangled.org/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 &cli.StringFlag{ 47 Name: "motd-file", 48 Usage: "path to message of the day file", 49 Value: "/home/git/motd", 50 }, 51 }, 52 } 53} 54 55func Run(ctx context.Context, cmd *cli.Command) error { 56 l := log.FromContext(ctx) 57 58 incomingUser := cmd.String("user") 59 gitDir := cmd.String("git-dir") 60 logPath := cmd.String("log-path") 61 endpoint := cmd.String("internal-api") 62 motdFile := cmd.String("motd-file") 63 64 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 65 if err != nil { 66 l.Error("failed to open log file", "error", err) 67 return err 68 } else { 69 fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) 70 l = slog.New(fileHandler) 71 } 72 73 var clientIP string 74 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 75 parts := strings.Fields(connInfo) 76 if len(parts) > 0 { 77 clientIP = parts[0] 78 } 79 } 80 81 if incomingUser == "" { 82 l.Error("access denied: no user specified") 83 fmt.Fprintln(os.Stderr, "access denied: no user specified") 84 os.Exit(-1) 85 } 86 87 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 88 89 l.Info("connection attempt", 90 "user", incomingUser, 91 "command", sshCommand, 92 "client", clientIP) 93 94 // TODO: greet user with their resolved handle instead of did 95 if sshCommand == "" { 96 l.Info("access denied: no interactive shells", "user", incomingUser) 97 fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) 98 os.Exit(-1) 99 } 100 101 cmdParts := strings.Fields(sshCommand) 102 if len(cmdParts) < 2 { 103 l.Error("invalid command format", "command", sshCommand) 104 fmt.Fprintln(os.Stderr, "invalid command format") 105 os.Exit(-1) 106 } 107 108 gitCommand := cmdParts[0] 109 repoPath := cmdParts[1] 110 111 validCommands := map[string]bool{ 112 "git-receive-pack": true, 113 "git-upload-pack": true, 114 "git-upload-archive": true, 115 } 116 if !validCommands[gitCommand] { 117 l.Error("access denied: invalid git command", "command", gitCommand) 118 fmt.Fprintln(os.Stderr, "access denied: invalid git command") 119 return fmt.Errorf("access denied: invalid git command") 120 } 121 122 // qualify repo path from internal server which holds the knot config 123 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 124 if err != nil { 125 l.Error("failed to run guard", "err", err) 126 fmt.Fprintln(os.Stderr, err) 127 os.Exit(1) 128 } 129 130 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 131 132 l.Info("processing command", 133 "user", incomingUser, 134 "command", gitCommand, 135 "repo", repoPath, 136 "fullPath", fullPath, 137 "client", clientIP) 138 139 var motdReader io.Reader 140 if reader, err := os.Open(motdFile); err != nil { 141 if !errors.Is(err, os.ErrNotExist) { 142 l.Error("failed to read motd file", "error", err) 143 } 144 motdReader = strings.NewReader("Welcome to this knot!\n") 145 } else { 146 motdReader = reader 147 } 148 if gitCommand == "git-upload-pack" { 149 io.WriteString(os.Stderr, "\x02") 150 } 151 io.Copy(os.Stderr, motdReader) 152 153 gitCmd := exec.Command(gitCommand, fullPath) 154 gitCmd.Stdout = os.Stdout 155 gitCmd.Stderr = os.Stderr 156 gitCmd.Stdin = os.Stdin 157 gitCmd.Env = append(os.Environ(), 158 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 159 ) 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", repoPath, 171 "success", true) 172 173 return nil 174} 175 176// runs guardAndQualifyRepo logic 177func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 178 u, _ := url.Parse(endpoint + "/guard") 179 q := u.Query() 180 q.Add("user", incomingUser) 181 q.Add("repo", repo) 182 q.Add("gitCmd", gitCommand) 183 u.RawQuery = q.Encode() 184 185 resp, err := http.Get(u.String()) 186 if err != nil { 187 return "", err 188 } 189 defer resp.Body.Close() 190 191 l.Info("Running guard", "url", u.String(), "status", resp.Status) 192 193 body, err := io.ReadAll(resp.Body) 194 if err != nil { 195 return "", err 196 } 197 text := string(body) 198 199 switch resp.StatusCode { 200 case http.StatusOK: 201 return text, nil 202 case http.StatusForbidden: 203 l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 204 return text, errors.New("access denied: user not allowed") 205 default: 206 return "", errors.New(text) 207 } 208}