1package main
2
3import (
4 "context"
5 "flag"
6 "fmt"
7 "log"
8 "net/http"
9 "net/url"
10 "os"
11 "os/exec"
12 "strings"
13 "time"
14
15 securejoin "github.com/cyphar/filepath-securejoin"
16 "github.com/sotangled/tangled/appview"
17)
18
19var (
20 logger *log.Logger
21 logFile *os.File
22 clientIP string
23
24 // Command line flags
25 incomingUser = flag.String("user", "", "Allowed git user")
26 baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories")
27 logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file")
28 endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint")
29)
30
31func main() {
32 flag.Parse()
33
34 defer cleanup()
35 initLogger()
36
37 // Get client IP from SSH environment
38 if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" {
39 parts := strings.Fields(connInfo)
40 if len(parts) > 0 {
41 clientIP = parts[0]
42 }
43 }
44
45 if *incomingUser == "" {
46 exitWithLog("access denied: no user specified")
47 }
48
49 sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND")
50
51 logEvent("Connection attempt", map[string]interface{}{
52 "user": *incomingUser,
53 "command": sshCommand,
54 "client": clientIP,
55 })
56
57 if sshCommand == "" {
58 exitWithLog("access denied: we don't serve interactive shells :)")
59 }
60
61 cmdParts := strings.Fields(sshCommand)
62 if len(cmdParts) < 2 {
63 exitWithLog("invalid command format")
64 }
65
66 gitCommand := cmdParts[0]
67
68 // did:foo/repo-name or
69 // handle/repo-name
70
71 components := strings.Split(strings.Trim(cmdParts[1], "'"), "/")
72 logEvent("Command components", map[string]interface{}{
73 "components": components,
74 })
75 if len(components) != 2 {
76 exitWithLog("invalid repo format, needs <user>/<repo>")
77 }
78
79 didOrHandle := components[0]
80 did := resolveToDid(didOrHandle)
81 repoName := components[1]
82 qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName)
83
84 validCommands := map[string]bool{
85 "git-receive-pack": true,
86 "git-upload-pack": true,
87 "git-upload-archive": true,
88 }
89 if !validCommands[gitCommand] {
90 exitWithLog("access denied: invalid git command")
91 }
92
93 if gitCommand != "git-upload-pack" {
94 if !isPushPermitted(*incomingUser, qualifiedRepoName) {
95 logEvent("all infos", map[string]interface{}{
96 "did": *incomingUser,
97 "reponame": qualifiedRepoName,
98 })
99 exitWithLog("access denied: user not allowed")
100 }
101 }
102
103 fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName)
104
105 logEvent("Processing command", map[string]interface{}{
106 "user": *incomingUser,
107 "command": gitCommand,
108 "repo": repoName,
109 "fullPath": fullPath,
110 "client": clientIP,
111 })
112
113 if gitCommand == "git-upload-pack" {
114 fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!")
115 } else {
116 fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!")
117 }
118
119 cmd := exec.Command(gitCommand, fullPath)
120 cmd.Stdout = os.Stdout
121 cmd.Stderr = os.Stderr
122 cmd.Stdin = os.Stdin
123
124 if err := cmd.Run(); err != nil {
125 exitWithLog(fmt.Sprintf("command failed: %v", err))
126 }
127
128 logEvent("Command completed", map[string]interface{}{
129 "user": *incomingUser,
130 "command": gitCommand,
131 "repo": repoName,
132 "success": true,
133 })
134}
135
136func resolveToDid(didOrHandle string) string {
137 resolver := appview.NewResolver()
138 ident, err := resolver.ResolveIdent(context.Background(), didOrHandle)
139 if err != nil {
140 exitWithLog(fmt.Sprintf("error resolving handle: %v", err))
141 }
142
143 // did:plc:foobarbaz/repo
144 return ident.DID.String()
145}
146
147func initLogger() {
148 var err error
149 logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
150 if err != nil {
151 fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err)
152 os.Exit(1)
153 }
154
155 logger = log.New(logFile, "", 0)
156}
157
158func logEvent(event string, fields map[string]interface{}) {
159 entry := fmt.Sprintf(
160 "timestamp=%q event=%q",
161 time.Now().Format(time.RFC3339),
162 event,
163 )
164
165 for k, v := range fields {
166 entry += fmt.Sprintf(" %s=%q", k, v)
167 }
168
169 logger.Println(entry)
170}
171
172func exitWithLog(message string) {
173 logEvent("Access denied", map[string]interface{}{
174 "error": message,
175 })
176 logFile.Sync()
177 fmt.Fprintf(os.Stderr, "error: %s\n", message)
178 os.Exit(1)
179}
180
181func cleanup() {
182 if logFile != nil {
183 logFile.Sync()
184 logFile.Close()
185 }
186}
187
188func isPushPermitted(user, qualifiedRepoName string) bool {
189 u, _ := url.Parse(*endpoint + "/push-allowed")
190 q := u.Query()
191 q.Add("user", user)
192 q.Add("repo", qualifiedRepoName)
193 u.RawQuery = q.Encode()
194
195 req, err := http.Get(u.String())
196 if err != nil {
197 exitWithLog(fmt.Sprintf("error verifying permissions: %v", err))
198 }
199
200 logEvent("url", map[string]interface{}{
201 "url": u.String(),
202 "status": req.Status,
203 })
204
205 return req.StatusCode == http.StatusNoContent
206}