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