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-ecore/instconf" "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 builds the tokens.sr.ht bearer plane from the instance // config. It is the only plane an agent can authenticate on, so passing this // option and having no [tokens.sr.ht] section is a startup failure — see // instancePlane. // // It is an option rather than a parameter because `specsrht doc` also builds a // Service, authenticates nobody, and has 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. // // A missing [tokens.sr.ht] origin used to be an answer rather than an error: // spec minted its own agent token, so an instance without the daemon simply kept // using the plane it shipped with. It no longer has one. A daemon that came up // without this plane would serve reads and refuse every agent write on the // instance — over HTTP and over `git push` alike — so the absence fails startup, // where an operator is looking, instead of surfacing one request at a time as an // unexplained 503. // // The origin is read in its internal form — instconf.InternalOrigin, which is // named rather than a bool, so that the reading cannot be flipped invisibly — // and 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 := instconf.InternalOrigin(conf, TokensSection) if origin == "" { return nil, fmt.Errorf( "service: no [%s] origin in config.ini; spec.sr.ht issues no agent credential of "+ "its own and cannot authenticate an agent without the daemon that does", TokensSection) } // 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 }