Files
at-container-registry/pkg/atproto/relays.go
T
2026-04-09 10:31:19 -05:00

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
}