Skip to main content

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}