Skip to main content

netget/server/gemini/
actions.rs

1//! Gemini protocol actions: what the model is told, and how its answers become responses.
2//!
3//! Every action is rendered by [`super::wire`], so the model chooses a status and supplies
4//! text; it never writes a header, a CRLF or a gemtext prefix.
5
6use super::wire;
7use crate::llm::actions::{
8    protocol_trait::{ActionResult, Protocol, Server},
9    ActionDefinition, Parameter,
10};
11use crate::protocol::log_template::LogTemplate;
12use crate::protocol::EventType;
13use crate::state::app_state::AppState;
14use anyhow::{anyhow, Context, Result};
15use serde_json::{json, Value};
16use std::sync::LazyLock;
17
18pub struct GeminiProtocol;
19
20impl GeminiProtocol {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Default for GeminiProtocol {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl Protocol for GeminiProtocol {
33    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
34        use crate::llm::actions::ParameterDefinition;
35        vec![
36            ParameterDefinition {
37                name: "cert_path".to_string(),
38                type_hint: "string".to_string(),
39                description: "Path to a PEM certificate for the capsule. Give key_path with it. \
40                              Without both, a self-signed certificate for localhost is \
41                              generated at startup - which is normal for Gemini, whose clients \
42                              trust on first use rather than through a CA."
43                    .to_string(),
44                required: false,
45                example: json!("/etc/gemini/cert.pem"),
46                default: None,
47            },
48            ParameterDefinition {
49                name: "key_path".to_string(),
50                type_hint: "string".to_string(),
51                description: "Path to the PEM private key for cert_path".to_string(),
52                required: false,
53                example: json!("/etc/gemini/key.pem"),
54                default: None,
55            },
56            ParameterDefinition {
57                name: "handshake_timeout_secs".to_string(),
58                type_hint: "number".to_string(),
59                description: "Seconds a peer has to complete the TLS handshake after \
60                              connecting. Default 60; a real client finishes in one round trip."
61                    .to_string(),
62                required: false,
63                example: json!(60),
64                default: Some(serde_json::json!(super::HANDSHAKE_TIMEOUT.as_secs())),
65            },
66            ParameterDefinition {
67                name: "first_byte_timeout_secs".to_string(),
68                type_hint: "number".to_string(),
69                description: "Seconds a peer that completed the handshake has to send its \
70                              request line. Default 300, the window a `manual` rule gives a \
71                              human - the peer may be NetGet's own TLS client waiting on its \
72                              operator. Lower it for a public capsule."
73                    .to_string(),
74                required: false,
75                example: json!(300),
76                default: Some(serde_json::json!(super::FIRST_BYTE_TIMEOUT.as_secs())),
77            },
78        ]
79    }
80    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
81        Vec::new()
82    }
83    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
84        vec![
85            send_gemtext_action(),
86            send_gemini_response_action(),
87            send_gemini_input_action(),
88            send_gemini_redirect_action(),
89            close_connection_action(),
90        ]
91    }
92    fn protocol_name(&self) -> &'static str {
93        "Gemini"
94    }
95    fn get_event_types(&self) -> Vec<EventType> {
96        vec![GEMINI_REQUEST_EVENT.clone()]
97    }
98    fn stack_name(&self) -> &'static str {
99        "ETH>IP>TCP>TLS>GEMINI"
100    }
101    fn keywords(&self) -> Vec<&'static str> {
102        vec!["gemini", "gemini://", "gemtext", "capsule", "smolweb"]
103    }
104    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
105        use crate::protocol::metadata::{
106            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
107        };
108
109        ProtocolMetadataV2::builder()
110            .state(DevelopmentState::Beta)
111            // 1965 is unprivileged.
112            .privilege_requirement(PrivilegeRequirement::None)
113            .well_known_port(1965)
114            .implementation(
115                "tokio-rustls TLS 1.2/1.3 with an rcgen self-signed certificate (or \
116                 cert_path/key_path); hand-written request validation, response header and \
117                 gemtext rendering",
118            )
119            .llm_control(
120                "The whole capsule: which status each URL gets, gemtext pages, input prompts, \
121                 redirects and failures",
122            )
123            .e2e_testing(
124                "tests/server/gemini/real_client_test.rs drives the Python Gemini client \
125                 library ignition (pip ignition-gemini, independent of rustls: CPython's ssl \
126                 module over OpenSSL) through TLS, TOFU pinning into a temporary known-hosts \
127                 file, and response parsing, asserting on the status, meta and body it parsed. \
128                 It fails, never skips, when python3 or ignition is absent. The same test relays \
129                 the connection through a recorder and runs the pcap oracle (Wireshark's TLS \
130                 dissector) over the captured bytes.",
131            )
132            .notes(
133                "Implements the request/response protocol: one absolute gemini:// URL (at most \
134                 1024 bytes) per connection, a <status> <meta> header, a body only after 2x, \
135                 then close_notify and close. Refused by NetGet without the model: over-long, \
136                 relative or malformed URLs, a BOM, userinfo or a fragment (59); another scheme \
137                 (53, no proxying). The host is passed to the model and not checked against the \
138                 certificate. No client-certificate handling: the model may answer 60-62, but \
139                 no client certificate is requested or read. On backend failure the client gets \
140                 41 (overloaded) or 40 (any other failure), and on a model that answered \
141                 nothing, 40.",
142            )
143            .max_inbound_bytes(wire::MAX_REQUEST_BYTES)
144            .build()
145    }
146    fn description(&self) -> &'static str {
147        "Gemini capsule (gemini://) over TLS - the model writes the pages"
148    }
149    fn example_prompt(&self) -> &'static str {
150        "Gemini capsule on port 1965 - a small personal site with a home page and a guestbook"
151    }
152    fn group_name(&self) -> &'static str {
153        "Core"
154    }
155    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
156        use crate::llm::actions::StartupExamples;
157        StartupExamples::new(
158            json!({
159                "type": "open_server",
160                "port": 1965,
161                "base_stack": "gemini",
162                "instruction": "A small personal Gemini capsule: a home page linking to /about \
163                                and /guestbook; /guestbook asks for input and thanks the visitor"
164            }),
165            json!({
166                "type": "open_server",
167                "port": 1965,
168                "base_stack": "gemini",
169                "event_handlers": [{
170                    "event_pattern": "gemini_request",
171                    "handler": {
172                        "type": "script",
173                        "language": "python",
174                        "code": "import json, sys\ne = json.load(sys.stdin)['event']\nif e.get('path') == '/search' and e.get('query') is None:\n    a = [{'type': 'send_gemini_input', 'prompt': 'Search for'}]\nelse:\n    a = [{'type': 'send_gemtext', 'lines': [{'type': 'heading1', 'text': 'You asked for ' + e.get('path', '/')}, {'type': 'link', 'url': '/search', 'text': 'Search'}]}]\nprint(json.dumps({'actions': a}))"
175                    }
176                }]
177            }),
178            json!({
179                "type": "open_server",
180                "port": 1965,
181                "base_stack": "gemini",
182                "event_handlers": [{
183                    "event_pattern": "gemini_request",
184                    "handler": {
185                        "type": "static",
186                        "actions": [{
187                            "type": "send_gemtext",
188                            "lines": [
189                                {"type": "heading1", "text": "Welcome"},
190                                {"type": "text", "text": "This capsule is served by NetGet."},
191                                {"type": "link", "url": "gemini://geminiprotocol.net/", "text": "About Gemini"}
192                            ]
193                        }]
194                    }
195                }]
196            }),
197        )
198    }
199}
200
201impl Server for GeminiProtocol {
202    fn spawn(
203        &self,
204        ctx: crate::protocol::SpawnContext,
205    ) -> std::pin::Pin<
206        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
207    > {
208        Box::pin(async move {
209            let string = |name: &str| -> anyhow::Result<Option<String>> {
210                Ok(ctx
211                    .startup_params
212                    .as_ref()
213                    .map(|p| p.get_optional_string(name))
214                    .transpose()?
215                    .flatten())
216            };
217            let secs = |name: &str| -> anyhow::Result<Option<u64>> {
218                Ok(ctx
219                    .startup_params
220                    .as_ref()
221                    .map(|p| p.get_optional_u64(name))
222                    .transpose()?
223                    .flatten())
224            };
225            // Library callers do not necessarily install a process TLS provider.
226            // File-backed certificates must work even when both providers are compiled.
227            let _ = rustls::crypto::ring::default_provider().install_default();
228            let tls_config = match (string("cert_path")?, string("key_path")?) {
229                (Some(cert), Some(key)) => {
230                    crate::server::tls_cert_manager::load_tls_config_from_files(&cert, &key)?
231                }
232                (None, None) => crate::server::tls_cert_manager::generate_custom_tls_config(
233                    Some("localhost".to_string()),
234                    Some(vec!["localhost".to_string()]),
235                    Some(365),
236                    Some("NetGet".to_string()),
237                    Some("Gemini capsule".to_string()),
238                )?,
239                _ => {
240                    return Err(anyhow!(
241                        "cert_path and key_path must be given together (or neither, for a \
242                         generated self-signed certificate)"
243                    ))
244                }
245            };
246            let deadlines = crate::server::gemini::Deadlines::new(
247                secs("handshake_timeout_secs")?,
248                secs("first_byte_timeout_secs")?,
249            );
250
251            crate::server::gemini::GeminiServer::spawn_with_llm_actions(
252                ctx.legacy_listen_addr(),
253                ctx.llm_client,
254                ctx.state,
255                ctx.status_tx,
256                ctx.server_id,
257                tls_config,
258                deadlines,
259            )
260            .await
261        })
262    }
263
264    fn execute_action(&self, action: Value) -> Result<ActionResult> {
265        let action_type = action
266            .get("type")
267            .and_then(|v| v.as_str())
268            .context("Missing 'type' field in action")?;
269
270        let rendered = match action_type {
271            "send_gemini_response" => {
272                let status = status_of(&action)?;
273                let meta = action.get("meta").and_then(Value::as_str).unwrap_or("");
274                let body = action.get("body").and_then(Value::as_str);
275                let (rendered, dropped) =
276                    wire::render_response(status, meta, body).map_err(|e| anyhow!("{e}"))?;
277                if dropped {
278                    tracing::warn!(
279                        "Gemini: dropped the body of a status-{status} response - only 2x \
280                         responses carry one"
281                    );
282                }
283                rendered
284            }
285            "send_gemtext" => {
286                let items = action
287                    .get("lines")
288                    .and_then(Value::as_array)
289                    .context("send_gemtext needs 'lines', an array of {type, text, url?, alt?}")?;
290                let mut lines = Vec::with_capacity(items.len());
291                for item in items {
292                    let kind = item.get("type").and_then(Value::as_str).unwrap_or("text");
293                    let text = item.get("text").and_then(Value::as_str).unwrap_or("");
294                    let url = item.get("url").and_then(Value::as_str);
295                    let alt = item.get("alt").and_then(Value::as_str);
296                    lines.push(
297                        wire::GemtextLine::from_parts(kind, text, url, alt)
298                            .map_err(|e| anyhow!("{e}"))?,
299                    );
300                }
301                let mime = wire::gemtext_mime(action.get("lang").and_then(Value::as_str));
302                let body = wire::render_gemtext(&lines);
303                wire::render_response(20, &mime, Some(&body))
304                    .map_err(|e| anyhow!("{e}"))?
305                    .0
306            }
307            "send_gemini_input" => {
308                let prompt = action.get("prompt").and_then(Value::as_str).unwrap_or("");
309                let sensitive = action
310                    .get("sensitive")
311                    .and_then(Value::as_bool)
312                    .unwrap_or(false);
313                wire::render_response(if sensitive { 11 } else { 10 }, prompt, None)
314                    .map_err(|e| anyhow!("{e}"))?
315                    .0
316            }
317            "send_gemini_redirect" => {
318                let url = action
319                    .get("url")
320                    .and_then(Value::as_str)
321                    .filter(|u| !u.trim().is_empty())
322                    .context("send_gemini_redirect needs 'url'")?;
323                let permanent = action
324                    .get("permanent")
325                    .and_then(Value::as_bool)
326                    .unwrap_or(false);
327                wire::render_response(if permanent { 31 } else { 30 }, url, None)
328                    .map_err(|e| anyhow!("{e}"))?
329                    .0
330            }
331            "close_connection" => return Ok(ActionResult::CloseConnection),
332            _ => return Err(anyhow!("Unknown Gemini action: {}", action_type)),
333        };
334        Ok(ActionResult::Output(rendered.into_bytes()))
335    }
336}
337
338fn status_of(action: &Value) -> Result<u16> {
339    let status = action
340        .get("status")
341        .and_then(|v| {
342            v.as_u64()
343                .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
344        })
345        .context("Missing or non-numeric 'status' parameter")?;
346    u16::try_from(status).map_err(|_| anyhow!("status {status} is not a Gemini status"))
347}
348
349fn send_gemtext_action() -> ActionDefinition {
350    ActionDefinition {
351        name: "send_gemtext".to_string(),
352        description: "Answer with a gemtext page (status 20, text/gemini). Give the page as \
353                      structured lines; NetGet writes the gemtext syntax, so text never turns \
354                      into a link or heading by accident. The titles, text and links are the \
355                      ones your instructions give the page, word for word; the example's are \
356                      placeholders for the shape, never data. Not for a page that asks the \
357                      visitor something first - that is send_gemini_input."
358            .to_string(),
359        parameters: vec![
360            Parameter {
361                name: "lines".to_string(),
362                type_hint: "array".to_string(),
363                description: "Array of {type, text, url?, alt?}. type is text, link (needs \
364                              url; text is its label), heading1, heading2, heading3, list, \
365                              quote or preformatted (alt is its caption). text may span \
366                              several lines for text, quote and preformatted."
367                    .to_string(),
368                required: true,
369            },
370            Parameter {
371                name: "lang".to_string(),
372                type_hint: "string".to_string(),
373                description: "Optional BCP 47 language tag for the page, e.g. en or fr".to_string(),
374                required: false,
375            },
376        ],
377        example: json!({
378            "type": "send_gemtext",
379            "lines": [
380                {"type": "heading1", "text": "<page title>"},
381                {"type": "text", "text": "<a paragraph>"},
382                {"type": "link", "url": "/<path>", "text": "<link label>"},
383                {"type": "list", "text": "<a list item>"},
384                {"type": "preformatted", "alt": "<caption>", "text": "<preformatted text>"}
385            ]
386        }),
387        log_template: Some(
388            LogTemplate::new()
389                .with_info("-> Gemini 20 gemtext")
390                .with_debug("Gemini send_gemtext"),
391        ),
392    }
393}
394
395fn send_gemini_response_action() -> ActionDefinition {
396    ActionDefinition {
397        name: "send_gemini_response".to_string(),
398        description: "Answer with any Gemini status. 20 = success (meta is the MIME type, \
399                      body is the content; prefer send_gemtext for pages), 40-44 temporary \
400                      failure (44 meta = seconds to wait), 50 permanent failure, 51 not found, \
401                      52 gone, 53 proxy refused, 59 bad request, 60-62 client certificate. \
402                      A body is sent only with a 2x status."
403            .to_string(),
404        parameters: vec![
405            Parameter {
406                name: "status".to_string(),
407                type_hint: "number".to_string(),
408                description: "10, 11, 20, 30, 31, 40, 41, 42, 43, 44, 50, 51, 52, 53, 59, 60, \
409                              61 or 62"
410                    .to_string(),
411                required: true,
412            },
413            Parameter {
414                name: "meta".to_string(),
415                type_hint: "string".to_string(),
416                description: "One line, at most 1024 bytes: the MIME type for 20, the URL for \
417                              30/31, the prompt for 10/11, an error message otherwise. Empty \
418                              uses a sensible default."
419                    .to_string(),
420                required: false,
421            },
422            Parameter {
423                name: "body".to_string(),
424                type_hint: "string".to_string(),
425                description: "The content, for a 2x status only".to_string(),
426                required: false,
427            },
428        ],
429        example: json!({
430            "type": "send_gemini_response",
431            "status": 51,
432            "meta": "No such page"
433        }),
434        log_template: Some(
435            LogTemplate::new()
436                .with_info("-> Gemini {status} {meta}")
437                .with_debug("Gemini send_gemini_response: status={status}"),
438        ),
439    }
440}
441
442fn send_gemini_input_action() -> ActionDefinition {
443    ActionDefinition {
444        name: "send_gemini_input".to_string(),
445        description: "Ask the visitor for a line of input (status 10, or 11 for sensitive \
446                      input such as a password), instead of a page: a page that asks for a \
447                      name, a search term or a password before showing anything answers its \
448                      first request (no query) with this alone. The client re-requests the same \
449                      URL with the answer as the query."
450            .to_string(),
451        parameters: vec![
452            Parameter {
453                name: "prompt".to_string(),
454                type_hint: "string".to_string(),
455                description: "The question shown to the visitor".to_string(),
456                required: true,
457            },
458            Parameter {
459                name: "sensitive".to_string(),
460                type_hint: "boolean".to_string(),
461                description: "true for status 11 (input is not echoed)".to_string(),
462                required: false,
463            },
464        ],
465        example: json!({
466            "type": "send_gemini_input",
467            "prompt": "Sign the guestbook",
468            "sensitive": false
469        }),
470        log_template: Some(
471            LogTemplate::new()
472                .with_info("-> Gemini input: {prompt}")
473                .with_debug("Gemini send_gemini_input: {prompt}"),
474        ),
475    }
476}
477
478fn send_gemini_redirect_action() -> ActionDefinition {
479    ActionDefinition {
480        name: "send_gemini_redirect".to_string(),
481        description: "Redirect the visitor (status 30, or 31 for a permanent move)".to_string(),
482        parameters: vec![
483            Parameter {
484                name: "url".to_string(),
485                type_hint: "string".to_string(),
486                description: "Where to go: an absolute gemini:// URL or a path on this capsule"
487                    .to_string(),
488                required: true,
489            },
490            Parameter {
491                name: "permanent".to_string(),
492                type_hint: "boolean".to_string(),
493                description: "true for 31 (permanent)".to_string(),
494                required: false,
495            },
496        ],
497        example: json!({
498            "type": "send_gemini_redirect",
499            "url": "/new-home",
500            "permanent": true
501        }),
502        log_template: Some(
503            LogTemplate::new()
504                .with_info("-> Gemini redirect {url}")
505                .with_debug("Gemini send_gemini_redirect: {url}"),
506        ),
507    }
508}
509
510fn close_connection_action() -> ActionDefinition {
511    ActionDefinition {
512        name: "close_connection".to_string(),
513        description: "Close the connection. Every Gemini response already ends the \
514                      connection, so this is only for hanging up without answering."
515            .to_string(),
516        parameters: vec![],
517        example: json!({"type": "close_connection"}),
518        log_template: Some(
519            LogTemplate::new()
520                .with_info("Gemini connection closed")
521                .with_debug("Gemini close_connection"),
522        ),
523    }
524}
525
526/// The `answer_with` field of `gemini_request`: which single response this request takes.
527///
528/// Two failures in the real-model eval (llama3.1:8b, seed 42) shaped it. Told "the page
529/// /guestbook asks the visitor for their name before showing anything", the model answered
530/// with a page reading "What is your name?" followed by `send_gemini_input` - and only the
531/// first response is ever sent, so the visitor got a page and never a prompt, five runs in
532/// five. Told "serve a home page titled Welcome to the NetGet capsule", it sent the example
533/// page ("Welcome", "A capsule served by NetGet.") first and the right page after it, four
534/// runs in five. The request now says the prompt comes alone and first, and where a page's
535/// words come from. It names the path against the home page and leads with the 51 as a literal
536/// action: without that, told "only the home page exists", the model served the home page for
537/// /nowhere 4 runs in 5.
538pub fn answer_with_for_request(path: &str, query: Option<&str>) -> String {
539    match query {
540        Some(answer) => format!(
541            "the visitor answered the prompt at {path} with \"{answer}\": answer with the \
542             page that follows (send_gemtext), as your instructions describe it. One action \
543             only"
544        ),
545        None => {
546            let which = if path.is_empty() || path == "/" {
547                " (the home page)"
548            } else {
549                " (not the home page, which is /)"
550            };
551            format!(
552                "the visitor asked for {path}{which}. Exactly one action - only the first is \
553                 sent. First look in your instructions for a page at {path}: if they give none \
554                 there - including when they say only other pages exist - answer exactly \
555                 {{\"type\": \"send_gemini_response\", \"status\": 51, \"meta\": \"Not \
556                 found\"}} and nothing else. If that page asks the visitor for something (a \
557                 name, a search term, a password) before showing anything, answer \
558                 send_gemini_input with that question and nothing else - no page before it. \
559                 Otherwise send_gemtext whose lines carry exactly the titles, text and links \
560                 they give that page, word for word"
561            )
562        }
563    }
564}
565
566/// One request: an absolute gemini:// URL.
567pub static GEMINI_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
568    EventType::new(
569        "gemini_request",
570        "A client requested a gemini:// URL. Answer with exactly one response - only the \
571         first is sent: a page (send_gemtext), an input prompt (send_gemini_input), a \
572         redirect, or a status. answer_with says which fits this request.",
573        json!({
574            "type": "send_gemtext",
575            "lines": [
576                {"type": "heading1", "text": "<page title>"},
577                {"type": "link", "url": "/<path>", "text": "<link label>"}
578            ]
579        }),
580    )
581    .with_parameters(vec![
582        Parameter {
583            name: "url".to_string(),
584            type_hint: "string".to_string(),
585            description: "The URL exactly as requested".to_string(),
586            required: true,
587        },
588        Parameter {
589            name: "host".to_string(),
590            type_hint: "string".to_string(),
591            description: "The host part of the URL".to_string(),
592            required: true,
593        },
594        Parameter {
595            name: "path".to_string(),
596            type_hint: "string".to_string(),
597            description: "The path, as it appears in the URL; / when empty".to_string(),
598            required: true,
599        },
600        Parameter {
601            name: "query".to_string(),
602            type_hint: "string".to_string(),
603            description: "The percent-decoded query (the visitor's input after a 10/11), or \
604                          null when the URL has none"
605                .to_string(),
606            required: false,
607        },
608        Parameter {
609            name: "answer_with".to_string(),
610            type_hint: "string".to_string(),
611            description: "Which single response this request takes: an input prompt, a page, \
612                          or 51, by what the instructions say about this path and whether \
613                          the visitor has answered a prompt"
614                .to_string(),
615            required: true,
616        },
617    ])
618    .with_log_template(
619        LogTemplate::new()
620            .with_info("Gemini {url}")
621            .with_debug("Gemini gemini_request: host={host} path={path}"),
622    )
623    .with_actions(vec![
624        send_gemtext_action(),
625        send_gemini_response_action(),
626        send_gemini_input_action(),
627        send_gemini_redirect_action(),
628        close_connection_action(),
629    ])
630    .with_alternative_example(json!({
631        "type": "send_gemini_response",
632        "status": 51,
633        "meta": "Not found"
634    }))
635});