This repository has no description
5.8 kB
185 lines
1package xrpc
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "maps"
9 "net/http"
10 "net/url"
11 "strings"
12
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/knotmirror/db"
17 "tangled.org/core/knotmirror/models"
18)
19
20var mirrorToKnotNSID = map[string]string{
21 tangled.GitTempListBranchesNSID: tangled.RepoBranchesNSID,
22 tangled.GitTempListTagsNSID: tangled.RepoTagsNSID,
23 tangled.GitTempListCommitsNSID: tangled.RepoLogNSID,
24 tangled.GitTempGetTreeNSID: tangled.RepoTreeNSID,
25 tangled.GitTempGetBranchNSID: tangled.RepoBranchNSID,
26 tangled.GitTempGetTagNSID: tangled.RepoTagNSID,
27 tangled.GitTempGetArchiveNSID: tangled.RepoArchiveNSID,
28 tangled.RepoBlobNSID: tangled.RepoBlobNSID,
29 tangled.GitTempListLanguagesNSID: tangled.RepoLanguagesNSID,
30}
31
32var hopByHopHeaders = map[string]bool{
33 "Connection": true,
34 "Keep-Alive": true,
35 "Transfer-Encoding": true,
36 "Te": true,
37 "Trailer": true,
38 "Upgrade": true,
39 "Proxy-Authorization": true,
40 "Proxy-Authenticate": true,
41}
42
43type knotInfo struct {
44 baseURL string
45 repoIdentifier string
46}
47
48// validateKnotURL ensures a knot base URL is safe to proxy to.
49// It rejects URLs with path components, query strings, or fragments
50// that could be used for path injection.
51func validateKnotURL(raw string) (string, error) {
52 u, err := url.Parse(raw)
53 if err != nil {
54 return "", fmt.Errorf("invalid knot URL: %w", err)
55 }
56 if u.Scheme != "http" && u.Scheme != "https" {
57 return "", errors.New("knot URL must use http or https scheme")
58 }
59 if u.Path != "" && u.Path != "/" {
60 return "", fmt.Errorf("knot URL must not contain a path: %q", raw)
61 }
62 if u.RawQuery != "" || u.Fragment != "" {
63 return "", fmt.Errorf("knot URL must not contain query or fragment: %q", raw)
64 }
65 if u.User != nil {
66 return "", fmt.Errorf("knot URL must not contain userinfo: %q", raw)
67 }
68 // Strip trailing slash for consistent formatting
69 return strings.TrimRight(u.String(), "/"), nil
70}
71
72func (x *Xrpc) resolveKnot(ctx context.Context, repoDid syntax.DID) (*knotInfo, error) {
73 if repo, err := db.GetRepoByRepoDid(ctx, x.db, repoDid); err == nil && repo != nil {
74 knotURL := repo.KnotDomain
75 if !strings.Contains(repo.KnotDomain, "://") {
76 if host, _ := db.GetHost(ctx, x.db, repo.KnotDomain); host != nil {
77 knotURL = host.URL()
78 } else {
79 x.logger.Warn("repo is from unknown knot")
80 if x.cfg.KnotUseSSL {
81 knotURL = "https://" + knotURL
82 } else {
83 knotURL = "http://" + knotURL
84 }
85 }
86 }
87 knotURL, err = validateKnotURL(knotURL)
88 if err != nil {
89 return nil, err
90 }
91 return &knotInfo{baseURL: knotURL, repoIdentifier: repo.RepoIdentifier()}, nil
92 }
93
94 ident, err := x.resolver.ResolveIdent(ctx, repoDid.String())
95 if err != nil {
96 return nil, fmt.Errorf("resolving repoDid %s: %w", repoDid, err)
97 }
98 knotURL, err := validateKnotURL(ident.GetServiceEndpoint("atproto_pds"))
99 if err != nil {
100 return nil, fmt.Errorf("repoDid %s: %w", repoDid, err)
101 }
102
103 xrpcc := &indigoxrpc.Client{Host: knotURL, Client: x.httpClient}
104 out, err := tangled.RepoDescribeRepo(ctx, xrpcc, repoDid.String())
105 if err != nil {
106 x.logger.Warn("describeRepo failed; serving without metadata upsert", "knot", knotURL, "repo", repoDid, "err", err)
107 return &knotInfo{baseURL: knotURL, repoIdentifier: repoDid.String()}, nil
108 }
109 if out.RepoDid != repoDid.String() {
110 return nil, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knotURL, out.RepoDid, repoDid)
111 }
112 ownerDid, err := syntax.ParseDID(out.OwnerDid)
113 if err != nil {
114 return nil, fmt.Errorf("describeRepo on %s returned invalid ownerDid %q: %w", knotURL, out.OwnerDid, err)
115 }
116 rkey, err := syntax.ParseRecordKey(out.Rkey)
117 if err != nil {
118 return nil, fmt.Errorf("describeRepo on %s returned invalid rkey %q: %w", knotURL, out.Rkey, err)
119 }
120
121 go func() {
122 pending := &models.Repo{
123 Did: ownerDid,
124 Rkey: rkey,
125 Name: string(rkey),
126 KnotDomain: knotURL,
127 RepoDid: repoDid,
128 State: models.RepoStatePending,
129 }
130 if err := db.UpsertRepo(context.Background(), x.db, pending); err != nil {
131 x.logger.Error("failed to upsert repo after directory resolution", "err", err)
132 }
133 }()
134
135 return &knotInfo{baseURL: knotURL, repoIdentifier: repoDid.String()}, nil
136}
137
138func (x *Xrpc) proxyToKnot(w http.ResponseWriter, r *http.Request, repoDid syntax.DID) bool {
139 mirrorNSID := strings.TrimPrefix(r.URL.Path, "/xrpc/")
140 knotNSID, ok := mirrorToKnotNSID[mirrorNSID]
141 if !ok {
142 return false
143 }
144
145 knot, err := x.resolveKnot(r.Context(), repoDid)
146 if err != nil {
147 x.logger.Warn("proxy: failed to resolve knot", "repo", repoDid, "err", err)
148 return false
149 }
150
151 params := make(url.Values)
152 maps.Copy(params, r.URL.Query())
153 params.Set("repo", knot.repoIdentifier)
154
155 target := fmt.Sprintf("%s/xrpc/%s?%s", knot.baseURL, knotNSID, params.Encode())
156
157 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
158 if err != nil {
159 x.logger.Warn("proxy: failed to build request", "target", target, "err", err)
160 return false
161 }
162
163 resp, err := x.httpClient.Do(req)
164 if err != nil {
165 x.logger.Warn("proxy: knot request failed", "target", target, "err", err)
166 return false
167 }
168 defer resp.Body.Close()
169
170 for k, vv := range resp.Header {
171 if hopByHopHeaders[k] {
172 continue
173 }
174 for _, v := range vv {
175 w.Header().Add(k, v)
176 }
177 }
178 w.WriteHeader(resp.StatusCode)
179 if _, err := io.Copy(w, resp.Body); err != nil {
180 x.logger.Warn("proxy: response copy interrupted", "target", target, "err", err)
181 }
182
183 x.logger.Info("proxy: served from knot", "repo", repoDid, "knot", knot.baseURL, "status", resp.StatusCode)
184 return true
185}