This repository has no description
3.7 kB
140 lines
1package repoverify
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/http"
9 "net/url"
10 "syscall"
11 "time"
12
13 "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16 "tangled.org/core/api/tangled"
17 "tangled.org/core/repoident"
18 "tangled.org/core/xrpc/xrpcclient"
19)
20
21func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) {
22 if raw == "" {
23 return nil, fmt.Errorf("empty knot URL")
24 }
25 u, err := url.Parse(raw)
26 if err != nil {
27 return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err)
28 }
29 if u.Host == "" {
30 return nil, fmt.Errorf("knot URL %q has no host", raw)
31 }
32 switch u.Scheme {
33 case "https":
34 case "http":
35 if !dev {
36 return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw)
37 }
38 default:
39 return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme)
40 }
41 return u, nil
42}
43
44type Result struct {
45 RepoDid repoident.RepoDid
46 OwnerDid repoident.OwnerDid
47 KnotURL *url.URL
48 // Rkey of the sh.tangled.repo record tracked by the knot; empty when the
49 // knot does not support describeRepo.
50 Rkey string
51}
52
53type Verifier func(ctx context.Context, repoDid repoident.RepoDid) (Result, error)
54
55const verifyTimeout = 10 * time.Second
56
57func New(dir identity.Directory, dev bool) Verifier {
58 transport := &http.Transport{
59 DialContext: safeDialer(dev).DialContext,
60 }
61 httpClient := &http.Client{
62 Timeout: verifyTimeout,
63 Transport: transport,
64 }
65
66 return func(ctx context.Context, repoDid repoident.RepoDid) (Result, error) {
67 ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
68 defer cancel()
69 return resolveAndDescribe(ctx, dir, httpClient, repoDid, dev)
70 }
71}
72
73func resolveAndDescribe(
74 ctx context.Context,
75 dir identity.Directory,
76 httpClient *http.Client,
77 repoDid repoident.RepoDid,
78 dev bool,
79) (Result, error) {
80 ident, err := dir.LookupDID(ctx, syntax.DID(repoDid))
81 if err != nil {
82 return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err)
83 }
84
85 knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev)
86 if err != nil {
87 return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err)
88 }
89
90 client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient}
91 out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String())
92 if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil {
93 if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) {
94 return Result{RepoDid: repoDid, KnotURL: knot}, nil
95 }
96 return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr)
97 }
98
99 if out.RepoDid != repoDid.String() {
100 return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid)
101 }
102
103 ownerDid, err := repoident.NewOwnerDid(out.OwnerDid)
104 if err != nil {
105 return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err)
106 }
107
108 return Result{
109 RepoDid: repoDid,
110 OwnerDid: ownerDid,
111 KnotURL: knot,
112 Rkey: out.Rkey,
113 }, nil
114}
115
116func safeDialer(dev bool) *net.Dialer {
117 d := &net.Dialer{
118 Timeout: 5 * time.Second,
119 KeepAlive: 30 * time.Second,
120 }
121 if dev {
122 return d
123 }
124 d.Control = func(network, address string, _ syscall.RawConn) error {
125 host, _, err := net.SplitHostPort(address)
126 if err != nil {
127 return fmt.Errorf("invalid dial address %q: %w", address, err)
128 }
129 ip := net.ParseIP(host)
130 if ip == nil {
131 return fmt.Errorf("dial address %q did not resolve to IP", address)
132 }
133 if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
134 ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
135 return fmt.Errorf("refusing to dial %s: reserved or private address", ip)
136 }
137 return nil
138 }
139 return d
140}