| 1 |
|
package main |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"log/slog" |
| 6 |
|
"os" |
| 7 |
|
|
| 8 |
|
"github.com/vaughan0/go-ini" |
| 9 |
|
|
| 10 |
|
"go.bigb.es/auxilia/culpa" |
| 11 |
|
|
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf" |
| 14 |
|
|
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/authn" |
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/browse" |
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv" |
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/web" |
| 19 |
|
) |
| 20 |
|
|
| 21 |
|
// mcpRoute is where the MCP surface answers (docs/DESIGN.mcp.md §3). One exact |
| 22 |
|
// path and not a subtree: the streamable transport is a single endpoint, which |
| 23 |
|
// is why mountRoutes registers it with Handle rather than Mount. |
| 24 |
|
const mcpRoute = "/mcp" |
| 25 |
|
|
| 26 |
|
// tokensSection is the section tokens.sr.ht occupies in the shared config.ini. |
| 27 |
|
// Its origin is the whole configuration of the working-token plane; there is no |
| 28 |
|
// key of ours that enables or disables /mcp (docs/DESIGN.mcp.md §10). |
| 29 |
|
const tokensSection = "tokens.sr.ht" |
| 30 |
|
|
| 31 |
|
// mcpBrowseOpener satisfies mcpsrv.BrowseOpener over browse.Open, the way |
| 32 |
|
// web.BrowseAdapter satisfies web's own — the same *browse.DB answers both |
| 33 |
|
// method sets, and each package declares the seam it consumes. |
| 34 |
|
// |
| 35 |
|
// It lives here rather than in mcpsrv/ because a package that named browse.Open |
| 36 |
|
// itself could not be driven over fakes, which is the whole point of the seam. |
| 37 |
|
type mcpBrowseOpener struct{} |
| 38 |
|
|
| 39 |
|
var _ mcpsrv.BrowseOpener = mcpBrowseOpener{} |
| 40 |
|
|
| 41 |
0 |
func (mcpBrowseOpener) Open(ctx context.Context, diskPath string) (mcpsrv.BrowseSession, error) { |
| 42 |
0 |
dbh, err := browse.Open(ctx, diskPath) |
| 43 |
0 |
if err != nil { |
| 44 |
0 |
return nil, err |
| 45 |
0 |
} |
| 46 |
0 |
return dbh, nil |
| 47 |
|
} |
| 48 |
|
|
| 49 |
|
// newMCPServer assembles the MCP surface over the seams the daemon already has: |
| 50 |
|
// the metadata store through web's request-scoped adapter — the one every browse |
| 51 |
|
// handler reads through, so both surfaces answer one question one way — and the |
| 52 |
|
// bare-store reader above. |
| 53 |
|
// |
| 54 |
|
// It is built before the router and its failures are fatal. An origin that names |
| 55 |
|
// no host leaves /mcp with nothing to guard itself with (mcpsrv.New), and a |
| 56 |
|
// [tokens.sr.ht] section that does not parse is an operator's typo rather than a |
| 57 |
|
// plane to drop quietly: both are boot failures, not requests answered 500 later. |
| 58 |
|
// |
| 59 |
|
// The one thing that is not a failure is the absence of that section. It is |
| 60 |
|
// reported once here, at the level an operator reads, so that "this instance |
| 61 |
|
// refuses working tokens" is a startup line rather than something deduced from |
| 62 |
|
// the first 401 an agent reports. |
| 63 |
7 |
func newMCPServer(conf ini.File, cfg settings) (*mcpsrv.Server, error) { |
| 64 |
7 |
validator, err := newBearerValidator(conf) |
| 65 |
7 |
if err != nil { |
| 66 |
0 |
return nil, err |
| 67 |
0 |
} |
| 68 |
7 |
if validator == nil { |
| 69 |
1 |
slog.Warn("no tokens.sr.ht origin is configured; /mcp serves anonymous and meta-PAT callers and refuses every working token", |
| 70 |
1 |
"component", "mcp", "key", "["+tokensSection+"] origin") |
| 71 |
1 |
} |
| 72 |
|
|
| 73 |
7 |
return mcpsrv.New(web.DBAdapter{}, mcpBrowseOpener{}, validator, cfg.origin) |
| 74 |
|
} |
| 75 |
|
|
| 76 |
|
// newBearerValidator builds the tokens.sr.ht working-token validator, or nil |
| 77 |
|
// when this instance runs no such daemon. |
| 78 |
|
// |
| 79 |
|
// The return type is the interface and not *bearer.Validator, and that is |
| 80 |
|
// load-bearing rather than a style: a nil *bearer.Validator handed to |
| 81 |
|
// authn.ResolveBearer as an InstanceValidator is a *typed* nil, which is not the |
| 82 |
|
// contract that function documents and which panics on the first working token |
| 83 |
|
// presented. Returning the interface makes the absent plane a genuinely nil one. |
| 84 |
|
// |
| 85 |
|
// A missing section is a supported configuration and not a degradation |
| 86 |
|
// (docs/DESIGN.mcp.md §10): meta PATs and anonymous callers keep working, and a |
| 87 |
|
// working token is refused because a credential this instance cannot verify is |
| 88 |
|
// refused rather than guessed at. Refusing to boot instead would turn "agents |
| 89 |
|
// cannot authenticate" into "the service is down", on an instance that may |
| 90 |
|
// deliberately run no tokens.sr.ht at all. |
| 91 |
|
// |
| 92 |
|
// The origin is read in its internal form — [tokens.sr.ht] internal-origin |
| 93 |
|
// falling back to origin, which is what instconf.InternalOrigin means — so the |
| 94 |
|
// revocation check of the tokens SPEC ch. 6 step 4 goes container to container |
| 95 |
|
// instead of out through the reverse proxy and back. |
| 96 |
|
// |
| 97 |
|
// ClientID and NodeID identify *us* to that endpoint. They are labels rather |
| 98 |
|
// than credentials — the internal guard admits every service on the instance |
| 99 |
|
// equally — and their job is to be right in a log line when the revocation cache |
| 100 |
|
// misbehaves, which is why the node name is taken from the OS and never |
| 101 |
|
// invented: a fleet of daemons all calling themselves the same made-up name is |
| 102 |
|
// exactly the diagnostic this field exists to provide. |
| 103 |
17 |
func newBearerValidator(conf ini.File) (authn.InstanceValidator, error) { |
| 104 |
17 |
origin := instconf.InternalOrigin(conf, tokensSection) |
| 105 |
17 |
if origin == "" { |
| 106 |
3 |
return nil, nil |
| 107 |
3 |
} |
| 108 |
|
|
| 109 |
14 |
node, err := os.Hostname() |
| 110 |
14 |
if err != nil { |
| 111 |
0 |
return nil, culpa.Wrap(err, "reading the hostname for the tokens.sr.ht node id") |
| 112 |
0 |
} |
| 113 |
|
|
| 114 |
14 |
v, err := bearer.New(bearer.Options{ |
| 115 |
14 |
Origin: origin, |
| 116 |
14 |
ClientID: serviceName, |
| 117 |
14 |
NodeID: node, |
| 118 |
14 |
}) |
| 119 |
14 |
if err != nil { |
| 120 |
1 |
return nil, culpa.Wrapf(err, "assembling the %s plane", tokensSection) |
| 121 |
1 |
} |
| 122 |
13 |
return v, nil |
| 123 |
|
} |
| 124 |
|
|
| 125 |
|
// tokensDescription is what the startup line says about the working-token |
| 126 |
|
// plane, so that "the instance credential is not accepted here" is visible in |
| 127 |
|
// the journal rather than deduced from the first refusal somebody reports. |
| 128 |
3 |
func tokensDescription(conf ini.File) string { |
| 129 |
3 |
origin := instconf.InternalOrigin(conf, tokensSection) |
| 130 |
3 |
if origin == "" { |
| 131 |
1 |
return "disabled ([" + tokensSection + "] origin is unset)" |
| 132 |
1 |
} |
| 133 |
2 |
return origin |
| 134 |
|
} |