A vibe coded tangled fork which supports pijul.
at master 297 lines 7.7 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 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 if cmdParts[0] == "pijul" { 109 if cmdParts[1] != "protocol" { 110 l.Error("access denied: invalid pijul command", "command", sshCommand) 111 fmt.Fprintln(os.Stderr, "access denied: invalid pijul command") 112 return fmt.Errorf("access denied: invalid pijul command") 113 } 114 115 repoPath, version, err := parsePijulProtocolArgs(cmdParts[2:]) 116 if err != nil { 117 l.Error("invalid pijul protocol args", "command", sshCommand, "err", err) 118 fmt.Fprintln(os.Stderr, "invalid pijul protocol args") 119 return err 120 } 121 if version != "" && version != "3" { 122 l.Error("unsupported pijul protocol version", "version", version) 123 fmt.Fprintln(os.Stderr, "unsupported pijul protocol version") 124 return fmt.Errorf("unsupported pijul protocol version") 125 } 126 127 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, "pijul-protocol") 128 if err != nil { 129 l.Error("failed to run guard", "err", err) 130 fmt.Fprintln(os.Stderr, err) 131 os.Exit(1) 132 } 133 134 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 135 args := []string{"protocol", "--repository", fullPath} 136 if version != "" { 137 args = append(args, "--version", version) 138 } 139 140 l.Info("processing command", 141 "user", incomingUser, 142 "command", "pijul protocol", 143 "repo", repoPath, 144 "fullPath", fullPath, 145 "client", clientIP) 146 147 pijulCmd := exec.Command("pijul", args...) 148 pijulCmd.Stdout = os.Stdout 149 pijulCmd.Stderr = os.Stderr 150 pijulCmd.Stdin = os.Stdin 151 152 if err := pijulCmd.Run(); err != nil { 153 l.Error("command failed", "error", err) 154 fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 155 return fmt.Errorf("command failed: %v", err) 156 } 157 158 l.Info("command completed", 159 "user", incomingUser, 160 "command", "pijul protocol", 161 "repo", repoPath, 162 "success", true) 163 164 return nil 165 } 166 167 gitCommand := cmdParts[0] 168 repoPath := cmdParts[1] 169 170 validCommands := map[string]bool{ 171 "git-receive-pack": true, 172 "git-upload-pack": true, 173 "git-upload-archive": true, 174 } 175 if !validCommands[gitCommand] { 176 l.Error("access denied: invalid git command", "command", gitCommand) 177 fmt.Fprintln(os.Stderr, "access denied: invalid git command") 178 return fmt.Errorf("access denied: invalid git command") 179 } 180 181 // qualify repo path from internal server which holds the knot config 182 qualifiedRepoPath, err := guardAndQualifyRepo(l, endpoint, incomingUser, repoPath, gitCommand) 183 if err != nil { 184 l.Error("failed to run guard", "err", err) 185 fmt.Fprintln(os.Stderr, err) 186 os.Exit(1) 187 } 188 189 fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoPath) 190 191 l.Info("processing command", 192 "user", incomingUser, 193 "command", gitCommand, 194 "repo", repoPath, 195 "fullPath", fullPath, 196 "client", clientIP) 197 198 var motdReader io.Reader 199 if reader, err := os.Open(motdFile); err != nil { 200 if !errors.Is(err, os.ErrNotExist) { 201 l.Error("failed to read motd file", "error", err) 202 } 203 motdReader = strings.NewReader("Welcome to this knot!\n") 204 } else { 205 motdReader = reader 206 } 207 if gitCommand == "git-upload-pack" { 208 io.WriteString(os.Stderr, "\x02") 209 } 210 io.Copy(os.Stderr, motdReader) 211 212 gitCmd := exec.Command(gitCommand, fullPath) 213 gitCmd.Stdout = os.Stdout 214 gitCmd.Stderr = os.Stderr 215 gitCmd.Stdin = os.Stdin 216 gitCmd.Env = append(os.Environ(), 217 fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 218 ) 219 220 if err := gitCmd.Run(); err != nil { 221 l.Error("command failed", "error", err) 222 fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 223 return fmt.Errorf("command failed: %v", err) 224 } 225 226 l.Info("command completed", 227 "user", incomingUser, 228 "command", gitCommand, 229 "repo", repoPath, 230 "success", true) 231 232 return nil 233} 234 235func parsePijulProtocolArgs(args []string) (string, string, error) { 236 var repo string 237 var version string 238 for i := 0; i < len(args); i++ { 239 arg := args[i] 240 switch { 241 case arg == "--repository" || arg == "-r": 242 if i+1 >= len(args) { 243 return "", "", fmt.Errorf("missing --repository value") 244 } 245 repo = args[i+1] 246 i++ 247 case strings.HasPrefix(arg, "--repository="): 248 repo = strings.TrimPrefix(arg, "--repository=") 249 case arg == "--version": 250 if i+1 >= len(args) { 251 return "", "", fmt.Errorf("missing --version value") 252 } 253 version = args[i+1] 254 i++ 255 case strings.HasPrefix(arg, "--version="): 256 version = strings.TrimPrefix(arg, "--version=") 257 } 258 } 259 if repo == "" { 260 return "", "", fmt.Errorf("missing --repository") 261 } 262 return repo, version, nil 263} 264 265// runs guardAndQualifyRepo logic 266func guardAndQualifyRepo(l *slog.Logger, endpoint, incomingUser, repo, gitCommand string) (string, error) { 267 u, _ := url.Parse(endpoint + "/guard") 268 q := u.Query() 269 q.Add("user", incomingUser) 270 q.Add("repo", repo) 271 q.Add("gitCmd", gitCommand) 272 u.RawQuery = q.Encode() 273 274 resp, err := http.Get(u.String()) 275 if err != nil { 276 return "", err 277 } 278 defer resp.Body.Close() 279 280 l.Info("Running guard", "url", u.String(), "status", resp.Status) 281 282 body, err := io.ReadAll(resp.Body) 283 if err != nil { 284 return "", err 285 } 286 text := string(body) 287 288 switch resp.StatusCode { 289 case http.StatusOK: 290 return text, nil 291 case http.StatusForbidden: 292 l.Error("access denied: user not allowed", "did", incomingUser, "reponame", text) 293 return text, errors.New("access denied: user not allowed") 294 default: 295 return "", errors.New(text) 296 } 297}