Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/main.go
T
jochen e7ff7c3045
mesh/merge-gate head e7ff7c3045fc merged into trunk afb94d925e94 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head e7ff7c3045fc merged into trunk afb94d925e94 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0349 clause 4)
2026-10-18 18:08:50 +02:00

170 lines
5.7 KiB
Go

// mesh-provisioner-client: the provisioner's client, holder of the node-provisioner seat on every node (novox/hq
// ADR 0349 clauses 3 to 5, design 57 P5, issue 671). A service of its own, outside the tool runner, on a bus account
// of its own (`provisioner.<node>`, mesh-controller internal/broker, design 57 P3): the vault hands a secret only to
// an account of its own, and the bus grants this one its machine's keys and nothing else. It fills the templates of
// each module handed to the provisioner on its node and has the node-engine write them (client.go).
package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Credential is the client's account as the controller delivers it, its own secret `provisioner-broker`
// (mesh-controller cmd/mesh-controller issueProvisionerClient): the bus, its certificate by fingerprint, the
// machine, the user and its password.
type Credential struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
User string `json:"user"`
Password string `json:"password"`
}
func readCredential(path string) (Credential, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Credential{}, fmt.Errorf("the client's account cannot be read: %w", err)
}
var c Credential
if err := json.Unmarshal(raw, &c); err != nil {
return Credential{}, errors.New("the client's account is not the JSON the mesh delivers (url, fingerprint, " +
"node, user, password)")
}
if c.URL == "" || c.Node == "" || c.User == "" || c.Password == "" {
return Credential{}, errors.New("the client's account names no bus, machine, user or password")
}
if c.User != "provisioner."+c.Node {
return Credential{}, fmt.Errorf("the account %s is not machine %s's provisioner client's", c.User, c.Node)
}
return c, nil
}
// pinnedTo accepts exactly the certificate with this SHA-256: the mesh's bus presents one of its own, in no trust
// store (mesh-controller internal/broker PinnedToFingerprint).
func pinnedTo(want string) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // replaced by the pin below, which is stricter
MinVersion: tls.VersionTLS12,
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("the bus presented no certificate")
}
sum := sha256.Sum256(rawCerts[0])
if got := "sha256:" + hex.EncodeToString(sum[:]); got != want {
return fmt.Errorf("the bus presented a certificate this mesh does not know (%s)", got)
}
return nil
},
}
}
func main() {
credential := flag.String("credential", os.Getenv("MESH_PROVISIONER_CREDENTIAL"),
"the client's account, its own secret provisioner-broker (MESH_PROVISIONER_CREDENTIAL)")
socket := flag.String("socket", DefaultSocket, "the node-engine's socket for writing a filled file")
stateDir := flag.String("state", os.Getenv("MESH_PROVISIONER_STATE"),
"where the client keeps the templates it fetched (MESH_PROVISIONER_STATE)")
flag.Parse()
if *credential == "" || *stateDir == "" {
logf("usage: mesh-provisioner-client -credential <file> -state <dir>")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := serve(ctx, *credential, *socket, *stateDir); err != nil && ctx.Err() == nil {
logf("[provisioner-client] %v", err)
os.Exit(1)
}
}
// serve connects and runs the client until ctx ends. A run that stops — a watch ended, the bus came back — is run
// again from the current values, after a wait that grows to a minute.
func serve(ctx context.Context, credential, socket, stateDir string) error {
c, err := readCredential(credential)
if err != nil {
return err
}
back := make(chan struct{}, 1)
opts := []nats.Option{
nats.UserInfo(c.User, c.Password),
nats.Name("mesh-provisioner-client " + c.Node),
// Answers come to the account's own inbox and no other (novox/hq design 25 §4).
nats.CustomInboxPrefix("_INBOX." + c.User),
nats.MaxReconnects(-1),
nats.ReconnectHandler(func(*nats.Conn) {
select {
case back <- struct{}{}:
default:
}
}),
}
if c.Fingerprint != "" {
opts = append(opts, nats.Secure(pinnedTo(c.Fingerprint)))
}
nc, err := nats.Connect(c.URL, opts...)
if err != nil {
return fmt.Errorf("cannot reach the bus at %s as %s: %w", c.URL, c.User, err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
return err
}
// Its own key, for the vault to seal each secret to: made now, kept in memory alone.
secrets, err := NewVaultSecrets(nc, c.User)
if err != nil {
return err
}
client := &Client{Node: c.Node, JS: js, Conn: nc, Secrets: secrets,
Fill: SocketFiller{Socket: socket, Busy: time.Second, Tries: 10},
Templates: &templateStore{dir: filepath.Join(stateDir, "templates"), fetch: HTTPFetch}}
wait := time.Second
for {
run, cancel := context.WithCancel(ctx)
go func() {
select {
case <-back:
logf("[provisioner-client] the bus is back; filling from the current values")
cancel()
case <-run.Done():
}
}()
started := time.Now()
err := client.Run(run)
cancel()
if ctx.Err() != nil {
return nil
}
if time.Since(started) > time.Minute {
wait = time.Second
}
if err != nil && !errors.Is(err, context.Canceled) {
logf("[provisioner-client] stopped (%v); running again in %s", err, wait)
}
select {
case <-ctx.Done():
return nil
case <-time.After(wait):
}
wait = min(2*wait, time.Minute)
}
}