package service import ( "context" "database/sql" "fmt" "os" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/database" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/db" ) // TokensSection is tokens.sr.ht's config section, spelled literally because it // is what the instance's config.ini says and what every other service on the // instance looks the daemon up by. const TokensSection = "tokens.sr.ht" // Option configures a Service at construction. type Option func(*options) // options is what the Option functions accumulate. type options struct { // conf is the instance config.ini, present only when WithInstanceTokens was // passed. It is held here rather than on Config because it carries the // instance's secrets — [sr.ht] network-key and [webhooks] private-key — and // Config is a value the daemon prints. conf ini.File haveConf bool } // WithInstanceTokens offers the instance config to the tokens.sr.ht bearer // plane. Whether a plane is actually built depends on what is in it: see // instancePlane, which treats a missing [tokens.sr.ht] section as "there is no // such daemon on this instance" rather than as a misconfiguration. // // It is an option rather than a parameter because the two CLI paths that build a // Service — `specsrht token` and `specsrht doc` — authenticate nobody and have // no use for a validator or the HTTP client behind it. func WithInstanceTokens(conf ini.File) Option { return func(o *options) { o.conf = conf o.haveConf = true } } // instancePlane builds the tokens.sr.ht bearer plane from the instance config, // or reports that there is none to build (a nil option, nil error). // // The absence is the case worth spelling out. An instance whose config.ini has // no [tokens.sr.ht] section has no such daemon, and spec must start anyway and // keep accepting its own agent token: the local plane is not a fallback for a // broken instance plane, it is the plane this service shipped with. So a missing // origin is an answer, not an error — while an origin that is present and // unusable is an error, and fails startup where an operator is looking rather // than one request at a time as an unexplained 503. // // The origin is read in its internal form (GetOrigin's external=false), so the // revocation check of SPEC ch. 6 step 4 crosses the docker network directly // instead of going out through the reverse proxy and back in. func instancePlane(conf ini.File, q db.Querier) (authn.ResolverOption, error) { origin := config.GetOrigin(conf, TokensSection, false) if origin == "" { return nil, nil } // The node id is what the daemon's internal guard logs the caller as. The // hostname is the honest answer and needs no config key to be forgotten or // to drift; a host that cannot name itself is a startup failure rather than // a guessed label, because a fabricated node id is worse than none — it is // the wrong answer to the only question the revocation log can be asked. node, err := os.Hostname() if err != nil { return nil, fmt.Errorf("service: the tokens.sr.ht plane needs a node id and this host cannot name itself: %w", err) } v, err := bearer.New(bearer.Options{ Origin: origin, ClientID: ConfigSection, NodeID: node, }) if err != nil { return nil, fmt.Errorf("service: build the tokens.sr.ht validator: %w", err) } // auth.LookupUser opens its own transaction, so the pool itself is needed // and not the Querier interface a *sql.Tx also satisfies. Refusing loudly // beats silently leaving the plane out: an instance that configured // tokens.sr.ht and got no instance plane would look identical to one that // did not configure it, and the difference would only surface as every agent // token being refused. pool, ok := q.(*sql.DB) if !ok { return nil, fmt.Errorf( "service: the tokens.sr.ht plane needs the *sql.DB pool (auth.LookupUser opens its own transaction), got %T", q) } return authn.WithInstancePlane(v, metaUserLookup{pool: pool, conf: conf}), nil } // metaUserLookup resolves the owner of an instance token to the local "user" // row, through core-go's auth.LookupUser — the same function dolt, cover and // bench resolve their token owners with. // // The two context values it installs are not optional and not defensive. // auth.LookupUser reads config.ServiceName out of the context on every call and // opens a read-only transaction through core-go's database context, and both of // those panic when absent. spec's resolver middleware runs on the *anonymous* // router, which core-go's WithDefaultMiddleware does not decorate — it installs // the config, database and auth middleware on the authenticated router only — // so nothing upstream has put either there. This adapter is what supplies them, // and it is the reason authn declares a UserLookup interface instead of calling // core-go itself. type metaUserLookup struct { pool *sql.DB conf ini.File } // LookupUser implements authn.UserLookup. // // A user this instance has never seen is resolved by core-go against // meta.sr.ht and written down. In practice that path is not reached: the // resolver refuses an instance token whose owner is not [sr.ht] owner-name, and // the daemon seeds that row with EnsureOwnerUser before it serves anything. func (l metaUserLookup) LookupUser(ctx context.Context, username string) (authn.InstanceUser, error) { ctx = database.Context(ctx, l.pool) ctx = config.Context(ctx, l.conf, ConfigSection) var ac auth.AuthContext if err := auth.LookupUser(ctx, username, &ac); err != nil { return authn.InstanceUser{}, fmt.Errorf("service: look up user %q: %w", username, err) } if ac.UserID == 0 { // A resolved user with no row id would key nothing and authenticate // everything; there is no sensible value to substitute. return authn.InstanceUser{}, fmt.Errorf("service: user %q resolved to no row id", username) } return authn.InstanceUser{ID: ac.UserID, Username: ac.Username}, nil }