// Command fluid-control serves the FLUID control-plane APIs. // // It publishes revisions and records governance decisions. It is not on the // request path: ArchitectureBlueprint.md invariant 2 requires the data plane to // keep serving when this process is down, and that separation is only real if // nothing here is reachable from the gateway. // // This surface must never be exposed to interface consumers. It is the // mechanism that evolves the interface in response to their behaviour, and a // consumer able to reach it could drive its own adaptation. package main import ( "context" "crypto/ed25519" "encoding/base64" "errors" "flag" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" _ "modernc.org/sqlite" "github.com/tegwick/fluid-core/internal/contract" "github.com/tegwick/fluid-core/internal/control" "github.com/tegwick/fluid-core/internal/evidence" "github.com/tegwick/fluid-core/internal/intent" "github.com/tegwick/fluid-core/internal/policy" "github.com/tegwick/fluid-core/internal/publish" "github.com/tegwick/fluid-core/internal/signing" ) func main() { if err := run(); err != nil { log.Fatalf("fluid-control: %v", err) } } func run() error { var ( addr = flag.String("addr", "127.0.0.1:8081", "listen address") store = flag.String("store", envOr("FLUID_STORE", "fluid.db"), "evidence store path") iface = flag.String("interface", os.Getenv("FLUID_INTERFACE"), "interface identifier") keyID = flag.String("key-id", envOr("FLUID_SIGNING_KEY_ID", "dev"), "signing key identifier") keyFile = flag.String("key-file", os.Getenv("FLUID_SIGNING_KEY"), "base64 ed25519 private key file") ephemeral = flag.Bool("ephemeral-key", false, "generate a throwaway signing key (development only)") ) flag.Parse() if *iface == "" { return errors.New("--interface is required") } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() ev, err := evidence.OpenSQLite(ctx, *store) if err != nil { return err } defer ev.Close() signer, err := loadSigner(*keyID, *keyFile, *ephemeral) if err != nil { return err } intents := intent.New(ev, contract.InterfaceID(*iface)) gate := policy.NewGate(policy.DefaultLimits()) pipeline, err := publish.New(publish.Options{ Gate: gate, Signer: signer, Store: ev, Intents: intents, }) if err != nil { return err } srv := &http.Server{ Addr: *addr, Handler: control.NewServer(control.NewRevisionAPI(ev, pipeline), control.NewIntentAPI(intents, gate)).Routes(), ReadHeaderTimeout: 10 * time.Second, } go func() { <-ctx.Done() shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdown) }() log.Printf("control plane for %s listening on %s (store %s, signing key %s)", *iface, *addr, *store, signer.KeyID()) log.Printf("public key: %s", base64.StdEncoding.EncodeToString(signer.PublicKey())) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } // loadSigner resolves the signing key. // // An ephemeral key must be asked for explicitly. Generating one silently when // none is configured would mean a deployment could sign revisions with a key // nobody trusts and never notice until the router refused them. func loadSigner(keyID, keyFile string, ephemeral bool) (*signing.Signer, error) { if keyFile != "" { raw, err := os.ReadFile(keyFile) if err != nil { return nil, fmt.Errorf("read signing key: %w", err) } decoded, err := base64.StdEncoding.DecodeString(trimSpace(string(raw))) if err != nil { return nil, fmt.Errorf("signing key is not valid base64: %w", err) } return signing.NewSigner(keyID, ed25519.PrivateKey(decoded)) } if ephemeral { signer, _, err := signing.GenerateKey(keyID) if err != nil { return nil, err } log.Print("WARNING: using an ephemeral signing key; revisions signed now " + "will not verify after a restart") return signer, nil } return nil, errors.New("no signing key: pass --key-file, or --ephemeral-key for development") } func trimSpace(s string) string { start, end := 0, len(s) for start < end && isSpace(s[start]) { start++ } for end > start && isSpace(s[end-1]) { end-- } return s[start:end] } func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }