gglib_core/settings_remote.rs
1//! The remote tunnel's settings (ADR 0012): the connect side's stored
2//! pairing as one value, and the remote half of merging and validating.
3//!
4//! Split out via `#[path]`, the way `settings_validate.rs` is, and for the
5//! same reason: `settings.rs` sits exactly on its ratchet baseline, so a type
6//! that carries its own doc comment cannot live in it — and neither can the
7//! arms that grow with it. `merge` and `validate_settings` each call into
8//! here once, so a remote field added later touches `settings.rs` by one
9//! line for its declaration and nothing else.
10
11use serde::{Deserialize, Serialize};
12
13use super::{Settings, SettingsError, SettingsUpdate};
14
15/// The machine `gglib remote join` paired with, and the key that machine
16/// issued — one record, because they are one fact.
17///
18/// They were two settings rows, `remote_last_ticket` and `remote_api_key`,
19/// written independently by the same call. A key is issued *by* the machine
20/// whose one-time code was redeemed, so it means nothing apart from the
21/// ticket that names that machine: a bare-ticket dial to a second machine
22/// rewrote the ticket and left the first machine's key sitting beside it,
23/// and `gglib remote status` then reported a fully paired connection whose
24/// every request came back `401`. Holding the two in one record makes that
25/// disagreement unrepresentable rather than merely wrong, and gives the
26/// stale-key question — *whose* key is this? — an answer.
27///
28/// Written only by `gglib remote join`: there is no CLI flag and no GUI
29/// control, and `gglib config settings show` reports the key as held-or-not
30/// rather than printing it.
31/// Persisted as one `settings_kv` row holding a JSON object, the way
32/// `inference_defaults` is — camelCase inside, to match it and so that the
33/// CLI's kebab-casing of nested keys reads `remote-pairing.api-key`.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "camelCase")]
36pub struct RemotePairing {
37 /// The ticket that machine handed out, in its canonical form.
38 ///
39 /// An address rather than a credential — reaching the far side still
40 /// takes [`Self::api_key`]. It used to go stale every time that machine
41 /// ran `enable`, because each one minted a fresh identity; identities
42 /// last now, so a ticket stays good across the far machine's restarts
43 /// and a device pairs once. A later dial to the *same* machine at a new
44 /// address still replaces this and keeps the key, which is what makes
45 /// the ticket the mutable half.
46 pub ticket: String,
47
48 /// That machine's API key: its `proxy_api_key`, received by redeeming
49 /// its one-time pairing code through the tunnel.
50 ///
51 /// Not optional, deliberately. A record that could hold a ticket with no
52 /// key is the shape the desync above lived in — the half-write that lost
53 /// the binding. A dial to a machine this one holds no key for is refused
54 /// before it is made, so there is no state left for such a record to
55 /// describe.
56 pub api_key: String,
57
58 /// The model a `--remote` turn is for when the command line names none:
59 /// the one this machine last asked that machine for.
60 ///
61 /// Remembered rather than configured — there is no flag and no setting
62 /// to type it into — because the alternative was naming the model on
63 /// every turn, and the model a person asks a machine for is the one
64 /// they asked it for last time. Per pairing, not global: it is a name
65 /// in *that* machine's catalogue, and it goes with the record when the
66 /// pairing does. `#[serde(default)]` so a record written before the
67 /// field existed loads as nothing remembered yet.
68 #[serde(default)]
69 pub default_model: Option<String>,
70
71 /// The loopback port the paired machine was last reachable at here,
72 /// tried first next time so the address a client was configured
73 /// against stays the address.
74 ///
75 /// Stable rather than fixed: a port can be taken by something else
76 /// between two sessions, and `connect` then binds the next free one,
77 /// says so, and remembers *that*. `--port` pins it, and is remembered
78 /// the same way. `#[serde(default)]` for the reason the field above
79 /// gives.
80 #[serde(default)]
81 pub port: Option<u16>,
82}
83
84/// How this machine was told to put its proxy on the tunnel, kept so a
85/// restart arms it the same way.
86///
87/// Its companion is [`Settings::remote_enabled`], the switch `gglib remote
88/// enable` and `disable` set and the one thing the daemon reads at startup
89/// to decide whether to bring the tunnel back up. That field mirrors
90/// `proxy_autostart` deliberately — same shape, same tri-state, same reason:
91/// a machine you reach from elsewhere is not a feature you want to remember
92/// to switch on after every reboot. Neither is ever typed; there is no flag
93/// for either, because the flag *is* the command.
94///
95/// This record is the other half — the flags that `enable` was given, so a
96/// resumed tunnel is armed the way it was enabled. Without it a restart
97/// would quietly change behaviour, and `--allow-mcp` is a deliberate
98/// decision on one machine: silently forgetting it is the failure that
99/// matters, not the noise of remembering. Absent means never enabled.
100///
101/// The flags `gglib remote enable` accepts, and nothing else: this is a
102/// record of a decision, not a place to configure one. There is no CLI path
103/// that writes it directly and no GUI field for it — `enable` writes it
104/// whole, the way `remote_pairing` is written whole, because the flags were
105/// one decision taken at one moment and a half-applied set of them is not a
106/// state anybody asked for.
107///
108/// Persisted as one `settings_kv` row holding a JSON object, camelCase
109/// inside, matching `remote_pairing`.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
111#[serde(rename_all = "camelCase")]
112pub struct RemoteServe {
113 /// Whether requests arriving through the tunnel may reach `/mcp`.
114 ///
115 /// Off unless asked for, and the one flag here with teeth: `invoke_tool`
116 /// starts the MCP servers configured on this machine, so a leaked key
117 /// with a shell server configured is remote code execution. Surviving a
118 /// restart is the point — silently dropping it would be a security
119 /// posture that changes when nobody is looking.
120 #[serde(default)]
121 pub allow_mcp: bool,
122
123 /// A self-hosted relay URL, or `None` for n0's public relays.
124 #[serde(default)]
125 pub relay: Option<String>,
126
127 /// Whether to publish to, and resolve through, n0's discovery service.
128 ///
129 /// `true` unless `--no-discovery` was given. With a lasting identity
130 /// this matters more than it did: the ticket now outlives the session,
131 /// so a ticket minted without discovery keeps only the paths it was
132 /// minted with and stops resolving the moment this machine changes
133 /// network — for good, not until the next `enable`.
134 #[serde(default = "default_true")]
135 pub discovery: bool,
136}
137
138/// One device this machine has issued a key to.
139///
140/// The roster, and only the roster: **no key field**. A device's key is a
141/// secret and lives in the `0600` file beside the endpoint identity, not
142/// here — `gglib config settings show` prints `proxy_api_key` unmasked by
143/// design, and that output gets pasted into bug reports. One shared key
144/// there was a known cost; every device key there would quietly undo what
145/// per-device revocation is for.
146///
147/// `id` is what modelpipe is told, and it travels to the backend as
148/// `X-Modelpipe-Device` on every request that device makes, so it is
149/// generated from the CSPRNG rather than derived from the key: an
150/// identifier that falls out of a live credential is needless coupling at
151/// best. `label` is for a person to read and is sent nowhere, because
152/// modelpipe's names are `[A-Za-z0-9._-]{1,64}` and "Matt's iPhone" is not
153/// one.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155#[serde(rename_all = "camelCase")]
156pub struct Device {
157 /// The name the tunnel edge holds this device's token under.
158 pub id: String,
159
160 /// What a person calls it, when the device said so at join.
161 #[serde(default)]
162 pub label: Option<String>,
163
164 /// Unix milliseconds at which this device's invite was minted.
165 ///
166 /// Not when it redeemed one: the row and the key are written before the
167 /// code is shown, so that a device cannot end up holding a key this side
168 /// has no record of. An invite nobody redeems therefore leaves a row
169 /// behind, which is why it is listed rather than swept on a timer.
170 pub joined_at: i64,
171
172 /// Unix milliseconds at which a device redeemed this row's invite, or
173 /// `None` if none ever has.
174 ///
175 /// The counterpart to [`joined_at`](Self::joined_at), which is when the
176 /// invite was *minted*: a row with a `joined_at` and no `redeemed_at` is
177 /// an invite nobody took, and is listed as such rather than swept on a
178 /// timer. Written by the roster's writer rather than on the request
179 /// path, so it is advisory in the same way a label is — which is why a
180 /// row is only ever called never-joined when `last_seen` is empty too.
181 /// A device that has made a request has plainly joined, whatever this
182 /// says.
183 #[serde(default)]
184 pub redeemed_at: Option<i64>,
185
186 /// Unix milliseconds of the last request that arrived bearing this
187 /// device's token, or `None` if none has since the daemon started.
188 ///
189 /// Advisory, like the tunnelled request counter: it is written from a
190 /// background task rather than the request path, and a local process
191 /// that forges the marker headers can move it. Nothing is granted on
192 /// it — it exists so a person deciding what to `forget` can see which
193 /// row is still in use.
194 #[serde(default)]
195 pub last_seen: Option<i64>,
196
197 /// The fingerprint of the endpoint that redeemed this device's invite, or
198 /// `None` if none was recorded.
199 ///
200 /// A record, not a check: nothing is refused on it. A device that does
201 /// not keep its endpoint key presents a new fingerprint every time it
202 /// connects, so this says which endpoint redeemed the invite, not where
203 /// the key is used from. Written by the roster's writer with `redeemed_at`, and
204 /// advisory in the same way.
205 #[serde(default)]
206 pub peer: Option<String>,
207}
208
209/// `serde(default)` for a field whose absence means yes.
210const fn default_true() -> bool {
211 true
212}
213
214impl Settings {
215 /// Apply the remote half of `other`: every remote field, and only those.
216 pub(super) fn merge_remote(&mut self, other: &SettingsUpdate) {
217 if let Some(ref v) = other.remote_pairing {
218 self.remote_pairing.clone_from(v);
219 }
220 if let Some(v) = other.remote_enabled {
221 self.remote_enabled = v;
222 }
223 if let Some(ref v) = other.remote_serve {
224 self.remote_serve.clone_from(v);
225 }
226 if let Some(ref v) = other.remote_devices {
227 self.remote_devices.clone_from(v);
228 }
229 }
230}
231
232/// The remote half of [`validate_settings`](super::validate_settings).
233///
234/// The connect side's stored pairing, same rule on each half: a blank is
235/// neither a key nor an address, and `connect` reading one would dial
236/// nothing with nothing rather than say the pairing is gone. Clearing the
237/// record is how a pairing is forgotten.
238pub(super) fn validate_remote(settings: &Settings) -> Result<(), SettingsError> {
239 if let Some(ref pairing) = settings.remote_pairing {
240 if pairing.api_key.trim().is_empty() {
241 return Err(SettingsError::BlankRemoteApiKey);
242 }
243 if pairing.ticket.trim().is_empty() {
244 return Err(SettingsError::BlankRemoteTicket);
245 }
246 }
247 // A row whose id is not a name modelpipe will hold is a row that cannot
248 // be seeded, and the failure would land at the next `enable` rather than
249 // at the write that caused it.
250 for device in settings.remote_devices.iter().flatten() {
251 if !valid_device_id(&device.id) {
252 return Err(SettingsError::InvalidDeviceId(device.id.clone()));
253 }
254 }
255 Ok(())
256}
257
258/// modelpipe's rule for a token name, applied before a row is written
259/// rather than when the listener refuses it: ASCII letters, digits, `.`,
260/// `_` and `-`, one to sixty-four bytes.
261fn valid_device_id(id: &str) -> bool {
262 !id.is_empty()
263 && id.len() <= 64
264 && id
265 .bytes()
266 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
267}