Monorepo for Tangled
tangled.org
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/appview/xrpcclient"
17 "tangled.org/core/idresolver"
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}
72
73type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error)
74
75const verifyTimeout = 10 * time.Second
76
77func New(resolver *idresolver.Resolver, dev bool) Verifier {
78 transport := &http.Transport{
79 DialContext: safeDialer(dev).DialContext,
80 }
81 httpClient := &http.Client{
82 Timeout: verifyTimeout,
83 Transport: transport,
84 }
85
86 return func(ctx context.Context, repoDid RepoDid) (Result, error) {
87 ctx, cancel := context.WithTimeout(ctx, verifyTimeout)
88 defer cancel()
89 return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev)
90 }
91}
92
93func resolveAndDescribe(
94 ctx context.Context,
95 resolver *idresolver.Resolver,
96 httpClient *http.Client,
97 repoDid RepoDid,
98 dev bool,
99) (Result, error) {
100 ident, err := resolver.ResolveIdent(ctx, repoDid.String())
101 if err != nil {
102 return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err)
103 }
104
105 knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev)
106 if err != nil {
107 return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err)
108 }
109
110 client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient}
111 out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String())
112 if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil {
113 if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) {
114 return Result{RepoDid: repoDid, KnotURL: knot}, nil
115 }
116 return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr)
117 }
118
119 if out.RepoDid != repoDid.String() {
120 return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid)
121 }
122
123 ownerDid, err := NewOwnerDid(out.OwnerDid)
124 if err != nil {
125 return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err)
126 }
127
128 return Result{
129 RepoDid: repoDid,
130 OwnerDid: ownerDid,
131 KnotURL: knot,
132 }, nil
133}
134
135func safeDialer(dev bool) *net.Dialer {
136 d := &net.Dialer{
137 Timeout: 5 * time.Second,
138 KeepAlive: 30 * time.Second,
139 }
140 if dev {
141 return d
142 }
143 d.Control = func(network, address string, _ syscall.RawConn) error {
144 host, _, err := net.SplitHostPort(address)
145 if err != nil {
146 return fmt.Errorf("invalid dial address %q: %w", address, err)
147 }
148 ip := net.ParseIP(host)
149 if ip == nil {
150 return fmt.Errorf("dial address %q did not resolve to IP", address)
151 }
152 if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
153 ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
154 return fmt.Errorf("refusing to dial %s: reserved or private address", ip)
155 }
156 return nil
157 }
158 return d
159}