977665d8c0
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request. - internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop; priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1). - internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family); sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches. RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove. Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
477 lines
17 KiB
Go
477 lines
17 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
|
|
)
|
|
|
|
// ── The family gate's answerer (v0.287.0, `09` §3 decisions 63 and 64; R-780) ─────────────────────────────
|
|
//
|
|
// traefik asks GET /__felhom_gate/family (forwardAuth) for every request to a family-gated app (except the anchored
|
|
// paths the template leaves to the app's own login — internal/stacks/family_gate.go writes that file). The answer:
|
|
//
|
|
// - a valid FAMILY APP COOKIE (`felhom_famgate`, host-only on the app host) → 200;
|
|
// - /__felhom_gate/fcb?t=<token> → the token (60 s, one use, bound to the host and to a family session) becomes the
|
|
// app cookie, and the browser goes back where it was going;
|
|
// - a browser GET without one → 302 to https://felhom.<domain>/__family/start?rd=<where it was going>;
|
|
// - anything else → 401 JSON.
|
|
//
|
|
// On the DASHBOARD host, outside the dashboard's own auth (CatchAllMiddleware answers them first):
|
|
//
|
|
// - /__family/start — with a valid FAMILY SESSION (`felhom_family`, Path=/__family, so the browser never even sends
|
|
// it to a dashboard page) or the household's dashboard session: a token and a 302 to the app; without: the family
|
|
// sign-in page;
|
|
// - /__family/login — a member's OWN name and password; counted per VISITOR (clientIP, R-753) and per NAME, short
|
|
// windows, never "everyone";
|
|
// - /__family/logout — ends the family session, and with it every app cookie minted from it (the store is asked on
|
|
// every request).
|
|
//
|
|
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
|
|
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
|
|
// request on every app. Pinned by internal/web/family_gate_test.go.
|
|
|
|
const (
|
|
familySessionCookie = "felhom_family"
|
|
familyAppCookie = "felhom_famgate"
|
|
familyAuthPath = "/__felhom_gate/family"
|
|
familyCallbackURI = "/__felhom_gate/fcb"
|
|
familyStartPath = "/__family/start"
|
|
familyLoginPath = "/__family/login"
|
|
familyLogoutPath = "/__family/logout"
|
|
familyCookiePath = "/__family"
|
|
familyFormLife = time.Hour
|
|
|
|
familyVisitorMax = 5
|
|
familyVisitorWindow = time.Minute
|
|
familyNameMax = 10
|
|
familyNameWindow = 10 * time.Minute
|
|
)
|
|
|
|
type familyState struct {
|
|
once sync.Once
|
|
store *family.Store
|
|
|
|
mu sync.Mutex
|
|
visitor map[string][]time.Time
|
|
name map[string][]time.Time
|
|
}
|
|
|
|
// familyStore opens the family list once. An unreadable list is logged and answers "nobody" (fail closed — the gate
|
|
// stays shut; the household still passes with its dashboard session).
|
|
func (s *Server) familyStore() *family.Store {
|
|
s.fam.once.Do(func() {
|
|
s.fam.visitor = map[string][]time.Time{}
|
|
s.fam.name = map[string][]time.Time{}
|
|
if s.familyStoreOverride != nil {
|
|
s.fam.store = s.familyStoreOverride
|
|
return
|
|
}
|
|
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
|
|
return
|
|
}
|
|
st, err := family.Open(s.cfg.Paths.DataDir)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] family gate: the family list could not be read (%v) — only the household passes until it is fixed", err)
|
|
return
|
|
}
|
|
s.fam.store = st
|
|
})
|
|
return s.fam.store
|
|
}
|
|
|
|
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
|
|
func (s *Server) familyLocked(visitor, name string) bool {
|
|
s.familyStore()
|
|
now := s.gateNow()
|
|
s.fam.mu.Lock()
|
|
defer s.fam.mu.Unlock()
|
|
prune := func(m map[string][]time.Time, k string, win time.Duration) int {
|
|
var keep []time.Time
|
|
for _, t := range m[k] {
|
|
if now.Sub(t) < win {
|
|
keep = append(keep, t)
|
|
}
|
|
}
|
|
if len(keep) == 0 {
|
|
delete(m, k)
|
|
} else {
|
|
m[k] = keep
|
|
}
|
|
return len(keep)
|
|
}
|
|
v := prune(s.fam.visitor, visitor, familyVisitorWindow)
|
|
n := 0
|
|
if name != "" {
|
|
n = prune(s.fam.name, name, familyNameWindow)
|
|
}
|
|
return v >= familyVisitorMax || n >= familyNameMax
|
|
}
|
|
|
|
func (s *Server) familyFailed(visitor, name string) {
|
|
now := s.gateNow()
|
|
s.fam.mu.Lock()
|
|
defer s.fam.mu.Unlock()
|
|
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
|
|
if name != "" {
|
|
s.fam.name[name] = append(s.fam.name[name], now)
|
|
}
|
|
}
|
|
|
|
func (s *Server) familyCleared(visitor, name string) {
|
|
s.fam.mu.Lock()
|
|
defer s.fam.mu.Unlock()
|
|
delete(s.fam.visitor, visitor)
|
|
delete(s.fam.name, name)
|
|
}
|
|
|
|
// familyRDHost: the host of a return address that may be used — https, on this household's domain, a family-gated app.
|
|
func (s *Server) familyRDHost(rd string) (string, bool) {
|
|
u, err := url.Parse(rd)
|
|
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil || s.cfg == nil {
|
|
return "", false
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) {
|
|
return "", false
|
|
}
|
|
if _, found := s.stackMgr.FamilyGateHost(host); !found {
|
|
return "", false
|
|
}
|
|
return host, true
|
|
}
|
|
|
|
type familyToken struct {
|
|
Host string `json:"h"`
|
|
Sid string `json:"s"`
|
|
Exp int64 `json:"e"`
|
|
Nonce string `json:"n"`
|
|
RD string `json:"r"`
|
|
MAC string `json:"m"`
|
|
}
|
|
|
|
func (s *Server) mintFamilyToken(host, sid, rd string) string {
|
|
nb := make([]byte, 12)
|
|
_, _ = rand.Read(nb)
|
|
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
|
|
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
|
|
b, _ := json.Marshal(t)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
|
|
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
|
|
b, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return "", "", errors.New("malformed")
|
|
}
|
|
var t familyToken
|
|
if json.Unmarshal(b, &t) != nil {
|
|
return "", "", errors.New("malformed")
|
|
}
|
|
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
|
|
return "", "", errors.New("bad signature")
|
|
}
|
|
if t.Host != host {
|
|
return "", "", errors.New("for another app")
|
|
}
|
|
now := s.gateNow()
|
|
if now.Unix() > t.Exp {
|
|
return "", "", errors.New("expired")
|
|
}
|
|
s.gateKey()
|
|
s.gate.mu.Lock()
|
|
defer s.gate.mu.Unlock()
|
|
for n, until := range s.gate.used {
|
|
if now.After(until) {
|
|
delete(s.gate.used, n)
|
|
}
|
|
}
|
|
if _, seen := s.gate.used["f:"+t.Nonce]; seen {
|
|
return "", "", errors.New("already used")
|
|
}
|
|
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
|
|
return t.Sid, t.RD, nil
|
|
}
|
|
|
|
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
|
|
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
|
|
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
|
|
c, err := r.Cookie(familyAppCookie)
|
|
if err != nil {
|
|
return family.Session{}, false
|
|
}
|
|
parts := strings.Split(c.Value, ".")
|
|
if len(parts) != 3 {
|
|
return family.Session{}, false
|
|
}
|
|
n, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil || s.gateNow().Unix() > n {
|
|
return family.Session{}, false
|
|
}
|
|
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
|
|
return family.Session{}, false
|
|
}
|
|
return s.familyStore().Valid(parts[0])
|
|
}
|
|
|
|
func familyRefuse(w http.ResponseWriter, code int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(code)
|
|
_, _ = w.Write([]byte(`{"error":"sign in with your family login"}`))
|
|
}
|
|
|
|
// ServeFamilyGateAuth is traefik's forwardAuth answer for a family-gated app. Like the setup gate it trusts only the
|
|
// X-Forwarded-Host/-Uri/-Method traefik writes from the request it forwards (forwardAuth's own, never the client's).
|
|
func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
|
|
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
|
|
if i := strings.LastIndex(host, ":"); i != -1 {
|
|
host = host[:i]
|
|
}
|
|
uri := r.Header.Get("X-Forwarded-Uri")
|
|
if uri == "" {
|
|
uri = "/"
|
|
}
|
|
method := r.Header.Get("X-Forwarded-Method")
|
|
if s.stackMgr == nil {
|
|
familyRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
app, found := s.stackMgr.FamilyGateHost(host)
|
|
if !found {
|
|
s.logger.Printf("[WARN] [web] family gate: asked about %q, which no family app owns — refused", host)
|
|
familyRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
|
|
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
|
|
if err == nil {
|
|
if _, ok := s.familyStore().Valid(sid); !ok {
|
|
err = errors.New("the session ended")
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] [web] family gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
|
|
familyRefuse(w, http.StatusForbidden)
|
|
return
|
|
}
|
|
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
|
|
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.Redirect(w, r, rd, http.StatusFound)
|
|
return
|
|
}
|
|
if _, ok := s.familyAppCookieSession(r, host); ok {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+familyStartPath+"?"+url.Values{"rd": {"https://" + host + uri}}.Encode(), http.StatusFound)
|
|
return
|
|
}
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] [web] family gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
|
|
}
|
|
familyRefuse(w, http.StatusUnauthorized)
|
|
}
|
|
|
|
// familySessionFromCookie: the family session this browser holds on the dashboard host.
|
|
func (s *Server) familySessionFromCookie(r *http.Request) (family.Session, bool) {
|
|
c, err := r.Cookie(familySessionCookie)
|
|
if err != nil {
|
|
return family.Session{}, false
|
|
}
|
|
return s.familyStore().Valid(c.Value)
|
|
}
|
|
|
|
// ServeFamilyStart is /__family/start on the dashboard host.
|
|
func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) {
|
|
rd := r.URL.Query().Get("rd")
|
|
host, ok := s.familyRDHost(rd)
|
|
if !ok {
|
|
s.renderFamilyPage(w, r, "", "", http.StatusOK)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
se, ok := s.familySessionFromCookie(r)
|
|
if !ok && s.hasSession(r) {
|
|
// The household's own dashboard session vouches (decision 46's rule) — a household session in the family store,
|
|
// never the dashboard session itself.
|
|
if st := s.familyStore(); st != nil {
|
|
if sid, err := st.NewSession(""); err == nil {
|
|
se, ok = family.Session{ID: sid}, true
|
|
}
|
|
}
|
|
}
|
|
if ok {
|
|
http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound)
|
|
return
|
|
}
|
|
s.renderFamilyPage(w, r, rd, "", http.StatusOK)
|
|
}
|
|
|
|
// familyFormToken is the sign-in form's own CSRF (the visitor has no session): an HMAC over its expiry.
|
|
func (s *Server) familyFormToken() string {
|
|
exp := strconv.FormatInt(s.gateNow().Add(familyFormLife).Unix(), 10)
|
|
return exp + "." + s.gateMAC("famform", exp)
|
|
}
|
|
|
|
func (s *Server) familyFormTokenValid(v string) bool {
|
|
exp, mac, ok := strings.Cut(v, ".")
|
|
n, err := strconv.ParseInt(exp, 10, 64)
|
|
if !ok || err != nil || s.gateNow().Unix() > n {
|
|
return false
|
|
}
|
|
return hmac.Equal([]byte(mac), []byte(s.gateMAC("famform", exp)))
|
|
}
|
|
|
|
// ServeFamilyLogin is /__family/login: GET = the page, POST = a member's own name and password.
|
|
func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
s.renderFamilyPage(w, r, r.URL.Query().Get("rd"), "", http.StatusOK)
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
rd := r.FormValue("rd")
|
|
if !s.familyFormTokenValid(r.FormValue("_ft")) {
|
|
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
|
|
return
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
|
visitor := rateKey(r)
|
|
if s.familyLocked(visitor, name) {
|
|
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
|
|
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
st := s.familyStore()
|
|
if st == nil || !st.Verify(name, r.FormValue("password")) {
|
|
s.familyFailed(visitor, name)
|
|
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
|
|
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
sid, err := st.NewSession(name)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] [web] family sign-in: the session could not be saved: %v", err)
|
|
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.familyCleared(visitor, name)
|
|
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: sid, Path: familyCookiePath,
|
|
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
|
|
s.logger.Printf("[INFO] [web] family sign-in: %s — visitor %s", name, visitor)
|
|
if _, ok := s.familyRDHost(rd); ok {
|
|
http.Redirect(w, r, familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound)
|
|
return
|
|
}
|
|
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
|
|
}
|
|
|
|
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
|
|
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(familySessionCookie); err == nil {
|
|
if err := s.familyStore().EndSession(c.Value); err != nil {
|
|
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
|
|
}
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
|
|
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
|
|
}
|
|
|
|
func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, notice string, code int) {
|
|
data := map[string]interface{}{"RD": rd, "Notice": notice, "FormToken": s.familyFormToken(), "Version": s.version}
|
|
if host, ok := s.familyRDHost(rd); ok {
|
|
data["Host"] = host
|
|
if app, found := s.stackMgr.FamilyGateHost(host); found {
|
|
if st, ok := s.stackMgr.GetStack(app); ok {
|
|
data["AppName"] = st.Meta.DisplayName
|
|
}
|
|
}
|
|
}
|
|
if _, ok := s.familySessionFromCookie(r); ok {
|
|
data["SignedIn"] = true
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(code)
|
|
if err := s.executeTemplateLang(w, r, "familygate", data); err != nil {
|
|
s.logger.Printf("[ERROR] [web] family page: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── the dashboard's „Család" card (authenticated, CSRF-protected) ───────────────────────────────────────────
|
|
|
|
func (s *Server) familyMembersHandler(w http.ResponseWriter, r *http.Request) {
|
|
st := s.familyStore()
|
|
if st == nil {
|
|
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
|
return
|
|
}
|
|
escrowJSON(w, http.StatusOK, map[string]any{"members": st.Names()}, "")
|
|
}
|
|
|
|
// familyMemberActionHandler: POST /family/members/{add,reset,remove} with `name`. add/reset answer the new password
|
|
// ONCE (never logged, never in a page render).
|
|
func (s *Server) familyMemberActionHandler(w http.ResponseWriter, r *http.Request, action string) {
|
|
st := s.familyStore()
|
|
if st == nil {
|
|
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
|
return
|
|
}
|
|
_ = r.ParseForm()
|
|
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
var pw string
|
|
var err error
|
|
switch action {
|
|
case "add":
|
|
pw, err = st.Add(name)
|
|
case "reset":
|
|
pw, err = st.Reset(name)
|
|
case "remove":
|
|
err = st.Remove(name)
|
|
default:
|
|
escrowJSON(w, http.StatusNotFound, nil, "")
|
|
return
|
|
}
|
|
switch {
|
|
case errors.Is(err, family.ErrBadName):
|
|
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "family_gate.msg.bad_name"))
|
|
return
|
|
case errors.Is(err, family.ErrExists):
|
|
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "family_gate.msg.exists"))
|
|
return
|
|
case errors.Is(err, family.ErrNoMember):
|
|
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "family_gate.msg.no_member"))
|
|
return
|
|
case err != nil:
|
|
s.logger.Printf("[ERROR] [web] family %s %q: %v", action, name, err)
|
|
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "family_gate.msg.store_unreadable"))
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] [web] family: the household's %s of %q from %s (password never logged)", action, name, clientIP(r))
|
|
out := map[string]any{"name": name, "members": st.Names()}
|
|
if pw != "" {
|
|
out["password"] = pw
|
|
}
|
|
escrowJSON(w, http.StatusOK, out, "")
|
|
}
|