netget/server/nfc/mod.rs
1//! NFC (Near Field Communication) virtual tag server.
2//!
3//! NetGet emulates an **NFC Forum Type 4 tag**, which is defined as ISO 7816-4
4//! APDU exchange over ISO-DEP. It cannot generate an RF field — no PC/SC reader
5//! can be driven into card-emulation mode — so the tag is reachable over a bound
6//! TCP socket instead, using the **vsmartcard `vpcd` framing**:
7//!
8//! ```text
9//! reader → tag: u16 big-endian length | payload
10//! tag → reader: u16 big-endian length | payload
11//! ```
12//!
13//! A payload of exactly one byte is a vpcd control code (`00` power off, `01`
14//! power on, `02` reset, `04` request ATR); anything longer is a command APDU.
15//!
16//! This is the same wire format `src/server/usb/smartcard/` already speaks, and
17//! it is not a private invention: configured with `DEVICENAME /dev/null:<host>:<port>`
18//! the real `vpcd` ifdhandler connects *out* to a listening virtual card, so a
19//! host running `pcscd` sees this server as a genuine PC/SC reader with a card
20//! in it. A test, meanwhile, only has to write a length-prefixed APDU to the
21//! socket — no hardware, no daemon.
22//!
23//! ## What the model controls
24//!
25//! - `nfc_server_started` → `set_atr`, `set_ndef_message` configure the tag.
26//! - `nfc_tag_selected` (SELECT by AID) and `nfc_apdu_received` (every other
27//! APDU) → `respond_to_apdu` supplies the response body and status word.
28//!
29//! Nothing is answered by hardcoded card logic: there is no file system and no
30//! stored NDEF parser here, only the state the model itself set. If no handler
31//! produces a response the tag replies `6F00` (fail closed), never a success;
32//! if the backend itself failed it replies `6400` when that backend is merely
33//! saturated, so a reader retries instead of recording a broken card.
34
35pub mod actions;
36pub mod apdu;
37
38use crate::llm::action_helper::call_llm;
39use crate::llm::ollama_client::OllamaClient;
40use crate::llm::ActionResult;
41use crate::logging::emit::Log;
42use crate::protocol::Event;
43use crate::server::connection::ConnectionId;
44use crate::server::nfc::actions::*;
45use crate::server::nfc::apdu::{ApduCommand, ApduResponse, SW_WRONG_LENGTH};
46use crate::state::app_state::AppState;
47use crate::state::server::ServerId;
48use crate::utils::WireFailure;
49use anyhow::{anyhow, Result};
50use serde_json::{json, Value};
51use std::net::SocketAddr;
52use std::sync::Arc;
53use tokio::io::{AsyncReadExt, AsyncWriteExt, WriteHalf};
54use tokio::net::TcpStream;
55use tokio::sync::{mpsc, Mutex};
56use tracing::{debug, trace};
57
58// Re-export protocol
59pub use actions::NfcServerProtocol;
60
61/// Largest frame accepted from a reader. Bounds the read buffer so a hostile
62/// length prefix cannot make the server allocate.
63///
64/// `ApduCommand::parse` understands the whole extended form, whose `Lc` reaches
65/// 65535 — but a frame carrying one is refused here long before that, so the
66/// extended form is usable only up to ~4089 data bytes. The cap is what makes
67/// the allocation bounded, so it wins over the parser's theoretical range; a
68/// reader needing more would have to raise this, not the parser.
69pub const MAX_FRAME_LEN: usize = 4096;
70
71/// How long a reader that has connected and sent no frame at all may hold this connection.
72///
73/// **The peer here is never NetGet's own client, and that is what decides the number.** The
74/// `[ + nfc client ]` button on this server's card does create `src/client/nfc/`, but that
75/// client is a PC/SC one: it calls `pcsc::Context::establish` and drives a physical reader, and
76/// it never opens a socket to the address the dashboard pre-fills. So the "connected and
77/// silent NetGet client waiting for a human at `[ send message ]`" that made `tcp`, `telnet`,
78/// `ldap`, `whois` and `redis` all settle on 300 seconds cannot exist on this listener. Nor can
79/// the operator reach a reader from the dashboard: this server registers no peer channel
80/// (`grep register_peer_channel src/server/nfc/mod.rs` finds nothing), so there is no
81/// `[ message ]` button whose answer a peer could be waiting for.
82///
83/// Every peer this socket can have speaks first, and immediately. vpcd is reader-driven by
84/// construction — the reader sends control codes (`01` power on, `02` reset, `04` request ATR)
85/// and command APDUs, and the tag only ever answers — and the `vpcd` ifdhandler configured
86/// `DEVICENAME /dev/null:<host>:<port>` is opened by `pcscd`, which polls card presence from
87/// the moment the reader exists. A test peer writes a length-prefixed APDU in its next
88/// statement. Thirty seconds is orders of magnitude more than any of that needs, and it is the
89/// same number `etcd` and `s3` argue for the same client-speaks-first reason.
90///
91/// Overridable with `first_byte_timeout_secs`, because a listener's right value is a property
92/// of who is on the other end and only the operator knows that.
93const FIRST_FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
94
95/// How long an established reader may send no further frame.
96///
97/// Much longer than the first bound, because a reader legitimately goes quiet: `pcscd` polls a
98/// reader while an application is watching it, and stops when none is. A vpcd connection with
99/// no traffic is therefore an idle *host*, not a dead one, and closing it makes the virtual
100/// card vanish from a `pcscd` that would have used it again. Five minutes bounds the hold
101/// without making that a normal event, and matches the window this product already uses for
102/// "how long a person might take" (`src/state/intercepts.rs`).
103///
104/// The deadline wraps the read and nothing else: the model round-trip that answers an APDU
105/// happens after the read has returned, so a slow backend — or a `manual` rule parking the
106/// APDU for a human — can never be timed out from under itself.
107const IDLE_BETWEEN_FRAMES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
108
109/// Concurrent readers this virtual tag admits.
110///
111/// [`accept_bounded::DEFAULT_MAX_CONNECTIONS`](crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS).
112/// Each admitted reader holds one [`MAX_FRAME_LEN`] buffer, so the cap turns that 4 KiB
113/// per-connection bound into a 1 MiB total one. A physical tag is in exactly one reader's field
114/// at a time and a smaller cap would be defensible on that ground — but the tag state here is
115/// shared and read-only to a reader, several hosts may legitimately mount the same virtual
116/// card, and a cap tight enough to be interesting is a cap that refuses honest use.
117const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
118
119/// What a reader over [`MAX_CONNECTIONS`] is told: nothing.
120///
121/// Deliberately empty, for the reason `src/server/accept_bounded.rs` gives for the empty case.
122/// Every frame this server can put on the wire is a *card response* — an ATR, or a response
123/// APDU with a status word — and every one of them is an answer to a command the reader has
124/// not sent. A `6F00` arriving unprompted would be read by the ifdhandler as the answer to
125/// whatever it asks next, desynchronising the exchange; there is no error frame in vpcd framing
126/// to say "full" with. This is the deliberately-silent case: a fabricated reply is worse than
127/// silence, and the refusal is logged with `decision=fail_closed_connection_cap` instead.
128const CONNECTION_CAP_REFUSAL: &[u8] = b"";
129
130/// vpcd control codes (1-byte frames).
131const VPCD_CTRL_OFF: u8 = 0x00;
132const VPCD_CTRL_ON: u8 = 0x01;
133const VPCD_CTRL_RESET: u8 = 0x02;
134const VPCD_CTRL_ATR: u8 = 0x04;
135
136/// Default ATR advertised for an ISO 14443-4 (Type 4) contactless tag.
137const DEFAULT_ATR_HEX: &str = "3B8F8001804F0CA0000003060300030000000068";
138
139/// Virtual NFC tag state, entirely LLM-configured.
140struct VirtualNfcTag {
141 /// Answer to Reset, returned for the vpcd `ATR` control code.
142 atr: Vec<u8>,
143 /// Tag UID, surfaced to the model in every APDU event.
144 uid: String,
145 /// Tag type, surfaced to the model in every APDU event.
146 tag_type: String,
147 /// NDEF records the model configured at startup, surfaced to the model in
148 /// every APDU event so it can serve them itself.
149 ndef_records: Vec<Value>,
150}
151
152impl VirtualNfcTag {
153 fn new(uid: String, tag_type: String) -> Self {
154 Self {
155 atr: hex::decode(DEFAULT_ATR_HEX).unwrap_or_default(),
156 uid,
157 tag_type,
158 ndef_records: Vec::new(),
159 }
160 }
161}
162
163/// NFC virtual tag server.
164pub struct NfcServer;
165
166impl NfcServer {
167 /// Bind the virtual tag and start accepting readers.
168 pub async fn start(
169 bind_addr: SocketAddr,
170 llm_client: OllamaClient,
171 app_state: Arc<AppState>,
172 status_tx: mpsc::UnboundedSender<String>,
173 server_id: ServerId,
174 startup_params: Value,
175 ) -> Result<SocketAddr> {
176 let tag_type = startup_params["tag_type"]
177 .as_str()
178 .unwrap_or("type4")
179 .to_string();
180 let uid = startup_params["uid"]
181 .as_str()
182 .map(|s| s.to_string())
183 .unwrap_or_else(|| {
184 // Generate a random 7-byte UID (the Type 4 double-size form).
185 let random_bytes: Vec<u8> = (0..7).map(|_| rand::random::<u8>()).collect();
186 hex::encode(random_bytes).to_uppercase()
187 });
188
189 // Both read deadlines are the operator's to choose: who is on the other end is the
190 // only thing that decides them, and only the operator knows that. The defaults are
191 // argued beside the constants.
192 let first_frame_timeout = startup_params["first_byte_timeout_secs"]
193 .as_u64()
194 .map(std::time::Duration::from_secs)
195 .unwrap_or(FIRST_FRAME_READ_TIMEOUT);
196 let idle_timeout = startup_params["idle_timeout_secs"]
197 .as_u64()
198 .map(std::time::Duration::from_secs)
199 .unwrap_or(IDLE_BETWEEN_FRAMES_TIMEOUT);
200
201 // Bind before anything else so a port conflict is reported as a startup
202 // failure rather than a server that claims to be Running with no socket.
203 let listener =
204 crate::server::socket_helpers::create_reusable_tcp_listener(bind_addr).await?;
205 let local_addr = listener.local_addr()?;
206
207 Log::new(Some(&status_tx)).info(format!(
208 "NFC virtual tag listening on {} (type={}, UID={})",
209 local_addr, tag_type, uid
210 ));
211
212 let tag_state = Arc::new(Mutex::new(VirtualNfcTag::new(
213 uid.clone(),
214 tag_type.clone(),
215 )));
216 let protocol = Arc::new(NfcServerProtocol);
217
218 // Configure the tag before the first reader can reach it.
219 let event = Event::new(
220 &NFC_SERVER_STARTED_EVENT,
221 json!({
222 "tag_type": tag_type,
223 "uid": uid,
224 "listen_addr": local_addr.to_string(),
225 }),
226 );
227 // Opening a socket must not require the model to be reachable: the LLM answers
228 // traffic, it does not open listeners. This call used to propagate with `?`, so an
229 // Ollama outage made `spawn()` return `Err` and the server never started at all — a
230 // backend blip took down a server that would have worked fine for every reader that
231 // arrived after the backend came back.
232 //
233 // The tag is fully usable without it: `VirtualNfcTag::new` has already applied the
234 // requested type and UID and a default ATR, and every *reader* APDU still goes through
235 // `call_llm` with its own failure handling. So a failure here means "unconfigured, not
236 // broken", and is reported as exactly that rather than as a dead server.
237 match call_llm(
238 &llm_client,
239 &app_state,
240 server_id,
241 None, // Server-level event, no connection yet
242 &event,
243 protocol.as_ref(),
244 )
245 .await
246 {
247 Ok(result) => {
248 for message in result.messages {
249 let _ = status_tx.send(message);
250 }
251 for action_result in result.protocol_results {
252 if let Err(e) =
253 Self::apply_startup_action(tag_state.clone(), action_result, &status_tx)
254 .await
255 {
256 // Non-fatal: the tag stays up with its defaults.
257 Log::new(Some(&status_tx))
258 .warn(format!("NFC startup action failed: {}", e));
259 }
260 }
261 }
262 Err(e) => {
263 // Non-fatal: no reader has been accepted yet, so there is nobody to
264 // answer; the tag is up with its built-in defaults (wire fallback), so
265 // this is WARN not ERROR. Tagged so an operator can tell a backend
266 // outage at startup from the model choosing to configure nothing.
267 let failure = WireFailure::classify(&e);
268 Log::new(Some(&status_tx)).warn(format!(
269 "NFC decision=fail_closed_llm_error at startup (category={}): {e}. \
270 The tag is up with its built-in defaults and carries no \
271 model-supplied NDEF records.",
272 if failure.is_overloaded() {
273 "overloaded"
274 } else {
275 "unavailable"
276 }
277 ));
278 }
279 }
280
281 {
282 let tag = tag_state.lock().await;
283 debug!(
284 "Virtual NFC tag configured: atr={}, ndef_records={}",
285 hex::encode_upper(&tag.atr),
286 tag.ndef_records.len()
287 );
288 }
289
290 let accept_state = app_state.clone();
291 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
292 let accept_handle = tokio::spawn(async move {
293 loop {
294 let (stream, remote_addr, permit) =
295 match crate::server::accept_bounded::accept_bounded(
296 &listener,
297 &limiter,
298 CONNECTION_CAP_REFUSAL,
299 "NFC",
300 Some(&status_tx),
301 )
302 .await
303 {
304 Ok(accepted) => accepted,
305 Err(e) => {
306 Log::new(Some(&status_tx)).error(format!("NFC accept error: {e}"));
307 break;
308 }
309 };
310
311 let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
312 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
313 Log::new(Some(&status_tx)).info(format!(
314 "NFC reader {connection_id} connected from {remote_addr}"
315 ));
316
317 {
318 use crate::state::server::{
319 ConnectionState, ConnectionStatus, ProtocolConnectionInfo,
320 };
321 let now = crate::utils::clock::Instant::now();
322 let conn_state = ConnectionState {
323 id: connection_id,
324 remote_addr,
325 local_addr: local_addr_conn,
326 bytes_sent: 0,
327 bytes_received: 0,
328 packets_sent: 0,
329 packets_received: 0,
330 last_activity: now,
331 status: ConnectionStatus::Active,
332 status_changed_at: now,
333 protocol_info: ProtocolConnectionInfo::new(json!({
334 "transport": "vpcd",
335 })),
336 };
337 app_state
338 .add_connection_to_server(server_id, conn_state)
339 .await;
340 }
341 let _ = status_tx.send("__UPDATE_UI__".to_string());
342
343 let llm_client = llm_client.clone();
344 let conn_state = app_state.clone();
345 let status_tx = status_tx.clone();
346 let tag_state = tag_state.clone();
347 let protocol = protocol.clone();
348 // Tracked, not detached: stop_server must abort this task too.
349 let task_owner = app_state.clone();
350 task_owner
351 .spawn_server_task(server_id, async move {
352 // The permit is released when this task ends, which is what makes
353 // MAX_CONNECTIONS a cap on live readers rather than on the accept rate.
354 let _permit = permit;
355 if let Err(e) = Self::handle_reader(
356 stream,
357 connection_id,
358 server_id,
359 tag_state,
360 llm_client,
361 conn_state.clone(),
362 status_tx.clone(),
363 protocol,
364 first_frame_timeout,
365 idle_timeout,
366 )
367 .await
368 {
369 Log::new(Some(&status_tx))
370 .error(format!("NFC reader {} error: {}", connection_id, e));
371 }
372 conn_state
373 .close_connection_on_server(server_id, connection_id)
374 .await;
375 Log::new(Some(&status_tx))
376 .info(format!("NFC reader {connection_id} disconnected"));
377 let _ = status_tx.send("__UPDATE_UI__".to_string());
378 })
379 .await;
380 }
381 });
382
383 accept_state
384 .register_server_task(server_id, accept_handle)
385 .await;
386
387 Ok(local_addr)
388 }
389
390 /// Serve one reader connection.
391 ///
392 /// No Idle/Processing/Accumulating state machine is needed here: the vpcd
393 /// framing is explicit, and the next frame is only read after the current
394 /// one has been answered, so a connection can never have two LLM calls in
395 /// flight and there is no partial-read reassembly to do.
396 #[allow(clippy::too_many_arguments)]
397 async fn handle_reader(
398 stream: TcpStream,
399 connection_id: ConnectionId,
400 server_id: ServerId,
401 tag_state: Arc<Mutex<VirtualNfcTag>>,
402 llm_client: OllamaClient,
403 app_state: Arc<AppState>,
404 status_tx: mpsc::UnboundedSender<String>,
405 protocol: Arc<NfcServerProtocol>,
406 first_frame_timeout: std::time::Duration,
407 idle_timeout: std::time::Duration,
408 ) -> Result<()> {
409 let (mut read_half, mut write_half) = tokio::io::split(stream);
410 let mut buffer = vec![0u8; MAX_FRAME_LEN];
411 let log = Log::new(Some(&status_tx));
412 // Has this reader completed a frame yet? Until it has, the shorter bound applies.
413 let mut spoke = false;
414
415 loop {
416 // The deadline wraps this read alone. Everything that can legitimately take a
417 // long time — the model round-trip answering an APDU, a `manual` rule parking it
418 // for a human — happens below, after the read has already returned, so none of it
419 // sits inside a bound that would close the connection it belongs to.
420 let read_timeout = if spoke {
421 idle_timeout
422 } else {
423 first_frame_timeout
424 };
425 let frame_len = match tokio::time::timeout(read_timeout, read_half.read_u16()).await {
426 Err(_) => {
427 log.info(format!(
428 "NFC reader {} sent nothing for {}s; closing idle connection \
429 decision=fail_closed_read_timeout",
430 connection_id,
431 read_timeout.as_secs()
432 ));
433 return Ok(());
434 }
435 Ok(Ok(len)) => len as usize,
436 Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
437 debug!("NFC reader {} closed the connection", connection_id);
438 return Ok(());
439 }
440 Ok(Err(e)) => {
441 debug!("NFC reader {} read error: {}", connection_id, e);
442 return Ok(());
443 }
444 };
445
446 if frame_len == 0 {
447 log.debug(format!(
448 "NFC reader {} sent an empty frame; ignoring",
449 connection_id
450 ));
451 continue;
452 }
453 if frame_len > MAX_FRAME_LEN {
454 // Attacker-controlled length: refuse rather than allocate. Handled
455 // defensively (connection closed), so WARN not ERROR.
456 log.warn(format!(
457 "NFC reader {} announced a {}-byte frame (max {}); closing",
458 connection_id, frame_len, MAX_FRAME_LEN
459 ));
460 return Ok(());
461 }
462
463 // The body of an announced frame is bounded by the *same* deadline the length
464 // prefix was, not by the longer idle one: two bytes must not be enough to buy a
465 // peer the established-connection bound, or the first-byte bound is one `write`
466 // away from being irrelevant.
467 match tokio::time::timeout(read_timeout, read_half.read_exact(&mut buffer[..frame_len]))
468 .await
469 {
470 Err(_) => {
471 log.info(format!(
472 "NFC reader {} announced a {}-byte frame and then sent nothing for {}s; \
473 closing decision=fail_closed_read_timeout",
474 connection_id,
475 frame_len,
476 read_timeout.as_secs()
477 ));
478 return Ok(());
479 }
480 Ok(Err(e)) => {
481 debug!("NFC reader {} disconnected mid-frame: {}", connection_id, e);
482 return Ok(());
483 }
484 Ok(Ok(_)) => {}
485 }
486 // A complete frame has arrived, so this is an established reader and every later
487 // read gets the longer idle bound.
488 spoke = true;
489 let frame = buffer[..frame_len].to_vec();
490 log.trace(format!(
491 "NFC <- {} ({} bytes) from {}",
492 hex::encode_upper(&frame),
493 frame_len,
494 connection_id
495 ));
496
497 if frame_len == 1 {
498 Self::handle_control(
499 frame[0],
500 connection_id,
501 &tag_state,
502 &mut write_half,
503 &status_tx,
504 )
505 .await?;
506 continue;
507 }
508
509 let command = match ApduCommand::parse(&frame) {
510 Ok(command) => command,
511 Err(e) => {
512 // Non-fatal: answered 6700 locally (wire fallback), so WARN.
513 log.warn(format!(
514 "NFC reader {} sent a malformed APDU: {}",
515 connection_id, e
516 ));
517 Self::write_response(
518 &mut write_half,
519 ApduResponse::new(Vec::new(), SW_WRONG_LENGTH.0, SW_WRONG_LENGTH.1),
520 connection_id,
521 &status_tx,
522 )
523 .await?;
524 continue;
525 }
526 };
527
528 let response = Self::respond_to_command(
529 &command,
530 connection_id,
531 server_id,
532 &tag_state,
533 &llm_client,
534 &app_state,
535 &status_tx,
536 protocol.as_ref(),
537 )
538 .await;
539
540 Self::write_response(&mut write_half, response, connection_id, &status_tx).await?;
541 }
542 }
543
544 /// Answer a vpcd control frame.
545 async fn handle_control(
546 code: u8,
547 connection_id: ConnectionId,
548 tag_state: &Arc<Mutex<VirtualNfcTag>>,
549 write_half: &mut WriteHalf<TcpStream>,
550 status_tx: &mpsc::UnboundedSender<String>,
551 ) -> Result<()> {
552 match code {
553 VPCD_CTRL_ATR => {
554 let atr = tag_state.lock().await.atr.clone();
555 Log::new(Some(status_tx)).debug(format!(
556 "NFC reader {} requested ATR ({} bytes)",
557 connection_id,
558 atr.len()
559 ));
560 Self::write_frame(write_half, &atr, connection_id, status_tx).await
561 }
562 VPCD_CTRL_ON | VPCD_CTRL_OFF | VPCD_CTRL_RESET => {
563 // Power on/off/reset are acknowledged by silence in the vpcd
564 // protocol; the reader follows them with an ATR request.
565 Log::new(Some(status_tx)).debug(format!(
566 "NFC reader {} sent power control 0x{:02X}",
567 connection_id, code
568 ));
569 Ok(())
570 }
571 other => {
572 Log::new(Some(status_tx)).warn(format!(
573 "NFC reader {connection_id} sent unknown control code 0x{other:02X}"
574 ));
575 Ok(())
576 }
577 }
578 }
579
580 /// Raise the appropriate event for a command APDU and turn the handler's
581 /// answer into a response APDU.
582 ///
583 /// Fails **closed**: if the handler errors, returns nothing, or returns an
584 /// unusable `respond_to_apdu`, the tag answers `6F00`. It never falls
585 /// through to a success status word.
586 #[allow(clippy::too_many_arguments)]
587 async fn respond_to_command(
588 command: &ApduCommand,
589 connection_id: ConnectionId,
590 server_id: ServerId,
591 tag_state: &Arc<Mutex<VirtualNfcTag>>,
592 llm_client: &OllamaClient,
593 app_state: &Arc<AppState>,
594 status_tx: &mpsc::UnboundedSender<String>,
595 protocol: &NfcServerProtocol,
596 ) -> ApduResponse {
597 let log = Log::new(Some(status_tx));
598 let mut event_data = {
599 let tag = tag_state.lock().await;
600 json!({
601 "tag_type": tag.tag_type,
602 "uid": tag.uid,
603 "ndef_records": tag.ndef_records,
604 })
605 };
606
607 let map = match event_data.as_object_mut() {
608 Some(map) => map,
609 None => return ApduResponse::card_error(),
610 };
611 map.insert("cla".into(), json!(format!("{:02X}", command.cla)));
612 map.insert("p1".into(), json!(format!("{:02X}", command.p1)));
613 map.insert("p2".into(), json!(format!("{:02X}", command.p2)));
614 map.insert("le".into(), json!(command.le));
615
616 let is_select = command.is_select_by_aid();
617 if is_select {
618 map.insert(
619 "application_id".into(),
620 json!(hex::encode_upper(&command.data)),
621 );
622 } else {
623 map.insert("ins".into(), json!(format!("{:02X}", command.ins)));
624 map.insert("ins_name".into(), json!(command.ins_name()));
625 map.insert("lc".into(), json!(command.data.len()));
626 map.insert("data_hex".into(), json!(hex::encode_upper(&command.data)));
627 if let Some(text) = printable_text(&command.data) {
628 map.insert("data_text".into(), json!(text));
629 }
630 }
631
632 let event = if is_select {
633 Event::new(&NFC_TAG_SELECTED_EVENT, event_data)
634 } else {
635 Event::new(&NFC_APDU_RECEIVED_EVENT, event_data)
636 };
637
638 // FileOnly: the nfc_* event template renders the equivalent line to the TUI.
639 log.debug(format!(
640 "NFC {} {} on {} (Lc={}, Le={:?})",
641 event.id(),
642 command.ins_name(),
643 connection_id,
644 command.data.len(),
645 command.le
646 ));
647
648 let execution = match call_llm(
649 llm_client,
650 app_state,
651 server_id,
652 Some(connection_id),
653 &event,
654 protocol,
655 )
656 .await
657 {
658 Ok(execution) => execution,
659 Err(e) => {
660 // The backend erred. The reader still gets an answer, but only a
661 // *category*: 6400 when the backend is saturated (retryable), 6F00
662 // otherwise. The error itself goes to the log and the status stream —
663 // never onto the wire.
664 let failure = WireFailure::classify(&e);
665 let response = ApduResponse::for_wire_failure(failure);
666 // Non-fatal: the tag answers a card error (wire fallback), so WARN.
667 log.warn(format!(
668 "NFC decision=fail_closed_llm_error for {} on {} (category={}, \
669 answering {}): {}",
670 event.id(),
671 connection_id,
672 if failure.is_overloaded() {
673 "overloaded"
674 } else {
675 "unavailable"
676 },
677 response.status_word(),
678 e
679 ));
680 return response;
681 }
682 };
683
684 for message in execution.messages {
685 let _ = status_tx.send(message);
686 }
687
688 let mut response = None;
689 let mut results = Vec::new();
690 flatten_results(execution.protocol_results, &mut results);
691 for result in results {
692 if let ActionResult::Custom { name, data } = result {
693 if name != "respond_to_apdu" {
694 continue;
695 }
696 match decode_apdu_response(&data) {
697 Ok(decoded) => {
698 if response.is_none() {
699 response = Some(decoded);
700 } else {
701 log.warn(format!(
702 "NFC handler returned more than one respond_to_apdu for {}; \
703 ignoring the extra one",
704 connection_id
705 ));
706 }
707 }
708 Err(e) => {
709 log.warn(format!(
710 "NFC decision=fail_closed_undecodable respond_to_apdu on {}: {}",
711 connection_id, e
712 ));
713 }
714 }
715 }
716 }
717
718 match response {
719 Some(response) => {
720 // The handler answered. Tag the two model outcomes apart from each other
721 // and from the fail-closed paths below: a status word outside the
722 // 61xx/62xx/63xx/90xx success-and-warning range is the model refusing,
723 // which is a real answer and must not read like an outage in the log.
724 let decision = if matches!(response.sw1, 0x90 | 0x61 | 0x62 | 0x63) {
725 "model_answer"
726 } else {
727 "model_reject"
728 };
729 log.debug(format!(
730 "NFC decision={} for {} on {} (SW={})",
731 decision,
732 event.id(),
733 connection_id,
734 response.status_word()
735 ));
736 response
737 }
738 None => {
739 // The handler ran and said nothing usable. Distinct from an LLM error
740 // above: this is the model declining to speak, and it fails closed with
741 // 6F00 — never a success.
742 // Non-fatal: the tag answers 6F00 (wire fallback), so WARN.
743 log.warn(format!(
744 "NFC decision=fail_closed_no_action: handler produced no respond_to_apdu \
745 for {} on {}; answering 6F00",
746 event.id(),
747 connection_id
748 ));
749 ApduResponse::card_error()
750 }
751 }
752 }
753
754 /// Apply a startup action to the virtual tag.
755 async fn apply_startup_action(
756 tag_state: Arc<Mutex<VirtualNfcTag>>,
757 result: ActionResult,
758 status_tx: &mpsc::UnboundedSender<String>,
759 ) -> Result<()> {
760 match result {
761 ActionResult::Custom { name, data } => match name.as_str() {
762 "set_atr" => {
763 let atr_hex = data["atr_hex"]
764 .as_str()
765 .ok_or_else(|| anyhow!("Missing atr_hex"))?;
766 let atr = hex::decode(atr_hex)
767 .map_err(|e| anyhow!("Invalid hex in atr_hex ({atr_hex}): {e}"))?;
768 if atr.is_empty() || atr.len() > MAX_FRAME_LEN {
769 return Err(anyhow!(
770 "ATR must be 1..={} bytes, got {}",
771 MAX_FRAME_LEN,
772 atr.len()
773 ));
774 }
775 tag_state.lock().await.atr = atr;
776 Log::new(Some(status_tx)).info(format!("NFC set ATR: {atr_hex}"));
777 Ok(())
778 }
779 "set_ndef_message" => {
780 let records = data["records"]
781 .as_array()
782 .ok_or_else(|| anyhow!("Missing records"))?;
783 tag_state.lock().await.ndef_records = records.clone();
784 Log::new(Some(status_tx))
785 .info(format!("NFC set NDEF message: {} record(s)", records.len()));
786 Ok(())
787 }
788 "respond_to_apdu" => Err(anyhow!(
789 "respond_to_apdu is only valid in response to nfc_apdu_received or \
790 nfc_tag_selected, not at startup"
791 )),
792 other => Err(anyhow!("Unhandled NFC startup action: {other}")),
793 },
794 ActionResult::NoAction => Ok(()),
795 other => Err(anyhow!("Unhandled NFC startup action result: {other:?}")),
796 }
797 }
798
799 /// Send a response APDU as a vpcd frame.
800 async fn write_response(
801 write_half: &mut WriteHalf<TcpStream>,
802 response: ApduResponse,
803 connection_id: ConnectionId,
804 status_tx: &mpsc::UnboundedSender<String>,
805 ) -> Result<()> {
806 let status_word = response.status_word();
807 let bytes = response.into_bytes();
808 // FileOnly: the respond_to_apdu action template already reports the response
809 // to the TUI.
810 Log::new(Some(status_tx)).debug(format!(
811 "NFC response SW={} ({} data byte(s)) to {}",
812 status_word,
813 bytes.len().saturating_sub(2),
814 connection_id
815 ));
816 Self::write_frame(write_half, &bytes, connection_id, status_tx).await
817 }
818
819 /// Send one length-prefixed frame.
820 async fn write_frame(
821 write_half: &mut WriteHalf<TcpStream>,
822 payload: &[u8],
823 connection_id: ConnectionId,
824 status_tx: &mpsc::UnboundedSender<String>,
825 ) -> Result<()> {
826 if payload.len() > u16::MAX as usize {
827 // Cannot be framed; answer the card error instead of truncating (wire
828 // fallback), so WARN not ERROR.
829 Log::new(Some(status_tx)).warn(format!(
830 "NFC response for {} is {} bytes, too large to frame; sending 6F00",
831 connection_id,
832 payload.len()
833 ));
834 let fallback = ApduResponse::card_error().into_bytes();
835 write_half.write_u16(fallback.len() as u16).await?;
836 write_half.write_all(&fallback).await?;
837 write_half.flush().await?;
838 return Ok(());
839 }
840
841 Log::new(Some(status_tx)).trace(format!(
842 "NFC -> {} ({} bytes) to {}",
843 hex::encode_upper(payload),
844 payload.len(),
845 connection_id
846 ));
847 write_half.write_u16(payload.len() as u16).await?;
848 write_half.write_all(payload).await?;
849 write_half.flush().await?;
850 trace!("NFC sent {} byte(s) to {}", payload.len(), connection_id);
851 Ok(())
852 }
853}
854
855/// `ExecutionResult::protocol_results` can nest via `ActionResult::Multiple`;
856/// flatten so a nested `respond_to_apdu` is not silently dropped.
857fn flatten_results(results: Vec<ActionResult>, out: &mut Vec<ActionResult>) {
858 for result in results {
859 match result {
860 ActionResult::Multiple(nested) => flatten_results(nested, out),
861 other => out.push(other),
862 }
863 }
864}
865
866/// Decode the normalised `respond_to_apdu` payload produced by
867/// `NfcServerProtocol::execute_action`.
868fn decode_apdu_response(data: &Value) -> Result<ApduResponse> {
869 let data_hex = data
870 .get("data_hex")
871 .and_then(|v| v.as_str())
872 .unwrap_or_default();
873 let body = if data_hex.is_empty() {
874 Vec::new()
875 } else {
876 hex::decode(data_hex).map_err(|e| anyhow!("Invalid hex in data_hex ({data_hex}): {e}"))?
877 };
878
879 let sw1 = decode_status_byte(data, "sw1")?;
880 let sw2 = decode_status_byte(data, "sw2")?;
881 Ok(ApduResponse::new(body, sw1, sw2))
882}
883
884/// Decode one status byte of a normalised `respond_to_apdu` payload.
885///
886/// **There is deliberately no default.** `execute_action` already refuses an action that
887/// names no status word; this is the second layer, so nothing between the model's answer and
888/// the wire can invent one and 9000 cannot be produced except by a handler that spelled it
889/// out. A missing field here is an error, which the caller logs as
890/// `decision=fail_closed_undecodable` and answers `6F00`.
891fn decode_status_byte(data: &Value, field: &str) -> Result<u8> {
892 let Some(value) = data.get(field).and_then(|v| v.as_str()) else {
893 return Err(anyhow!(
894 "'{field}' is missing from the respond_to_apdu payload; a status word is never \
895 defaulted, because the default would be success"
896 ));
897 };
898 let bytes =
899 hex::decode(value).map_err(|e| anyhow!("Invalid hex in '{field}' ({value}): {e}"))?;
900 match bytes.as_slice() {
901 [byte] => Ok(*byte),
902 other => Err(anyhow!(
903 "'{field}' must be exactly one hex byte, got {} byte(s)",
904 other.len()
905 )),
906 }
907}
908
909/// Render an APDU data field as text when every byte is printable ASCII, so the
910/// model gets something readable alongside the hex.
911fn printable_text(data: &[u8]) -> Option<String> {
912 if data.is_empty() {
913 return None;
914 }
915 if data
916 .iter()
917 .all(|&b| b.is_ascii_graphic() || b == b' ' || b == b'\t')
918 {
919 Some(String::from_utf8_lossy(data).to_string())
920 } else {
921 None
922 }
923}