1use 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 .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 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
526pub 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
566pub 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});