@@ -14,7 +14,7 @@ use axum::{
1414 Router ,
1515} ;
1616use password_hash:: { PasswordHash , PasswordHasher , PasswordVerifier , SaltString } ;
17- use rand_core:: OsRng ;
17+ use rand_core:: { OsRng , RngCore } ;
1818use serde:: Deserialize ;
1919use serde_json:: json;
2020use tokio:: sync:: Mutex ;
@@ -1667,6 +1667,134 @@ async fn api_pair(
16671667 )
16681668}
16691669
1670+ // --- Connect slots (pre-authorised pairing with secret) ---
1671+
1672+ #[ derive( Deserialize ) ]
1673+ struct CreateSlotRequest {
1674+ label : String ,
1675+ }
1676+
1677+ /// `POST /api/slots/create` — create a pre-authorised connect slot.
1678+ ///
1679+ /// Generates a random secret, stores it in `slots.json`, and returns a
1680+ /// bunker URI with `&secret=<secret>` embedded. When a NIP-46 client
1681+ /// connects with the matching secret, the bunker auto-approves it with
1682+ /// the slot's label.
1683+ async fn api_create_slot (
1684+ State ( state) : State < Arc < AppState > > ,
1685+ axum:: Json ( req) : axum:: Json < CreateSlotRequest > ,
1686+ ) -> impl IntoResponse {
1687+ let label = req. label . trim ( ) . to_string ( ) ;
1688+ if label. is_empty ( ) || label. len ( ) > 64 {
1689+ return (
1690+ StatusCode :: BAD_REQUEST ,
1691+ axum:: Json ( json ! ( { "error" : "label must be 1–64 characters" } ) ) ,
1692+ ) ;
1693+ }
1694+
1695+ // Generate random secret (32 bytes hex)
1696+ let mut secret_bytes = [ 0u8 ; 32 ] ;
1697+ rand_core:: OsRng . fill_bytes ( & mut secret_bytes) ;
1698+ let secret: String = secret_bytes. iter ( ) . map ( |b| format ! ( "{b:02x}" ) ) . collect ( ) ;
1699+
1700+ // Read bunker URI
1701+ let bunker_uri = match std:: fs:: read_to_string ( state. data_file ( "bunker-uri.txt" ) ) {
1702+ Ok ( uri) => uri. trim ( ) . to_string ( ) ,
1703+ Err ( _) => {
1704+ return (
1705+ StatusCode :: INTERNAL_SERVER_ERROR ,
1706+ axum:: Json ( json ! ( { "error" : "bunker not running" } ) ) ,
1707+ ) ;
1708+ }
1709+ } ;
1710+
1711+ // Load existing slots
1712+ let slots_path = state. data_file ( "slots.json" ) ;
1713+ let mut slots: serde_json:: Value = std:: fs:: read_to_string ( & slots_path)
1714+ . ok ( )
1715+ . and_then ( |s| serde_json:: from_str ( & s) . ok ( ) )
1716+ . unwrap_or_else ( || json ! ( { } ) ) ;
1717+
1718+ if !slots. is_object ( ) {
1719+ slots = json ! ( { } ) ;
1720+ }
1721+
1722+ // Store slot keyed by secret
1723+ slots. as_object_mut ( ) . unwrap ( ) . insert (
1724+ secret. clone ( ) ,
1725+ json ! ( {
1726+ "label" : label,
1727+ "createdAt" : chrono_now_iso( ) ,
1728+ } ) ,
1729+ ) ;
1730+
1731+ if let Err ( e) = std:: fs:: write ( & slots_path, serde_json:: to_string_pretty ( & slots) . unwrap ( ) ) {
1732+ tracing:: error!( "Failed to write slots.json: {e}" ) ;
1733+ return (
1734+ StatusCode :: INTERNAL_SERVER_ERROR ,
1735+ axum:: Json ( json ! ( { "error" : "failed to save slot" } ) ) ,
1736+ ) ;
1737+ }
1738+ // Restrictive permissions on slots.json (contains secrets)
1739+ #[ cfg( unix) ]
1740+ {
1741+ use std:: os:: unix:: fs:: PermissionsExt ;
1742+ let _ = std:: fs:: set_permissions ( & slots_path, std:: fs:: Permissions :: from_mode ( 0o600 ) ) ;
1743+ }
1744+
1745+ // Build bunker URI with secret
1746+ let slot_uri = format ! ( "{}&secret={}" , bunker_uri, secret) ;
1747+ info ! ( "Created connect slot '{}' with secret {}..." , label, & secret[ ..12 ] ) ;
1748+
1749+ (
1750+ StatusCode :: OK ,
1751+ axum:: Json ( json ! ( {
1752+ "label" : label,
1753+ "secret" : secret,
1754+ "bunker_uri" : slot_uri,
1755+ } ) ) ,
1756+ )
1757+ }
1758+
1759+ /// `GET /api/slots` — list all connect slots with bunker URIs.
1760+ async fn api_list_slots ( State ( state) : State < Arc < AppState > > ) -> impl IntoResponse {
1761+ let slots_path = state. data_file ( "slots.json" ) ;
1762+ let raw: serde_json:: Value = std:: fs:: read_to_string ( & slots_path)
1763+ . ok ( )
1764+ . and_then ( |s| serde_json:: from_str ( & s) . ok ( ) )
1765+ . unwrap_or_else ( || json ! ( { } ) ) ;
1766+
1767+ let bunker_uri = std:: fs:: read_to_string ( state. data_file ( "bunker-uri.txt" ) )
1768+ . ok ( )
1769+ . map ( |s| s. trim ( ) . to_string ( ) )
1770+ . unwrap_or_default ( ) ;
1771+
1772+ // Transform { secret: { label, ... } } → array of { label, secret, bunker_uri, clients }
1773+ let slots: Vec < serde_json:: Value > = raw
1774+ . as_object ( )
1775+ . map ( |obj| {
1776+ obj. iter ( )
1777+ . map ( |( secret, info) | {
1778+ let slot_uri = if bunker_uri. is_empty ( ) {
1779+ String :: new ( )
1780+ } else {
1781+ format ! ( "{}&secret={}" , bunker_uri, secret)
1782+ } ;
1783+ json ! ( {
1784+ "label" : info. get( "label" ) . and_then( |v| v. as_str( ) ) . unwrap_or( "unnamed" ) ,
1785+ "secret" : secret,
1786+ "bunker_uri" : slot_uri,
1787+ "createdAt" : info. get( "createdAt" ) ,
1788+ "clients" : info. get( "clients" ) . cloned( ) . unwrap_or_else( || json!( [ ] ) ) ,
1789+ } )
1790+ } )
1791+ . collect ( )
1792+ } )
1793+ . unwrap_or_default ( ) ;
1794+
1795+ axum:: Json ( json ! ( slots) )
1796+ }
1797+
16701798/// Frame type bytes for the ESP32 serial protocol.
16711799const FRAME_MAGIC : [ u8 ; 2 ] = [ 0x48 , 0x57 ] ;
16721800const FRAME_TYPE_SET_PIN : u8 = 0x25 ;
@@ -1902,6 +2030,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
19022030 . route ( "/api/clients/revoke" , post ( api_revoke_client) )
19032031 . route ( "/api/clients/clear" , post ( api_clear_clients) )
19042032 . route ( "/api/pair" , post ( api_pair) )
2033+ . route ( "/api/slots" , get ( api_list_slots) )
2034+ . route ( "/api/slots/create" , post ( api_create_slot) )
19052035 . route ( "/api/generate-mnemonic" , get ( api_generate_mnemonic) )
19062036 . route ( "/api/wordlist" , get ( api_wordlist) )
19072037 . route ( "/api/client-keys" , get ( api_list_client_keys) )
0 commit comments