mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
improve UI around credential helper authorization. have the hold requestCrawl on restart. Update comments that relay_endpoints must suport listreposbycollection
This commit is contained in:
@@ -62,12 +62,9 @@ func runUpdate(cmd *cobra.Command, args []string) error {
|
||||
// fetchLatestVersion resolves the latest released version by reading the
|
||||
// redirect Location header of {tangledReleasesBase}/tags/latest.
|
||||
func fetchLatestVersion() (string, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
client := httpClientWithTimeout(10*time.Second, func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
})
|
||||
|
||||
resp, err := client.Get(tangledReleasesBase + "/tags/latest")
|
||||
if err != nil {
|
||||
@@ -219,7 +216,7 @@ func performUpdate(latest string) error {
|
||||
|
||||
// downloadFile downloads a file from a URL to a local path
|
||||
func downloadFile(url, destPath string) error {
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
resp, err := httpClient().Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) {
|
||||
deviceName := hostname()
|
||||
|
||||
reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName})
|
||||
resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
|
||||
resp, err := httpClient().Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account,
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode})
|
||||
tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
|
||||
tokenResp, err := httpClient().Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -120,9 +120,7 @@ func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account,
|
||||
|
||||
// validateCredentials checks if the credentials are still valid by making a test request
|
||||
func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := httpClientWithTimeout(5*time.Second, nil)
|
||||
|
||||
tokenURL := appViewURL + "/auth/token?service=" + appViewURL
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// userAgent returns the User-Agent string for outgoing HTTP requests.
|
||||
//
|
||||
// Format: docker-credential-atcr/<version> (<os>/<arch>; commit <short>)
|
||||
//
|
||||
// Format follows the convention Docker's own clients use, so it parses
|
||||
// cleanly with the same regexes server-side log analyzers already
|
||||
// understand. The commit suffix lets users on the device-approval page
|
||||
// distinguish two devices on the same version line if they ever need to.
|
||||
func userAgent() string {
|
||||
short := commit
|
||||
if len(short) > 7 {
|
||||
short = short[:7]
|
||||
}
|
||||
return fmt.Sprintf("docker-credential-atcr/%s (%s/%s; commit %s)",
|
||||
version, runtime.GOOS, runtime.GOARCH, short)
|
||||
}
|
||||
|
||||
// uaTransport wraps another RoundTripper and sets the User-Agent header
|
||||
// on every request that doesn't already carry one. Used as the default
|
||||
// transport for the helper's shared http.Client so we can't forget to
|
||||
// set the UA on a future call site.
|
||||
type uaTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
// Clone before mutating: net/http may retry a request and the
|
||||
// caller could be using the same *Request elsewhere.
|
||||
clone := req.Clone(req.Context())
|
||||
clone.Header.Set("User-Agent", userAgent())
|
||||
req = clone
|
||||
}
|
||||
base := t.base
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return base.RoundTrip(req)
|
||||
}
|
||||
|
||||
var sharedHTTPClient = sync.OnceValue(func() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &uaTransport{base: http.DefaultTransport},
|
||||
}
|
||||
})
|
||||
|
||||
// httpClient returns the shared UA-tagged http.Client used for all of
|
||||
// the helper's outgoing HTTP requests. It carries no per-request
|
||||
// timeout — call sites that want one should use httpClientWithTimeout.
|
||||
func httpClient() *http.Client {
|
||||
return sharedHTTPClient()
|
||||
}
|
||||
|
||||
// httpClientWithTimeout returns a fresh client that shares the shared
|
||||
// transport (so connection pooling and the UA header are preserved) but
|
||||
// scopes a per-client timeout. CheckRedirect can be supplied for cases
|
||||
// like fetchLatestVersion that need to inspect a redirect rather than
|
||||
// follow it.
|
||||
func httpClientWithTimeout(timeout time.Duration, checkRedirect func(*http.Request, []*http.Request) error) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: sharedHTTPClient().Transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: checkRedirect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserAgent_Format(t *testing.T) {
|
||||
old := commit
|
||||
commit = "abc1234deadbeef"
|
||||
t.Cleanup(func() { commit = old })
|
||||
|
||||
ua := userAgent()
|
||||
if !strings.HasPrefix(ua, "docker-credential-atcr/") {
|
||||
t.Errorf("UA missing product prefix: %q", ua)
|
||||
}
|
||||
if !strings.Contains(ua, "commit abc1234)") {
|
||||
t.Errorf("UA should truncate commit to 7 chars, got %q", ua)
|
||||
}
|
||||
if strings.Contains(ua, "Go-http-client") {
|
||||
t.Errorf("UA leaked default Go client string: %q", ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_SetsUserAgent(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
resp, err := httpClient().Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
want := userAgent()
|
||||
if got != want {
|
||||
t.Errorf("server saw User-Agent %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClient_RespectsExplicitUserAgent(t *testing.T) {
|
||||
var got string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
req, err := http.NewRequest("GET", srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "explicit-test/1.0")
|
||||
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got != "explicit-test/1.0" {
|
||||
t.Errorf("explicit UA was overwritten: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -70,15 +70,13 @@ jetstream:
|
||||
backfill_enabled: true
|
||||
# How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
|
||||
backfill_interval: 24h0m0s
|
||||
# Relay endpoints for backfill, tried in order on failure.
|
||||
# Endpoints used for backfill. MUST support com.atproto.sync.listReposByCollection. Tried in order on failure.
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
- https://relay1.us-west.bsky.network
|
||||
# JWT authentication settings.
|
||||
auth:
|
||||
# RSA private key for signing registry JWTs issued to Docker clients.
|
||||
key_path: /var/lib/atcr/auth/private-key.pem
|
||||
# X.509 certificate matching the JWT signing key.
|
||||
# X.509 certificate matching the JWT signing key (auto-generated on each boot from the JWT key in the database).
|
||||
cert_path: /var/lib/atcr/auth/private-key.crt
|
||||
# Credential helper download settings.
|
||||
credential_helper:
|
||||
|
||||
@@ -45,8 +45,10 @@ server:
|
||||
successor: ""
|
||||
# Use localhost for OAuth redirects during development.
|
||||
test_mode: false
|
||||
# Request crawl from this relay on startup to make the embedded PDS discoverable.
|
||||
relay_endpoint: ""
|
||||
# Endpoints used for proactive scan discovery. MUST support com.atproto.sync.listReposByCollection. Also sent requestCrawl on startup (best-effort, in addition to built-in known relays).
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
- https://relay1.us-west.bsky.network
|
||||
# DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key.
|
||||
appview_did: did:web:172.28.0.2%3A5000
|
||||
# Read timeout for HTTP requests.
|
||||
|
||||
@@ -20,7 +20,9 @@ server:
|
||||
public: false
|
||||
successor: ""
|
||||
test_mode: false
|
||||
relay_endpoint: ""
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
- https://relay1.us-west.bsky.network
|
||||
appview_did: did:web:seamark.dev
|
||||
read_timeout: 5m0s
|
||||
write_timeout: 5m0s
|
||||
|
||||
@@ -107,8 +107,8 @@ type JetstreamConfig struct {
|
||||
// How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
|
||||
BackfillInterval time.Duration `yaml:"backfill_interval" comment:"How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup."`
|
||||
|
||||
// Relay endpoints for backfill, tried in order on failure.
|
||||
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."`
|
||||
// Relay endpoints for backfill — MUST support com.atproto.sync.listReposByCollection. Tried in order on failure.
|
||||
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Endpoints used for backfill. MUST support com.atproto.sync.listReposByCollection. Tried in order on failure."`
|
||||
}
|
||||
|
||||
// AuthConfig defines authentication settings
|
||||
|
||||
+126
-146
@@ -1,14 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
@@ -205,18 +207,18 @@ func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
|
||||
// Get pending authorization
|
||||
pending, ok := h.DeviceStore.GetPendingByUserCode(userCode)
|
||||
if !ok {
|
||||
h.renderError(w, "Invalid or expired authorization code")
|
||||
h.renderError(w, r, "That authorization code has expired or doesn't exist. Start a fresh `docker login` from your terminal to get a new one.")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already approved
|
||||
if pending.ApprovedDID != nil && *pending.ApprovedDID != "" {
|
||||
h.renderSuccess(w, pending.DeviceName)
|
||||
h.renderSuccess(w, r, pending.DeviceName)
|
||||
return
|
||||
}
|
||||
|
||||
// Render approval page
|
||||
h.renderApprovalPage(w, sess.Handle, pending)
|
||||
h.renderApprovalPage(w, r, sess, pending)
|
||||
}
|
||||
|
||||
// DeviceApproveRequest is the request to approve a device
|
||||
@@ -359,60 +361,157 @@ func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
// renderApprovalPage renders the device authorization confirmation page.
|
||||
// The browser-side identity (avatar + handle + display name) and the
|
||||
// terminal-side device facts are paired side-by-side so a wrong-account
|
||||
// approval is visually obvious before the Approve button is clicked.
|
||||
func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, r *http.Request, sess *db.Session, pending *db.PendingAuthorization) {
|
||||
// Hydrate the signed-in sailor: cached avatar/handle from our local
|
||||
// users table; live displayName from their PDS (best-effort with a tight
|
||||
// timeout — the page must still render quickly if Bluesky is slow).
|
||||
user := &db.User{
|
||||
DID: sess.DID,
|
||||
Handle: sess.Handle,
|
||||
PDSEndpoint: sess.PDSEndpoint,
|
||||
}
|
||||
if h.ReadOnlyDB != nil {
|
||||
if u, err := db.GetUserByDID(h.ReadOnlyDB, sess.DID); err == nil && u != nil {
|
||||
user = u
|
||||
}
|
||||
}
|
||||
|
||||
displayName := fetchDisplayName(r.Context(), sess)
|
||||
|
||||
meta := NewPageMeta(
|
||||
"Authorize device - "+h.ClientShortName,
|
||||
"Confirm device authorization for "+h.ClientShortName,
|
||||
).WithRobots("noindex").
|
||||
WithSiteName(h.ClientShortName)
|
||||
|
||||
pd := NewPageData(r, &h.BaseUIHandler)
|
||||
pd.User = user
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *db.PendingAuthorization) {
|
||||
tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate))
|
||||
data := struct {
|
||||
Handle string
|
||||
DeviceName string
|
||||
UserCode string
|
||||
IPAddress string
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
Pending *db.PendingAuthorization
|
||||
ProfileDisplayName string
|
||||
UserDIDShort string
|
||||
UserAgentShort string
|
||||
}{
|
||||
Handle: handle,
|
||||
DeviceName: pending.DeviceName,
|
||||
UserCode: pending.UserCode,
|
||||
IPAddress: pending.IPAddress,
|
||||
PageData: pd,
|
||||
Meta: meta,
|
||||
Pending: pending,
|
||||
ProfileDisplayName: displayName,
|
||||
UserDIDShort: shortenDID(sess.DID),
|
||||
UserAgentShort: shortenUserAgent(pending.UserAgent),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
if err := h.Templates.ExecuteTemplate(w, "device-approve", data); err != nil {
|
||||
slog.Error("Failed to render device approval page", "component", "device/approve", "error", err)
|
||||
http.Error(w, "failed to render template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, deviceName string) {
|
||||
tmpl := template.Must(template.New("success").Parse(deviceSuccessTemplate))
|
||||
func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, r *http.Request, deviceName string) {
|
||||
meta := NewPageMeta(
|
||||
"Device authorized - "+h.ClientShortName,
|
||||
"Device authorization complete",
|
||||
).WithRobots("noindex").
|
||||
WithSiteName(h.ClientShortName)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
DeviceName string
|
||||
}{
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
Meta: meta,
|
||||
DeviceName: deviceName,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
if err := h.Templates.ExecuteTemplate(w, "device-approved", data); err != nil {
|
||||
slog.Error("Failed to render device success page", "component", "device/approve", "error", err)
|
||||
http.Error(w, "failed to render template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, message string) {
|
||||
tmpl := template.Must(template.New("error").Parse(deviceErrorTemplate))
|
||||
func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
|
||||
meta := NewPageMeta(
|
||||
"Authorization error - "+h.ClientShortName,
|
||||
"Device authorization could not be completed",
|
||||
).WithRobots("noindex").
|
||||
WithSiteName(h.ClientShortName)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Meta *PageMeta
|
||||
Message string
|
||||
}{
|
||||
Message: message,
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
Meta: meta,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "failed to render template", http.StatusInternalServerError)
|
||||
return
|
||||
if err := h.Templates.ExecuteTemplate(w, "device-error", data); err != nil {
|
||||
slog.Error("Failed to render device error page", "component", "device/approve", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchDisplayName best-effort fetches the sailor's display name from
|
||||
// their PDS. Returns "" on any failure — the template falls back to the
|
||||
// handle so the page never blocks on a slow upstream.
|
||||
func fetchDisplayName(ctx context.Context, sess *db.Session) string {
|
||||
if sess == nil || sess.PDSEndpoint == "" {
|
||||
return ""
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
client := atproto.NewClient(sess.PDSEndpoint, sess.DID, "")
|
||||
profile, err := client.GetActorProfile(timeoutCtx, sess.DID)
|
||||
if err != nil || profile == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(profile.DisplayName)
|
||||
}
|
||||
|
||||
// shortenDID returns a compact DID for display (e.g.
|
||||
// "did:plc:abc…xyz") without obscuring its kind.
|
||||
func shortenDID(did string) string {
|
||||
if len(did) <= 24 {
|
||||
return did
|
||||
}
|
||||
// Keep the prefix (did:plc: / did:web:) and the last 6 chars.
|
||||
prefixEnd := strings.Index(did[4:], ":")
|
||||
if prefixEnd < 0 {
|
||||
return did[:14] + "…" + did[len(did)-6:]
|
||||
}
|
||||
prefixEnd += 5 // include "did:" and the trailing ":"
|
||||
if len(did)-prefixEnd <= 14 {
|
||||
return did
|
||||
}
|
||||
return did[:prefixEnd+6] + "…" + did[len(did)-6:]
|
||||
}
|
||||
|
||||
// shortenUserAgent picks a readable summary of the device's UA string —
|
||||
// almost always something like "docker-credential-atcr/0.x" — and caps
|
||||
// the length so the device card doesn't blow up on long UA strings.
|
||||
func shortenUserAgent(ua string) string {
|
||||
ua = strings.TrimSpace(ua)
|
||||
if ua == "" {
|
||||
return ""
|
||||
}
|
||||
if len(ua) > 80 {
|
||||
return ua[:80] + "…"
|
||||
}
|
||||
return ua
|
||||
}
|
||||
|
||||
func getClientIP(r *http.Request) string {
|
||||
// Check X-Forwarded-For header
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
@@ -435,122 +534,3 @@ func getClientIP(r *http.Request) string {
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// HTML templates
|
||||
|
||||
const deviceApprovalTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorize Device - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.approval-box { background: #e3f2fd; border: 1px solid #90caf9; padding: 30px; border-radius: 8px; }
|
||||
.user-code { font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 20px 0; color: #1976d2; }
|
||||
.device-info { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; }
|
||||
.device-info dt { font-weight: bold; margin-top: 10px; }
|
||||
.device-info dd { margin-left: 0; color: #666; }
|
||||
.actions { text-align: center; margin-top: 30px; }
|
||||
button { font-size: 16px; padding: 12px 30px; margin: 0 10px; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.approve { background: #4caf50; color: white; }
|
||||
.approve:hover { background: #45a049; }
|
||||
.deny { background: #f44336; color: white; }
|
||||
.deny:hover { background: #da190b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="approval-box">
|
||||
<h1>Authorize Device</h1>
|
||||
<p>User: <strong>{{.Handle}}</strong></p>
|
||||
|
||||
<div class="user-code">{{.UserCode}}</div>
|
||||
|
||||
<div class="device-info">
|
||||
<dl>
|
||||
<dt>Device Name:</dt>
|
||||
<dd>{{.DeviceName}}</dd>
|
||||
<dt>IP Address:</dt>
|
||||
<dd>{{.IPAddress}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p><strong>Do you want to authorize this device?</strong></p>
|
||||
<p>This device will be able to push and pull container images to your registry.</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="approve" onclick="approve(true)">Approve</button>
|
||||
<button class="deny" onclick="approve(false)">Deny</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function approve(approved) {
|
||||
const resp = await fetch('/device/approve', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
user_code: '{{.UserCode}}',
|
||||
approve: approved
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
if (approved) {
|
||||
window.location.href = '/device?user_code={{.UserCode}}';
|
||||
} else {
|
||||
alert('Device authorization denied');
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
alert('Failed to process authorization');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const deviceSuccessTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Device Authorized - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 30px; border-radius: 8px; }
|
||||
h1 { color: #155724; }
|
||||
a { color: #007bff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Device Authorized!</h1>
|
||||
<p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
<p><a href="/settings/devices">View your authorized devices</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const deviceErrorTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Error - ATCR</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 30px; border-radius: 8px; }
|
||||
h1 { color: #721c24; }
|
||||
a { color: #007bff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error">
|
||||
<h1>✗ Authorization Error</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<p><a href="/">Return to home</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
@@ -435,8 +436,10 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
if holdDID != "" {
|
||||
go func() {
|
||||
storage.EnsureCrewMembership(
|
||||
context.Background(), client, h.Refresher,
|
||||
holdDID, middleware.GetGlobalAuthorizer(),
|
||||
context.Background(), user.DID, holdDID, middleware.GetGlobalAuthorizer(),
|
||||
func(ctx context.Context, holdDID string) (string, error) {
|
||||
return auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
},
|
||||
)
|
||||
refreshCaptainRecord(holdDID, h.DB)
|
||||
refreshCrewMembership(holdDID, user.DID, h.DB)
|
||||
|
||||
@@ -359,8 +359,13 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
// (returns quickly if already a member - hold returns 200/201)
|
||||
if holdDID != "" && nr.refresher != nil {
|
||||
slog.Debug("Auto-reconciling crew membership", "component", "registry/middleware", "did", did, "hold_did", holdDID)
|
||||
client := atproto.NewClient(pdsEndpoint, did, "")
|
||||
storage.EnsureCrewMembership(ctx, client, nr.refresher, holdDID, nr.authorizer)
|
||||
ownerDID := did
|
||||
ownerPDS := pdsEndpoint
|
||||
refresher := nr.refresher
|
||||
storage.EnsureCrewMembership(ctx, ownerDID, holdDID, nr.authorizer,
|
||||
func(ctx context.Context, holdDID string) (string, error) {
|
||||
return auth.GetOrFetchServiceToken(ctx, refresher, ownerDID, holdDID, ownerPDS)
|
||||
})
|
||||
}
|
||||
|
||||
// Get service token for hold authentication (only if authenticated)
|
||||
|
||||
+20
-3
@@ -448,10 +448,13 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
}
|
||||
// Register crew in background
|
||||
slog.Debug("Attempting crew registration", "component", "appview/callback", "did", did, "hold_did", holdDID)
|
||||
go func(client *atproto.Client, refresher *oauth.Refresher, holdDID string, authorizer auth.HoldAuthorizer) {
|
||||
go func(userDID, pdsEndpoint, holdDID string, refresher *oauth.Refresher, authorizer auth.HoldAuthorizer) {
|
||||
ctx := context.Background()
|
||||
storage.EnsureCrewMembership(ctx, client, refresher, holdDID, authorizer)
|
||||
}(client, s.Refresher, holdDID, s.HoldAuthorizer)
|
||||
storage.EnsureCrewMembership(ctx, userDID, holdDID, authorizer,
|
||||
func(ctx context.Context, holdDID string) (string, error) {
|
||||
return auth.GetOrFetchServiceToken(ctx, refresher, userDID, holdDID, pdsEndpoint)
|
||||
})
|
||||
}(did, pdsEndpoint, holdDID, s.Refresher, s.HoldAuthorizer)
|
||||
}
|
||||
|
||||
// Drain manifests from old hold to successor in background
|
||||
@@ -566,6 +569,20 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
slog.Debug("Profile ensured with default hold", "component", "appview/callback", "did", did, "default_hold_did", defaultHoldDID)
|
||||
}
|
||||
|
||||
// Self-register as crew of the user's defaultHold so the first docker
|
||||
// pull/push doesn't 404 because the hold has no crew record for them.
|
||||
// The web OAuth callback already does this for OAuth flows; this is
|
||||
// the parity path for app-password logins (only callers of this hook).
|
||||
if profile, err := storage.GetProfile(ctx, atprotoClient); err == nil && profile != nil && profile.DefaultHold != "" {
|
||||
go func(userDID, pdsEndpoint, holdDID string, authorizer auth.HoldAuthorizer) {
|
||||
bgCtx := context.Background()
|
||||
storage.EnsureCrewMembership(bgCtx, userDID, holdDID, authorizer,
|
||||
func(ctx context.Context, holdDID string) (string, error) {
|
||||
return auth.GetOrFetchServiceTokenWithAppPassword(ctx, userDID, holdDID, pdsEndpoint)
|
||||
})
|
||||
}(did, pdsEndpoint, profile.DefaultHold, s.HoldAuthorizer)
|
||||
}
|
||||
|
||||
// Run consumer hooks
|
||||
for _, hook := range s.tokenHooks {
|
||||
if err := hook(ctx, did, handle, pdsEndpoint, accessToken); err != nil {
|
||||
|
||||
@@ -967,6 +967,97 @@
|
||||
.provider-cta { grid-area: cta; width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
DEVICE AUTHORIZATION — pairing card
|
||||
The approval page leans on the visual pairing of *account* and
|
||||
*device*. `device-mark` is the round identity slot (avatar fits
|
||||
inside); `device-mark-square` is its rounded-rect counterpart for
|
||||
the device side, so the two halves read as related but distinct.
|
||||
|
||||
`bearing-line` renders a dashed plotted-depth pattern as either a
|
||||
horizontal or vertical separator — same texture used on the signup
|
||||
provider rows, repurposed here as a maritime gesture on the
|
||||
connector between the two cards and as a section rule.
|
||||
---------------------------------------- */
|
||||
.device-mark {
|
||||
@apply rounded-full overflow-hidden bg-base-300;
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--color-base-300),
|
||||
0 0 0 4px color-mix(in oklch, var(--color-primary) 14%, transparent);
|
||||
}
|
||||
.device-mark-square {
|
||||
@apply rounded-lg;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px oklch(100% 0 0 / 0.06),
|
||||
0 0 0 1px var(--color-base-300);
|
||||
}
|
||||
|
||||
/* Plotted-depth dashed line — same pattern as .provider-row dividers,
|
||||
extracted into reusable utilities. The horizontal variant fills its
|
||||
container; the vertical variant uses gradient direction swap. */
|
||||
.bearing-line {
|
||||
display: block;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
.bearing-line-h {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color-base-300) 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
background-size: 8px 1px;
|
||||
}
|
||||
.bearing-line-v {
|
||||
flex: 1;
|
||||
width: 1px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-base-300) 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
background-size: 1px 8px;
|
||||
}
|
||||
/* Variant: dashed top border on a block (used as a section separator) */
|
||||
.bearing-line-h-full {
|
||||
border-top: 0;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--color-base-300) 0 4px,
|
||||
transparent 4px 8px
|
||||
);
|
||||
background-size: 8px 1px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
/* Instrument readout — a boxed, mono, letter-spaced display for the
|
||||
verification code. Tick marks at top and bottom evoke a depth
|
||||
sounder or a chart's tick scale, signalling "instrument", not
|
||||
"decorative pill". */
|
||||
.instrument-readout {
|
||||
@apply relative bg-base-200 rounded-lg px-6 py-5;
|
||||
@apply flex flex-col items-center gap-3;
|
||||
box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.06);
|
||||
}
|
||||
.instrument-tick {
|
||||
display: block;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
color-mix(in oklch, var(--color-primary) 70%, transparent) 0 1px,
|
||||
transparent 1px 12px
|
||||
);
|
||||
background-size: 12px 6px;
|
||||
background-repeat: repeat-x;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ----------------------------------------
|
||||
SIGNUP CONTINUE — handoff mark
|
||||
A slightly larger avatar ringed in primary, so the "you are leaving"
|
||||
|
||||
+33
-28
@@ -10,14 +10,27 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// ServiceTokenFetcher returns a hold service token for the resolved holdDID.
|
||||
// Implementations wire to the appropriate auth path (OAuth refresher or
|
||||
// app-password access token).
|
||||
type ServiceTokenFetcher func(ctx context.Context, holdDID string) (string, error)
|
||||
|
||||
// EnsureCrewMembership attempts to register the user as a crew member on their default hold.
|
||||
// The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, existing membership, etc).
|
||||
// On success, clears any cached denial to ensure immediate access.
|
||||
// This is best-effort and does not fail on errors.
|
||||
func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, defaultHoldDID string, authorizer auth.HoldAuthorizer) {
|
||||
// The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew,
|
||||
// existing membership, etc). On success, warms the approval cache and clears any cached
|
||||
// denial. Best-effort: logs and returns on any error.
|
||||
//
|
||||
// fetchServiceToken is invoked only on cache miss. Pass nil to skip when no auth path
|
||||
// is available (callers that just want the cache short-circuit behavior).
|
||||
func EnsureCrewMembership(
|
||||
ctx context.Context,
|
||||
userDID string,
|
||||
defaultHoldDID string,
|
||||
authorizer auth.HoldAuthorizer,
|
||||
fetchServiceToken ServiceTokenFetcher,
|
||||
) {
|
||||
if defaultHoldDID == "" {
|
||||
return
|
||||
}
|
||||
@@ -34,31 +47,27 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher
|
||||
// is pure waste. Cache lookup uses the same key (holdDID, userDID) that
|
||||
// CheckWriteAccess will hit later in the request.
|
||||
if authorizer != nil {
|
||||
if cached, err := authorizer.IsCachedCrewMember(ctx, holdDID, client.DID()); err == nil && cached {
|
||||
if cached, err := authorizer.IsCachedCrewMember(ctx, holdDID, userDID); err == nil && cached {
|
||||
slog.Debug("crew membership cached, skipping requestCrew",
|
||||
"holdDID", holdDID, "userDID", client.DID())
|
||||
"holdDID", holdDID, "userDID", userDID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve hold DID to HTTP endpoint
|
||||
if fetchServiceToken == nil {
|
||||
slog.Debug("skipping crew registration - no service token fetcher", "holdDID", holdDID, "userDID", userDID)
|
||||
return
|
||||
}
|
||||
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get service token for the hold
|
||||
// Only works with OAuth (refresher required) - app passwords can't get service tokens
|
||||
if refresher == nil {
|
||||
slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
|
||||
return
|
||||
}
|
||||
|
||||
// Wrap the refresher to match OAuthSessionRefresher interface
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(ctx, refresher, client.DID(), holdDID, client.PDSEndpoint())
|
||||
serviceToken, err := fetchServiceToken(ctx, holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
|
||||
slog.Warn("failed to get service token", "holdDID", holdDID, "userDID", userDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -67,28 +76,24 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher
|
||||
// - Checks if already a crew member (returns success if so)
|
||||
// - Creates crew record if authorized
|
||||
if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
|
||||
slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
|
||||
slog.Warn("failed to request crew membership", "holdDID", holdDID, "userDID", userDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
|
||||
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", userDID)
|
||||
|
||||
if authorizer != nil {
|
||||
// Warm the approval cache so subsequent CheckWriteAccess calls within this
|
||||
// request (e.g. each layer in a multi-layer push) skip the XRPC getRecord.
|
||||
if err := authorizer.RecordCrewApproval(ctx, holdDID, client.DID()); err != nil {
|
||||
if err := authorizer.RecordCrewApproval(ctx, holdDID, userDID); err != nil {
|
||||
slog.Warn("failed to record crew approval after crew registration",
|
||||
"holdDID", holdDID,
|
||||
"userDID", client.DID(),
|
||||
"error", err)
|
||||
"holdDID", holdDID, "userDID", userDID, "error", err)
|
||||
}
|
||||
|
||||
// Clear any cached denial to ensure immediate access
|
||||
if err := authorizer.ClearCrewDenial(ctx, holdDID, client.DID()); err != nil {
|
||||
if err := authorizer.ClearCrewDenial(ctx, holdDID, userDID); err != nil {
|
||||
slog.Warn("failed to clear denial cache after crew registration",
|
||||
"holdDID", holdDID,
|
||||
"userDID", client.DID(),
|
||||
"error", err)
|
||||
"holdDID", holdDID, "userDID", userDID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
|
||||
// Test that empty hold DID returns early without error (best-effort function)
|
||||
EnsureCrewMembership(context.Background(), nil, nil, "", nil)
|
||||
EnsureCrewMembership(context.Background(), "did:plc:user123", "", nil, nil)
|
||||
// If we get here without panic, test passes
|
||||
}
|
||||
|
||||
@@ -51,22 +51,26 @@ func (f *fakeAuthorizer) RecordCrewApproval(ctx context.Context, holdDID, userDI
|
||||
var _ auth.HoldAuthorizer = (*fakeAuthorizer)(nil)
|
||||
|
||||
// TestEnsureCrewMembership_SkipsRequestCrewWhenCached verifies the cache short-circuit:
|
||||
// when IsCachedCrewMember returns true, the function returns before attempting the
|
||||
// requestCrew POST (and before fetching a service token, which would otherwise fail
|
||||
// with a nil refresher).
|
||||
// when IsCachedCrewMember returns true, the function returns before invoking the
|
||||
// service-token fetcher (and thus before any requestCrew POST).
|
||||
func TestEnsureCrewMembership_SkipsRequestCrewWhenCached(t *testing.T) {
|
||||
authz := &fakeAuthorizer{cachedReturn: true}
|
||||
client := atproto.NewClient("https://pds.example", "did:plc:user123", "")
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
|
||||
// Pass nil refresher: if the cache check did NOT short-circuit, the function
|
||||
// would log "skipping crew registration" and still return without panic — but
|
||||
// it also would NOT have called IsCachedCrewMember, which is what we assert.
|
||||
EnsureCrewMembership(context.Background(), client, nil, holdDID, authz)
|
||||
fetcherCalls := atomic.Int32{}
|
||||
fetcher := func(ctx context.Context, _ string) (string, error) {
|
||||
fetcherCalls.Add(1)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
EnsureCrewMembership(context.Background(), "did:plc:user123", holdDID, authz, fetcher)
|
||||
|
||||
if authz.isCachedCalls.Load() != 1 {
|
||||
t.Errorf("Expected IsCachedCrewMember to be called once, got %d", authz.isCachedCalls.Load())
|
||||
}
|
||||
if fetcherCalls.Load() != 0 {
|
||||
t.Errorf("Expected service token fetcher not to be called on cache hit, got %d", fetcherCalls.Load())
|
||||
}
|
||||
if authz.recordApprovalCalls.Load() != 0 {
|
||||
t.Errorf("Expected RecordCrewApproval not to be called on cache hit, got %d", authz.recordApprovalCalls.Load())
|
||||
}
|
||||
@@ -75,15 +79,13 @@ func TestEnsureCrewMembership_SkipsRequestCrewWhenCached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureCrewMembership_NoRefresherFallsThroughCacheCheck verifies that a cache
|
||||
// miss with a nil refresher takes the existing app-password skip path, and does not
|
||||
// call RecordCrewApproval (since requestCrew never ran).
|
||||
func TestEnsureCrewMembership_NoRefresherFallsThroughCacheCheck(t *testing.T) {
|
||||
// TestEnsureCrewMembership_NilFetcherSkipsAfterCacheCheck verifies that a cache miss
|
||||
// with a nil fetcher returns silently and does not call RecordCrewApproval.
|
||||
func TestEnsureCrewMembership_NilFetcherSkipsAfterCacheCheck(t *testing.T) {
|
||||
authz := &fakeAuthorizer{cachedReturn: false}
|
||||
client := atproto.NewClient("https://pds.example", "did:plc:user123", "")
|
||||
holdDID := "did:web:hold01.atcr.io"
|
||||
|
||||
EnsureCrewMembership(context.Background(), client, nil, holdDID, authz)
|
||||
EnsureCrewMembership(context.Background(), "did:plc:user123", holdDID, authz, nil)
|
||||
|
||||
if authz.isCachedCalls.Load() != 1 {
|
||||
t.Errorf("Expected IsCachedCrewMember to be called once, got %d", authz.isCachedCalls.Load())
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
{{/*
|
||||
Device authorization templates.
|
||||
|
||||
Three states share a layout shell:
|
||||
- device-approve : pending — show identity, device, code, actions
|
||||
- device-approved : success
|
||||
- device-error : invalid / expired
|
||||
|
||||
The visual idea on the approval page is to make the *pairing* obvious:
|
||||
the signed-in account on one side, the requesting device on the other.
|
||||
If the user is staring at someone else's avatar, the page should make
|
||||
that mismatch impossible to miss before the Approve button is clicked.
|
||||
*/}}
|
||||
|
||||
{{ define "device-approve" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main id="main-content" class="px-4 py-12 sm:py-16">
|
||||
<div class="mx-auto w-full max-w-xl">
|
||||
|
||||
{{/* Eyebrow — small-caps bearing label */}}
|
||||
<div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-base-content/50 mb-3">
|
||||
{{ icon "compass" "size-4 text-primary" }}
|
||||
<span>Device authorization</span>
|
||||
</div>
|
||||
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-semibold tracking-tight">Confirm the pairing</h1>
|
||||
<p class="mt-3 text-base-content/70 leading-relaxed max-w-prose">
|
||||
A device is requesting credentials for {{ .ClientShortName }}.
|
||||
The pairing below will let it push and pull on your behalf, so make sure
|
||||
both halves are correct before approving.
|
||||
</p>
|
||||
|
||||
{{/* The pairing — identity on one side, device on the other.
|
||||
On md+ they sit side-by-side with a connector between; on mobile
|
||||
they stack with the connector rotated. */}}
|
||||
<section aria-label="Pairing" class="mt-10 flex flex-col gap-3">
|
||||
|
||||
{{/* Identity card */}}
|
||||
<article class="relative bg-base-200 rounded-lg p-6 sm:p-7
|
||||
shadow-[inset_0_0_0_1px_oklch(100%_0_0/0.06)]
|
||||
flex flex-col items-center text-center">
|
||||
<span class="block text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-base-content/55 mb-4">
|
||||
Account
|
||||
</span>
|
||||
<div class="device-mark shrink-0">
|
||||
{{ if .User.Avatar }}
|
||||
<img src="{{ resizeImage .User.Avatar 192 }}"
|
||||
alt="" aria-hidden="true"
|
||||
width="80" height="80"
|
||||
class="block w-full h-full rounded-full object-cover" />
|
||||
{{ else }}
|
||||
<div class="w-full h-full rounded-full bg-secondary text-secondary-content
|
||||
flex items-center justify-center font-display font-semibold text-2xl uppercase">
|
||||
{{ firstChar .User.Handle }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="font-display text-lg font-semibold leading-tight mt-4 max-w-full truncate" title="@{{ .User.Handle }}">
|
||||
@{{ .User.Handle }}
|
||||
</div>
|
||||
{{ if .ProfileDisplayName }}
|
||||
<div class="text-sm text-base-content/70 mt-0.5 max-w-full truncate" title="{{ .ProfileDisplayName }}">
|
||||
{{ .ProfileDisplayName }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="font-mono text-[0.7rem] text-base-content/45 mt-2 max-w-full truncate" title="{{ .User.DID }}">
|
||||
{{ .UserDIDShort }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{/* Connector — horizontal divider through the verb */}}
|
||||
<div class="flex items-center gap-3 px-2" aria-hidden="true">
|
||||
<span class="bearing-line bearing-line-h"></span>
|
||||
<span class="font-mono text-[0.65rem] tracking-[0.18em] uppercase text-base-content/55 whitespace-nowrap">
|
||||
authorizes
|
||||
</span>
|
||||
<span class="bearing-line bearing-line-h"></span>
|
||||
</div>
|
||||
|
||||
{{/* Device card */}}
|
||||
<article class="relative bg-base-200 rounded-lg p-6 sm:p-7
|
||||
shadow-[inset_0_0_0_1px_oklch(100%_0_0/0.06)]
|
||||
flex flex-col items-center text-center">
|
||||
<span class="block text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-base-content/55 mb-4">
|
||||
Device
|
||||
</span>
|
||||
<div class="device-mark device-mark-square shrink-0
|
||||
bg-base-300 text-primary
|
||||
flex items-center justify-center">
|
||||
{{ icon "terminal" "size-8" }}
|
||||
</div>
|
||||
<div class="font-display text-lg font-semibold leading-tight mt-4 max-w-full truncate" title="{{ .Pending.DeviceName }}">
|
||||
{{ .Pending.DeviceName }}
|
||||
</div>
|
||||
<div class="font-mono text-sm text-base-content/70 mt-0.5 max-w-full truncate" title="{{ .Pending.IPAddress }}">
|
||||
{{ .Pending.IPAddress }}
|
||||
</div>
|
||||
{{ if .UserAgentShort }}
|
||||
<div class="text-[0.7rem] text-base-content/45 mt-2 max-w-full truncate" title="{{ .Pending.UserAgent }}">
|
||||
{{ .UserAgentShort }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{/* Verification code — instrument readout */}}
|
||||
<section aria-label="Verification code" class="mt-10">
|
||||
<div class="flex items-baseline justify-between gap-3 mb-2">
|
||||
<span class="text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-base-content/55">
|
||||
Verification code
|
||||
</span>
|
||||
<span class="text-xs text-base-content/55">
|
||||
Should match your terminal
|
||||
</span>
|
||||
</div>
|
||||
<div class="instrument-readout">
|
||||
<span class="instrument-tick" aria-hidden="true"></span>
|
||||
<code class="font-mono font-semibold text-2xl sm:text-3xl tracking-[0.4em] text-base-content tabular-nums">{{ .Pending.UserCode }}</code>
|
||||
<span class="instrument-tick" aria-hidden="true"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* Actions */}}
|
||||
<form id="device-approve-form" class="mt-10 flex flex-col-reverse sm:flex-row sm:justify-center gap-3">
|
||||
<button type="button" class="btn btn-ghost order-2 sm:order-1"
|
||||
data-action="deny" aria-label="Cancel and deny this device">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary order-1 sm:order-2"
|
||||
data-action="approve" aria-label="Approve this device for {{ .User.Handle }}">
|
||||
{{ icon "check" "size-4" }}
|
||||
<span>Approve as @{{ .User.Handle }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{/* Switch-account affordance — bearing-line separator above it */}}
|
||||
<div class="mt-12 pt-6 bearing-line bearing-line-h-full text-sm text-base-content/65">
|
||||
Not @{{ .User.Handle }}?
|
||||
<a href="/auth/logout?return_to=/device?user_code={{ urlquery .Pending.UserCode }}"
|
||||
class="link link-primary font-medium ml-1">
|
||||
Switch accounts to authorize as someone else
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="device-error-banner" role="alert" class="alert alert-error mt-6 hidden">
|
||||
{{ icon "circle-x" "size-5" }}
|
||||
<span data-bind="message">Something went wrong. Please try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var form = document.getElementById('device-approve-form');
|
||||
if (!form) return;
|
||||
var banner = document.getElementById('device-error-banner');
|
||||
var bannerMsg = banner ? banner.querySelector('[data-bind="message"]') : null;
|
||||
var userCode = {{ .Pending.UserCode }};
|
||||
|
||||
function showError(msg) {
|
||||
if (!banner) return;
|
||||
if (bannerMsg && msg) bannerMsg.textContent = msg;
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
var buttons = form.querySelectorAll('button');
|
||||
buttons.forEach(function(b) { b.disabled = busy; });
|
||||
}
|
||||
|
||||
function submit(approve) {
|
||||
setBusy(true);
|
||||
if (banner) banner.classList.add('hidden');
|
||||
fetch('/device/approve', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ user_code: userCode, approve: approve })
|
||||
}).then(function(resp) {
|
||||
if (!resp.ok) throw new Error('approval_failed');
|
||||
if (approve) {
|
||||
window.location.href = '/device?user_code=' + encodeURIComponent(userCode);
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}).catch(function() {
|
||||
setBusy(false);
|
||||
showError("We couldn't process that request. Please try again.");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
submit(true);
|
||||
});
|
||||
var deny = form.querySelector('[data-action="deny"]');
|
||||
if (deny) deny.addEventListener('click', function() { submit(false); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{ define "device-approved" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main id="main-content" class="px-4 py-16 sm:py-24">
|
||||
<div class="mx-auto w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/15 text-primary mb-6">
|
||||
{{ icon "check-circle" "size-8" }}
|
||||
</div>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-semibold tracking-tight">Device authorized</h1>
|
||||
<p class="mt-3 text-base-content/70 leading-relaxed">
|
||||
<span class="font-mono text-base-content">{{ .DeviceName }}</span>
|
||||
is now authorized. You can return to your terminal and close this tab.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<a href="/settings/devices" class="btn btn-primary">Manage devices</a>
|
||||
<a href="/" class="btn btn-ghost">Back to home</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{ define "device-error" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{ template "head" . }}
|
||||
{{ template "meta" .Meta }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav-simple" . }}
|
||||
|
||||
<main id="main-content" class="px-4 py-16 sm:py-24">
|
||||
<div class="mx-auto w-full max-w-xl text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-error/15 text-error mb-6">
|
||||
{{ icon "alert-triangle" "size-8" }}
|
||||
</div>
|
||||
<h1 class="font-display text-3xl sm:text-4xl font-semibold tracking-tight">Authorization couldn't be completed</h1>
|
||||
<p class="mt-3 text-base-content/70 leading-relaxed">{{ .Message }}</p>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<a href="/" class="btn btn-primary">Back to home</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
+10
-3
@@ -163,8 +163,12 @@ type ServerConfig struct {
|
||||
// Use localhost for OAuth redirects during development.
|
||||
TestMode bool `yaml:"test_mode" comment:"Use localhost for OAuth redirects during development."`
|
||||
|
||||
// Request crawl from this relay on startup.
|
||||
RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
|
||||
// Relay endpoints used primarily for proactive scan discovery via
|
||||
// com.atproto.sync.listReposByCollection. Endpoints listed here MUST
|
||||
// support listReposByCollection. They are also sent requestCrawl on
|
||||
// startup (in addition to the built-in known-relay list); endpoints that
|
||||
// don't implement requestCrawl just 404 silently.
|
||||
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Endpoints used for proactive scan discovery. MUST support com.atproto.sync.listReposByCollection. Also sent requestCrawl on startup (best-effort, in addition to built-in known relays)."`
|
||||
|
||||
// DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
|
||||
AppviewDID string `yaml:"appview_did" comment:"DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key."`
|
||||
@@ -254,7 +258,10 @@ func setHoldDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.public", false)
|
||||
v.SetDefault("server.successor", "")
|
||||
v.SetDefault("server.test_mode", false)
|
||||
v.SetDefault("server.relay_endpoint", "")
|
||||
v.SetDefault("server.relay_endpoints", []string{
|
||||
"https://relay1.us-east.bsky.network",
|
||||
"https://relay1.us-west.bsky.network",
|
||||
})
|
||||
v.SetDefault("server.appview_did", "did:web:atcr.io")
|
||||
v.SetDefault("server.read_timeout", "5m")
|
||||
v.SetDefault("server.write_timeout", "5m")
|
||||
|
||||
@@ -42,7 +42,9 @@ type ScanBroadcaster struct {
|
||||
stopCh chan struct{} // Signal to stop background goroutines
|
||||
wg sync.WaitGroup // Wait for background goroutines to finish
|
||||
predecessorCache map[string]bool // holdDID → "has this hold been migrated (has successor)?"
|
||||
relayEndpoint string // Relay URL for listReposByCollection
|
||||
relayEndpoints []string // Relay URLs for listReposByCollection (failover order)
|
||||
relayStartIdx int // Rotates per discovery pass so load is shared across endpoints
|
||||
relayStartMu sync.Mutex
|
||||
|
||||
// Work queues for proactive scanning (populated by discovery/stale goroutines)
|
||||
unscannedQueue chan *scanCandidate // Medium priority: manifests with no scan record
|
||||
@@ -99,7 +101,7 @@ type VulnerabilitySummary struct {
|
||||
|
||||
// NewScanBroadcaster creates a new scan job broadcaster
|
||||
// dbPath should point to a SQLite database file (e.g., "/path/to/pds/db.sqlite3")
|
||||
func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
|
||||
func NewScanBroadcaster(holdDID, holdEndpoint, secret string, relayEndpoints []string, dbPath string, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
|
||||
dsn := dbPath
|
||||
if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file:") {
|
||||
dsn = "file:" + dbPath
|
||||
@@ -125,9 +127,7 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
|
||||
}
|
||||
|
||||
if relayEndpoint == "" {
|
||||
relayEndpoint = "https://relay1.us-east.bsky.network"
|
||||
}
|
||||
relayEndpoints = normalizeRelayEndpoints(relayEndpoints)
|
||||
|
||||
sb := &ScanBroadcaster{
|
||||
subscribers: make([]*ScanSubscriber, 0),
|
||||
@@ -142,7 +142,7 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
rescanInterval: rescanInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
predecessorCache: make(map[string]bool),
|
||||
relayEndpoint: relayEndpoint,
|
||||
relayEndpoints: relayEndpoints,
|
||||
unscannedQueue: make(chan *scanCandidate, 500),
|
||||
staleQueue: make(chan *scanCandidate, 200),
|
||||
inflight: make(map[string]struct{}),
|
||||
@@ -164,7 +164,7 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
go sb.discoveryLoop()
|
||||
go sb.staleScanLoop()
|
||||
go sb.dispatchLoop()
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoints", relayEndpoints)
|
||||
}
|
||||
|
||||
return sb, nil
|
||||
@@ -172,10 +172,8 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
|
||||
// NewScanBroadcasterWithDB creates a scan job broadcaster using an existing *sql.DB connection.
|
||||
// The caller is responsible for the DB lifecycle.
|
||||
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
|
||||
if relayEndpoint == "" {
|
||||
relayEndpoint = "https://relay1.us-east.bsky.network"
|
||||
}
|
||||
func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret string, relayEndpoints []string, db *sql.DB, s3svc *s3.S3Service, holdPDS *HoldPDS, rescanInterval time.Duration) (*ScanBroadcaster, error) {
|
||||
relayEndpoints = normalizeRelayEndpoints(relayEndpoints)
|
||||
|
||||
sb := &ScanBroadcaster{
|
||||
subscribers: make([]*ScanSubscriber, 0),
|
||||
@@ -190,7 +188,7 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
|
||||
rescanInterval: rescanInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
predecessorCache: make(map[string]bool),
|
||||
relayEndpoint: relayEndpoint,
|
||||
relayEndpoints: relayEndpoints,
|
||||
unscannedQueue: make(chan *scanCandidate, 500),
|
||||
staleQueue: make(chan *scanCandidate, 200),
|
||||
inflight: make(map[string]struct{}),
|
||||
@@ -209,12 +207,30 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
|
||||
go sb.discoveryLoop()
|
||||
go sb.staleScanLoop()
|
||||
go sb.dispatchLoop()
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoint", relayEndpoint)
|
||||
slog.Info("Proactive scan scheduler started", "rescanInterval", rescanInterval, "relayEndpoints", relayEndpoints)
|
||||
}
|
||||
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
// normalizeRelayEndpoints drops empty entries and falls back to a sensible default
|
||||
// if the resulting list is empty. The default mirrors the appview backfill default.
|
||||
func normalizeRelayEndpoints(endpoints []string) []string {
|
||||
out := make([]string, 0, len(endpoints))
|
||||
for _, e := range endpoints {
|
||||
if e != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{
|
||||
"https://relay1.us-east.bsky.network",
|
||||
"https://relay1.us-west.bsky.network",
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// initSchema creates the scan_jobs table if it doesn't exist
|
||||
func (sb *ScanBroadcaster) initSchema() error {
|
||||
// Execute statements individually for go-libsql compatibility
|
||||
@@ -913,18 +929,51 @@ func (sb *ScanBroadcaster) runDiscoveryPass() {
|
||||
slog.Info("Discovery: pass complete", "users", len(userDIDs), "unscannedFound", found)
|
||||
}
|
||||
|
||||
// fetchManifestDIDs queries the relay for all DIDs with io.atcr.manifest records.
|
||||
// fetchManifestDIDs queries relays for all DIDs with io.atcr.manifest records.
|
||||
// Uses failover: starts from a rotated index (so load is shared across passes)
|
||||
// and falls through to the next relay if one fails. Returns DIDs from the first
|
||||
// relay that produces a complete result; partial results from a failing relay
|
||||
// are discarded so we don't mix incomplete responses.
|
||||
func (sb *ScanBroadcaster) fetchManifestDIDs(ctx context.Context) []string {
|
||||
client := atproto.NewClient(sb.relayEndpoint, "", "")
|
||||
endpoints := sb.relayEndpoints
|
||||
if len(endpoints) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sb.relayStartMu.Lock()
|
||||
start := sb.relayStartIdx % len(endpoints)
|
||||
sb.relayStartIdx = (sb.relayStartIdx + 1) % len(endpoints)
|
||||
sb.relayStartMu.Unlock()
|
||||
|
||||
for i := 0; i < len(endpoints); i++ {
|
||||
relay := endpoints[(start+i)%len(endpoints)]
|
||||
dids, err := sb.fetchManifestDIDsFrom(ctx, relay)
|
||||
if err != nil {
|
||||
slog.Warn("Discovery: relay failed, trying next",
|
||||
"relay", relay, "error", err)
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
slog.Info("Discovery: succeeded after failover", "relay", relay, "attemptsTried", i+1)
|
||||
}
|
||||
return dids
|
||||
}
|
||||
|
||||
slog.Warn("Discovery: all relays failed", "relays", endpoints)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchManifestDIDsFrom paginates through io.atcr.manifest repos on a single relay.
|
||||
// Any error during pagination invalidates the partial result.
|
||||
func (sb *ScanBroadcaster) fetchManifestDIDsFrom(ctx context.Context, relay string) ([]string, error) {
|
||||
client := atproto.NewClient(relay, "", "")
|
||||
var allDIDs []string
|
||||
var cursor string
|
||||
|
||||
for {
|
||||
result, err := client.ListReposByCollection(ctx, atproto.ManifestCollection, 1000, cursor)
|
||||
if err != nil {
|
||||
slog.Warn("Discovery: failed to list repos from relay",
|
||||
"relay", sb.relayEndpoint, "error", err)
|
||||
return allDIDs // Return what we have so far
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, repo := range result.Repos {
|
||||
@@ -937,7 +986,7 @@ func (sb *ScanBroadcaster) fetchManifestDIDs(ctx context.Context) []string {
|
||||
cursor = result.Cursor
|
||||
}
|
||||
|
||||
return allDIDs
|
||||
return allDIDs, nil
|
||||
}
|
||||
|
||||
// discoverUnscannedForUser fetches manifests from a user's PDS and pushes any
|
||||
|
||||
+52
-10
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -228,10 +229,10 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
rescanInterval := cfg.Scanner.RescanInterval
|
||||
var sb *pds.ScanBroadcaster
|
||||
if s.holdDB != nil {
|
||||
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoint, s.holdDB.DB, s3Service, s.PDS, rescanInterval)
|
||||
sb, err = pds.NewScanBroadcasterWithDB(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoints, s.holdDB.DB, s3Service, s.PDS, rescanInterval)
|
||||
} else {
|
||||
scanDBPath := cfg.Database.Path + "/db.sqlite3"
|
||||
sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoint, scanDBPath, s3Service, s.PDS, rescanInterval)
|
||||
sb, err = pds.NewScanBroadcaster(holdDID, cfg.Server.PublicURL, cfg.Scanner.Secret, cfg.Server.RelayEndpoints, scanDBPath, s3Service, s.PDS, rescanInterval)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize scan broadcaster: %w", err)
|
||||
@@ -374,14 +375,14 @@ func (s *HoldServer) Serve() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Request crawl from relay to make PDS discoverable
|
||||
if s.Config.Server.RelayEndpoint != "" {
|
||||
slog.Info("Requesting crawl from relay", "relay", s.Config.Server.RelayEndpoint)
|
||||
if err := atproto.RequestCrawl(s.Config.Server.RelayEndpoint, s.Config.Server.PublicURL); err != nil {
|
||||
slog.Warn("Failed to request crawl from relay", "error", err)
|
||||
} else {
|
||||
slog.Info("Crawl requested successfully")
|
||||
}
|
||||
// Request crawl from every known relay (plus any custom endpoint) so the
|
||||
// embedded PDS becomes discoverable. Without this, did:web holds are
|
||||
// invisible to relays — and to any appview that backfills via them.
|
||||
// Skipped in test_mode: local dev holds aren't reachable by public relays.
|
||||
if !s.Config.Server.TestMode {
|
||||
go s.requestCrawls()
|
||||
} else {
|
||||
slog.Info("Skipping relay crawl requests (test_mode enabled)")
|
||||
}
|
||||
|
||||
// Start garbage collector (runs on startup + nightly)
|
||||
@@ -409,6 +410,47 @@ func (s *HoldServer) Serve() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestCrawls fans out com.atproto.sync.requestCrawl to every known relay so
|
||||
// the embedded PDS becomes discoverable on the relay network. Configured
|
||||
// RelayEndpoints (if any aren't already in KnownRelays) are included as well.
|
||||
// Best-effort: per-relay failures are logged but never block startup.
|
||||
func (s *HoldServer) requestCrawls() {
|
||||
publicURL := s.Config.Server.PublicURL
|
||||
if publicURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
targets := make([]string, 0, len(atproto.KnownRelays)+len(s.Config.Server.RelayEndpoints))
|
||||
for _, r := range atproto.KnownRelays {
|
||||
if !seen[r.URL] {
|
||||
seen[r.URL] = true
|
||||
targets = append(targets, r.URL)
|
||||
}
|
||||
}
|
||||
for _, custom := range s.Config.Server.RelayEndpoints {
|
||||
if custom != "" && !seen[custom] {
|
||||
seen[custom] = true
|
||||
targets = append(targets, custom)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Requesting crawl from relays", "count", len(targets))
|
||||
var wg sync.WaitGroup
|
||||
for _, relay := range targets {
|
||||
wg.Add(1)
|
||||
go func(relay string) {
|
||||
defer wg.Done()
|
||||
if err := atproto.RequestCrawl(relay, publicURL); err != nil {
|
||||
slog.Warn("Failed to request crawl from relay", "relay", relay, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("Crawl requested from relay", "relay", relay)
|
||||
}(relay)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (s *HoldServer) shutdown() {
|
||||
// Update status post to "offline" before shutdown
|
||||
if s.PDS != nil {
|
||||
|
||||
Reference in New Issue
Block a user