Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

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