Skip to main content

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}