take ends its preview with a digest and --yes names it, as the flip does; a changed preview or an account older than the flip allows is refused. A module the machine holds nothing for has nothing to compare, and --yes suffices. A published port's reach is said as the machine reported it. Every secret the module holds on the machine is listed with where it came from, and one the mesh minted for a service whose data was found refuses unless --mint names it. One judgement of a module's settings against its definition, in the catalogue: settings set refuses what cannot compose or reaches nothing, naming node, module, layer and key; Compose leaves out a module whose definition moved under a stored setting, the envelope says so (left_out), plan and push say it by name, and the machine is told everything else. A stray setting no longer refuses the whole machine where it is read (issue 096). The per-machine setting networks keeps a found network for a taken container, on an adopted machine only; the container's declaration carries it and the preview names it (rule 4).
187 lines
7.4 KiB
Go
187 lines
7.4 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, takeOptions{Yes: in.Yes, Digest: in.Digest})
|
|
}))
|
|
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 and take's: do it rather than preview it, the digest
|
|
// of the preview it acts on, and (converge) 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()})
|
|
}
|