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