| 1 |
|
package service |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"database/sql" |
| 6 |
|
"fmt" |
| 7 |
|
"os" |
| 8 |
|
|
| 9 |
|
"github.com/vaughan0/go-ini" |
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-core/config" |
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-core/database" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer" |
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/instconf" |
| 15 |
|
|
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/db" |
| 18 |
|
) |
| 19 |
|
|
| 20 |
|
// TokensSection is tokens.sr.ht's config section, spelled literally because it |
| 21 |
|
// is what the instance's config.ini says and what every other service on the |
| 22 |
|
// instance looks the daemon up by. |
| 23 |
|
const TokensSection = "tokens.sr.ht" |
| 24 |
|
|
| 25 |
|
// Option configures a Service at construction. |
| 26 |
|
type Option func(*options) |
| 27 |
|
|
| 28 |
|
// options is what the Option functions accumulate. |
| 29 |
|
type options struct { |
| 30 |
|
// conf is the instance config.ini, present only when WithInstanceTokens was |
| 31 |
|
// passed. It is held here rather than on Config because it carries the |
| 32 |
|
// instance's secrets — [sr.ht] network-key and [webhooks] private-key — and |
| 33 |
|
// Config is a value the daemon prints. |
| 34 |
|
conf ini.File |
| 35 |
|
haveConf bool |
| 36 |
|
} |
| 37 |
|
|
| 38 |
|
// WithInstanceTokens builds the tokens.sr.ht bearer plane from the instance |
| 39 |
|
// config. It is the only plane an agent can authenticate on, so passing this |
| 40 |
|
// option and having no [tokens.sr.ht] section is a startup failure — see |
| 41 |
|
// instancePlane. |
| 42 |
|
// |
| 43 |
|
// It is an option rather than a parameter because `specsrht doc` also builds a |
| 44 |
|
// Service, authenticates nobody, and has no use for a validator or the HTTP |
| 45 |
|
// client behind it. |
| 46 |
2 |
func WithInstanceTokens(conf ini.File) Option { |
| 47 |
2 |
return func(o *options) { |
| 48 |
2 |
o.conf = conf |
| 49 |
2 |
o.haveConf = true |
| 50 |
2 |
} |
| 51 |
|
} |
| 52 |
|
|
| 53 |
|
// instancePlane builds the tokens.sr.ht bearer plane from the instance config. |
| 54 |
|
// |
| 55 |
|
// A missing [tokens.sr.ht] origin used to be an answer rather than an error: |
| 56 |
|
// spec minted its own agent token, so an instance without the daemon simply kept |
| 57 |
|
// using the plane it shipped with. It no longer has one. A daemon that came up |
| 58 |
|
// without this plane would serve reads and refuse every agent write on the |
| 59 |
|
// instance — over HTTP and over `git push` alike — so the absence fails startup, |
| 60 |
|
// where an operator is looking, instead of surfacing one request at a time as an |
| 61 |
|
// unexplained 503. |
| 62 |
|
// |
| 63 |
|
// The origin is read in its internal form — instconf.InternalOrigin, which is |
| 64 |
|
// named rather than a bool, so that the reading cannot be flipped invisibly — |
| 65 |
|
// and so the revocation check of SPEC ch. 6 step 4 crosses the docker network |
| 66 |
|
// directly instead of going out through the reverse proxy and back in. |
| 67 |
6 |
func instancePlane(conf ini.File, q db.Querier) (authn.ResolverOption, error) { |
| 68 |
6 |
origin := instconf.InternalOrigin(conf, TokensSection) |
| 69 |
6 |
if origin == "" { |
| 70 |
3 |
return nil, fmt.Errorf( |
| 71 |
3 |
"service: no [%s] origin in config.ini; spec.sr.ht issues no agent credential of "+ |
| 72 |
3 |
"its own and cannot authenticate an agent without the daemon that does", TokensSection) |
| 73 |
3 |
} |
| 74 |
|
|
| 75 |
|
// The node id is what the daemon's internal guard logs the caller as. The |
| 76 |
|
// hostname is the honest answer and needs no config key to be forgotten or |
| 77 |
|
// to drift; a host that cannot name itself is a startup failure rather than |
| 78 |
|
// a guessed label, because a fabricated node id is worse than none — it is |
| 79 |
|
// the wrong answer to the only question the revocation log can be asked. |
| 80 |
3 |
node, err := os.Hostname() |
| 81 |
3 |
if err != nil { |
| 82 |
0 |
return nil, fmt.Errorf("service: the tokens.sr.ht plane needs a node id and this host cannot name itself: %w", err) |
| 83 |
0 |
} |
| 84 |
|
|
| 85 |
3 |
v, err := bearer.New(bearer.Options{ |
| 86 |
3 |
Origin: origin, |
| 87 |
3 |
ClientID: ConfigSection, |
| 88 |
3 |
NodeID: node, |
| 89 |
3 |
}) |
| 90 |
3 |
if err != nil { |
| 91 |
1 |
return nil, fmt.Errorf("service: build the tokens.sr.ht validator: %w", err) |
| 92 |
1 |
} |
| 93 |
|
|
| 94 |
|
// auth.LookupUser opens its own transaction, so the pool itself is needed |
| 95 |
|
// and not the Querier interface a *sql.Tx also satisfies. Refusing loudly |
| 96 |
|
// beats silently leaving the plane out: an instance that configured |
| 97 |
|
// tokens.sr.ht and got no instance plane would look identical to one that |
| 98 |
|
// did not configure it, and the difference would only surface as every agent |
| 99 |
|
// token being refused. |
| 100 |
2 |
pool, ok := q.(*sql.DB) |
| 101 |
2 |
if !ok { |
| 102 |
0 |
return nil, fmt.Errorf( |
| 103 |
0 |
"service: the tokens.sr.ht plane needs the *sql.DB pool (auth.LookupUser opens its own transaction), got %T", q) |
| 104 |
0 |
} |
| 105 |
|
|
| 106 |
2 |
return authn.WithInstancePlane(v, metaUserLookup{pool: pool, conf: conf}), nil |
| 107 |
|
} |
| 108 |
|
|
| 109 |
|
// metaUserLookup resolves the owner of an instance token to the local "user" |
| 110 |
|
// row, through core-go's auth.LookupUser — the same function dolt, cover and |
| 111 |
|
// bench resolve their token owners with. |
| 112 |
|
// |
| 113 |
|
// The two context values it installs are not optional and not defensive. |
| 114 |
|
// auth.LookupUser reads config.ServiceName out of the context on every call and |
| 115 |
|
// opens a read-only transaction through core-go's database context, and both of |
| 116 |
|
// those panic when absent. spec's resolver middleware runs on the *anonymous* |
| 117 |
|
// router, which core-go's WithDefaultMiddleware does not decorate — it installs |
| 118 |
|
// the config, database and auth middleware on the authenticated router only — |
| 119 |
|
// so nothing upstream has put either there. This adapter is what supplies them, |
| 120 |
|
// and it is the reason authn declares a UserLookup interface instead of calling |
| 121 |
|
// core-go itself. |
| 122 |
|
type metaUserLookup struct { |
| 123 |
|
pool *sql.DB |
| 124 |
|
conf ini.File |
| 125 |
|
} |
| 126 |
|
|
| 127 |
|
// LookupUser implements authn.UserLookup. |
| 128 |
|
// |
| 129 |
|
// A user this instance has never seen is resolved by core-go against |
| 130 |
|
// meta.sr.ht and written down. In practice that path is not reached: the |
| 131 |
|
// resolver refuses an instance token whose owner is not [sr.ht] owner-name, and |
| 132 |
|
// the daemon seeds that row with EnsureOwnerUser before it serves anything. |
| 133 |
0 |
func (l metaUserLookup) LookupUser(ctx context.Context, username string) (authn.InstanceUser, error) { |
| 134 |
0 |
ctx = database.Context(ctx, l.pool) |
| 135 |
0 |
ctx = config.Context(ctx, l.conf, ConfigSection) |
| 136 |
0 |
|
| 137 |
0 |
var ac auth.AuthContext |
| 138 |
0 |
if err := auth.LookupUser(ctx, username, &ac); err != nil { |
| 139 |
0 |
return authn.InstanceUser{}, fmt.Errorf("service: look up user %q: %w", username, err) |
| 140 |
0 |
} |
| 141 |
0 |
if ac.UserID == 0 { |
| 142 |
0 |
// A resolved user with no row id would key nothing and authenticate |
| 143 |
0 |
// everything; there is no sensible value to substitute. |
| 144 |
0 |
return authn.InstanceUser{}, fmt.Errorf("service: user %q resolved to no row id", username) |
| 145 |
0 |
} |
| 146 |
0 |
return authn.InstanceUser{ID: ac.UserID, Username: ac.Username}, nil |
| 147 |
|
} |