36c5cd5fdf
A generic ISO carries NO customer secret. The box registers itself at the hub as an unclaimed appliance; the operator binds it to a customer; the hub delivers the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path. Hub (v0.62.0): - store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only. Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard. - api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound / 200 deliver-once / 410 delivered). Passphrase read live, never logged. - web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer picker, host count display-only) + DISCARD; SSH host-key fingerprints; events. - Red-proofs: one-shot delivery + register idempotency (both proven red); 404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate. Scripts (v1.19.0): - felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase) = slice-A path, byte-identical, only branched around. Pairing (generic) = register + poll (RestartSec=30 is the poll timer); on delivery write the env 0600 and fall through to direct. Secrets + token shredded on success. - build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename, manifest mode=pairing. profiles/generic.profile (new). - test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing register/poll + delivery handoff — all green in a debian container.
150 lines
5.5 KiB
Go
150 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// R-21 slice C — the appliance register + one-shot poll delivery. The load-bearing contracts:
|
|
// - register is idempotent by (uuid, mac_set); a DIFFERENT mac-set is a distinct appliance.
|
|
// - a bound appliance's credentials are delivered EXACTLY ONCE; every later poll → 410.
|
|
// - an unknown / discarded token → 404, indistinguishable (no enumeration oracle).
|
|
|
|
func registerAppliance(t *testing.T, h *Handler, uuid string, macs []string) string {
|
|
t.Helper()
|
|
body, _ := json.Marshal(applianceRegisterReq{UUID: uuid, MACs: macs, SSHHostPubkeys: []string{"ssh-ed25519 AAAAKEY host"}})
|
|
req := httptest.NewRequest("POST", "/api/v1/appliance/register", bytes.NewReader(body))
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("register = %d (%s), want 200", rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
Token string `json:"appliance_token"`
|
|
Poll int `json:"poll_interval_sec"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(resp.Token) < 32 || resp.Poll != 30 {
|
|
t.Fatalf("bad register response: token_len=%d poll=%d", len(resp.Token), resp.Poll)
|
|
}
|
|
return resp.Token
|
|
}
|
|
|
|
func poll(t *testing.T, h *Handler, token string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", "/api/v1/appliance/poll", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
func countAppliances(t *testing.T, st *store.Store) int {
|
|
t.Helper()
|
|
list, err := st.ListUnclaimedAppliances()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(list)
|
|
}
|
|
|
|
// Scenario A: register is idempotent by (uuid, mac_set); a different mac-set is a distinct appliance.
|
|
// (Red-proof: making RegisterAppliance always-INSERT — dropping the (uuid,mac_set) upsert — makes the
|
|
// re-register assertion see 2 rows → FAIL. Verified run-fail-revert.)
|
|
func TestApplianceRegister_Idempotent(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
macs := []string{"bc:24:11:98:10:0e", "bc:24:11:98:10:0f"}
|
|
|
|
registerAppliance(t, h, "uuid-A", macs)
|
|
registerAppliance(t, h, "uuid-A", macs) // same box, re-register
|
|
if n := countAppliances(t, st); n != 1 {
|
|
t.Fatalf("re-register duplicated the appliance: %d rows, want 1", n)
|
|
}
|
|
// MAC order must not matter (normalized/sorted).
|
|
registerAppliance(t, h, "uuid-A", []string{macs[1], macs[0]})
|
|
if n := countAppliances(t, st); n != 1 {
|
|
t.Fatalf("MAC reorder duplicated the appliance: %d rows, want 1", n)
|
|
}
|
|
// Same UUID, DIFFERENT mac-set = a distinct appliance (cheap-board duplicate-UUID tiebreaker).
|
|
registerAppliance(t, h, "uuid-A", []string{"aa:aa:aa:aa:aa:aa"})
|
|
if n := countAppliances(t, st); n != 2 {
|
|
t.Fatalf("different mac-set should be a distinct appliance: %d rows, want 2", n)
|
|
}
|
|
}
|
|
|
|
// Scenario C: one-shot delivery + 410; and 404-no-oracle.
|
|
func TestAppliancePoll_OneShotAnd410(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", APIKey: "k", RetrievalPassword: "the-passphrase"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
token := registerAppliance(t, h, "uuid-C", []string{"bc:24:11:98:10:0e"})
|
|
|
|
// Unbound → 204 (keep polling), no credentials.
|
|
if rr := poll(t, h, token); rr.Code != http.StatusNoContent {
|
|
t.Fatalf("unbound poll = %d, want 204", rr.Code)
|
|
}
|
|
|
|
// Operator binds it.
|
|
list, _ := st.ListUnclaimedAppliances()
|
|
if err := st.BindAppliance(list[0].ID, "acme", "appliance", "--cores 4"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// First poll after bind: ONE delivery with the credentials.
|
|
rr := poll(t, h, token)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("bound poll = %d (%s), want 200", rr.Code, rr.Body.String())
|
|
}
|
|
var creds map[string]string
|
|
json.Unmarshal(rr.Body.Bytes(), &creds)
|
|
if creds["customer_id"] != "acme" || creds["retrieval_passphrase"] != "the-passphrase" ||
|
|
creds["mode"] != "appliance" || creds["extra_args"] != "--cores 4" {
|
|
t.Fatalf("delivery payload wrong: %+v", creds)
|
|
}
|
|
|
|
// Red-proof target: every subsequent poll → 410, NEVER re-delivers the passphrase.
|
|
// (Defeating MarkApplianceDelivered's status flip makes this re-receive 200+passphrase → FAIL.)
|
|
rr2 := poll(t, h, token)
|
|
if rr2.Code != http.StatusGone {
|
|
t.Fatalf("second poll = %d, want 410 (one-shot)", rr2.Code)
|
|
}
|
|
if strings.Contains(rr2.Body.String(), "the-passphrase") {
|
|
t.Fatal("the passphrase was re-delivered on the second poll — one-shot broken")
|
|
}
|
|
}
|
|
|
|
func TestAppliancePoll_NoOracle(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
// Unknown token → 404.
|
|
if rr := poll(t, h, "totally-unknown-token"); rr.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown token = %d, want 404", rr.Code)
|
|
}
|
|
// Registered-then-discarded → 404, indistinguishable from unknown.
|
|
token := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"})
|
|
list, _ := st.ListUnclaimedAppliances()
|
|
if err := st.DiscardAppliance(list[0].ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr := poll(t, h, token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("discarded token poll = %d, want 404 (no oracle)", rr.Code)
|
|
}
|
|
// Discard is sticky across a re-register: the box gets a token but its poll stays 404.
|
|
token2 := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"})
|
|
if rr := poll(t, h, token2); rr.Code != http.StatusNotFound {
|
|
t.Fatalf("re-registered-after-discard poll = %d, want 404 (sticky discard)", rr.Code)
|
|
}
|
|
if n := countAppliances(t, st); n != 0 {
|
|
t.Fatalf("discarded appliance still listed as unclaimed: %d", n)
|
|
}
|
|
}
|