clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / tinygit / private_transport.go
private_transport.go 7.4K
1package tinygit23import (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"1718 "github.com/FelineStateMachine/tinyrelay/internal/peerurl"19 "github.com/FelineStateMachine/tinyrelay/protocol/auth"20)2122// 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)2627// ConfigurePrivatePeers refreshes operator-controlled private transport after28// 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 = signer34}3536type privateProxy struct {37 server *http.Server38 listener net.Listener39 done chan struct{}40 base *url.URL41 token string42 sign HTTPAuthSigner43 addr string44 client *http.Client45}4647func 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 = listener67 p.addr = listener.Addr().String()68 go func() { defer close(p.done); _ = p.server.Serve(listener) }()69 return p, nil70}7172func (p *privateProxy) URL() string {73 return "http://" + p.addr + "/tiny/" + p.token74}7576func (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.done91 }92 return err93}9495func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {96 prefix := "/tiny/" + p.token97 if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") {98 http.NotFound(w, r)99 return100 }101 u := *p.base102 suffix := strings.TrimPrefix(r.URL.Path, prefix)103 if strings.Contains(suffix, "..") {104 http.Error(w, "invalid private peer path", http.StatusBadRequest)105 return106 }107 u.Path = strings.TrimSuffix(p.base.Path, "/") + "/" + strings.TrimPrefix(suffix, "/")108 u.RawQuery = r.URL.RawQuery109 file, _, size, err := spoolRequest(r.Body)110 if err != nil {111 http.Error(w, err.Error(), http.StatusBadGateway)112 return113 }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 return121 }122 // GRASP-08 uses one reusable GET proof for the repository root. The123 // 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 return128 }129 var body io.Reader130 if file != nil {131 if _, err := file.Seek(0, io.SeekStart); err != nil {132 http.Error(w, err.Error(), http.StatusBadGateway)133 return134 }135 body = file136 }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 return141 }142 copyHeaders(request.Header, r.Header)143 request.Header.Set("Authorization", authorization)144 request.ContentLength = size145 response, err := p.client.Do(request)146 if err != nil {147 http.Error(w, err.Error(), http.StatusBadGateway)148 return149 }150 defer response.Body.Close()151 copyHeaders(w.Header(), response.Header)152 w.WriteHeader(response.StatusCode)153 _, _ = io.Copy(w, response.Body)154}155156func spoolRequest(body io.ReadCloser) (*os.File, [32]byte, int64, error) {157 var digest [32]byte158 if body == nil || body == http.NoBody {159 return nil, digest, 0, nil160 }161 file, err := os.CreateTemp("", "tiny-private-git-")162 if err != nil {163 return nil, digest, 0, err164 }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, err171 }172 copy(digest[:], hash.Sum(nil))173 return file, digest, size, nil174}175176func 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 continue180 }181 for _, value := range values {182 dst.Add(key, value)183 }184 }185}186187func privateHTTPClient(allowPrivate bool) *http.Client {188 transport := http.DefaultTransport.(*http.Transport).Clone()189 transport.Proxy = nil190 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, err194 }195 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)196 if err != nil {197 return nil, err198 }199 for _, ip := range ips {200 if !allowPrivate && privateIP(ip) {201 continue202 }203 conn, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))204 if dialErr == nil {205 return conn, nil206 }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}212213func (g *GitRelay) privatePeer(source string) bool {214 return g.privatePeerBase(source) != ""215}216217func (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}223224func probePrivatePeer(ctx context.Context, source string, allowPrivate ...bool) error {225 u, err := url.Parse(source)226 if err != nil {227 return err228 }229 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)230 if err != nil {231 return err232 }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 err238 }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 err249 }250 for _, profile := range info.SupportedGRASPs {251 if strings.EqualFold(profile, "GRASP-08") {252 return nil253 }254 }255 return errors.New("private peer does not advertise GRASP-08")256}