mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-21 09:44:15 +00:00
293 lines
8.5 KiB
Go
293 lines
8.5 KiB
Go
package atproto
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// KnownRelay represents a known ATProto relay.
|
|
type KnownRelay struct {
|
|
Name string
|
|
URL string
|
|
}
|
|
|
|
// KnownRelays is the hard-coded list of known public ATProto relays.
|
|
// There is no relay discovery protocol in ATProto — this list is manually maintained.
|
|
var KnownRelays = []KnownRelay{
|
|
{Name: "Bluesky", URL: "https://bsky.network"},
|
|
{Name: "Bluesky US-East", URL: "https://relay1.us-east.bsky.network"},
|
|
{Name: "Bluesky US-West", URL: "https://relay1.us-west.bsky.network"},
|
|
{Name: "Firehose NA", URL: "https://northamerica.firehose.network"},
|
|
{Name: "Firehose EU", URL: "https://europe.firehose.network"},
|
|
{Name: "Firehose Asia", URL: "https://asia.firehose.network"},
|
|
{Name: "Microcosm Montreal", URL: "https://relay.fire.hose.cam"},
|
|
{Name: "Microcosm France", URL: "https://relay3.fr.hose.cam"},
|
|
{Name: "Upcloud", URL: "https://relay.upcloud.world"},
|
|
{Name: "Blacksky", URL: "https://atproto.africa"},
|
|
{Name: "Hayes", URL: "https://relay.hayescmd.net"},
|
|
{Name: "Xero", URL: "https://relay.xero.systems"},
|
|
{Name: "Feeds Blue", URL: "https://relay.feeds.blue"},
|
|
{Name: "Waow", URL: "https://relay.waow.tech"},
|
|
{Name: "Bassh", URL: "https://relay.bas.sh"},
|
|
}
|
|
|
|
// RelayHTTPError indicates the relay responded with a non-200 status code.
|
|
// This means the relay is online but returned an error (e.g. 404 for unknown host).
|
|
type RelayHTTPError struct {
|
|
StatusCode int
|
|
}
|
|
|
|
func (e *RelayHTTPError) Error() string {
|
|
return fmt.Sprintf("relay returned status %d", e.StatusCode)
|
|
}
|
|
|
|
// RepoStatus represents the response from com.atproto.sync.getRepoStatus.
|
|
type RepoStatus struct {
|
|
DID string `json:"did"`
|
|
Active bool `json:"active"`
|
|
Status string `json:"status,omitempty"`
|
|
Rev string `json:"rev,omitempty"`
|
|
}
|
|
|
|
// HostStatus represents the response from com.atproto.sync.getHostStatus.
|
|
type HostStatus struct {
|
|
Hostname string `json:"hostname"`
|
|
Seq int64 `json:"seq,omitempty"`
|
|
Active bool `json:"active"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// RelayStatus is the result of probing a relay for its status and capabilities.
|
|
type RelayStatus struct {
|
|
Online bool
|
|
Error string
|
|
HasRequestCrawl bool
|
|
RequestCrawlStatus int // HTTP status code from probe (400=open, 401/403=auth required, 5xx=error)
|
|
HasListReposByCollection bool
|
|
RepoStatus *RepoStatus
|
|
HostStatus *HostStatus
|
|
}
|
|
|
|
// CheckRelayStatus probes a relay to determine its online status, capabilities,
|
|
// and whether it knows about the given host/DID. Runs checks concurrently.
|
|
func CheckRelayStatus(relayURL, hostname, did string) *RelayStatus {
|
|
result := &RelayStatus{}
|
|
var mu sync.Mutex
|
|
var wg sync.WaitGroup
|
|
wg.Add(4)
|
|
|
|
// Mark relay as online if any check gets an HTTP response
|
|
markOnline := func() {
|
|
mu.Lock()
|
|
result.Online = true
|
|
mu.Unlock()
|
|
}
|
|
|
|
// Probe requestCrawl
|
|
go func() {
|
|
defer wg.Done()
|
|
supported, statusCode, online := probeRequestCrawl(relayURL)
|
|
if online {
|
|
markOnline()
|
|
}
|
|
mu.Lock()
|
|
result.HasRequestCrawl = supported
|
|
result.RequestCrawlStatus = statusCode
|
|
mu.Unlock()
|
|
}()
|
|
|
|
// Check host status
|
|
go func() {
|
|
defer wg.Done()
|
|
status, err := CheckHostStatus(relayURL, hostname)
|
|
if err != nil {
|
|
var httpErr *RelayHTTPError
|
|
if errors.As(err, &httpErr) {
|
|
markOnline()
|
|
}
|
|
return
|
|
}
|
|
markOnline()
|
|
mu.Lock()
|
|
result.HostStatus = status
|
|
mu.Unlock()
|
|
}()
|
|
|
|
// Check repo status
|
|
go func() {
|
|
defer wg.Done()
|
|
status, err := CheckRepoStatus(relayURL, did)
|
|
if err != nil {
|
|
var httpErr *RelayHTTPError
|
|
if errors.As(err, &httpErr) {
|
|
markOnline()
|
|
}
|
|
return
|
|
}
|
|
markOnline()
|
|
mu.Lock()
|
|
result.RepoStatus = status
|
|
mu.Unlock()
|
|
}()
|
|
|
|
// Probe listReposByCollection
|
|
go func() {
|
|
defer wg.Done()
|
|
supported, online := probeListReposByCollection(relayURL)
|
|
if online {
|
|
markOnline()
|
|
}
|
|
if supported {
|
|
mu.Lock()
|
|
result.HasListReposByCollection = true
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
|
|
if !result.Online {
|
|
result.Error = "connection failed"
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// probeRequestCrawl checks if a relay supports the requestCrawl endpoint by POSTing
|
|
// an empty hostname. Returns (supported, statusCode, online):
|
|
// - 400 = endpoint exists and accepts unauthenticated crawls (supported=true)
|
|
// - 401/403 = endpoint exists but requires auth (supported=false)
|
|
// - 5xx = endpoint is broken (supported=false)
|
|
// - connection error = relay offline (online=false)
|
|
func probeRequestCrawl(relayURL string) (supported bool, statusCode int, online bool) {
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
body := bytes.NewReader([]byte(`{"hostname":""}`))
|
|
req, err := http.NewRequest("POST", relayURL+SyncRequestCrawl, body)
|
|
if err != nil {
|
|
return false, 0, false
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return false, 0, false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// 400 = endpoint exists, accepts unauthenticated requests (empty hostname rejected as expected)
|
|
// 401/403 = endpoint exists but requires authentication
|
|
// 5xx = endpoint is broken
|
|
return resp.StatusCode == http.StatusBadRequest, resp.StatusCode, true
|
|
}
|
|
|
|
// probeListReposByCollection checks if a relay supports the listReposByCollection endpoint.
|
|
// Returns (supported, online) — online is true if we got any HTTP response.
|
|
func probeListReposByCollection(relayURL string) (supported bool, online bool) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
reqURL := fmt.Sprintf("%s%s?collection=io.atcr.manifest&limit=1", relayURL, SyncListReposByCollection)
|
|
|
|
resp, err := client.Get(reqURL)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Any HTTP response means the relay is online.
|
|
// 200 = endpoint exists and works. Anything else (400, 404) = not supported.
|
|
return resp.StatusCode == http.StatusOK, true
|
|
}
|
|
|
|
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
|
|
// This makes a PDS discoverable by the relay network.
|
|
func RequestCrawl(relayEndpoint, publicURL string) error {
|
|
if relayEndpoint == "" {
|
|
return nil
|
|
}
|
|
|
|
parsed, err := url.Parse(publicURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse public URL: %w", err)
|
|
}
|
|
hostname := parsed.Host
|
|
|
|
requestURL := relayEndpoint + SyncRequestCrawl
|
|
body := map[string]string{"hostname": hostname}
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("POST", requestURL, bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("relay returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckRepoStatus checks if a relay knows about a specific DID.
|
|
// Returns RelayHTTPError for non-200 responses (relay is online but doesn't know the DID).
|
|
// Returns a plain error for connection failures (relay is offline).
|
|
func CheckRepoStatus(relayURL, did string) (*RepoStatus, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
reqURL := fmt.Sprintf("%s%s?did=%s", relayURL, SyncGetRepoStatus, url.QueryEscape(did))
|
|
|
|
resp, err := client.Get(reqURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &RelayHTTPError{StatusCode: resp.StatusCode}
|
|
}
|
|
|
|
var status RepoStatus
|
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &status, nil
|
|
}
|
|
|
|
// CheckHostStatus checks if a relay knows about a specific hostname.
|
|
// Returns RelayHTTPError for non-200 responses (relay is online but doesn't know the host).
|
|
// Returns a plain error for connection failures (relay is offline).
|
|
func CheckHostStatus(relayURL, hostname string) (*HostStatus, error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
reqURL := fmt.Sprintf("%s%s?hostname=%s", relayURL, SyncGetHostStatus, url.QueryEscape(hostname))
|
|
|
|
resp, err := client.Get(reqURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &RelayHTTPError{StatusCode: resp.StatusCode}
|
|
}
|
|
|
|
var status HostStatus
|
|
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &status, nil
|
|
}
|