hub v0.37.0: offsite provisioning SLICE 1 — Cloud-API client + provisioning core
Hetzner storage-box provisioning against api.hetzner.com/v1 (NOT .cloud).
internal/hetznerapi (typed client + CloudAPI interface + Fake + WaitAction);
internal/offsite (Provisioner.ProvisionOffsite — idempotent by label, shared
sub-account/dedicated box, transient password, non-secret Descriptor,
fail-closed); one_time_secrets store (single-use Save/Consume); POST
/offsite/consume-password/{id} (customer-key auth, once); config-form Offsite
section → applyOffsite (502+no-save on error) → descriptor in ConfigJSON →
version bump. Token/passwords never logged/committed/in ConfigJSON. Tested vs a
faked Cloud API + fail-closed red-proof. NOT yet live-provisioned (needs the
dedicated-project scoped token; current token can delete ep0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -244,6 +244,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||
customerID := strings.TrimPrefix(path, "/config/")
|
||||
h.handleConfigRetrieve(w, r, customerID)
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
|
||||
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
|
||||
h.handleOffsiteConsumePassword(w, r, customerID)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
|
||||
customerID := strings.TrimPrefix(path, "/artifacts/")
|
||||
h.handleArtifactManifest(w, r, customerID)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// handleOffsiteConsumePassword serves the one-time transient offsite password to the controller EXACTLY
|
||||
// ONCE (SLICE 1; SLICE 2 controller consumes it, installs its key, then the hub resets the box password).
|
||||
// Auth = the customer's API key (same credential as config-pull); the token's customer must match the
|
||||
// path. The value is returned once then marked consumed — a second call 404s. NEVER logged.
|
||||
func (h *Handler) handleOffsiteConsumePassword(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
|
||||
if !ok || (!isGlobal && authCustomerID != customerID) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
pw, err := h.store.ConsumeOneTimeSecret(customerID)
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "no unconsumed offsite password", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] offsite consume-password %s: %v", customerID, err) // no secret
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"password": pw}) // one-time; never logged
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// The one-time offsite password is served once to the authenticated customer, then 404s; a wrong/absent or
|
||||
// cross-customer key is rejected.
|
||||
func TestOffsite_ConsumePassword(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
|
||||
st.SaveOneTimeSecret("c1", "the-transient-pw")
|
||||
|
||||
do := func(token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/offsite/consume-password/c1", nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
if rr := do(""); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no auth → %d, want 401", rr.Code)
|
||||
}
|
||||
if rr := do("wrongkey"); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong key → %d, want 401", rr.Code)
|
||||
}
|
||||
if rr := do("ckey2"); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("cross-customer key → %d, want 401", rr.Code)
|
||||
}
|
||||
// first (authorized) consume → 200 + the password
|
||||
rr := do("ckey")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("consume → %d, want 200", rr.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || body["password"] != "the-transient-pw" {
|
||||
t.Fatalf("body = %q (%v)", rr.Body.String(), err)
|
||||
}
|
||||
// second consume → 404 (single use)
|
||||
if rr2 := do("ckey"); rr2.Code != http.StatusNotFound {
|
||||
t.Fatalf("second consume → %d, want 404", rr2.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user