package main // service_reach.go — the SERVICE REACH GRANT: which host may reach which // server in this machine's service slot. // // A slot server has no network (service.go); the host's tunnel forward is the // only way in. Holding the `services` caps family used to be enough to use // that forward, so every host granted `services` here reached EVERY server on // the machine, and the person never said which. A reach grant says it: host X // may reach server S on this machine until a set date. The host signs it, the // person approves it, and the forward checks it on EVERY connection // (default-deny — no grant, no connection). // // It is built exactly like a DCN store grant (dcn.go), and shares its file, // its lock and its pinned key: the same /grants.json, a `reach` // map beside `stores`, grantsMu around every load→modify→save, and the entity // key pinned per pairing by pinVerifyKey. Two things a store grant does not // do, and this one does: // - its KIND is checked, so a store grant (or any other signed record) // cannot be offered as a reach grant; and // - its SUBJECT is enforced: the grant names the host it is for, the box // installs it only from that host's own pairing, and the forward honours // it only for that pairing. A grant for host A never lets host B in. import ( "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" "os" "sort" "strconv" "strings" "time" "github.com/gorilla/websocket" ) const reachKind = "service_reach_grant" // reachDroppedMax caps the dropped-because-stopped list each pairing reports. const reachDroppedMax = 100 // reachTerms is parsed FROM the verified canonical bytes — never from anything else. type reachTerms struct { Kind string `json:"kind"` Server string `json:"server"` // this machine's hostname Slot string `json:"slot"` // the server's slot name (service.go) Subject string `json:"subject"` // the host this grant is for — the pairing's host name Expires string `json:"expires"` TTLDays int `json:"ttl_days,omitempty"` Approval string `json:"approval,omitempty"` // this box's approval, when the person confirmed it here // Mirrors (0.38.0, service_mirror.go): the upstream repos this machine may // fetch — over its OWN connection — into this server, each an https URL and // the repo it lands in. Part of what the person approves; empty = none. Mirrors []mirrorSource `json:"mirrors,omitempty"` } // mirrorSource is one approved upstream: fetch URL into server repo Repo. type mirrorSource struct { URL string `json:"url"` Repo string `json:"repo"` } // verifySignedRecord checks one record's signature under the pinned key and // returns the exact bytes that were signed. The kind-specific verifiers parse // those bytes and check their own kind; nothing else is trusted. func verifySignedRecord(pinnedKeyB64 string, rec dcnGrantRec) ([]byte, error) { pub, err := base64.StdEncoding.DecodeString(pinnedKeyB64) if err != nil || len(pub) != ed25519.PublicKeySize { return nil, fmt.Errorf("bad pinned verify key") } canonical, err := base64.StdEncoding.DecodeString(rec.TermsCanonical) if err != nil { return nil, fmt.Errorf("bad canonical terms") } sig, err := base64.StdEncoding.DecodeString(rec.Signature) if err != nil { return nil, fmt.Errorf("bad signature encoding") } if !ed25519.Verify(ed25519.PublicKey(pub), canonical, sig) { return nil, fmt.Errorf("signature does not verify") } return canonical, nil } // verifySignedUnexpired is verifySignedRecord plus the rule every signed // record here shares: it states an expiry, and it has not passed. Returns the // signed bytes for the caller to parse and check the KIND of. func verifySignedUnexpired(pinnedKeyB64 string, rec dcnGrantRec) ([]byte, error) { canonical, err := verifySignedRecord(pinnedKeyB64, rec) if err != nil { return nil, err } var e struct { Expires string `json:"expires"` } if err := json.Unmarshal(canonical, &e); err != nil { return nil, fmt.Errorf("signed terms unparsable") } exp, err := time.Parse(time.RFC3339, e.Expires) if err != nil { return nil, fmt.Errorf("grant states no expiry (open-ended terms are refused)") } if !exp.After(time.Now()) { return nil, fmt.Errorf("grant expired") } return canonical, nil } // reachVerifyRec verifies a reach grant: signature, KIND, a stated and future // expiry, and a well-formed slot and subject. Re-run on every connection. func reachVerifyRec(pinnedKeyB64 string, rec dcnGrantRec) (*reachTerms, error) { canonical, err := verifySignedRecord(pinnedKeyB64, rec) if err != nil { return nil, err } var t reachTerms if err := json.Unmarshal(canonical, &t); err != nil { return nil, fmt.Errorf("signed terms unparsable") } if t.Kind != reachKind { return nil, fmt.Errorf("signed record is a %q, not a %s", t.Kind, reachKind) } exp, err := time.Parse(time.RFC3339, t.Expires) if err != nil { return nil, fmt.Errorf("grant states no expiry (open-ended terms are refused)") } if !exp.After(time.Now()) { return nil, fmt.Errorf("grant expired") } if !serviceNameRe.MatchString(t.Slot) { return nil, fmt.Errorf("grant names no valid server") } if strings.TrimSpace(t.Subject) == "" || strings.ContainsAny(t.Subject, "|\n ") { return nil, fmt.Errorf("grant names no host") } if why := mirrorListRefusal(t.Mirrors); why != "" { return nil, fmt.Errorf("grant names a bad mirror list: %s", why) } return &t, nil } // purposeForReach is what the person approves for one reach grant, built the // same way on the host (nac_stepup.purpose_for_reach_terms). func purposeForReach(t *reachTerms) (string, error) { if why := mirrorListRefusal(t.Mirrors); why != "" { return "", fmt.Errorf("%s", why) } entries := make([]string, 0, len(t.Mirrors)) for _, m := range t.Mirrors { entries = append(entries, m.URL+">"+m.Repo) } sort.Strings(entries) if err := purposeBad([]string{t.Server, t.Slot, t.Subject}, entries); err != nil { return "", err } p := reachKind + "|" + t.Server + "|" + t.Slot + "|" + t.Subject + "|ttl:" + strconv.Itoa(t.TTLDays) // Appended only when there are mirrors, so a grant without them is // byte-for-byte the 0.36.0 purpose. if len(entries) > 0 { p += "|mirrors:" + strings.Join(entries, ",") } return p, nil } // handleServiceGrantSync installs one signed reach grant pushed by the host. // Payload (b64 JSON): {grant_id, terms_canonical, signature, verify_pubkey}. func handleServiceGrantSync(ws *websocket.Conn, dir string, args []string) { refuse := func(id int64, why string) { dcnAudit(dir, "service_grant_sync", id, "", "refused: "+why) sendMgmt(ws, 'R', []byte("service_grant_sync refused: "+why+"\n")) sendMgmtExit(ws, 126) } if len(args) < 1 { sendMgmt(ws, 'R', []byte("service_grant_sync: need \n")) sendMgmtExit(ws, 1) return } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[0])) if err != nil { sendMgmt(ws, 'R', []byte("bad base64\n")) sendMgmtExit(ws, 1) return } var payload struct { GrantID int64 `json:"grant_id"` TermsCanonical string `json:"terms_canonical"` Signature string `json:"signature"` VerifyPubkey string `json:"verify_pubkey"` } if err := json.Unmarshal(raw, &payload); err != nil || payload.GrantID <= 0 { sendMgmt(ws, 'R', []byte("bad payload json\n")) sendMgmtExit(ws, 1) return } grantsMu.Lock() defer grantsMu.Unlock() f, err := dcnLoadFile(dir) if err != nil { sendMgmt(ws, 'R', []byte("grant store unreadable\n")) sendMgmtExit(ws, 1) return } self := "" if a := mgmtAccountOf(ws); a != nil { self = a.self } if _, why := pinVerifyKey(f, self, payload.VerifyPubkey); why != "" { refuse(payload.GrantID, why) return } rec := dcnGrantRec{TermsCanonical: payload.TermsCanonical, Signature: payload.Signature} terms, err := reachVerifyRec(f.VerifyPubkey, rec) if err != nil { refuse(payload.GrantID, "grant does not verify: "+err.Error()) return } if hn, _ := os.Hostname(); terms.Server == "" || terms.Server != hn { refuse(payload.GrantID, fmt.Sprintf("grant is for server '%s', this box is '%s'", terms.Server, hn)) return } // A host installs only its OWN reach: the subject must be the pairing this // lane belongs to. A host cannot put a grant here that lets another host in. if self == "" || terms.Subject != self { refuse(payload.GrantID, fmt.Sprintf("grant is for host '%s', not the host that sent it", terms.Subject)) return } gated := false if prev, held := f.Reach[fmt.Sprint(payload.GrantID)]; !held || prev.Signature != rec.Signature { if approvalRequired(dir) { purpose, perr := purposeForReach(terms) if perr != nil { refuse(payload.GrantID, "grant refused: "+perr.Error()) return } if err := boxApproval(dir, terms.Approval, purpose); err != nil { refuse(payload.GrantID, err.Error()) return } if terms.TTLDays <= 0 { refuse(payload.GrantID, "grant states no duration (ttl_days)") return } if exp, err := time.Parse(time.RFC3339, terms.Expires); err != nil || exp.After(time.Now().Add(time.Duration(terms.TTLDays+1)*24*time.Hour)) { refuse(payload.GrantID, "grant expiry exceeds the approved duration") return } gated = true } } if f.Reach == nil { f.Reach = map[string]dcnGrantRec{} } f.Reach[fmt.Sprint(payload.GrantID)] = rec if err := dcnSaveFile(dir, f); err != nil { sendMgmt(ws, 'R', []byte("grant persist failed: "+err.Error()+"\n")) sendMgmtExit(ws, 1) return } if gated { markApprovalUsed(dir, terms.Approval) // one approval installs it once; a revoke is final } dcnAudit(dir, "service_grant_sync", payload.GrantID, terms.Slot, "installed for "+terms.Subject) b, _ := json.Marshal(map[string]any{"state": "ok", "grant_id": payload.GrantID, "slot": terms.Slot, "subject": terms.Subject, "expires": terms.Expires}) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // reachDropLocked removes one reach grant; the caller holds grantsMu. func reachDropLocked(dir string, id int64, why string) (bool, error) { f, err := dcnLoadFile(dir) if err != nil { return false, err } key := strconv.FormatInt(id, 10) if _, ok := f.Reach[key]; !ok { return false, nil } delete(f.Reach, key) if err := dcnSaveFile(dir, f); err != nil { return true, err } dcnAudit(dir, "service_grant_drop", id, "", why) return true, nil } // handleServiceGrantDrop — the host revoked a reach grant: forget it here, now. // A tighten needs no verification: it can only remove reach. func handleServiceGrantDrop(ws *websocket.Conn, dir string, args []string) { if len(args) < 1 { sendMgmt(ws, 'R', []byte("service_grant_drop: need \n")) sendMgmtExit(ws, 1) return } id, err := strconv.ParseInt(strings.TrimSpace(args[0]), 10, 64) if err != nil { sendMgmt(ws, 'R', []byte("service_grant_drop: bad grant_id\n")) sendMgmtExit(ws, 1) return } grantsMu.Lock() present, err := reachDropLocked(dir, id, "host-revoked") grantsMu.Unlock() if err != nil { sendMgmt(ws, 'R', []byte("grant drop failed: "+err.Error()+"\n")) sendMgmtExit(ws, 1) return } b, _ := json.Marshal(map[string]any{"state": "ok", "grant_id": id, "dropped": present}) sendMgmt(ws, 'O', b) sendMgmtExit(ws, 0) } // serviceReachAllowed is the forward's per-connection wall: "" when pairing // `self` holds a verified, unexpired reach grant for slot `slot` on this // machine, else the refusal. Read from disk and re-verified every time — a // revoke or an expiry takes effect on the next connection. func serviceReachAllowed(dir, self, slot string) string { if dir == "" || self == "" { return "no pairing identity for this connection" } grantsMu.Lock() f, err := dcnLoadFile(dir) grantsMu.Unlock() if err != nil { return "grant store unreadable" } if f.VerifyPubkey == "" { return "no entity key pinned on this box yet (default-deny)" } hn, _ := os.Hostname() for _, rec := range f.Reach { t, err := reachVerifyRec(f.VerifyPubkey, rec) if err != nil { continue } if t.Slot == slot && t.Subject == self && t.Server == hn { return "" } } return "no reach grant for server '" + slot + "' held by " + self + " (default-deny)" } // reachDropForSlot removes every reach grant naming `slot` from every pairing's // grant file on this machine — called when the server in that slot is stopped // (serviceDown). Returns how many were dropped. Each host is told by its next // heartbeat reconcile; its Host sees the grant gone and revokes its row. func reachDropForSlot(slot string) int { cfgs, err := loadPairings() if err != nil { return 0 } n := 0 grantsMu.Lock() defer grantsMu.Unlock() for _, c := range cfgs { dir := c.dcnDir() if dir == "" { continue } f, err := dcnLoadFile(dir) if err != nil || len(f.Reach) == 0 { continue } changed := false for key, rec := range f.Reach { var t reachTerms raw, err := base64.StdEncoding.DecodeString(rec.TermsCanonical) if err != nil || json.Unmarshal(raw, &t) != nil || t.Slot != slot { continue } delete(f.Reach, key) changed = true n++ if id, err := strconv.ParseInt(key, 10, 64); err == nil { dcnAudit(dir, "service_grant_drop", id, slot, "server stopped") f.ReachDropped = append(f.ReachDropped, id) } } if changed { if len(f.ReachDropped) > reachDroppedMax { f.ReachDropped = f.ReachDropped[len(f.ReachDropped)-reachDroppedMax:] } _ = dcnSaveFile(dir, f) } } return n } // droppedReachGrants is the enforcing report's list of reach grants this // pairing lost because their server was stopped — the host revokes those rows. func droppedReachGrants(dir string) []int64 { out := []int64{} if dir == "" { return out } if f, err := dcnLoadFile(dir); err == nil { out = append(out, f.ReachDropped...) } return out } // heldReachGrants is what the enforcing report tells the host this pairing // holds: {grant_id, signature}, so it can re-sync what is missing or stale. func heldReachGrants(dir string) []map[string]any { out := []map[string]any{} if dir == "" { return out } f, err := dcnLoadFile(dir) if err != nil { return out } ids := make([]int64, 0, len(f.Reach)) for k := range f.Reach { if id, err := strconv.ParseInt(k, 10, 64); err == nil { ids = append(ids, id) } } sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) for _, id := range ids { out = append(out, map[string]any{"grant_id": id, "signature": f.Reach[strconv.FormatInt(id, 10)].Signature}) } return out } // maybeReconcileReach is maybeReconcileGrants (reconcile.go) for reach grants: // the heartbeat reply may carry {"service_grants": {"grants": [{grant_id, // signature}]}}. Held but unlisted on TWO consecutive replies → a revoke this // box missed → dropped. ABSENT field → no instruction (never "drop all"). func maybeReconcileReach(heartbeatResponse []byte, dir string) []int64 { if dir == "" { return nil } var hb struct { ServiceGrants *struct { Grants []struct { GrantID int64 `json:"grant_id"` Signature string `json:"signature"` } `json:"grants"` } `json:"service_grants"` } if json.Unmarshal(heartbeatResponse, &hb) != nil || hb.ServiceGrants == nil { return nil } expected := map[string]bool{} for _, g := range hb.ServiceGrants.Grants { if g.GrantID > 0 { expected[strconv.FormatInt(g.GrantID, 10)] = true } } grantsMu.Lock() defer grantsMu.Unlock() f, err := dcnLoadFile(dir) if err != nil { return nil } var dropped []int64 for key := range f.Reach { ukey := "reach:" + key // its own counter namespace, apart from store ids if expected[key] { clearUnlisted(dir, ukey) continue } if bumpUnlisted(dir, ukey) < dropAfterUnlisted { continue } if id, err := strconv.ParseInt(key, 10, 64); err == nil { if ok, err := reachDropLocked(dir, id, "reconcile: not granted by host"); err == nil && ok { dropped = append(dropped, id) } } clearUnlisted(dir, ukey) } sort.Slice(dropped, func(i, j int) bool { return dropped[i] < dropped[j] }) if len(dropped) > 0 { fmt.Printf("reconcile: dropped %d reach grant(s) the host no longer lists: %v\n", len(dropped), dropped) } return dropped }