hub v0.127.0: off-site key registrar (box never gets the storage password), password sealed at rest, daily key check, clean-up window (shipped off) — decisions 68-69, R-820/R-821/R-822
gates / gates (push) Successful in 29s

Part A evidence (migration spike, sftp-written repo through the pinned rclone key) and the hub
red-proofs under documentation/audits/offsite-lock-build-2026-10-03/. Manifest bump follows after
the image is built and Secret/offsite-secret-key exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-03 16:56:42 +02:00
parent 5188dbdb44
commit f417cdede1
28 changed files with 2338 additions and 49 deletions
+54 -10
View File
@@ -65,16 +65,21 @@ type Server struct {
versionChecker *VersionChecker
templateFetcher *TemplateFetcher
assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
offsiteBox func() (monitor.BoxSnapshot, bool) // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor
pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
bindResendMu sync.Mutex // R-719: the fresh-link resend limiter
bindResendAt map[string]time.Time // customer → last fresh-link mail (R-719)
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
// offsiteKeyAudit runs the daily off-site key check on demand (decision 69). nil → 503.
offsiteKeyAudit func(ctx context.Context) any
// offsiteWindowAdmin: operator one-shot grant / weekly switch (decision 68). nil → 503.
offsiteWindowGrant func(customerID string) error
offsiteWindowSwitch func(on bool) error
offsiteBox func() (monitor.BoxSnapshot, bool) // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor
pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27)
bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
bindResendMu sync.Mutex // R-719: the fresh-link resend limiter
bindResendAt map[string]time.Time // customer → last fresh-link mail (R-719)
// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
@@ -194,6 +199,14 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
// SetOffsiteKeyAudit wires the on-demand run of the daily off-site key check (decision 69).
func (s *Server) SetOffsiteKeyAudit(fn func(ctx context.Context) any) { s.offsiteKeyAudit = fn }
// SetOffsiteWindowAdmin wires the operator's window controls (decision 68).
func (s *Server) SetOffsiteWindowAdmin(grant func(string) error, sw func(bool) error) {
s.offsiteWindowGrant, s.offsiteWindowSwitch = grant, sw
}
// SetOffsiteBox wires the pool-box aggregate snapshot accessor (v0.64.0, R-5): the checker's cached
// snapshot, read on the Offsite tab + the Dashboard tile. nil (no HETZNER_TOKEN/box id) → both render an
// honest "not configured". The web layer NEVER fetches from Hetzner — it only reads this cache.
@@ -604,6 +617,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
s.handleConfigEditForm(w, r, customerID)
}
case strings.HasPrefix(path, "/offsite/window-grant/") || path == "/offsite/windows-enabled":
// Operator (decision 68): a one-shot grant lets the customer's NEXT window request through;
// the switch turns the WEEKLY window on/off fleet-wide (off = the interim: nothing prunes).
if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
var err error
if path == "/offsite/windows-enabled" {
err = s.offsiteWindowSwitch(r.FormValue("on") == "1")
} else {
err = s.offsiteWindowGrant(strings.TrimPrefix(path, "/offsite/window-grant/"))
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{\"ok\":true}\n"))
case path == "/offsite/key-audit":
// Operator: run the daily off-site key check now (decision 69). Same code path as the 07:10 job.
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if s.offsiteKeyAudit == nil {
http.Error(w, "off-site key check not configured", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.offsiteKeyAudit(r.Context()))
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-reissue"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/offsite-reissue")