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
}