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
170 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|