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, func(ctx context.Context, open *stores, in request) (string, error) { return assign(ctx, open, in.Node, in.Module) })) mux.HandleFunc("POST /unassign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) { return unassign(ctx, open, in.Node, in.Module) })) // 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 and "+ "POST /unassign", r.Method, r.URL.Path)) }) return mux } type request struct { Node string `json:"node"` Module string `json:"module"` } // acting is the shape every route shares: authenticate, read, act, answer. func acting( who Authenticator, 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 == "" || in.Module == "" { refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are 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()}) }