Skip to main content

netget/server/dbus/
actions.rs

1//! What the model sees of a D-Bus method call and how it answers. `dbus_return` and
2//! `dbus_error` answer the call being handled — the protocol instance carries it — and
3//! `dbus_emit_signal` may accompany either, or be injected into a connection at any time.
4use super::wire::{self, Message};
5use crate::llm::actions::{
6    protocol_trait::{ActionResult, Protocol, Server},
7    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
8};
9use crate::protocol::{log_template::LogTemplate, EventType, SpawnContext};
10use crate::state::app_state::AppState;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Value};
13use std::sync::atomic::{AtomicU32, Ordering};
14use std::sync::Arc;
15use std::sync::LazyLock;
16
17/// Serials for messages produced outside a connection's own loop (injected signals). The
18/// connection's loop counts up from 1; this range cannot reach it in any real session.
19static INJECTED_SERIAL: AtomicU32 = AtomicU32::new(0x8000_0000);
20
21pub fn next_injected_serial() -> u32 {
22    INJECTED_SERIAL
23        .fetch_add(1, Ordering::Relaxed)
24        .max(0x8000_0000)
25}
26
27#[derive(Default, Clone)]
28pub struct DbusProtocol {
29    /// The call being answered, when this instance answers one.
30    call: Option<Message>,
31    /// The connection's serial counter, so every message the answer produces has its own.
32    serials: Option<Arc<AtomicU32>>,
33}
34
35impl DbusProtocol {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// The instance that answers `call` on a connection whose next serial is `serials`.
41    pub fn for_call(call: Message, serials: Arc<AtomicU32>) -> Self {
42        Self {
43            call: Some(call),
44            serials: Some(serials),
45        }
46    }
47}
48
49pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
50    Parameter {
51        name: name.into(),
52        type_hint: type_hint.into(),
53        description: description.into(),
54        required,
55    }
56}
57
58pub fn action(
59    name: &str,
60    description: &str,
61    parameters: Vec<Parameter>,
62    example: Value,
63) -> ActionDefinition {
64    let info = match name {
65        "dbus_return" => "-> D-Bus return {signature}".to_string(),
66        "dbus_error" => "-> D-Bus error {name}: {preview(message,80)}".to_string(),
67        "dbus_emit_signal" => "-> D-Bus signal {interface}.{member} on {path}".to_string(),
68        "dbus_call" => "-> D-Bus call {destination} {interface}.{member}".to_string(),
69        _ => format!("-> D-Bus {}", name.trim_start_matches("dbus_")),
70    };
71    ActionDefinition {
72        name: name.into(),
73        description: description.into(),
74        parameters,
75        example,
76        log_template: Some(LogTemplate::new().with_info(info)),
77    }
78}
79
80pub const VALUES_HELP: &str = "Values in signature order as JSON: numbers, strings, booleans; arrays for a/(...); objects for a{...}; a variant as {\"signature\":\"s\",\"value\":\"x\"} or a plain value";
81
82pub fn return_action() -> ActionDefinition {
83    action(
84        "dbus_return",
85        "Answer the method call with a return value.",
86        vec![
87            p(
88                "signature",
89                "string",
90                "D-Bus signature of the values, e.g. \"s\", \"ai\", \"a{sv}\"; empty for none",
91                false,
92            ),
93            p("values", "array", VALUES_HELP, false),
94        ],
95        json!({"type":"dbus_return","signature":"s","values":["pong"]}),
96    )
97}
98
99pub fn error_action() -> ActionDefinition {
100    action(
101        "dbus_error",
102        "Refuse the method call with a D-Bus error.",
103        vec![
104            p(
105                "name",
106                "string",
107                "Error name, e.g. org.freedesktop.DBus.Error.InvalidArgs or one of your own",
108                true,
109            ),
110            p("message", "string", "Human-readable message", false),
111        ],
112        json!({"type":"dbus_error","name":"org.freedesktop.DBus.Error.AccessDenied","message":"not allowed"}),
113    )
114}
115
116pub fn signal_action() -> ActionDefinition {
117    action(
118        "dbus_emit_signal",
119        "Emit a signal to the peer.",
120        vec![
121            p("path", "string", "Object path the signal comes from", true),
122            p(
123                "interface",
124                "string",
125                "Interface name in dotted form, e.g. net.netget.Demo",
126                true,
127            ),
128            p(
129                "member",
130                "string",
131                "Signal name, e.g. Changed (letters, digits, underscore)",
132                true,
133            ),
134            p(
135                "signature",
136                "string",
137                "Signature of the values (empty for none)",
138                false,
139            ),
140            p("values", "array", VALUES_HELP, false),
141        ],
142        json!({"type":"dbus_emit_signal","path":"/net/netget/Demo","interface":"net.netget.Demo","member":"Changed","signature":"s","values":["ready"]}),
143    )
144}
145
146pub static METHOD_CALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
147    EventType::new(
148        "dbus_method_call",
149        "A peer called a method. Answer with dbus_return or dbus_error (unless no_reply_expected); dbus_emit_signal may accompany either.",
150        json!({"type":"dbus_return","signature":"s","values":["pong"]}),
151    )
152    .with_parameters(vec![
153        p("path", "string", "Object path the call was made on, e.g. /net/netget/Demo", true),
154        p("interface", "string", "Interface, when the caller named one", false),
155        p("member", "string", "Method name, e.g. ListNames (letters, digits, underscore)", true),
156        p("destination", "string", "Bus name the call was addressed to", false),
157        p("sender", "string", "The caller's unique name", false),
158        p("signature", "string", "D-Bus type signature of args, e.g. \"si\"", true),
159        p("args", "array", "The arguments as JSON, in signature order; variants as {signature, value}", true),
160        p("no_reply_expected", "boolean", "The caller wants no answer", true),
161    ])
162    .with_actions(vec![return_action(), error_action(), signal_action()])
163});
164
165pub static SIGNAL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
166    EventType::new(
167        "dbus_signal_received",
168        "A peer emitted a signal. Nothing has to be answered.",
169        json!({"type":"dbus_emit_signal","path":"/net/netget/Demo","interface":"net.netget.Demo","member":"Seen","signature":"","values":[]}),
170    )
171    .with_parameters(vec![
172        p("path", "string", "Object path, e.g. /org/freedesktop/DBus", true),
173        p("interface", "string", "Interface name in dotted form, e.g. org.freedesktop.DBus", true),
174        p("member", "string", "Signal name, e.g. Changed (letters, digits, underscore)", true),
175        p("sender", "string", "The emitter's unique name", false),
176        p("signature", "string", "D-Bus type signature of args, e.g. \"si\"", true),
177        p("args", "array", "The arguments as JSON", true),
178    ])
179    .with_actions(vec![signal_action()])
180});
181
182/// The message an answer action builds: a reply to `call`, or a signal.
183pub fn answer_message(
184    call: Option<&Message>,
185    action: &Value,
186    sender: Option<&str>,
187) -> Result<Message> {
188    let kind = action["type"].as_str().unwrap_or_default();
189    let values = |key: &str| -> Result<Vec<Value>> {
190        match action.get(key) {
191            None | Some(Value::Null) => Ok(vec![]),
192            Some(Value::Array(a)) => Ok(a.clone()),
193            Some(other) => bail!("{key} must be an array, not {other}"),
194        }
195    };
196    let signature = action["signature"].as_str().unwrap_or_default();
197    let mut m = match kind {
198        "dbus_return" => {
199            let call =
200                call.context("dbus_return answers a method call; there is none to answer here")?;
201            Message::reply_to(call, signature, values("values")?)
202        }
203        "dbus_error" => {
204            let call =
205                call.context("dbus_error answers a method call; there is none to answer here")?;
206            let name = action["name"].as_str().context("dbus_error needs a name")?;
207            ensure!(
208                wire::valid_interface(name),
209                "{name:?} is not a D-Bus error name (two or more dotted elements)"
210            );
211            Message::error_to(call, name, action["message"].as_str().unwrap_or(name))
212        }
213        "dbus_emit_signal" => {
214            let text = |k: &str| {
215                action[k]
216                    .as_str()
217                    .with_context(|| format!("dbus_emit_signal needs {k}"))
218            };
219            let (path, interface, member) = (text("path")?, text("interface")?, text("member")?);
220            ensure!(
221                wire::valid_object_path(path),
222                "{path:?} is not an object path"
223            );
224            Message {
225                kind: wire::SIGNAL,
226                path: Some(path.into()),
227                interface: Some(interface.into()),
228                member: Some(member.into()),
229                destination: call.and_then(|c| c.sender.clone()),
230                sender: sender.map(Into::into),
231                signature: signature.into(),
232                body: values("values")?,
233                ..Default::default()
234            }
235        }
236        other => bail!("{other:?} is not a D-Bus answer action"),
237    };
238    if m.sender.is_none() {
239        m.sender = sender.map(Into::into);
240    }
241    // Encode once now so a malformed answer is refused here, as the model's error.
242    m.serial = 1;
243    m.encode()?;
244    Ok(m)
245}
246
247impl Protocol for DbusProtocol {
248    fn protocol_name(&self) -> &'static str {
249        "D-Bus"
250    }
251    fn stack_name(&self) -> &'static str {
252        "ETH>IP>TCP>D-Bus"
253    }
254    fn keywords(&self) -> Vec<&'static str> {
255        vec!["dbus", "d-bus", "message bus", "freedesktop"]
256    }
257    fn description(&self) -> &'static str {
258        "D-Bus server: answers method calls from D-Bus clients (dbus-send, gdbus, any binding) over TCP or a Unix socket, acting as a small message bus"
259    }
260    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
261        vec![signal_action()]
262    }
263    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
264        vec![return_action(), error_action(), signal_action()]
265    }
266    fn get_event_types(&self) -> Vec<EventType> {
267        vec![METHOD_CALL_EVENT.clone(), SIGNAL_EVENT.clone()]
268    }
269    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
270        vec![
271            ParameterDefinition {
272                name: "socket_path".into(),
273                type_hint: "string".into(),
274                description: "Also listen on this Unix socket (unix:path=...), where EXTERNAL authentication checks the peer's uid".into(),
275                required: false,
276                example: json!("/tmp/netget-dbus.sock"),
277                default: None,
278            },
279            ParameterDefinition {
280                name: "allow_anonymous".into(),
281                type_hint: "boolean".into(),
282                description: "Accept SASL ANONYMOUS (the only mechanism a TCP peer can complete here)".into(),
283                required: false,
284                example: json!(false),
285                default: Some(json!(super::DEFAULT_ALLOW_ANONYMOUS)),
286            },
287            ParameterDefinition {
288                name: "idle_timeout_secs".into(),
289                type_hint: "number".into(),
290                description: "Seconds an authenticated connection may stay silent (1-86400)".into(),
291                required: false,
292                example: json!(600),
293                default: Some(json!(super::IDLE_TIMEOUT.as_secs())),
294            },
295        ]
296    }
297    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
298        use crate::protocol::metadata::*;
299        ProtocolMetadataV2::builder()
300            .state(DevelopmentState::Experimental)
301            .privilege_requirement(PrivilegeRequirement::None)
302            .implementation("Hand-rolled D-Bus 1 wire protocol (src/server/dbus/wire.rs): SASL EXTERNAL (checked against SO_PEERCRED on the Unix socket) and ANONYMOUS, both byte orders in, little-endian out, signature-driven JSON marshalling. Acts as a small bus: Hello, RequestName, ReleaseName, GetId, ListNames, NameHasOwner, GetNameOwner, AddMatch/RemoveMatch and org.freedesktop.DBus.Peer are answered in Rust; every other call goes to the model")
303            .llm_control("The answer to every method call (a return value with its signature, or a D-Bus error) and the signals sent back")
304            .e2e_testing("tests/server/dbus: dbus-send (libdbus, peer-to-peer over TCP), gdbus (GLib, as a bus client over the Unix socket) and python dbus-next (asyncio, receiving a signal); raw tests for the size and variant-depth bounds and the fail-closed error")
305            .notes("FAILS CLOSED: a call expecting a reply that the model does not answer gets org.freedesktop.DBus.Error.Failed (LimitsExceeded when the backend is overloaded), never silence. Messages over 1 MiB and values nested past 32 (variants count; the specification allows 64, but NetGet's event pipeline holds 64 JSON levels) close the connection. No Unix file descriptors, no DBUS_COOKIE_SHA1, no routing between peers: each connection talks to NetGet only.")
306            .answers_on_failure()
307            .max_inbound_bytes(wire::MAX_MESSAGE_BYTES)
308            .build()
309    }
310    fn example_prompt(&self) -> &'static str {
311        "Run a D-Bus server where net.netget.Demo.Ping answers pong and everything else is UnknownMethod"
312    }
313    fn get_startup_examples(&self) -> StartupExamples {
314        let base = json!({"type":"open_server","base_stack":"dbus","port":0,
315            "instruction":"Answer net.netget.Demo.Ping with the string pong; refuse anything else with org.freedesktop.DBus.Error.UnknownMethod"});
316        let mut scripted = base.clone();
317        scripted["event_handlers"] = json!([{"event_pattern":"dbus_method_call","handler":{"type":"script","language":"python",
318            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'dbus_return','signature':'s','values':['pong']}] if e['member']=='Ping' else [{'type':'dbus_error','name':'org.freedesktop.DBus.Error.UnknownMethod','message':e['member']}]}))"}}]);
319        let mut static_example = base.clone();
320        static_example["event_handlers"] = json!([{"event_pattern":"dbus_method_call","handler":{"type":"static","actions":[{"type":"dbus_return","signature":"s","values":["pong"]}]}}]);
321        StartupExamples::new(base, scripted, static_example)
322    }
323    fn group_name(&self) -> &'static str {
324        "Application"
325    }
326}
327
328impl Server for DbusProtocol {
329    fn spawn(
330        &self,
331        ctx: SpawnContext,
332    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
333    {
334        Box::pin(super::spawn(ctx))
335    }
336    fn execute_action(&self, action: Value) -> Result<ActionResult> {
337        if self.call.is_none()
338            && matches!(
339                action["type"].as_str(),
340                Some("dbus_return") | Some("dbus_error")
341            )
342        {
343            // The registry's instance answers no call: check the answer's shape and send
344            // nothing. Injection offers only dbus_emit_signal (the async set), so nothing
345            // reaches a peer this way.
346            let mut placeholder = Message::call("/", None, "Check");
347            placeholder.serial = 1;
348            answer_message(Some(&placeholder), &action, None)?;
349            return Ok(ActionResult::NoAction);
350        }
351        let mut m = answer_message(
352            self.call.as_ref(),
353            &action,
354            self.call.as_ref().and_then(|c| c.destination.as_deref()),
355        )?;
356        m.serial = match &self.serials {
357            Some(counter) => counter.fetch_add(1, Ordering::Relaxed),
358            None => next_injected_serial(),
359        };
360        if m.kind != wire::SIGNAL {
361            if let Some(call) = &self.call {
362                if !call.expects_reply() {
363                    return Ok(ActionResult::NoAction);
364                }
365            }
366        }
367        Ok(ActionResult::Output(m.encode()?))
368    }
369}