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