206 lines
4.7 kB
1
package main
2
3
import (
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
19
var (
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
31
func 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
136
func 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
147
func 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
158
func 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
172
func 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
181
func cleanup() {
182
if logFile != nil {
183
logFile.Sync()
184
logFile.Close()
185
}
186
}
187
188
func 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
}
207