clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / tinyagent / diagnose.go
diagnose.go 17.7K
1package main23import (4 "bytes"5 "context"6 "crypto/rand"7 "crypto/sha256"8 "encoding/base64"9 "encoding/hex"10 "encoding/json"11 "errors"12 "flag"13 "fmt"14 "io"15 "net/http"16 "os"17 "strconv"18 "strings"19 "time"2021 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"22 "github.com/FelineStateMachine/tinyrelay/tinyagent/client"23)2425// Diagnostics answer one question for an agent and its operator: can this26// key do what the connector needs on this relay, and if not, is the relay27// out of reach, is the key refused, or is the grant too narrow. Transport28// failures and authorization failures are kept apart throughout: a signed29// request the relay answers, whatever the status, proves the relay is30// reachable.3132const (33 verdictOK = "ok"34 verdictNeedsGrant = "needs-grant"35 verdictNotMember = "not-a-member"36 verdictUnauthorized = "unauthorized"37 verdictUnreachable = "unreachable"38 verdictError = "error"3940 classTransport = "transport"41 classAuthorization = "authorization"42 classRateLimited = "rate-limited"43 classProtocol = "protocol"4445 diagnoseProbeTimeout = 10 * time.Second46 diagnoseBodyLimit = 1 << 2047)4849// diagnoseNeeds names the rooms and kinds the connector wants to use.50type diagnoseNeeds struct {51 Rooms []string `json:"rooms"`52 Kinds []int `json:"kinds"`53}5455// diagnoseCheck is one probe's outcome. Class is set on failure only.56type diagnoseCheck struct {57 OK bool `json:"ok"`58 Check string `json:"check,omitempty"`59 Status int `json:"status,omitempty"`60 Class string `json:"class,omitempty"`61 Error string `json:"error,omitempty"`62}6364// diagnoseMissing lists what the grant lacks among the needs.65type diagnoseMissing struct {66 Rooms []string `json:"rooms"`67 Kinds []int `json:"kinds"`68}6970// diagnosis is the report written by `tinyagent diagnose`, the diagnose71// RPC method and the tiny_diagnose MCP tool.72type diagnosis struct {73 Relay string `json:"relay"`74 PubKey string `json:"pubkey"`75 Transport diagnoseCheck `json:"transport"`76 Authentication diagnoseCheck `json:"authentication"`77 Member bool `json:"member"`78 Role string `json:"role"`79 Grant json.RawMessage `json:"grant"`80 State string `json:"state"`81 Enforced bool `json:"enforced"`82 Allows json.RawMessage `json:"allows,omitempty"`83 Rate int `json:"rate,omitempty"`84 Expires int64 `json:"expires,omitempty"`85 Needs *diagnoseNeeds `json:"needs,omitempty"`86 Missing *diagnoseMissing `json:"missing,omitempty"`87 Verdict string `json:"verdict"`88 Advice string `json:"advice"`89}9091// grantAnswer is the relay's browsegrant result.92type grantAnswer struct {93 Member bool `json:"member"`94 Role string `json:"role"`95 Grant json.RawMessage `json:"grant"`96 State string `json:"state"`97 Enforced bool `json:"enforced"`98 Allows json.RawMessage `json:"allows"`99 Rate int `json:"rate"`100 Expires int64 `json:"expires"`101 Missing *diagnoseMissing `json:"missing"`102}103104// exitError carries a process exit status through run.105type exitError struct {106 code int107 message string108}109110func (e *exitError) Error() string { return e.message }111112// exitStatus is the process status for an error from run: 1 unless the113// error names its own code.114func exitStatus(err error) int {115 var exit *exitError116 if errors.As(err, &exit) {117 return exit.code118 }119 return 1120}121122// diagnoseExit maps a verdict to the documented exit status.123func diagnoseExit(verdict string) int {124 switch verdict {125 case verdictOK:126 return 0127 case verdictUnreachable, verdictError:128 return 3129 default:130 return 2131 }132}133134func diagnoseCommand(args []string, out io.Writer) error {135 fs := flag.NewFlagSet("diagnose", flag.ContinueOnError)136 fs.SetOutput(io.Discard)137 relay := fs.String("relay", "", "relay URL")138 keyEnv := fs.String("key-env", "TINY_PRIVATE_KEY", "private key environment variable")139 rooms := fs.String("rooms", "", "comma-separated room ids the connector needs")140 kinds := fs.String("kinds", "", "comma-separated event kinds the connector needs")141 if err := fs.Parse(args); err != nil {142 return err143 }144 if *relay == "" {145 return errors.New("usage: tinyagent diagnose --relay URL [--key-env TINY_PRIVATE_KEY] [--rooms a,b] [--kinds 9,12]")146 }147 secret := os.Getenv(*keyEnv)148 if secret == "" {149 return fmt.Errorf("%s is not set", *keyEnv)150 }151 c, err := client.New(*relay, secret)152 if err != nil {153 return err154 }155 needs, err := parseNeedsFlags(*rooms, *kinds)156 if err != nil {157 return err158 }159 report := diagnose(context.Background(), c, needs)160 encoder := json.NewEncoder(out)161 encoder.SetIndent("", " ")162 if err := encoder.Encode(report); err != nil {163 return err164 }165 if code := diagnoseExit(report.Verdict); code != 0 {166 return &exitError{code: code, message: report.Verdict + ": " + report.Advice}167 }168 return nil169}170171func parseNeedsFlags(rooms, kinds string) (diagnoseNeeds, error) {172 var needs diagnoseNeeds173 for _, room := range strings.Split(rooms, ",") {174 if room = strings.TrimSpace(room); room != "" {175 needs.Rooms = append(needs.Rooms, room)176 }177 }178 for _, raw := range strings.Split(kinds, ",") {179 raw = strings.TrimSpace(raw)180 if raw == "" {181 continue182 }183 kind, err := strconv.Atoi(raw)184 if err != nil || kind < 0 || kind > 65535 {185 return needs, fmt.Errorf("--kinds: %q is not an event kind", raw)186 }187 needs.Kinds = append(needs.Kinds, kind)188 }189 return needs, nil190}191192// parseNeedsParams reads rooms and kinds from RPC params or tool arguments.193func parseNeedsParams(raw map[string]json.RawMessage) (diagnoseNeeds, error) {194 var needs diagnoseNeeds195 if len(raw["rooms"]) > 0 {196 if err := json.Unmarshal(raw["rooms"], &needs.Rooms); err != nil {197 return needs, errors.New("rooms must be an array of room ids")198 }199 }200 if len(raw["kinds"]) > 0 {201 if err := json.Unmarshal(raw["kinds"], &needs.Kinds); err != nil {202 return needs, errors.New("kinds must be an array of event kinds")203 }204 }205 for _, room := range needs.Rooms {206 if strings.TrimSpace(room) == "" {207 return needs, errors.New("rooms must be room ids")208 }209 }210 for _, kind := range needs.Kinds {211 if kind < 0 || kind > 65535 {212 return needs, errors.New("kinds must be event kinds")213 }214 }215 return needs, nil216}217218// diagnose runs the probes in order and stops at the first one that rules219// out the rest: an unreachable relay is not asked to authenticate, and a220// refused key is not asked about its grant.221func (s *rpcServer) diagnose(ctx context.Context, raw map[string]json.RawMessage) (any, error) {222 needs, err := parseNeedsParams(raw)223 if err != nil {224 return nil, err225 }226 return diagnose(ctx, s.client, needs), nil227}228229func diagnose(ctx context.Context, c *client.Client, needs diagnoseNeeds) diagnosis {230 report := diagnosis{Relay: c.BaseURL, PubKey: c.PubKey, Grant: json.RawMessage("null"), State: "none"}231 if len(needs.Rooms) > 0 || len(needs.Kinds) > 0 {232 copied := diagnoseNeeds{Rooms: append([]string{}, needs.Rooms...), Kinds: append([]int{}, needs.Kinds...)}233 report.Needs = &copied234 }235 report.Transport = probeTransport(ctx, c)236 if !report.Transport.OK {237 report.Verdict = verdictUnreachable238 report.Advice = "the relay at " + c.BaseURL + " did not answer: check the URL, the network and that the relay is running (" + report.Transport.Error + ")"239 return report240 }241 answer, check := probeGrant(ctx, c, needs)242 report.Authentication = check243 if !check.OK {244 switch check.Class {245 case classAuthorization:246 if check.Status == http.StatusForbidden && strings.Contains(check.Error, "members-only") {247 report.Verdict = verdictNotMember248 report.Advice = "the relay is members-only and this key is not a member: ask the owner for membership or an agent grant" + needsClause(needs)249 } else {250 report.Verdict = verdictUnauthorized251 report.Advice = "the relay refused the signed request (HTTP " + strconv.Itoa(check.Status) + "): check that TINY_PRIVATE_KEY is the agent key the relay expects and that --relay is the exact origin the relay serves (" + check.Error + ")"252 }253 case classRateLimited:254 report.Verdict = verdictUnreachable255 report.Advice = "the relay rate-limited the signed request (HTTP 429): retry later (" + check.Error + ")"256 case classProtocol:257 report.Verdict = verdictError258 report.Advice = "the relay answered the signed browsegrant call with " + check.Error + " instead of a result: check that --relay is the exact origin the relay serves, with its tenant prefix, and that the relay is current"259 default:260 report.Verdict = verdictUnreachable261 report.Advice = "the relay answered its health check but not a signed request: " + check.Error262 }263 return report264 }265 if answer == nil {266 // The signature was accepted but the relay predates browsegrant.267 report.Verdict = verdictOK268 report.Advice = "the relay accepts this key's signature but does not answer browsegrant, so its grant cannot be read: upgrade the relay to see grant state"269 return report270 }271 report.Member, report.Role, report.State, report.Enforced = answer.Member, answer.Role, answer.State, answer.Enforced272 report.Rate, report.Expires = answer.Rate, answer.Expires273 if len(answer.Grant) > 0 {274 report.Grant = answer.Grant275 }276 if len(answer.Allows) > 0 && string(answer.Allows) != "null" {277 report.Allows = answer.Allows278 }279 if answer.Missing != nil {280 report.Missing = answer.Missing281 } else if report.Needs != nil {282 report.Missing = &diagnoseMissing{Rooms: []string{}, Kinds: []int{}}283 }284 report.Verdict, report.Advice = grantVerdict(answer, needs)285 return report286}287288// grantVerdict reads a browsegrant answer against the needs.289func grantVerdict(answer *grantAnswer, needs diagnoseNeeds) (string, string) {290 if !answer.Member {291 return verdictNotMember, "this key is not a member of the relay: ask the owner for an agent grant" + needsClause(needs)292 }293 if !answer.Enforced {294 return verdictOK, "this key holds the " + answer.Role + " role; agent grants do not apply to it"295 }296 switch answer.State {297 case "paused":298 return verdictNeedsGrant, "the agent grant is paused: ask the operator to resume it"299 case "revoked":300 return verdictNeedsGrant, "the agent grant is revoked: ask the operator for a new grant" + needsClause(needs)301 case "expired":302 return verdictNeedsGrant, "the agent grant expired at " + time.Unix(answer.Expires, 0).UTC().Format(time.RFC3339) + ": ask the operator for a new grant" + needsClause(needs)303 case "none":304 return verdictNeedsGrant, "this key is a member but holds no agent grant: ask the operator for one" + needsClause(needs)305 }306 if answer.Missing != nil && (len(answer.Missing.Rooms) > 0 || len(answer.Missing.Kinds) > 0) {307 return verdictNeedsGrant, "call request_grant with " + missingClause(*answer.Missing)308 }309 return verdictOK, "the grant covers what was asked"310}311312func needsClause(needs diagnoseNeeds) string {313 if len(needs.Rooms) == 0 && len(needs.Kinds) == 0 {314 return ""315 }316 return " covering " + missingClause(diagnoseMissing(needs))317}318319func missingClause(missing diagnoseMissing) string {320 parts := []string{}321 if len(missing.Rooms) > 0 {322 parts = append(parts, "rooms "+strings.Join(missing.Rooms, ", "))323 }324 if len(missing.Kinds) > 0 {325 kinds := make([]string, len(missing.Kinds))326 for i, kind := range missing.Kinds {327 kinds[i] = strconv.Itoa(kind)328 }329 parts = append(parts, "kinds "+strings.Join(kinds, ", "))330 }331 return strings.Join(parts, " and ")332}333334// probeTransport asks the relay whether it is up without a signature. The335// readiness route answers first; a relay that lacks it or is not ready is336// still reachable when its NIP-11 document answers with any status below337// 500, including 401 on a private relay.338func probeTransport(ctx context.Context, c *client.Client) diagnoseCheck {339 status, _, err := diagnoseGet(ctx, c, "/readyz", "application/json")340 if err == nil && status/100 == 2 {341 return diagnoseCheck{OK: true, Check: "readyz", Status: status}342 }343 readyz := describeProbe(status, err)344 status, _, err = diagnoseGet(ctx, c, "/", "application/nostr+json")345 if err == nil && status < 500 {346 return diagnoseCheck{OK: true, Check: "nip11", Status: status}347 }348 return diagnoseCheck{OK: false, Check: "nip11", Status: status, Class: classTransport, Error: "readyz: " + readyz + "; nip11: " + describeProbe(status, err)}349}350351func describeProbe(status int, err error) string {352 if err != nil {353 return err.Error()354 }355 return "HTTP " + strconv.Itoa(status)356}357358// probeGrant makes the signed browsegrant call. A nil answer with an ok359// check means the relay accepted the signature but has no such method: it360// answered HTTP 400 with the unsupported-method error its dispatcher writes361// for a name it does not know. 401 and 403 are authorization failures, 429362// is rate limiting, 5xx is transport, and any other status is a protocol363// error; none of those is read as a working signature.364func probeGrant(ctx context.Context, c *client.Client, needs diagnoseNeeds) (*grantAnswer, diagnoseCheck) {365 params := map[string]any{}366 if len(needs.Rooms) > 0 {367 params["rooms"] = needs.Rooms368 }369 if len(needs.Kinds) > 0 {370 params["kinds"] = needs.Kinds371 }372 body, err := json.Marshal(map[string]any{"method": "browsegrant", "params": []any{params}})373 if err != nil {374 return nil, diagnoseCheck{Class: classTransport, Error: err.Error()}375 }376 status, data, err := diagnoseSigned(ctx, c, "/manage/rpc", body)377 if err != nil {378 return nil, diagnoseCheck{Class: classTransport, Error: err.Error()}379 }380 var envelope struct {381 Result *grantAnswer `json:"result"`382 Error string `json:"error"`383 }384 _ = json.Unmarshal(data, &envelope)385 message := envelope.Error386 if message == "" {387 message = strings.TrimSpace(string(data))388 }389 if message == "" {390 message = http.StatusText(status)391 }392 switch {393 case status == http.StatusUnauthorized || status == http.StatusForbidden:394 return nil, diagnoseCheck{Status: status, Class: classAuthorization, Error: message}395 case status == http.StatusTooManyRequests:396 return nil, diagnoseCheck{Status: status, Class: classRateLimited, Error: message}397 case status >= 500:398 return nil, diagnoseCheck{Status: status, Class: classTransport, Error: "HTTP " + strconv.Itoa(status) + ": " + message}399 case status == http.StatusBadRequest && strings.HasPrefix(envelope.Error, "unsupported:"):400 // The signature was checked before the method was dispatched, and401 // the dispatcher did not know browsegrant.402 return nil, diagnoseCheck{OK: true, Status: status, Error: message}403 case status/100 != 2:404 return nil, diagnoseCheck{Status: status, Class: classProtocol, Error: "HTTP " + strconv.Itoa(status) + ": " + message}405 case envelope.Result == nil:406 return nil, diagnoseCheck{Status: status, Class: classTransport, Error: "the relay returned an invalid browsegrant result"}407 }408 return envelope.Result, diagnoseCheck{OK: true, Status: status}409}410411func diagnoseGet(ctx context.Context, c *client.Client, p, accept string) (int, []byte, error) {412 ctx, cancel := context.WithTimeout(ctx, diagnoseProbeTimeout)413 defer cancel()414 req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+p, nil)415 if err != nil {416 return 0, nil, err417 }418 req.Header.Set("Accept", accept)419 return diagnoseDo(c, req)420}421422// diagnoseSigned posts a NIP-86 body with a fresh NIP-98 proof bound to the423// method, the full URL and the body hash, the same binding the client uses.424func diagnoseSigned(ctx context.Context, c *client.Client, p string, body []byte) (int, []byte, error) {425 ctx, cancel := context.WithTimeout(ctx, diagnoseProbeTimeout)426 defer cancel()427 target := c.BaseURL + p428 nonce := make([]byte, 12)429 if _, err := rand.Read(nonce); err != nil {430 return 0, nil, fmt.Errorf("generate request nonce: %w", err)431 }432 sum := sha256.Sum256(body)433 proof := nostr.Event{Kind: 27235, CreatedAt: time.Now().Unix(), Tags: [][]string{{"u", target}, {"method", http.MethodPost}, {"nonce", hex.EncodeToString(nonce)}, {"payload", hex.EncodeToString(sum[:])}}}434 if err := nostr.Sign(&proof, c.Secret); err != nil {435 return 0, nil, err436 }437 canonical, err := nostr.Canonical(proof)438 if err != nil {439 return 0, nil, err440 }441 req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))442 if err != nil {443 return 0, nil, err444 }445 req.Header.Set("Authorization", "Nostr "+base64.StdEncoding.EncodeToString(canonical))446 req.Header.Set("Content-Type", "application/nostr+json+rpc")447 req.Header.Set("Accept", "application/json")448 return diagnoseDo(c, req)449}450451func diagnoseDo(c *client.Client, req *http.Request) (int, []byte, error) {452 httpClient := c.HTTP453 if httpClient == nil {454 httpClient = http.DefaultClient455 }456 resp, err := httpClient.Do(req)457 if err != nil {458 return 0, nil, err459 }460 defer resp.Body.Close()461 data, err := io.ReadAll(io.LimitReader(resp.Body, diagnoseBodyLimit))462 if err != nil {463 return resp.StatusCode, nil, err464 }465 return resp.StatusCode, data, nil466}467468// diagnoseSummary is the one-line text form of a report for tool output.469func diagnoseSummary(report diagnosis) string {470 return report.Verdict + ": " + report.Advice471}472473// diagnose answers the tiny_diagnose tool: the report as structured content474// with its verdict and advice as the text block. The report is an answer,475// not a failure, whatever the verdict.476func (f *mcpFacade) diagnose(ctx context.Context, arguments map[string]any) mcpToolResult {477 raw := make(map[string]json.RawMessage, len(arguments))478 for key, value := range arguments {479 encoded, err := json.Marshal(value)480 if err != nil {481 return mcpFailure(key + " is not JSON")482 }483 raw[key] = encoded484 }485 needs, err := parseNeedsParams(raw)486 if err != nil {487 return mcpFailure(err.Error())488 }489 f.relayMu.Lock()490 defer f.relayMu.Unlock()491 ctx, cancel := context.WithTimeout(ctx, mcpCallTimeout)492 defer cancel()493 report := diagnose(ctx, f.client, needs)494 return mcpToolResult{Content: []map[string]any{{"type": "text", "text": diagnoseSummary(report)}}, StructuredContent: report}495}