package spindle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"slices"
"sync"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/hashicorp/golang-lru/v2/expirable"
gossh "golang.org/x/crypto/ssh"
"tangled.org/core/api/org_tangled"
"tangled.org/core/rbac"
"tangled.org/core/spindle/knotclient"
)
const (
debugKeyTTL = time.Minute
// the owner plus this many collaborators at most, so one login can't fan out to every pds
debugKeyHolderCap = 20
debugKeysPerHolder = 50
debugKeyCacheSize = 512
)
// marshalled keys per did, only successful reads get cached so a pds blip doesn't lock people out for a minute
type debugKeyCache struct {
once sync.Once
byDid *expirable.LRU[syntax.DID, [][]byte]
}
func (c *debugKeyCache) lru() *expirable.LRU[syntax.DID, [][]byte] {
c.once.Do(func() {
c.byDid = expirable.NewLRU[syntax.DID, [][]byte](debugKeyCacheSize, nil, debugKeyTTL)
})
return c.byDid
}
// fails closed, any error along the way refuses the key
func (s *Spindle) DebugKeyAllowed(ctx context.Context, repoDid syntax.DID, key gossh.PublicKey) bool {
l := s.l.With("component", "debugssh", "repo", repoDid)
repo, err := s.db.GetRepoByDid(repoDid)
if err != nil {
l.Warn("debug key refused, repo lookup failed", "err", err)
return false
}
if ban, err := s.db.IsBanned(repoDid, repo.Owner); err != nil || ban != nil {
l.Warn("debug key refused, repo is banned or the ban lookup failed", "err", err)
return false
}
if gate, err := s.db.Gate(&s.cfg.Server, repoDid); err != nil || gate.Paused {
l.Warn("debug key refused, owner is paused or the gate lookup failed", "err", err)
return false
}
want := key.Marshal()
for _, holder := range s.debugKeyHolders(l, repoDid, repo.Owner) {
keys, err := s.sshKeysOf(ctx, holder)
if err != nil {
l.Warn("debug key holder skipped, reading their keys failed", "holder", holder, "err", err)
continue
}
if slices.ContainsFunc(keys, func(k []byte) bool { return bytes.Equal(k, want) }) {
return true
}
}
return false
}
// owner first and a stable order, otherwise the cap could cut different collaborators each login
func (s *Spindle) debugKeyHolders(l *slog.Logger, repoDid, owner syntax.DID) []syntax.DID {
candidates := []string{owner.String()}
byRepo, err := s.e.GetCollaboratorsByRepo(rbac.ThisServer)
if err != nil {
l.Warn("debug key holders limited to the owner, collaborator lookup failed", "err", err)
} else {
candidates = append(candidates, byRepo[repoDid.String()]...)
}
var holders []syntax.DID
for _, candidate := range candidates {
did, err := syntax.ParseDID(candidate)
if err != nil || slices.Contains(holders, did) {
continue
}
if ok, err := s.e.IsPushAllowed(candidate, rbac.ThisServer, repoDid.String()); err != nil || !ok {
continue
}
if ban, err := s.db.GetBan(did); err != nil || ban != nil {
continue
}
holders = append(holders, did)
if len(holders) == debugKeyHolderCap {
break
}
}
return holders
}
func (s *Spindle) sshKeysOf(ctx context.Context, did syntax.DID) ([][]byte, error) {
cache := s.debugKeys.lru()
if keys, ok := cache.Get(did); ok {
return keys, nil
}
pds, err := s.pdsOf(ctx, did)
if err != nil {
return nil, err
}
records, err := s.knots.ListRecords(ctx, pds, did, org_tangled.KeySshKeyNSID, debugKeysPerHolder)
if err != nil {
return nil, fmt.Errorf("listing ssh keys of %s: %w", did, err)
}
keys := make([][]byte, 0, len(records))
for _, raw := range records {
var record org_tangled.KeySshKey
if err := json.Unmarshal(raw, &record); err != nil {
continue
}
// usages default to auth and sign when omitted
if len(record.Usages) > 0 && !slices.Contains(record.Usages, "auth") {
continue
}
parsed, _, _, _, err := gossh.ParseAuthorizedKey([]byte(record.PublicKey))
if err != nil {
continue
}
keys = append(keys, parsed.Marshal())
}
cache.Add(did, keys)
return keys, nil
}
func (s *Spindle) pdsOf(ctx context.Context, did syntax.DID) (knotclient.Knot, error) {
ident, err := s.res.ResolveIdent(ctx, did.String())
if err != nil {
return knotclient.Knot{}, fmt.Errorf("resolving %s: %w", did, err)
}
pds, ok := knotAt(ident.PDSEndpoint())
if !ok {
return knotclient.Knot{}, fmt.Errorf("%s has no usable pds endpoint", did)
}
return pds, nil
}
func knotAt(endpoint string) (knotclient.Knot, bool) {
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
return knotclient.Knot{}, false
}
return knotclient.Knot{Host: u.Host, Scheme: u.Scheme}, true
}