A node holds its link open, and applies what the mesh signs
The loop the whole thing exists for: told, apply, report. `run` holds one outbound connection open and consumes the node's own queue. Every declaration is verified against the control plane's signing key before a byte of it is read as an instruction -- not once at connect, every time. The transport being pinned is a different question from the instruction being genuine, and pinning only the first would make the second transitive: a compromised broker could forge declarations, and this host applies whatever the link delivers. Malformed and forged are reported differently, because ADR 0004 requires a host to tell "this is not from the mesh I joined" from "this is broken". One means somebody is trying and the other means something needs fixing. A node now keeps what it needs to come back on its own: the broker's address and fingerprint, the signing key it believes, and its own broker password -- which the mesh issues at enrolment to replace the token's secret, so the one-time thing stays one-time and the credential it holds for years is not the one that was pasted into a terminal. Verified in the lab end to end. The node enrolled, held its link, received a signed declaration and applied it -- the file is on the machine with the right contents, and the host's own record lists both resources. That run also found issue 010, which is recorded in novox/hq: the declaration removed every container on the machine, including the control plane that sent it. Correct reconciliation, shared store, and the first thing that happens.
This commit is contained in:
@@ -224,6 +224,9 @@ func run(ctx context.Context, command string, opts options) error {
|
|||||||
case "enrol", "enroll":
|
case "enrol", "enroll":
|
||||||
return enrol(ctx, opts)
|
return enrol(ctx, opts)
|
||||||
|
|
||||||
|
case "run":
|
||||||
|
return runLink(ctx, opts)
|
||||||
|
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Println(version)
|
fmt.Println(version)
|
||||||
return nil
|
return nil
|
||||||
@@ -455,6 +458,20 @@ func enrol(ctx context.Context, opts options) error {
|
|||||||
// The mesh's name for this node wins over what the machine called itself: the token was
|
// The mesh's name for this node wins over what the machine called itself: the token was
|
||||||
// issued for a node record, and that record is what the identity binds to.
|
// issued for a node record, and that record is what the identity binds to.
|
||||||
mine.Node = reply.Node
|
mine.Node = reply.Node
|
||||||
|
mine.Membership = identity.Membership{
|
||||||
|
Broker: firstNonEmpty(reply.Broker, token.Broker),
|
||||||
|
Fingerprint: firstNonEmpty(reply.Fingerprint, token.Fingerprint),
|
||||||
|
Signer: firstNonEmpty2(reply.Signer, token.Signer),
|
||||||
|
Password: reply.Password,
|
||||||
|
}
|
||||||
|
if mine.Membership.Password == "" {
|
||||||
|
// The mesh did not replace the token's secret, so it is still this node's broker
|
||||||
|
// password. Said rather than silently kept: a one-time secret living on as a credential
|
||||||
|
// is worth knowing about.
|
||||||
|
mine.Membership.Password = token.Secret
|
||||||
|
fmt.Println("\nnote: the mesh issued no separate broker password, so the token's secret " +
|
||||||
|
"remains this node's credential")
|
||||||
|
}
|
||||||
|
|
||||||
// Saved only now, and only once the mesh has said it knows this node. A node holding an
|
// Saved only now, and only once the mesh has said it knows this node. A node holding an
|
||||||
// identity the mesh has never recorded would believe it had joined and be believed by
|
// identity the mesh has never recorded would believe it had joined and be believed by
|
||||||
@@ -465,8 +482,108 @@ func enrol(ctx context.Context, opts options) error {
|
|||||||
"That token is spent, so getting back needs a new one", reply.Node, err)
|
"That token is spent, so getting back needs a new one", reply.Node, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !mine.Membership.Joined() {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"the mesh accepted this node as %q but did not say how to reach it again, so this "+
|
||||||
|
"identity could not be used after a restart. Nothing was saved", reply.Node)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("\nenrolled as %s\n", reply.Node)
|
fmt.Printf("\nenrolled as %s\n", reply.Node)
|
||||||
fmt.Printf(" identity %s\n", identityPath)
|
fmt.Printf(" identity %s\n", identityPath)
|
||||||
fmt.Printf(" queue %s\n", reply.Queue)
|
fmt.Printf(" queue %s\n", reply.Queue)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty2(values ...[]byte) []byte {
|
||||||
|
for _, v := range values {
|
||||||
|
if len(v) > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLink holds this node's link to the mesh open, applying what arrives.
|
||||||
|
//
|
||||||
|
// One outbound connection and nothing listening. While it is up this node is enrolled; while it
|
||||||
|
// is down it is disconnected, which is an ordinary situation rather than a failure — the machine
|
||||||
|
// keeps running whatever it was last told, from its own store.
|
||||||
|
func runLink(ctx context.Context, opts options) error {
|
||||||
|
mine, err := identity.Load(identity.Path(opts.state))
|
||||||
|
if errors.Is(err, identity.ErrNoIdentity) {
|
||||||
|
return errors.New("this machine has not joined a mesh. Enrol it first: " +
|
||||||
|
"mesh-host enrol --token <token> --name <name>")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("node %s, linking to %s\n", mine.Node, mine.Membership.Broker)
|
||||||
|
|
||||||
|
apply := func(ctx context.Context, raw []byte) link.Report {
|
||||||
|
return applyDeclared(ctx, opts, raw)
|
||||||
|
}
|
||||||
|
return link.Run(ctx, link.Membership{
|
||||||
|
Node: mine.Node,
|
||||||
|
Broker: mine.Membership.Broker,
|
||||||
|
Fingerprint: mine.Membership.Fingerprint,
|
||||||
|
Password: mine.Membership.Password,
|
||||||
|
Signer: mine.Membership.Signer,
|
||||||
|
}, apply, opts.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDeclared applies a declaration that has already been proved to come from the mesh.
|
||||||
|
//
|
||||||
|
// Signature checking happens before this is called, in the link. By the time anything here runs,
|
||||||
|
// the question "is this from the mesh I joined" is settled — which is why this can treat the
|
||||||
|
// bytes as instructions.
|
||||||
|
func applyDeclared(ctx context.Context, opts options, raw []byte) link.Report {
|
||||||
|
declared, err := declaration.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return link.Report{Refused: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
built, err := system.For(builtFor)
|
||||||
|
if err != nil {
|
||||||
|
return link.Report{Refused: err.Error()}
|
||||||
|
}
|
||||||
|
if err := system.Check(built, declared); err != nil {
|
||||||
|
return link.Report{Refused: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
known, err := store.Load(opts.state)
|
||||||
|
if err != nil {
|
||||||
|
return link.Report{Refused: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := built.Confirm(ctx, apply.ExecRunner); err != nil {
|
||||||
|
return link.Report{Refused: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome, updated, applyErr := apply.Apply(ctx, built, declared, known, apply.ExecRunner, nil)
|
||||||
|
|
||||||
|
// Saved whichever way it went. Recording only on success would lose the footprint of a
|
||||||
|
// failed apply, and that footprint is on the machine either way.
|
||||||
|
if saveErr := store.Save(opts.state, updated); saveErr != nil {
|
||||||
|
return link.Report{Refused: "applied, and the node's state could not be saved: " +
|
||||||
|
saveErr.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
report := link.Report{}
|
||||||
|
for _, change := range outcome.Outcomes {
|
||||||
|
report.Applied = append(report.Applied, change.ID)
|
||||||
|
}
|
||||||
|
if applyErr != nil {
|
||||||
|
report.Failed = map[string]string{"apply": applyErr.Error()}
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
|
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
|
||||||
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
|
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
|
||||||
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
|
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
|
||||||
"192.0.2.250:5000/mesh-control@sha256:c6e96dc574ea52085bae4ed0a64e593ee512265ecb226643aa1fcbaf56d78396",
|
"192.0.2.250:5000/mesh-control@sha256:1dfcf6a879e16e671d4d6459271fd2630e30560afa1211508ab3994494786893",
|
||||||
"migrate"],
|
"migrate"],
|
||||||
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key"]
|
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key"]
|
||||||
},
|
},
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"id": "control-plane",
|
"id": "control-plane",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
"name": "mesh-control",
|
"name": "mesh-control",
|
||||||
"image": "192.0.2.250:5000/mesh-control@sha256:c6e96dc574ea52085bae4ed0a64e593ee512265ecb226643aa1fcbaf56d78396",
|
"image": "192.0.2.250:5000/mesh-control@sha256:1dfcf6a879e16e671d4d6459271fd2630e30560afa1211508ab3994494786893",
|
||||||
"network": "host",
|
"network": "host",
|
||||||
"args": ["serve"],
|
"args": ["serve"],
|
||||||
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
|
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
|
||||||
|
|||||||
@@ -28,14 +28,51 @@ func Path(statePath string) string {
|
|||||||
return filepath.Join(filepath.Dir(statePath), FileName)
|
return filepath.Join(filepath.Dir(statePath), FileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity is this node's own keypair, and the name the mesh knows it by.
|
// Identity is this node's own keypair, the name the mesh knows it by, and what it needs to get
|
||||||
|
// back to that mesh without a person.
|
||||||
|
//
|
||||||
|
// The keypair is the node's own and was never anybody else's. Everything under Membership was
|
||||||
|
// learned at enrolment and is the mesh's answer rather than this machine's — kept here because a
|
||||||
|
// node that could not reconnect after a restart without a new token would make disconnection a
|
||||||
|
// crisis instead of an ordinary situation (novox/hq ADR 0004).
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
// Node is the name in the mesh's records. Learned at enrolment, from the mesh — it is the one
|
// Node is the name in the mesh's records. Learned at enrolment — the one thing here the node
|
||||||
// thing here the node does not decide for itself.
|
// does not decide for itself.
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
|
|
||||||
Public []byte `json:"public"`
|
Public []byte `json:"public"`
|
||||||
Private []byte `json:"private"`
|
Private []byte `json:"private"`
|
||||||
|
|
||||||
|
Membership Membership `json:"membership"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership is how this node reaches the mesh it belongs to, and who it believes.
|
||||||
|
type Membership struct {
|
||||||
|
// Broker is an address, not a name: there is no resolution before the link.
|
||||||
|
Broker string `json:"broker"`
|
||||||
|
|
||||||
|
// Fingerprint is checked before anything is sent, on every connection and not only the first.
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
|
||||||
|
// Signer is the control plane's public signing key. Kept because **each declaration is
|
||||||
|
// verified by its signature, every time** (novox/hq ADR 0004) — a node that only pinned the
|
||||||
|
// broker would make the control plane's authority transitive, and a compromised broker could
|
||||||
|
// then forge declarations, which is the whole machine.
|
||||||
|
Signer []byte `json:"signer"`
|
||||||
|
|
||||||
|
// Password is this node's own broker account, issued at enrolment and belonging to it alone.
|
||||||
|
// Not the token's secret: that is spent, and a credential that lives for ever should not be
|
||||||
|
// the same string as one that was meant to be used once.
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue is where this node listens. Its account may read this and nothing else.
|
||||||
|
func (i Identity) Queue() string { return "node." + i.Node }
|
||||||
|
|
||||||
|
// Joined reports whether this identity can reach its mesh unaided.
|
||||||
|
func (m Membership) Joined() bool {
|
||||||
|
return m.Broker != "" && m.Fingerprint != "" && len(m.Signer) == ed25519.PublicKeySize &&
|
||||||
|
m.Password != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNoIdentity means this machine has not enrolled.
|
// ErrNoIdentity means this machine has not enrolled.
|
||||||
@@ -92,6 +129,13 @@ func Load(path string) (Identity, error) {
|
|||||||
if strings.TrimSpace(i.Node) == "" {
|
if strings.TrimSpace(i.Node) == "" {
|
||||||
return Identity{}, fmt.Errorf("the identity at %s names no node", path)
|
return Identity{}, fmt.Errorf("the identity at %s names no node", path)
|
||||||
}
|
}
|
||||||
|
// Checked here rather than at the moment it is used, which would be while trying to
|
||||||
|
// reconnect on a machine nobody is watching.
|
||||||
|
if !i.Membership.Joined() {
|
||||||
|
return Identity{}, fmt.Errorf(
|
||||||
|
"the identity at %s does not say how to reach its mesh, so this node cannot "+
|
||||||
|
"reconnect. It needs a new token", path)
|
||||||
|
}
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ type EnrolReply struct {
|
|||||||
Accepted bool `json:"accepted"`
|
Accepted bool `json:"accepted"`
|
||||||
Node string `json:"node,omitempty"`
|
Node string `json:"node,omitempty"`
|
||||||
Queue string `json:"queue,omitempty"`
|
Queue string `json:"queue,omitempty"`
|
||||||
|
|
||||||
|
// What this node keeps so it can come back on its own. Without these a restart would need a
|
||||||
|
// person with a new token, which would make disconnection a crisis rather than the ordinary
|
||||||
|
// situation novox/hq ADR 0004 says it is.
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Broker string `json:"broker,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Signer []byte `json:"signer,omitempty"`
|
||||||
|
|
||||||
Refusal string `json:"refusal,omitempty"`
|
Refusal string `json:"refusal,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
// The wire formats shared with the control plane, which defines them separately because this
|
||||||
|
// binary requires nothing present and does not import it. A test on each side asserts the field
|
||||||
|
// names, so a rename breaks both at once rather than on a real machine months later.
|
||||||
|
|
||||||
|
// Routing keys a node may publish. Its broker account is scoped to this exchange and its own
|
||||||
|
// queue, so it can say these things and nothing else.
|
||||||
|
const (
|
||||||
|
KeyReport = "report"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signed is a declaration and the signature over it.
|
||||||
|
//
|
||||||
|
// novox/hq ADR 0004: the transport is verified once at connect, and **each declaration is
|
||||||
|
// verified by its signature, every time**. The two are different questions — a node connects to
|
||||||
|
// the broker and takes instruction from the control plane behind it, and pinning only the first
|
||||||
|
// would make the second transitive.
|
||||||
|
//
|
||||||
|
// The signature is over Declaration exactly as it arrived, bytes unchanged. Re-encoding before
|
||||||
|
// verifying would mean checking a signature over something other than what was sent, and any
|
||||||
|
// difference in key order or spacing would break it — so the raw message is what is signed and
|
||||||
|
// what is checked.
|
||||||
|
type Signed struct {
|
||||||
|
Declaration []byte `json:"declaration"`
|
||||||
|
Signature []byte `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is what a node says after applying, and it is a statement rather than a write.
|
||||||
|
//
|
||||||
|
// A node states; the context that owns the data writes (novox/hq ADR 0006). The difference is the
|
||||||
|
// security boundary: something that can write cannot be prevented from writing anything, and
|
||||||
|
// something that can only state has its blast radius bounded by what this struct can say.
|
||||||
|
type Report struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
|
||||||
|
// Applied is what this machine now owns, by resource id.
|
||||||
|
Applied []string `json:"applied,omitempty"`
|
||||||
|
|
||||||
|
// Failed says what could not be applied, and why, in words for a person.
|
||||||
|
Failed map[string]string `json:"failed,omitempty"`
|
||||||
|
|
||||||
|
// Refused is set when the declaration was rejected whole rather than applied in part.
|
||||||
|
Refused string `json:"refused,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verified runs what Run does to a delivery body, without a broker: unmarshal, check the
|
||||||
|
// signature, and only then apply. Isolating it keeps this test about the check rather than about
|
||||||
|
// AMQP, which is tested against a real broker in the lab.
|
||||||
|
func verified(t *testing.T, signer ed25519.PublicKey, body []byte) (Report, bool) {
|
||||||
|
t.Helper()
|
||||||
|
applied := false
|
||||||
|
report := handleBody(context.Background(), Membership{Node: "anchor", Signer: signer}, body,
|
||||||
|
func(context.Context, []byte) Report {
|
||||||
|
applied = true
|
||||||
|
return Report{Applied: []string{"something"}}
|
||||||
|
})
|
||||||
|
return report, applied
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedBody(t *testing.T, private ed25519.PrivateKey, declaration string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(Signed{
|
||||||
|
Declaration: []byte(declaration),
|
||||||
|
Signature: ed25519.Sign(private, []byte(declaration)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheMeshsOwnDeclarationIsApplied(t *testing.T) {
|
||||||
|
public, private, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, applied := verified(t, public, signedBody(t, private, `{"declaration":1}`))
|
||||||
|
if !applied {
|
||||||
|
t.Fatalf("a declaration the mesh signed was not applied: %s", report.Refused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAForgedDeclarationIsNeverApplied(t *testing.T) {
|
||||||
|
// The check that stands between "the mesh changes this machine" and "anybody does". The host
|
||||||
|
// applies whatever the link delivers, so a forged declaration is the whole machine.
|
||||||
|
public, _, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, other, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, applied := verified(t, public, signedBody(t, other, `{"declaration":1}`))
|
||||||
|
if applied {
|
||||||
|
t.Fatal("a declaration signed by another key was applied")
|
||||||
|
}
|
||||||
|
if report.Refused != ErrForged.Error() {
|
||||||
|
t.Errorf("refused, but not as a forgery: %q", report.Refused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATamperedDeclarationIsNeverApplied(t *testing.T) {
|
||||||
|
// A broker that changed the declaration in flight, keeping the signature. This is what makes
|
||||||
|
// pinning the transport insufficient on its own.
|
||||||
|
public, private, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(Signed{
|
||||||
|
Declaration: []byte(`{"declaration":1,"resources":["something else entirely"]}`),
|
||||||
|
Signature: ed25519.Sign(private, []byte(`{"declaration":1}`)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, applied := verified(t, public, raw)
|
||||||
|
if applied {
|
||||||
|
t.Fatal("a declaration altered after signing was applied")
|
||||||
|
}
|
||||||
|
if report.Refused != ErrForged.Error() {
|
||||||
|
t.Errorf("refused, but not as a forgery: %q", report.Refused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAMalformedMessageIsToldApartFromAForgery(t *testing.T) {
|
||||||
|
// novox/hq ADR 0004 requires these to be distinguishable: one means somebody is trying, the
|
||||||
|
// other means something is broken, and they need different responses from a person.
|
||||||
|
public, _, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, applied := verified(t, public, []byte("this is not a message"))
|
||||||
|
if applied {
|
||||||
|
t.Fatal("something unparseable was applied")
|
||||||
|
}
|
||||||
|
if report.Refused == ErrForged.Error() {
|
||||||
|
t.Error("a malformed message was reported as a forgery; those must be distinguishable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
|
||||||
|
// The contract with the control plane, which defines these separately. A matching test lives
|
||||||
|
// there; rename a field on either side and both fail.
|
||||||
|
for _, c := range []struct {
|
||||||
|
value any
|
||||||
|
expect []string
|
||||||
|
}{
|
||||||
|
{Signed{Declaration: []byte("{}"), Signature: []byte("x")}, []string{"declaration", "signature"}},
|
||||||
|
{Report{Node: "n", Applied: []string{"a"}, Failed: map[string]string{"k": "v"}, Refused: "r"},
|
||||||
|
[]string{"node", "applied", "failed", "refused"}},
|
||||||
|
} {
|
||||||
|
raw, err := json.Marshal(c.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range c.expect {
|
||||||
|
if _, ok := fields[want]; !ok {
|
||||||
|
t.Errorf("%T has no %q field; the control plane uses that name", c.value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fields) != len(c.expect) {
|
||||||
|
t.Errorf("%T has %d fields, expected %d: %v", c.value, len(fields), len(c.expect), fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrForged is what a node returns for a declaration whose signature is not the mesh's.
|
||||||
|
//
|
||||||
|
// Its own error, and it must never be confused with a malformed message. novox/hq ADR 0004
|
||||||
|
// requires a host to tell *this is not from the mesh I joined* apart from *this is malformed*:
|
||||||
|
// the first means somebody is trying, the second means something is broken.
|
||||||
|
var ErrForged = errors.New("this declaration was not signed by the mesh this node joined")
|
||||||
|
|
||||||
|
// Membership is what a node needs to reach its mesh again, held by the caller.
|
||||||
|
type Membership struct {
|
||||||
|
Node string
|
||||||
|
Broker string
|
||||||
|
Fingerprint string
|
||||||
|
Password string
|
||||||
|
Signer ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applier is what the host does with a declaration that has been proved to come from the mesh.
|
||||||
|
type Applier func(ctx context.Context, declaration []byte) Report
|
||||||
|
|
||||||
|
// Run holds the link open, applying what arrives and reporting what happened.
|
||||||
|
//
|
||||||
|
// Outbound only, and nothing listens on this machine. The connection is the node's presence in
|
||||||
|
// the mesh: while it is up the node is enrolled, and while it is down the node is disconnected —
|
||||||
|
// which is an ordinary situation and not a failure, so this returns rather than panicking and
|
||||||
|
// leaves restarting to whatever supervises it.
|
||||||
|
func Run(ctx context.Context, m Membership, apply Applier, timeout time.Duration) error {
|
||||||
|
config, err := PinnedConfig(m.Fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("amqps://%s:%s@%s/",
|
||||||
|
url.QueryEscape(m.Node), url.QueryEscape(m.Password), m.Broker)
|
||||||
|
conn, err := amqp.DialConfig(dsn, amqp.Config{
|
||||||
|
TLSClientConfig: config,
|
||||||
|
Dial: amqp.DefaultDial(timeout),
|
||||||
|
// Kept short so a node that has silently lost its route notices, rather than holding a
|
||||||
|
// connection the broker forgot about and believing it is still in the mesh.
|
||||||
|
Heartbeat: 10 * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrWrongCertificate) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot reach the broker at %s: %w", m.Broker, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
channel, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer channel.Close()
|
||||||
|
|
||||||
|
queue := QueueFor(m.Node)
|
||||||
|
if _, err := channel.QueueDeclare(queue, true, false, false, false, nil); err != nil {
|
||||||
|
return fmt.Errorf("cannot declare this node's queue %s: %w", queue, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One at a time. A declaration is applied to a machine, and applying two at once would race
|
||||||
|
// on the same filesystem — so the broker holds the next one until this one is finished,
|
||||||
|
// where it survives a restart.
|
||||||
|
if err := channel.Qos(1, 0, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveries, err := channel.ConsumeWithContext(ctx, queue, "", false, false, false, false, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
closed := conn.NotifyClose(make(chan *amqp.Error, 1))
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case reason := <-closed:
|
||||||
|
return fmt.Errorf("the link closed: %v", reason)
|
||||||
|
case delivery, ok := <-deliveries:
|
||||||
|
if !ok {
|
||||||
|
return errors.New("the broker stopped delivering")
|
||||||
|
}
|
||||||
|
report := handle(ctx, m, apply, delivery)
|
||||||
|
publishReport(ctx, channel, m, report, timeout)
|
||||||
|
// Acknowledged after the report is published. A node that dies between applying and
|
||||||
|
// reporting leaves the declaration on the broker and applies it again on return,
|
||||||
|
// which is safe because applying is reconciliation — it converges rather than
|
||||||
|
// repeating.
|
||||||
|
_ = delivery.Ack(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(ctx context.Context, m Membership, apply Applier, delivery amqp.Delivery) Report {
|
||||||
|
return handleBody(ctx, m, delivery.Body, apply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBody is the whole of deciding whether to trust a message, separated from the broker so it
|
||||||
|
// can be tested as the security check it is rather than as message plumbing.
|
||||||
|
func handleBody(ctx context.Context, m Membership, body []byte, apply Applier) Report {
|
||||||
|
var signed Signed
|
||||||
|
if err := json.Unmarshal(body, &signed); err != nil {
|
||||||
|
return Report{Node: m.Node, Refused: "this message is not a declaration: " + err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before anything is read out of it, let alone applied. The host applies whatever the link
|
||||||
|
// delivers, so this check is the difference between the mesh changing this machine and
|
||||||
|
// anybody changing it.
|
||||||
|
if !ed25519.Verify(m.Signer, signed.Declaration, signed.Signature) {
|
||||||
|
return Report{Node: m.Node, Refused: ErrForged.Error()}
|
||||||
|
}
|
||||||
|
return apply(ctx, signed.Declaration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publishReport(ctx context.Context, channel *amqp.Channel, m Membership, report Report,
|
||||||
|
timeout time.Duration) {
|
||||||
|
report.Node = m.Node
|
||||||
|
body, err := json.Marshal(report)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publish, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
_ = channel.PublishWithContext(publish, Exchange, KeyReport, false, false,
|
||||||
|
amqp.Publishing{ContentType: "application/json", Body: body})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user