| 1 |
|
package hooks |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"log/slog" |
| 8 |
|
"net" |
| 9 |
|
"net/http" |
| 10 |
|
"os" |
| 11 |
|
"path/filepath" |
| 12 |
|
"strings" |
| 13 |
|
"sync" |
| 14 |
|
"sync/atomic" |
| 15 |
|
"time" |
| 16 |
|
|
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 19 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/gitx" |
| 20 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 21 |
|
) |
| 22 |
|
|
| 23 |
|
// Backend is the daemon this package fronts. *service.Service satisfies it as |
| 24 |
|
// written; it is an interface so the receive path can be exercised end to end, |
| 25 |
|
// against a real `git push`, without a Postgres instance. |
| 26 |
|
// |
| 27 |
|
// Everything policy-shaped lives behind ValidatePush. This package decides |
| 28 |
|
// which space a repository is, who is pushing, and whether validation was |
| 29 |
|
// waived — and then asks. That split is the point of the whole design: the API |
| 30 |
|
// and the push path must not be able to disagree about what is valid. |
| 31 |
|
type Backend interface { |
| 32 |
|
// ReposRoot is [spec.sr.ht] repos, the only directory a hook may address a |
| 33 |
|
// repository under. |
| 34 |
|
ReposRoot() string |
| 35 |
|
|
| 36 |
|
// Resolver carries the instance owner username — the one identity an |
| 37 |
|
// "owner" credential can resolve to — and the tokens.sr.ht plane an agent's |
| 38 |
|
// credential is validated on. It is the same resolver the HTTP surfaces |
| 39 |
|
// authenticate through, which is the point: one plane, one implementation of |
| 40 |
|
// "is this credential good?". |
| 41 |
|
Resolver() *authn.Resolver |
| 42 |
|
|
| 43 |
|
// ValidatePush answers whether one ref may move. A *service.PushRejection |
| 44 |
|
// is a policy refusal whose Error() is the text to print; anything else is |
| 45 |
|
// an infrastructure failure and must fail the push closed. |
| 46 |
|
ValidatePush(ctx context.Context, req service.PushRequest) error |
| 47 |
|
} |
| 48 |
|
|
| 49 |
|
// PushNotifier is what the daemon does when a push lands: reindex the changed |
| 50 |
|
// documents and advance the space's index rev stamp. |
| 51 |
|
// |
| 52 |
|
// It is injected rather than being a Backend method because Phase 1 has no |
| 53 |
|
// indexer — bleve and the index rev stamp belong to Phase 2 — and a stamp |
| 54 |
|
// advanced without a reindex behind it would be a lie the reconciler could not |
| 55 |
|
// detect. Until then the daemon supplies a notifier that records the push and |
| 56 |
|
// nothing else, and the reconciler reports the staleness. |
| 57 |
|
type PushNotifier func(ctx context.Context, space core.SpaceRef, updates []RefUpdate) error |
| 58 |
|
|
| 59 |
|
// Options configures a Server. |
| 60 |
|
type Options struct { |
| 61 |
|
// Backend and Socket are required. |
| 62 |
|
Backend Backend |
| 63 |
|
Socket string |
| 64 |
|
|
| 65 |
|
// OnPush is called for each landed push. Required: a server with no |
| 66 |
|
// notifier would accept post-receive calls and drop them, which reads |
| 67 |
|
// exactly like a working index that never updates. |
| 68 |
|
OnPush PushNotifier |
| 69 |
|
|
| 70 |
|
// Log defaults to slog.Default(). |
| 71 |
|
Log *slog.Logger |
| 72 |
|
|
| 73 |
|
// Timeout bounds handling one request. Zero means DefaultTimeout. |
| 74 |
|
Timeout time.Duration |
| 75 |
|
|
| 76 |
|
// OptionTTL is how long a push's recorded options survive without the |
| 77 |
|
// matching update calls. Zero means DefaultOptionTTL. |
| 78 |
|
OptionTTL time.Duration |
| 79 |
|
} |
| 80 |
|
|
| 81 |
|
const ( |
| 82 |
|
// DefaultOptionTTL is how long the daemon remembers a push's options. The |
| 83 |
|
// gap between pre-receive and the last update of one push is milliseconds; |
| 84 |
|
// this is three orders of magnitude of slack, and the only cost of an |
| 85 |
|
// entry outliving its push is a few hundred bytes until it is swept. |
| 86 |
|
DefaultOptionTTL = 10 * time.Minute |
| 87 |
|
|
| 88 |
|
// maxPendingPushes caps the option table. Any local process can connect to |
| 89 |
|
// the socket, so the table must not be a way to exhaust memory; at the |
| 90 |
|
// documented volume — one human, tens of documents a day — a thousand |
| 91 |
|
// pushes in flight at once means something is wrong, and failing closed is |
| 92 |
|
// the right answer to that. |
| 93 |
|
maxPendingPushes = 1024 |
| 94 |
|
|
| 95 |
|
// socketDirMode keeps the socket's directory private to the service user. |
| 96 |
|
// The socket is an unauthenticated write path to the daemon: anyone who |
| 97 |
|
// can connect can assert the owner principal. |
| 98 |
|
socketDirMode = 0o700 |
| 99 |
|
socketMode = 0o600 |
| 100 |
|
) |
| 101 |
|
|
| 102 |
|
// Server is the daemon side of the receive path: a unix socket the hooks call. |
| 103 |
|
// |
| 104 |
|
// The socket is unix-domain and not a TCP port on localhost, for one reason |
| 105 |
|
// that decides it: filesystem permissions. An "owner" credential is an |
| 106 |
|
// assertion, so the ability to connect is the ability to push as the owner — |
| 107 |
|
// a 0700 directory holding a 0600 socket makes that reachable only by the |
| 108 |
|
// service user, which no localhost TCP port can do. |
| 109 |
|
type Server struct { |
| 110 |
|
backend Backend |
| 111 |
|
socket string |
| 112 |
|
log *slog.Logger |
| 113 |
|
onPush PushNotifier |
| 114 |
|
timeout time.Duration |
| 115 |
|
optionTTL time.Duration |
| 116 |
|
|
| 117 |
|
listener net.Listener |
| 118 |
|
wg sync.WaitGroup |
| 119 |
|
|
| 120 |
|
// closing distinguishes a listener we shut down from one that failed. Both |
| 121 |
|
// surface as net.ErrClosed out of Accept, and only one of them is an error. |
| 122 |
|
closing atomic.Bool |
| 123 |
|
|
| 124 |
|
mu sync.Mutex |
| 125 |
|
pending map[string]*pendingPush |
| 126 |
|
} |
| 127 |
|
|
| 128 |
|
// pendingPush is what pre-receive told us about a push, waiting for the update |
| 129 |
|
// calls it belongs to. |
| 130 |
|
type pendingPush struct { |
| 131 |
|
updates []RefUpdate |
| 132 |
|
skip bool |
| 133 |
|
expires time.Time |
| 134 |
|
} |
| 135 |
|
|
| 136 |
|
// NewServer builds a server. It does not listen; call Listen. |
| 137 |
37 |
func NewServer(opts Options) (*Server, error) { |
| 138 |
37 |
if opts.Backend == nil { |
| 139 |
1 |
return nil, errors.New("hooks: no backend") |
| 140 |
1 |
} |
| 141 |
36 |
if opts.Socket == "" { |
| 142 |
1 |
return nil, errors.New("hooks: no socket path") |
| 143 |
1 |
} |
| 144 |
35 |
if !filepath.IsAbs(opts.Socket) { |
| 145 |
1 |
return nil, fmt.Errorf("hooks: socket path %q is not absolute", opts.Socket) |
| 146 |
1 |
} |
| 147 |
34 |
if opts.OnPush == nil { |
| 148 |
1 |
return nil, errors.New("hooks: no push notifier") |
| 149 |
1 |
} |
| 150 |
33 |
if opts.Backend.ReposRoot() == "" { |
| 151 |
0 |
return nil, errors.New("hooks: backend has no repos root") |
| 152 |
0 |
} |
| 153 |
33 |
log := opts.Log |
| 154 |
33 |
if log == nil { |
| 155 |
1 |
log = slog.Default() |
| 156 |
1 |
} |
| 157 |
33 |
timeout := opts.Timeout |
| 158 |
33 |
if timeout <= 0 { |
| 159 |
3 |
timeout = DefaultTimeout |
| 160 |
3 |
} |
| 161 |
33 |
ttl := opts.OptionTTL |
| 162 |
33 |
if ttl <= 0 { |
| 163 |
32 |
ttl = DefaultOptionTTL |
| 164 |
32 |
} |
| 165 |
33 |
return &Server{ |
| 166 |
33 |
backend: opts.Backend, |
| 167 |
33 |
socket: opts.Socket, |
| 168 |
33 |
log: log, |
| 169 |
33 |
onPush: opts.OnPush, |
| 170 |
33 |
timeout: timeout, |
| 171 |
33 |
optionTTL: ttl, |
| 172 |
33 |
pending: make(map[string]*pendingPush), |
| 173 |
33 |
}, nil |
| 174 |
|
} |
| 175 |
|
|
| 176 |
|
// Socket is the path this server listens on. |
| 177 |
35 |
func (s *Server) Socket() string { return s.socket } |
| 178 |
|
|
| 179 |
|
// Listen binds the socket. |
| 180 |
|
// |
| 181 |
|
// A leftover socket file from a crashed daemon is removed, but only after |
| 182 |
|
// proving it is dead: if something answers on it, another daemon is running |
| 183 |
|
// and this one refuses to start rather than stealing the push path from it. |
| 184 |
32 |
func (s *Server) Listen() error { |
| 185 |
32 |
if err := os.MkdirAll(filepath.Dir(s.socket), socketDirMode); err != nil { |
| 186 |
0 |
return fmt.Errorf("hooks: create %s: %w", filepath.Dir(s.socket), err) |
| 187 |
0 |
} |
| 188 |
|
// The directory may pre-date this version, or have been created with a |
| 189 |
|
// looser umask; make it private either way. |
| 190 |
32 |
if err := os.Chmod(filepath.Dir(s.socket), socketDirMode); err != nil { |
| 191 |
0 |
return fmt.Errorf("hooks: restrict %s: %w", filepath.Dir(s.socket), err) |
| 192 |
0 |
} |
| 193 |
|
|
| 194 |
32 |
if _, err := os.Stat(s.socket); err == nil { |
| 195 |
2 |
conn, dialErr := net.DialTimeout("unix", s.socket, time.Second) |
| 196 |
2 |
if dialErr == nil { |
| 197 |
1 |
conn.Close() |
| 198 |
1 |
return fmt.Errorf("hooks: %s is already served by another process", s.socket) |
| 199 |
1 |
} |
| 200 |
1 |
if err := os.Remove(s.socket); err != nil { |
| 201 |
0 |
return fmt.Errorf("hooks: remove the stale socket %s: %w", s.socket, err) |
| 202 |
0 |
} |
| 203 |
1 |
s.log.Warn("removed a stale hook socket", "socket", s.socket, "dial_error", dialErr) |
| 204 |
30 |
} else if !errors.Is(err, os.ErrNotExist) { |
| 205 |
0 |
return fmt.Errorf("hooks: stat %s: %w", s.socket, err) |
| 206 |
0 |
} |
| 207 |
|
|
| 208 |
31 |
ln, err := net.Listen("unix", s.socket) |
| 209 |
31 |
if err != nil { |
| 210 |
0 |
return fmt.Errorf("hooks: listen on %s: %w", s.socket, err) |
| 211 |
0 |
} |
| 212 |
31 |
if err := os.Chmod(s.socket, socketMode); err != nil { |
| 213 |
0 |
ln.Close() |
| 214 |
0 |
return fmt.Errorf("hooks: restrict %s: %w", s.socket, err) |
| 215 |
0 |
} |
| 216 |
31 |
s.listener = ln |
| 217 |
31 |
return nil |
| 218 |
|
} |
| 219 |
|
|
| 220 |
|
// Serve accepts hook connections until ctx is cancelled, then waits for the |
| 221 |
|
// calls already in flight. Listen must have succeeded first. |
| 222 |
30 |
func (s *Server) Serve(ctx context.Context) error { |
| 223 |
30 |
if s.listener == nil { |
| 224 |
0 |
return errors.New("hooks: Serve called before Listen") |
| 225 |
0 |
} |
| 226 |
|
|
| 227 |
30 |
done := make(chan struct{}) |
| 228 |
30 |
defer close(done) |
| 229 |
30 |
go func() { |
| 230 |
30 |
select { |
| 231 |
29 |
case <-ctx.Done(): |
| 232 |
29 |
s.listener.Close() |
| 233 |
|
case <-done: |
| 234 |
|
} |
| 235 |
|
}() |
| 236 |
|
|
| 237 |
95 |
for { |
| 238 |
95 |
conn, err := s.listener.Accept() |
| 239 |
95 |
if err != nil { |
| 240 |
30 |
s.wg.Wait() |
| 241 |
30 |
if ctx.Err() != nil || s.closing.Load() { |
| 242 |
30 |
return nil |
| 243 |
30 |
} |
| 244 |
0 |
return fmt.Errorf("hooks: accept on %s: %w", s.socket, err) |
| 245 |
|
} |
| 246 |
65 |
s.wg.Add(1) |
| 247 |
65 |
go func() { |
| 248 |
65 |
defer s.wg.Done() |
| 249 |
65 |
s.serveConn(ctx, conn) |
| 250 |
65 |
}() |
| 251 |
|
} |
| 252 |
|
} |
| 253 |
|
|
| 254 |
|
// Close stops listening and removes the socket. It is idempotent, and safe to |
| 255 |
|
// call after Serve has already returned: a daemon shuts down by cancelling |
| 256 |
|
// Serve's context and then calling this, and a daemon that failed to start |
| 257 |
|
// after Listen calls only this. |
| 258 |
32 |
func (s *Server) Close() error { |
| 259 |
32 |
if s.listener == nil { |
| 260 |
0 |
return nil |
| 261 |
0 |
} |
| 262 |
32 |
s.closing.Store(true) |
| 263 |
32 |
err := s.listener.Close() |
| 264 |
32 |
if errors.Is(err, net.ErrClosed) { |
| 265 |
30 |
err = nil |
| 266 |
30 |
} |
| 267 |
32 |
s.wg.Wait() |
| 268 |
32 |
// net's unix listener unlinks the socket itself; removing it again is |
| 269 |
32 |
// tolerated so a listener built elsewhere is still cleaned up. |
| 270 |
32 |
if rmErr := os.Remove(s.socket); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { |
| 271 |
0 |
if err == nil { |
| 272 |
0 |
err = fmt.Errorf("hooks: remove %s: %w", s.socket, rmErr) |
| 273 |
0 |
} |
| 274 |
|
} |
| 275 |
32 |
return err |
| 276 |
|
} |
| 277 |
|
|
| 278 |
65 |
func (s *Server) serveConn(ctx context.Context, conn net.Conn) { |
| 279 |
65 |
defer conn.Close() |
| 280 |
65 |
|
| 281 |
65 |
ctx, cancel := context.WithTimeout(ctx, s.timeout) |
| 282 |
65 |
defer cancel() |
| 283 |
65 |
if deadline, ok := ctx.Deadline(); ok { |
| 284 |
65 |
// The context bounds the handler, not the socket reads; without a |
| 285 |
65 |
// deadline on the connection a peer that connects and says nothing |
| 286 |
65 |
// holds a goroutine forever. |
| 287 |
65 |
if err := conn.SetDeadline(deadline); err != nil { |
| 288 |
0 |
s.log.Error("could not bound a hook connection", "socket", s.socket, "error", err) |
| 289 |
0 |
return |
| 290 |
0 |
} |
| 291 |
|
} |
| 292 |
|
|
| 293 |
65 |
req, err := ReadRequest(conn) |
| 294 |
65 |
if err != nil { |
| 295 |
2 |
s.log.Error("unreadable hook request", "socket", s.socket, "error", err) |
| 296 |
2 |
// The peer may be something that is not a hook at all; answer anyway, |
| 297 |
2 |
// so a hook that sent a message we could not parse still gets a |
| 298 |
2 |
// refusal rather than a closed connection it has to interpret. |
| 299 |
2 |
s.reply(conn, errorResponse("the daemon could not read the request: %v", err)) |
| 300 |
2 |
return |
| 301 |
2 |
} |
| 302 |
|
|
| 303 |
63 |
resp := s.handle(ctx, req) |
| 304 |
63 |
s.reply(conn, resp) |
| 305 |
|
} |
| 306 |
|
|
| 307 |
65 |
func (s *Server) reply(conn net.Conn, resp Response) { |
| 308 |
65 |
if err := WriteResponse(conn, resp); err != nil { |
| 309 |
1 |
s.log.Error("could not answer a hook", "socket", s.socket, "error", err) |
| 310 |
1 |
} |
| 311 |
|
} |
| 312 |
|
|
| 313 |
|
// handle answers one request. It never panics a connection into the pusher's |
| 314 |
|
// terminal: every failure becomes a Response the hook knows how to print. |
| 315 |
63 |
func (s *Server) handle(ctx context.Context, req Request) Response { |
| 316 |
63 |
if err := req.Validate(); err != nil { |
| 317 |
0 |
s.log.Error("malformed hook request", "method", req.Method, "error", err) |
| 318 |
0 |
return errorResponse("malformed %s request: %v", req.Method, err) |
| 319 |
0 |
} |
| 320 |
|
|
| 321 |
63 |
space, err := s.spaceFor(req.Repo) |
| 322 |
63 |
if err != nil { |
| 323 |
6 |
s.log.Error("hook named a repository we do not own", "repo", req.Repo, "error", err) |
| 324 |
6 |
return errorResponse("%v", err) |
| 325 |
6 |
} |
| 326 |
|
|
| 327 |
57 |
principal, resp, ok := s.principal(ctx, space, req.Credential) |
| 328 |
57 |
if !ok { |
| 329 |
8 |
return resp |
| 330 |
8 |
} |
| 331 |
|
|
| 332 |
49 |
log := s.log.With( |
| 333 |
49 |
"space", space.String(), |
| 334 |
49 |
"principal", principal.String(), |
| 335 |
49 |
"push", req.Push, |
| 336 |
49 |
"method", string(req.Method), |
| 337 |
49 |
) |
| 338 |
49 |
|
| 339 |
49 |
switch req.Method { |
| 340 |
22 |
case MethodPushOptions: |
| 341 |
22 |
return s.handlePushOptions(req, space, log) |
| 342 |
21 |
case MethodValidateRef: |
| 343 |
21 |
return s.handleValidateRef(ctx, req, space, principal, log) |
| 344 |
6 |
case MethodPushed: |
| 345 |
6 |
return s.handlePushed(ctx, req, space, log) |
| 346 |
0 |
default: |
| 347 |
0 |
// Request.Validate already rejected anything else. |
| 348 |
0 |
return errorResponse("unhandled method %q", req.Method) |
| 349 |
|
} |
| 350 |
|
} |
| 351 |
|
|
| 352 |
|
// handlePushOptions records what pre-receive saw, and refuses a push option |
| 353 |
|
// this service does not understand. |
| 354 |
22 |
func (s *Server) handlePushOptions(req Request, space core.SpaceRef, log *slog.Logger) Response { |
| 355 |
22 |
if unknown := UnknownOptions(req.Options); len(unknown) > 0 { |
| 356 |
2 |
log.Info("refused unknown push options", "options", unknown) |
| 357 |
2 |
return rejectedResponse(unknownOptionMessage(space, unknown)) |
| 358 |
2 |
} |
| 359 |
20 |
skip := SkipValidation(req.Options) |
| 360 |
20 |
if err := s.remember(req, skip); err != nil { |
| 361 |
0 |
log.Error("could not record push options", "error", err) |
| 362 |
0 |
return errorResponse("%v", err) |
| 363 |
0 |
} |
| 364 |
20 |
log.Info("push received", "refs", len(req.Updates), "skip_validation", skip) |
| 365 |
20 |
return okResponse() |
| 366 |
|
} |
| 367 |
|
|
| 368 |
|
// handleValidateRef is the whole of the rejecting path: the refs rule, then |
| 369 |
|
// frontmatter and document-id validation, both inside service.ValidatePush so |
| 370 |
|
// the push path and the API cannot drift apart. |
| 371 |
|
func (s *Server) handleValidateRef(ctx context.Context, req Request, space core.SpaceRef, |
| 372 |
21 |
principal authn.Principal, log *slog.Logger) Response { |
| 373 |
21 |
|
| 374 |
21 |
update := req.Updates[0] |
| 375 |
21 |
skip, err := s.recall(req, update) |
| 376 |
21 |
if err != nil { |
| 377 |
3 |
log.Error("no recorded pre-receive phase for this push", "ref", update.Ref, "error", err) |
| 378 |
3 |
return errorResponse("%v", err) |
| 379 |
3 |
} |
| 380 |
|
|
| 381 |
18 |
err = s.backend.ValidatePush(ctx, service.PushRequest{ |
| 382 |
18 |
Space: space, |
| 383 |
18 |
Principal: principal, |
| 384 |
18 |
Ref: update.Ref, |
| 385 |
18 |
Old: update.Old, |
| 386 |
18 |
New: update.New, |
| 387 |
18 |
SkipValidation: skip, |
| 388 |
18 |
}) |
| 389 |
18 |
var rejection *service.PushRejection |
| 390 |
18 |
switch { |
| 391 |
11 |
case err == nil: |
| 392 |
11 |
log.Info("ref accepted", "ref", update.Ref, "skip_validation", skip) |
| 393 |
11 |
return okResponse() |
| 394 |
6 |
case errors.As(err, &rejection): |
| 395 |
6 |
log.Info("ref rejected", "ref", update.Ref, "problems", len(rejection.Problems), |
| 396 |
6 |
"skippable", rejection.Skippable) |
| 397 |
6 |
return rejectedResponse(rejection.Error()) |
| 398 |
1 |
default: |
| 399 |
1 |
// Not a policy answer: Postgres down, an unreadable object, a space |
| 400 |
1 |
// with no row. The hook fails the push closed on it. |
| 401 |
1 |
log.Error("could not validate a ref", "ref", update.Ref, "error", err) |
| 402 |
1 |
return errorResponse("spec.sr.ht could not validate %s: %v", update.Ref, err) |
| 403 |
|
} |
| 404 |
|
} |
| 405 |
|
|
| 406 |
|
// handlePushed notifies the daemon that refs moved. Its answer cannot stop |
| 407 |
|
// anything — git ignores post-receive's exit status — but it is still reported |
| 408 |
|
// honestly so the hook can warn that the index is stale. |
| 409 |
6 |
func (s *Server) handlePushed(ctx context.Context, req Request, space core.SpaceRef, log *slog.Logger) Response { |
| 410 |
6 |
s.forget(req) |
| 411 |
6 |
if err := s.onPush(ctx, space, req.Updates); err != nil { |
| 412 |
1 |
log.Error("could not record a landed push", "refs", len(req.Updates), "error", err) |
| 413 |
1 |
return errorResponse("%v", err) |
| 414 |
1 |
} |
| 415 |
5 |
log.Info("push landed", "refs", len(req.Updates)) |
| 416 |
5 |
return okResponse() |
| 417 |
|
} |
| 418 |
|
|
| 419 |
|
// spaceFor turns the repository a hook is running in into a space. |
| 420 |
|
// |
| 421 |
|
// The hook's claim is never taken at face value: the path is matched against |
| 422 |
|
// this daemon's own repos root and then re-derived through gitx.DiskPath, so |
| 423 |
|
// the only paths that resolve are the ones this daemon would itself have |
| 424 |
|
// created. gitx is the single source of truth for that layout — deriving it |
| 425 |
|
// twice is how the two copies drift. |
| 426 |
63 |
func (s *Server) spaceFor(repo string) (core.SpaceRef, error) { |
| 427 |
63 |
root, err := filepath.EvalSymlinks(s.backend.ReposRoot()) |
| 428 |
63 |
if err != nil { |
| 429 |
0 |
return core.SpaceRef{}, fmt.Errorf("the repos root %s is unreadable: %w", |
| 430 |
0 |
s.backend.ReposRoot(), err) |
| 431 |
0 |
} |
| 432 |
63 |
root, err = filepath.Abs(root) |
| 433 |
63 |
if err != nil { |
| 434 |
0 |
return core.SpaceRef{}, fmt.Errorf("the repos root %s is unresolvable: %w", |
| 435 |
0 |
s.backend.ReposRoot(), err) |
| 436 |
0 |
} |
| 437 |
|
|
| 438 |
63 |
clean := filepath.Clean(repo) |
| 439 |
63 |
rel, err := filepath.Rel(root, clean) |
| 440 |
63 |
if err != nil { |
| 441 |
0 |
return core.SpaceRef{}, fmt.Errorf("%s is not under the repos root %s", repo, root) |
| 442 |
0 |
} |
| 443 |
63 |
segs := strings.Split(rel, string(filepath.Separator)) |
| 444 |
63 |
if len(segs) != 2 || segs[0] == ".." || !strings.HasPrefix(segs[0], "~") { |
| 445 |
6 |
return core.SpaceRef{}, fmt.Errorf("%s is not a space repository; "+ |
| 446 |
6 |
"this daemon serves %s/~<owner>/<space> only", repo, root) |
| 447 |
6 |
} |
| 448 |
57 |
ref, err := core.ParseSpaceRef(rel) |
| 449 |
57 |
if err != nil { |
| 450 |
0 |
return core.SpaceRef{}, fmt.Errorf("%s does not name a valid space: %w", repo, err) |
| 451 |
0 |
} |
| 452 |
57 |
if want := gitx.DiskPath(root, ref); want != clean { |
| 453 |
0 |
return core.SpaceRef{}, fmt.Errorf("%s does not name a space; %s would live at %s", |
| 454 |
0 |
repo, ref, want) |
| 455 |
0 |
} |
| 456 |
57 |
return ref, nil |
| 457 |
|
} |
| 458 |
|
|
| 459 |
|
// principal resolves the credential a hook forwarded. |
| 460 |
|
// |
| 461 |
|
// The owner is not looked up: sshd authenticated the SSH key and the forced |
| 462 |
|
// command asserted it, and there is exactly one owner on this instance, so the |
| 463 |
|
// name comes from the resolver rather than from the wire — a hook cannot name |
| 464 |
|
// somebody else. An agent's credential is checked on every push. |
| 465 |
|
// |
| 466 |
|
// An agent goes through authn.Resolver.ResolveAgent, the same call the HTTP |
| 467 |
|
// surfaces reach through their middleware. This path used to read the |
| 468 |
|
// agent_token table directly, which is exactly how it ended up accepting a |
| 469 |
|
// credential the HTTP planes had already stopped being the only door for: two |
| 470 |
|
// implementations of one question, drifting. There is one credential plane now |
| 471 |
|
// and one implementation of checking it. |
| 472 |
|
// |
| 473 |
|
// The grant check is here rather than in ValidatePush because it is about the |
| 474 |
|
// credential and not about the ref: a push by an agent is a proposal by another |
| 475 |
|
// transport, so it needs spec:propose exactly as the REST and MCP write planes |
| 476 |
|
// do. The refs rule still runs afterwards, inside ValidatePush, and still |
| 477 |
|
// confines the agent to proposals/* — a grant does not replace it and cannot |
| 478 |
|
// widen it. |
| 479 |
57 |
func (s *Server) principal(ctx context.Context, space core.SpaceRef, cred Credential) (authn.Principal, Response, bool) { |
| 480 |
57 |
owner := s.backend.Resolver().Owner() |
| 481 |
57 |
switch cred.Kind { |
| 482 |
42 |
case PrincipalOwner: |
| 483 |
42 |
return authn.Principal{Kind: authn.KindOwner, Owner: owner, CookieUser: owner}, Response{}, true |
| 484 |
15 |
case PrincipalAgent: |
| 485 |
15 |
p, err := s.backend.Resolver().ResolveAgent(ctx, cred.Token, cred.Agent, cred.Session) |
| 486 |
15 |
if err != nil { |
| 487 |
5 |
// StatusFor is the one table: a bad or foreign credential (401) and |
| 488 |
5 |
// a good one this instance has nothing to grant (403) are policy |
| 489 |
5 |
// refusals the pusher can read and act on; anything else means we |
| 490 |
5 |
// could not check, and an unanswerable check fails the push closed |
| 491 |
5 |
// rather than reading as a bad token. |
| 492 |
5 |
if status := authn.StatusFor(err); status < http.StatusInternalServerError { |
| 493 |
4 |
s.log.Warn("refused an agent push", "space", space.String(), "error", err) |
| 494 |
4 |
return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false |
| 495 |
4 |
} |
| 496 |
1 |
s.log.Error("could not validate an agent credential", "space", space.String(), "error", err) |
| 497 |
1 |
return authn.Principal{}, errorResponse( |
| 498 |
1 |
"spec.sr.ht could not check the credential presented with this push: %v", err), false |
| 499 |
|
} |
| 500 |
10 |
if err := p.Authorize(authn.ActionPropose); err != nil { |
| 501 |
3 |
s.log.Warn("refused an agent push", "space", space.String(), "error", err) |
| 502 |
3 |
return authn.Principal{}, rejectedResponse(badTokenMessage(space, err)), false |
| 503 |
3 |
} |
| 504 |
7 |
return p, Response{}, true |
| 505 |
0 |
default: |
| 506 |
0 |
// Request.Validate rejected every other spelling already. |
| 507 |
0 |
return authn.Principal{}, errorResponse("unknown principal kind %q", cred.Kind), false |
| 508 |
|
} |
| 509 |
|
} |
| 510 |
|
|
| 511 |
|
// pendingKey identifies one push: the repository plus the pid of the |
| 512 |
|
// receive-pack process every hook of that push is a child of. |
| 513 |
47 |
func pendingKey(req Request) string { return req.Repo + "\x00" + req.Push } |
| 514 |
|
|
| 515 |
|
// remember stores what pre-receive saw. It sweeps expired entries first, and |
| 516 |
|
// refuses rather than growing without bound. |
| 517 |
20 |
func (s *Server) remember(req Request, skip bool) error { |
| 518 |
20 |
now := time.Now() |
| 519 |
20 |
s.mu.Lock() |
| 520 |
20 |
defer s.mu.Unlock() |
| 521 |
20 |
s.sweepLocked(now) |
| 522 |
20 |
if len(s.pending) >= maxPendingPushes { |
| 523 |
0 |
return fmt.Errorf("spec.sr.ht is already tracking %d pushes in flight and cannot accept another", |
| 524 |
0 |
len(s.pending)) |
| 525 |
0 |
} |
| 526 |
20 |
s.pending[pendingKey(req)] = &pendingPush{ |
| 527 |
20 |
updates: append([]RefUpdate(nil), req.Updates...), |
| 528 |
20 |
skip: skip, |
| 529 |
20 |
expires: now.Add(s.optionTTL), |
| 530 |
20 |
} |
| 531 |
20 |
return nil |
| 532 |
|
} |
| 533 |
|
|
| 534 |
|
// recall answers whether validation was waived for this ref. |
| 535 |
|
// |
| 536 |
|
// The pre-receive record is required, not optional. Absence is not read as |
| 537 |
|
// "not waived": it means either that the repository's hooks are half installed |
| 538 |
|
// — no pre-receive, so no push option would ever be seen and skip-validation |
| 539 |
|
// would silently never work — or that the daemon restarted mid-push. Both |
| 540 |
|
// deserve a sentence rather than a guess. |
| 541 |
|
// |
| 542 |
|
// The recorded ref update must also match this one exactly. That is what makes |
| 543 |
|
// the pid safe as a correlation key: a recycled pid would have to be paired |
| 544 |
|
// with an identical ref, old and new object name to be mistaken for this push. |
| 545 |
21 |
func (s *Server) recall(req Request, update RefUpdate) (bool, error) { |
| 546 |
21 |
now := time.Now() |
| 547 |
21 |
s.mu.Lock() |
| 548 |
21 |
defer s.mu.Unlock() |
| 549 |
21 |
s.sweepLocked(now) |
| 550 |
21 |
|
| 551 |
21 |
entry, ok := s.pending[pendingKey(req)] |
| 552 |
21 |
if !ok { |
| 553 |
2 |
return false, fmt.Errorf("the daemon did not see the pre-receive phase of this push. "+ |
| 554 |
2 |
"Either the repository's hooks are only partly installed (all of %s must be present) "+ |
| 555 |
2 |
"or the daemon restarted mid-push; push again", |
| 556 |
2 |
strings.Join(modeNames(), ", ")) |
| 557 |
2 |
} |
| 558 |
19 |
for _, u := range entry.updates { |
| 559 |
19 |
if u == update { |
| 560 |
18 |
return entry.skip, nil |
| 561 |
18 |
} |
| 562 |
|
} |
| 563 |
1 |
return false, fmt.Errorf("the pre-receive phase of push %s did not announce %s; "+ |
| 564 |
1 |
"the daemon will not validate a ref it was not told about", req.Push, update) |
| 565 |
|
} |
| 566 |
|
|
| 567 |
|
// forget drops a push's record once post-receive has run. |
| 568 |
6 |
func (s *Server) forget(req Request) { |
| 569 |
6 |
s.mu.Lock() |
| 570 |
6 |
defer s.mu.Unlock() |
| 571 |
6 |
delete(s.pending, pendingKey(req)) |
| 572 |
6 |
s.sweepLocked(time.Now()) |
| 573 |
6 |
} |
| 574 |
|
|
| 575 |
47 |
func (s *Server) sweepLocked(now time.Time) { |
| 576 |
47 |
for k, v := range s.pending { |
| 577 |
40 |
if now.After(v.expires) { |
| 578 |
1 |
delete(s.pending, k) |
| 579 |
1 |
} |
| 580 |
|
} |
| 581 |
|
} |
| 582 |
|
|
| 583 |
2 |
func modeNames() []string { |
| 584 |
2 |
out := make([]string, 0, len(Modes())) |
| 585 |
6 |
for _, m := range Modes() { |
| 586 |
6 |
out = append(out, string(m)) |
| 587 |
6 |
} |
| 588 |
2 |
return out |
| 589 |
|
} |
| 590 |
|
|
| 591 |
|
// unknownOptionMessage is what a mistyped push option prints. It is a |
| 592 |
|
// rejection rather than a shrug because there is exactly one option in the |
| 593 |
|
// vocabulary: silently ignoring "--push-option=skip-validaton" would reject |
| 594 |
|
// the push for the very thing the human believed they had waived. |
| 595 |
2 |
func unknownOptionMessage(space core.SpaceRef, unknown []string) string { |
| 596 |
2 |
var b strings.Builder |
| 597 |
2 |
fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n") |
| 598 |
2 |
fmt.Fprintf(&b, " space: %s\n\n", space) |
| 599 |
2 |
for _, o := range unknown { |
| 600 |
2 |
fmt.Fprintf(&b, " --push-option=%s is not a push option this service knows\n", o) |
| 601 |
2 |
} |
| 602 |
2 |
fmt.Fprintf(&b, "\nThe only push option is --push-option=%s, which waives\n", OptionSkipValidation) |
| 603 |
2 |
fmt.Fprintf(&b, "frontmatter and document-id validation. Nothing was written.\n") |
| 604 |
2 |
return b.String() |
| 605 |
|
} |
| 606 |
|
|
| 607 |
|
// badTokenMessage is what an agent sees when its credential does not |
| 608 |
|
// authenticate or does not carry spec:propose. It never echoes the token. |
| 609 |
7 |
func badTokenMessage(space core.SpaceRef, cause error) string { |
| 610 |
7 |
var b strings.Builder |
| 611 |
7 |
fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n") |
| 612 |
7 |
fmt.Fprintf(&b, " space: %s\n\n", space) |
| 613 |
7 |
fmt.Fprintf(&b, " the agent credential presented with this push was refused:\n") |
| 614 |
7 |
fmt.Fprintf(&b, " %v\n\n", cause) |
| 615 |
7 |
fmt.Fprintf(&b, "Nothing was written. Agent credentials are tokens.sr.ht working tokens,\n") |
| 616 |
7 |
fmt.Fprintf(&b, "the same ones the REST and MCP planes take, and a push needs the\n") |
| 617 |
7 |
fmt.Fprintf(&b, "spec:propose grant just as those do.\n") |
| 618 |
7 |
return b.String() |
| 619 |
7 |
} |