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
}