187 lines
7.3 KiB
Go
187 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// The command API: what the mesh can be asked to do, over a network.
|
|
//
|
|
// **An adapter and nothing else** (novox/hq ADR 0035). Every route here calls the same function
|
|
// the command line calls, so a refusal is the same refusal in the same words. Nothing is decided
|
|
// in this file — the moment it validates something the command line does not, the mesh has two
|
|
// answers to one question.
|
|
//
|
|
// **It refuses everything unless it was told how to know who is asking.** The board is published
|
|
// on a public name, and this is what stands behind it: an unauthenticated command surface reachable
|
|
// from the internet is authority over the mesh handed to whoever finds it. So there is no
|
|
// permissive default and no flag that removes the check — a mesh that has not been told how to
|
|
// authenticate serves nothing, loudly.
|
|
//
|
|
// The intended authenticator is an OAuth2 provider (ADR 0035), which is an ordinary module. Until
|
|
// one is configured this refuses, which is the correct behaviour rather than a placeholder: a
|
|
// surface that worked without authentication would be one somebody left running.
|
|
func apiCommand(ctx context.Context, args []string) error {
|
|
set := flag.NewFlagSet("api", flag.ContinueOnError)
|
|
listen := set.String("listen", "127.0.0.1:8081", "where to serve it")
|
|
issuer := set.String("issuer", "",
|
|
"the OAuth2 issuer whose tokens this accepts; without it, nothing is served")
|
|
if _, err := parseAround(set, args); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(*issuer) == "" {
|
|
// Refused at start rather than per request, so it is discovered by whoever ran it rather
|
|
// than by whoever finds it.
|
|
return errors.New(
|
|
"--issuer is not set, and this serves commands rather than pages: it will not run " +
|
|
"without being told whose tokens to believe. An OAuth2 provider is an ordinary " +
|
|
"module (novox/hq ADR 0035)")
|
|
}
|
|
|
|
server := &http.Server{
|
|
Addr: *listen,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
Handler: commands(mustAuthenticate(*issuer)),
|
|
}
|
|
fmt.Printf("the command API is on http://%s\n", *listen)
|
|
fmt.Printf(" it accepts tokens from %s and refuses everything else\n", *issuer)
|
|
fmt.Printf(" every route calls what the command line calls\n")
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
closing, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = server.Shutdown(closing)
|
|
}()
|
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Authenticator says whether a request may act, and as whom.
|
|
//
|
|
// An interface so the check can be driven by a test without an identity provider, and so the one
|
|
// real implementation is the only thing that has to be right.
|
|
type Authenticator interface {
|
|
// Who returns the subject a request is acting as, or an error naming why it may not.
|
|
Who(r *http.Request) (string, error)
|
|
}
|
|
|
|
// mustAuthenticate is the real one: a bearer token from the configured issuer.
|
|
//
|
|
// **Not yet verifying the signature**, and it says so rather than pretending. Verification needs
|
|
// the issuer's keys, which needs an identity provider to exist — so this refuses every request
|
|
// until that is built, which is the same answer as having no API at all and is honest about why.
|
|
func mustAuthenticate(issuer string) Authenticator { return notYet{issuer: issuer} }
|
|
|
|
type notYet struct{ issuer string }
|
|
|
|
func (n notYet) Who(*http.Request) (string, error) {
|
|
return "", fmt.Errorf(
|
|
"this mesh has no way to verify a token from %s yet: the identity provider is a module "+
|
|
"and none is running. Use the command line, which authenticates through nothing "+
|
|
"because it is already behind the machine's own login", n.issuer)
|
|
}
|
|
|
|
// commands is the routing, separate so a test can drive it without a listener.
|
|
func commands(who Authenticator) http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("POST /assign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
|
return assign(ctx, open, in.Node, in.Module)
|
|
}))
|
|
mux.HandleFunc("POST /unassign", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
|
return unassign(ctx, open, in.Node, in.Module)
|
|
}))
|
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
|
return take(ctx, open, in.Node, in.Module)
|
|
}))
|
|
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
|
}))
|
|
mux.HandleFunc("POST /adopt", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
|
return adopt(ctx, open, in.Node)
|
|
}))
|
|
|
|
// Anything else is said plainly, because a command surface answering 404 to a verb somebody
|
|
// expected is indistinguishable from one that is down.
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
refuse(w, http.StatusNotFound, fmt.Errorf(
|
|
"%s %s is not something this mesh can be asked; it accepts POST /assign, "+
|
|
"POST /unassign, POST /take, POST /converge and POST /adopt", r.Method, r.URL.Path))
|
|
})
|
|
return mux
|
|
}
|
|
|
|
type request struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
// Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the
|
|
// preview it acts on, and which module loads the mesh's filter.
|
|
Yes bool `json:"yes,omitempty"`
|
|
Digest string `json:"digest,omitempty"`
|
|
Filter string `json:"filter,omitempty"`
|
|
}
|
|
|
|
// acting is the shape every route shares: authenticate, read, act, answer.
|
|
func acting(
|
|
who Authenticator,
|
|
needsModule bool,
|
|
do func(context.Context, *stores, request) (string, error),
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := who.Who(r); err != nil {
|
|
refuse(w, http.StatusUnauthorized, err)
|
|
return
|
|
}
|
|
var in request
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
refuse(w, http.StatusBadRequest, fmt.Errorf("this is not a request this understands: %w", err))
|
|
return
|
|
}
|
|
if in.Node == "" || (needsModule && in.Module == "") {
|
|
if needsModule {
|
|
refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are needed`))
|
|
} else {
|
|
refuse(w, http.StatusBadRequest, errors.New(`"node" is needed`))
|
|
}
|
|
return
|
|
}
|
|
|
|
open, err := openStores(r.Context())
|
|
if err != nil {
|
|
refuse(w, http.StatusServiceUnavailable, err)
|
|
return
|
|
}
|
|
defer open.Close()
|
|
|
|
said, err := do(r.Context(), open, in)
|
|
if err != nil {
|
|
// **The refusal the command line would have given, unchanged.** Carrying `said` with
|
|
// it matters: an assignment that was kept and still does not resolve is two facts,
|
|
// and dropping either makes the answer wrong.
|
|
answer(w, http.StatusConflict, map[string]any{"said": said, "refused": err.Error()})
|
|
return
|
|
}
|
|
answer(w, http.StatusOK, map[string]any{"said": said})
|
|
}
|
|
}
|
|
|
|
func answer(w http.ResponseWriter, status int, body map[string]any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
}
|
|
|
|
func refuse(w http.ResponseWriter, status int, err error) {
|
|
answer(w, status, map[string]any{"refused": err.Error()})
|
|
}
|