Skip to content

Commit c391c48

Browse files
author
gluck
committed
peer registry: figaro ls sees another daemon's arias
THE CLI STAYS DUMB, which is the whole design. It has one transport -- the local unix socket -- holds no credentials and resolves no URLs. Everything remote arrives as a richer ANSWER to a local call, which is why every other verb keeps working unchanged across a federation instead of each one growing an --origin flag. figaro peer add spain https://fig.kelliher.info --token-file <path> figaro peer list, and whether each one answers figaro ls merged, with a NODE column The daemon holds the peer set (peers.json beside the store, write-then- rename so a crash cannot leave a truncation that reads as 'no peers'), holds the credentials, dials outward and merges. Deliberately NOT a form: forms fork with arias, and which machines this daemon can reach is a property of the daemon rather than of any conversation inside it. A TOKEN PATH IS STORED, NEVER THE SECRET. peers.json sits in the state directory in plain JSON beside every aria; a token in it would be a token on disk in the clear. Same reason --token-file takes a path: an argument is visible in /proc and in every other user's process table. UNREACHABLE PEERS ARE REPORTED, NOT SWALLOWED. A listing that silently omits a machine it could not reach tells the reader those arias are GONE, which is a different and much worse claim than 'I could not ask'. Each peer gets a 6s budget; the local answer is never held hostage to the worst node. MULTI-AUTHENTICATOR, because the platform requires it. Caddy forward-auths only requests WITHOUT a bearer: @no_bearer not header Authorization Bearer* forward_auth @no_bearer ... so a browser arrives with Remote-User and a machine arrives with a bearer Caddy passed straight through. One listener, two populations. --authn takes an ordered list and the first that succeeds wins. The security consequence is stated and tested: a caller CHOOSES which credential to present, so a door is exactly as strong as its weakest authenticator. Serve therefore admits an exposure only when EVERY configured authenticator is legal on it -- checking that any one is would let '--authn doorkey,none' serve the world, because doorkey is fine there. The nix module flips to upstream,doorkey when a doorkeyFile is set, and loads it through LoadCredential with systemd's %d specifier rather than $CREDENTIALS_DIRECTORY, so a typo fails the unit rather than silently reading an empty path.
1 parent 9e39d29 commit c391c48

18 files changed

Lines changed: 740 additions & 40 deletions

‎api/rpc/methods.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ const (
110110

111111
// SESSIONS: finding an aria, and the pid bindings a shell holds.
112112
const (
113-
MethodList = "figaro.list"
113+
MethodList = "figaro.list"
114+
// MethodPeers reads, adds and removes the FEDERATED NODES this daemon
115+
// knows. It lives on the angelus door because peers are a property of
116+
// the daemon, not of any aria: the CLI never dials one itself.
117+
MethodPeers = "angelus.peers"
114118
MethodAttach = "figaro.attach"
115119
MethodBind = "pid.bind"
116120
MethodResolve = "pid.resolve"

‎api/rpc/session.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ type FigaroInfoResponse struct {
5252
Present string `json:"present,omitempty"`
5353
Kind string `json:"kind,omitempty"` // "conversation" | "form" | "outfit" | "null" (set in global listings)
5454

55+
// Node names the PEER this row came from, empty for local. A federated
56+
// listing without it is a pile of ids the reader cannot act on.
57+
Node string `json:"node,omitempty"`
58+
5559
// Unbound-form rows only: the form's "name" key, and: when the form is
5660
// a role (duck-typed by the key's presence): its target-aria.
5761
Name string `json:"name,omitempty"`
@@ -86,6 +90,31 @@ type ListRequest struct {
8690

8791
type ListResponse struct {
8892
Figaros []FigaroInfoResponse `json:"figaros"`
93+
// PeerErrors maps a peer name to why it could not be asked. It is
94+
// REPORTED rather than swallowed: a listing that silently omits an
95+
// unreachable machine tells the reader those arias are gone, which is a
96+
// different and much worse claim than "I could not ask".
97+
PeerErrors map[string]string `json:"peer_errors,omitempty"`
98+
}
99+
100+
// PeerSpec is one federated node as the wire carries it.
101+
type PeerSpec struct {
102+
Name string `json:"name"`
103+
URL string `json:"url"`
104+
TokenFile string `json:"token_file,omitempty"`
105+
}
106+
107+
// PeerRequest is add (Peer set), remove (Remove set), or list (neither).
108+
type PeerRequest struct {
109+
Peer *PeerSpec `json:"peer,omitempty"`
110+
Remove string `json:"remove,omitempty"`
111+
}
112+
113+
type PeerResponse struct {
114+
Peers []PeerSpec `json:"peers"`
115+
// Reachable maps a peer name to its build string, or to the error that
116+
// stopped us. Filled only when the request asked for a probe.
117+
Reachable map[string]string `json:"reachable,omitempty"`
89118
}
90119

91120
type BindRequest struct {

‎internal/angelus/angelus.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ import (
2929

3030
// Angelus is the figaro supervisor.
3131
type Angelus struct {
32+
// Peers is the federated node set. Nil on a daemon that federates with
33+
// nobody, which is the default and the common case.
34+
Peers *peers
35+
3236
Registry *Registry
3337
Handlers map[string]jkrpc.HandlerFunc // set before Run()
3438
Backend store.Backend // aria persistence
@@ -97,6 +101,7 @@ type Angelus struct {
97101
// Config holds the settings for creating an Angelus.
98102
type Config struct {
99103
RuntimeDir string // e.g. $XDG_RUNTIME_DIR/figaro
104+
StateDir string // e.g. ~/.local/state/figaro; where peers.json lives
100105
Backend store.Backend // aria persistence
101106
Settings *config.Loaded // reclamation policy; nil = defaults
102107
}
@@ -115,6 +120,12 @@ func New(cfg Config) *Angelus {
115120
uiProj: uiir.New(nil),
116121
Hubs: newHubs(),
117122
}
123+
// The peer set is durable and lives beside the store rather than in a
124+
// form: forms fork with arias, and which machines this daemon can reach
125+
// is a property of the daemon, not of any conversation inside it.
126+
if cfg.StateDir != "" {
127+
a.Peers = newPeers(cfg.StateDir)
128+
}
118129
a.UICache = aria.NewComposedCache(uiBudget(cfg.Settings), a.composeTurns, a.uiLineage)
119130
return a
120131
}

‎internal/angelus/list_handlers.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,15 @@ func (h *handlers) list(ctx context.Context, params json.RawMessage) (interface{
161161
h.fillTTL(result)
162162
}
163163

164+
// FEDERATE. The local rows are always answered even when a peer is
165+
// unreachable: a listing that blocks on the worst node is worse than one
166+
// that names it, and a listing that silently omits it is worse still --
167+
// it tells the reader those arias are gone.
168+
if h.angelus.Peers != nil {
169+
remote, errs := h.angelus.Peers.federate(ctx, req)
170+
result = append(result, remote...)
171+
return rpc.ListResponse{Figaros: result, PeerErrors: errs}, nil
172+
}
164173
return rpc.ListResponse{Figaros: result}, nil
165174
}
166175

‎internal/angelus/peer_handlers.go‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package angelus
2+
3+
// The angelus.peers handler, and the federation half of figaro.list.
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
10+
"github.com/jack-work/figaro/api/rpc"
11+
"github.com/jack-work/figaro/sdk"
12+
)
13+
14+
func (h *handlers) peersHandler(ctx context.Context, params json.RawMessage) (interface{}, error) {
15+
var req rpc.PeerRequest
16+
if len(params) > 0 {
17+
if err := json.Unmarshal(params, &req); err != nil {
18+
return nil, err
19+
}
20+
}
21+
p := h.angelus.Peers
22+
if p == nil {
23+
return nil, fmt.Errorf("this daemon has no peer registry")
24+
}
25+
26+
switch {
27+
case req.Peer != nil:
28+
if req.Peer.Name == "" || req.Peer.URL == "" {
29+
return nil, fmt.Errorf("a peer needs a name and a url")
30+
}
31+
peer := Peer{Name: req.Peer.Name, URL: req.Peer.URL, TokenFile: req.Peer.TokenFile}
32+
// FAIL AT ADD TIME, not at the first listing. An endpoint that
33+
// cannot be built -- a bad scheme, an unreadable token file -- is a
34+
// mistake the operator is standing right there to fix, and finding
35+
// it later means finding it as a mysteriously absent machine.
36+
if _, err := peer.endpoint(); err != nil {
37+
return nil, err
38+
}
39+
if err := p.put(peer); err != nil {
40+
return nil, err
41+
}
42+
case req.Remove != "":
43+
ok, err := p.remove(req.Remove)
44+
if err != nil {
45+
return nil, err
46+
}
47+
if !ok {
48+
return nil, fmt.Errorf("no peer named %q", req.Remove)
49+
}
50+
}
51+
52+
list := p.list()
53+
resp := rpc.PeerResponse{Peers: make([]rpc.PeerSpec, 0, len(list))}
54+
for _, peer := range list {
55+
resp.Peers = append(resp.Peers, rpc.PeerSpec{
56+
Name: peer.Name, URL: peer.URL, TokenFile: peer.TokenFile,
57+
})
58+
}
59+
resp.Reachable = h.probePeers(ctx, list)
60+
return resp, nil
61+
}
62+
63+
// probePeers asks each peer who it is. It is part of `peer ls` rather than a
64+
// separate verb because "which of these can I actually reach" is the only
65+
// question anyone asks of a peer list, and answering it later costs a second
66+
// round trip and a second chance to be wrong.
67+
func (h *handlers) probePeers(ctx context.Context, list []Peer) map[string]string {
68+
if len(list) == 0 {
69+
return nil
70+
}
71+
out := make(map[string]string, len(list))
72+
type res struct{ name, val string }
73+
ch := make(chan res, len(list))
74+
for _, peer := range list {
75+
go func(peer Peer) {
76+
r := res{name: peer.Name}
77+
defer func() { ch <- r }()
78+
ep, err := peer.endpoint()
79+
if err != nil {
80+
r.val = "error: " + err.Error()
81+
return
82+
}
83+
pctx, cancel := context.WithTimeout(ctx, peerFanoutTimeout)
84+
defer cancel()
85+
cli, err := sdk.DialAngelus(ep)
86+
if err != nil {
87+
r.val = "unreachable: " + err.Error()
88+
return
89+
}
90+
defer cli.Close()
91+
st, err := cli.Status(pctx)
92+
if err != nil {
93+
r.val = "error: " + err.Error()
94+
return
95+
}
96+
r.val = "ok build=" + st.Build
97+
}(peer)
98+
}
99+
for range list {
100+
r := <-ch
101+
out[r.name] = r.val
102+
}
103+
return out
104+
}

0 commit comments

Comments
 (0)