package repoverify import ( "context" "errors" "fmt" "net" "net/http" "net/url" "syscall" "time" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/idresolver" "tangled.org/core/xrpc/xrpcclient" ) type RepoDid syntax.DID func (r RepoDid) String() string { return string(r) } func NewRepoDid(s string) (RepoDid, error) { did, err := syntax.ParseDID(s) if err != nil { return "", fmt.Errorf("invalid repoDid %q: %w", s, err) } return RepoDid(did), nil } type OwnerDid syntax.DID func (o OwnerDid) String() string { return string(o) } func NewOwnerDid(s string) (OwnerDid, error) { did, err := syntax.ParseDID(s) if err != nil { return "", fmt.Errorf("invalid ownerDid %q: %w", s, err) } return OwnerDid(did), nil } func ParseKnotEndpoint(raw string, dev bool) (*url.URL, error) { if raw == "" { return nil, fmt.Errorf("empty knot URL") } u, err := url.Parse(raw) if err != nil { return nil, fmt.Errorf("invalid knot URL %q: %w", raw, err) } if u.Host == "" { return nil, fmt.Errorf("knot URL %q has no host", raw) } switch u.Scheme { case "https": case "http": if !dev { return nil, fmt.Errorf("knot URL %q must use https outside dev mode", raw) } default: return nil, fmt.Errorf("knot URL %q has unsupported scheme %q", raw, u.Scheme) } return u, nil } type Result struct { RepoDid RepoDid OwnerDid OwnerDid KnotURL *url.URL // Rkey of the sh.tangled.repo record tracked by the knot; empty when the // knot does not support describeRepo. Rkey string } type Verifier func(ctx context.Context, repoDid RepoDid) (Result, error) const verifyTimeout = 10 * time.Second func New(resolver *idresolver.Resolver, dev bool) Verifier { transport := &http.Transport{ DialContext: safeDialer(dev).DialContext, } httpClient := &http.Client{ Timeout: verifyTimeout, Transport: transport, } return func(ctx context.Context, repoDid RepoDid) (Result, error) { ctx, cancel := context.WithTimeout(ctx, verifyTimeout) defer cancel() return resolveAndDescribe(ctx, resolver, httpClient, repoDid, dev) } } func resolveAndDescribe( ctx context.Context, resolver *idresolver.Resolver, httpClient *http.Client, repoDid RepoDid, dev bool, ) (Result, error) { ident, err := resolver.ResolveIdent(ctx, repoDid.String()) if err != nil { return Result{}, fmt.Errorf("resolve repoDid %s: %w", repoDid, err) } knot, err := ParseKnotEndpoint(ident.GetServiceEndpoint("atproto_pds"), dev) if err != nil { return Result{}, fmt.Errorf("repoDid %s: %w", repoDid, err) } client := &indigoxrpc.Client{Host: knot.String(), Client: httpClient} out, err := tangled.RepoDescribeRepo(ctx, client, repoDid.String()) if xrpcErr := xrpcclient.HandleXrpcErr(err); xrpcErr != nil { if errors.Is(xrpcErr, xrpcclient.ErrXrpcUnsupported) { return Result{RepoDid: repoDid, KnotURL: knot}, nil } return Result{}, fmt.Errorf("describeRepo on %s: %w", knot, xrpcErr) } if out.RepoDid != repoDid.String() { return Result{}, fmt.Errorf("knot %s returned mismatched repoDid: got %q, want %q", knot, out.RepoDid, repoDid) } ownerDid, err := NewOwnerDid(out.OwnerDid) if err != nil { return Result{}, fmt.Errorf("describeRepo on %s returned invalid ownerDid: %w", knot, err) } return Result{ RepoDid: repoDid, OwnerDid: ownerDid, KnotURL: knot, Rkey: out.Rkey, }, nil } func safeDialer(dev bool) *net.Dialer { d := &net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, } if dev { return d } d.Control = func(network, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return fmt.Errorf("invalid dial address %q: %w", address, err) } ip := net.ParseIP(host) if ip == nil { return fmt.Errorf("dial address %q did not resolve to IP", address) } if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { return fmt.Errorf("refusing to dial %s: reserved or private address", ip) } return nil } return d }