tinyrelayfile
tinyrelay/tinygit/private_transport.go/

tinygit/private_transport.go raw
1package tinygit
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "net/http"
14 "net/url"
15 "os"
16 "strings"
17
18 "github.com/FelineStateMachine/tinyrelay/internal/peerurl"
19 "github.com/FelineStateMachine/tinyrelay/protocol/auth"
20)
21
22// HTTPAuthSigner returns the authorization header for a private peer request.
23// GRASP-08 callers receive the repository root, GET and an empty payload hash;
24// it is deliberately a callback so GitRelay never owns private keys.
25type HTTPAuthSigner func(context.Context, string, string, string) (string, error)
26
27// ConfigurePrivatePeers refreshes operator-controlled private transport after
28// a tenant policy update without reopening the Git store.
29func (g *GitRelay) ConfigurePrivatePeers(peers []string, signer HTTPAuthSigner) {
30 g.mu.Lock()
31 defer g.mu.Unlock()
32 g.privatePeers = append([]string(nil), peers...)
33 g.httpAuth = signer
34}
35
36type privateProxy struct {
37 server *http.Server
38 listener net.Listener
39 done chan struct{}
40 base *url.URL
41 token string
42 sign HTTPAuthSigner
43 addr string
44 client *http.Client
45}
46
47func newPrivateProxy(ctx context.Context, target string, sign HTTPAuthSigner, allowPrivate ...bool) (*privateProxy, error) {
48 base, err := url.Parse(target)
49 if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
50 return nil, errors.New("Git private peer: invalid target")
51 }
52 if sign == nil {
53 return nil, errors.New("Git private peer: missing signer")
54 }
55 raw := make([]byte, 16)
56 if _, err := rand.Read(raw); err != nil {
57 return nil, fmt.Errorf("Git private peer token: %w", err)
58 }
59 private := len(allowPrivate) > 0 && allowPrivate[0]
60 p := &privateProxy{base: base, token: hex.EncodeToString(raw), sign: sign, done: make(chan struct{}), client: privateHTTPClient(private)}
61 p.server = &http.Server{Handler: http.HandlerFunc(p.serve)}
62 listener, err := net.Listen("tcp", "127.0.0.1:0")
63 if err != nil {
64 return nil, fmt.Errorf("Git private peer listener: %w", err)
65 }
66 p.listener = listener
67 p.addr = listener.Addr().String()
68 go func() { defer close(p.done); _ = p.server.Serve(listener) }()
69 return p, nil
70}
71
72func (p *privateProxy) URL() string {
73 return "http://" + p.addr + "/tiny/" + p.token
74}
75
76func (p *privateProxy) Close(ctx context.Context) error {
77 if ctx == nil || ctx.Err() != nil {
78 ctx = context.Background()
79 }
80 err := p.server.Shutdown(ctx)
81 if err != nil && p.listener != nil {
82 _ = p.listener.Close()
83 }
84 select {
85 case <-p.done:
86 case <-ctx.Done():
87 if p.listener != nil {
88 _ = p.listener.Close()
89 }
90 <-p.done
91 }
92 return err
93}
94
95func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
96 prefix := "/tiny/" + p.token
97 if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") {
98 http.NotFound(w, r)
99 return
100 }
101 u := *p.base
102 suffix := strings.TrimPrefix(r.URL.Path, prefix)
103 if strings.Contains(suffix, "..") {
104 http.Error(w, "invalid private peer path", http.StatusBadRequest)
105 return
106 }
107 u.Path = strings.TrimSuffix(p.base.Path, "/") + "/" + strings.TrimPrefix(suffix, "/")
108 u.RawQuery = r.URL.RawQuery
109 file, _, size, err := spoolRequest(r.Body)
110 if err != nil {
111 http.Error(w, err.Error(), http.StatusBadGateway)
112 return
113 }
114 if file != nil {
115 defer func() { _ = os.Remove(file.Name()); _ = file.Close() }()
116 }
117 signURL, ok := auth.GRASP08RepositoryRoot(u.String())
118 if !ok {
119 http.Error(w, "private peer: invalid Git repository URL", http.StatusBadGateway)
120 return
121 }
122 // GRASP-08 uses one reusable GET proof for the repository root. The
123 // concrete Smart HTTP path and request body are deliberately out of scope.
124 authorization, err := p.sign(r.Context(), http.MethodGet, signURL, "")
125 if err != nil {
126 http.Error(w, err.Error(), http.StatusBadGateway)
127 return
128 }
129 var body io.Reader
130 if file != nil {
131 if _, err := file.Seek(0, io.SeekStart); err != nil {
132 http.Error(w, err.Error(), http.StatusBadGateway)
133 return
134 }
135 body = file
136 }
137 request, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), body)
138 if err != nil {
139 http.Error(w, err.Error(), http.StatusBadGateway)
140 return
141 }
142 copyHeaders(request.Header, r.Header)
143 request.Header.Set("Authorization", authorization)
144 request.ContentLength = size
145 response, err := p.client.Do(request)
146 if err != nil {
147 http.Error(w, err.Error(), http.StatusBadGateway)
148 return
149 }
150 defer response.Body.Close()
151 copyHeaders(w.Header(), response.Header)
152 w.WriteHeader(response.StatusCode)
153 _, _ = io.Copy(w, response.Body)
154}
155
156func spoolRequest(body io.ReadCloser) (*os.File, [32]byte, int64, error) {
157 var digest [32]byte
158 if body == nil || body == http.NoBody {
159 return nil, digest, 0, nil
160 }
161 file, err := os.CreateTemp("", "tiny-private-git-")
162 if err != nil {
163 return nil, digest, 0, err
164 }
165 hash := sha256.New()
166 size, err := io.Copy(io.MultiWriter(file, hash), body)
167 if err != nil {
168 _ = file.Close()
169 _ = os.Remove(file.Name())
170 return nil, digest, 0, err
171 }
172 copy(digest[:], hash.Sum(nil))
173 return file, digest, size, nil
174}
175
176func copyHeaders(dst http.Header, src http.Header) {
177 for key, values := range src {
178 if strings.EqualFold(key, "Authorization") || strings.EqualFold(key, "Host") || strings.EqualFold(key, "Content-Length") || strings.EqualFold(key, "Transfer-Encoding") {
179 continue
180 }
181 for _, value := range values {
182 dst.Add(key, value)
183 }
184 }
185}
186
187func privateHTTPClient(allowPrivate bool) *http.Client {
188 transport := http.DefaultTransport.(*http.Transport).Clone()
189 transport.Proxy = nil
190 transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
191 host, port, err := net.SplitHostPort(address)
192 if err != nil {
193 return nil, err
194 }
195 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
196 if err != nil {
197 return nil, err
198 }
199 for _, ip := range ips {
200 if !allowPrivate && privateIP(ip) {
201 continue
202 }
203 conn, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
204 if dialErr == nil {
205 return conn, nil
206 }
207 }
208 return nil, fmt.Errorf("private peer: no usable address for %s", host)
209 }
210 return &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
211}
212
213func (g *GitRelay) privatePeer(source string) bool {
214 return g.privatePeerBase(source) != ""
215}
216
217func (g *GitRelay) privatePeerBase(source string) string {
218 g.mu.RLock()
219 peers := append([]string(nil), g.privatePeers...)
220 g.mu.RUnlock()
221 return peerurl.Match(source, peers)
222}
223
224func probePrivatePeer(ctx context.Context, source string, allowPrivate ...bool) error {
225 u, err := url.Parse(source)
226 if err != nil {
227 return err
228 }
229 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
230 if err != nil {
231 return err
232 }
233 req.Header.Set("Accept", "application/nostr+json")
234 private := len(allowPrivate) > 0 && allowPrivate[0]
235 res, err := privateHTTPClient(private).Do(req)
236 if err != nil {
237 return err
238 }
239 defer res.Body.Close()
240 if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
241 return fmt.Errorf("private peer NIP-11 status %d", res.StatusCode)
242 }
243 var info struct {
244 SupportedGRASPs []string `json:"supported_grasps"`
245 PrivateService bool `json:"private_service"`
246 }
247 if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&info); err != nil {
248 return err
249 }
250 for _, profile := range info.SupportedGRASPs {
251 if strings.EqualFold(profile, "GRASP-08") {
252 return nil
253 }
254 }
255 return errors.New("private peer does not advertise GRASP-08")
256}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs