1818
1919from chris_plugin import chris_plugin
2020
21- __version__ = "0.1.4 "
21+ __version__ = "0.1.7 "
2222
2323APP_PACKAGE = "fedmed_flower_app"
2424APP_DIR = Path (resources .files (APP_PACKAGE ))
@@ -81,6 +81,44 @@ def build_parser() -> ArgumentParser:
8181 help = "fraction of clients used for evaluation" ,
8282 )
8383 parser .add_argument ("--json" , action = "store_true" , help = argparse .SUPPRESS )
84+
85+ # NEW optional bastion-related arguments (for reverse tunnelling)
86+ parser .add_argument ("--bastion-host" , default = None , help = "SSH bastion hostname for reverse tunneling" )
87+ parser .add_argument ("--bastion-user" , default = None , help = "SSH user on bastion" )
88+ parser .add_argument ("--bastion-port" , type = int , default = 22 , help = "SSH port on bastion" )
89+
90+ parser .add_argument (
91+ "--bastion-key" ,
92+ type = str ,
93+ default = "id_ed25519" ,
94+ help = "path to SSH private key inside container" ,
95+ )
96+ parser .add_argument (
97+ "--bastion-known-hosts" ,
98+ type = str ,
99+ default = "known_hosts" ,
100+ help = "path to known_hosts file; if missing, host key checking is relaxed" ,
101+ )
102+
103+ parser .add_argument (
104+ "--bastion-fleet-port" ,
105+ type = int ,
106+ default = 19092 ,
107+ help = "remote port on bastion forwarding to Fleet API" ,
108+ )
109+ parser .add_argument (
110+ "--bastion-control-port" ,
111+ type = int ,
112+ default = 19093 ,
113+ help = "remote port on bastion forwarding to Control API" ,
114+ )
115+ parser .add_argument (
116+ "--bastion-serverapp-port" ,
117+ type = int ,
118+ default = 19091 ,
119+ help = "remote port on bastion forwarding to ServerAppIo" ,
120+ )
121+
84122 parser .add_argument (
85123 "-V" ,
86124 "--version" ,
@@ -90,6 +128,7 @@ def build_parser() -> ArgumentParser:
90128 return parser
91129
92130
131+
93132parser = build_parser ()
94133
95134
@@ -171,6 +210,175 @@ def _prepare_environment(state_dir: str) -> tuple[dict[str, str], Path]:
171210 env ["FLWR_HOME" ] = str (flwr_home )
172211 return env , flwr_home
173212
213+ def _resolve_input_file (inputdir : Path , raw_path : str ) -> Path | None :
214+ """
215+ Resolve a file that is supposed to live under the plugin's input directory.
216+
217+ Cases:
218+ - relative path: look in inputdir / raw_path, else search by basename
219+ - absolute path starting with /incoming: treat it as a hint and search
220+ under inputdir for the same basename
221+ - any other absolute path: only use it if it actually exists
222+ """
223+ base = inputdir
224+ p = Path (raw_path )
225+
226+ # Case 1: relative path like "id_ed25519" or "keys/id_ed25519"
227+ if not p .is_absolute ():
228+ candidate = base / p
229+ if candidate .is_file ():
230+ print (f"[fedmed-pl-superlink] using bastion file: { candidate } " , flush = True )
231+ return candidate
232+
233+ # try by basename anywhere under inputdir
234+ basename = p .name
235+ matches = list (base .rglob (basename ))
236+ if matches :
237+ chosen = matches [0 ]
238+ print (
239+ f"[fedmed-pl-superlink] resolved { raw_path } -> { chosen } (found under { base } )" ,
240+ flush = True ,
241+ )
242+ return chosen
243+
244+ print (
245+ f"[fedmed-pl-superlink] ERROR: could not find { raw_path } under { base } " ,
246+ flush = True ,
247+ )
248+ return None
249+
250+ # Case 2: absolute path under /incoming – treat as hint
251+ if str (p ).startswith ("/incoming/" ):
252+ basename = p .name
253+ if base .exists ():
254+ matches = list (base .rglob (basename ))
255+ if matches :
256+ chosen = matches [0 ]
257+ print (
258+ f"[fedmed-pl-superlink] resolved { raw_path } -> { chosen } (found under { base } )" ,
259+ flush = True ,
260+ )
261+ return chosen
262+ print (
263+ f"[fedmed-pl-superlink] ERROR: { raw_path } not usable and nothing named { basename } under { base } " ,
264+ flush = True ,
265+ )
266+ return None
267+
268+ # Case 3: other absolute path – only accept if it actually exists
269+ if p .is_file ():
270+ print (f"[fedmed-pl-superlink] using bastion file (absolute): { p } " , flush = True )
271+ return p
272+
273+ print (
274+ f"[fedmed-pl-superlink] ERROR: absolute path { raw_path } does not exist" ,
275+ flush = True ,
276+ )
277+ return None
278+
279+ # Added for reverse tunelling
280+ def _maybe_open_reverse_tunnels (
281+ options : Namespace ,
282+ inputdir : Path ,
283+ fleet_local : str ,
284+ control_local : str ,
285+ serverapp_local : str ,
286+ ) -> Process | None :
287+ """Optionally open reverse SSH tunnels from this container to a bastion.
288+
289+ Exposes bastion:<bastion_*_port> -> container:<local ports>.
290+ If bastion_host or bastion_user is unset, this is a no-op.
291+ """
292+ bastion_host = options .bastion_host
293+ bastion_user = options .bastion_user
294+ if not bastion_host or not bastion_user :
295+ return None
296+
297+ # Resolve original key under /share/incoming (read-only bind mount)
298+ orig_key_path = _resolve_input_file (inputdir , options .bastion_key )
299+ if orig_key_path is None :
300+ print ("[fedmed-pl-superlink] WARNING: no valid SSH key found; skipping reverse tunnels" , flush = True )
301+ return None
302+
303+ # 🔑 Copy key to a writable location and fix permissions there
304+ keys_dir = Path ("/tmp/fedmed-ssh" )
305+ keys_dir .mkdir (parents = True , exist_ok = True )
306+
307+ key_path = keys_dir / orig_key_path .name
308+ try :
309+ shutil .copy2 (orig_key_path , key_path )
310+ key_path .chmod (0o600 )
311+ print (
312+ f"[fedmed-pl-superlink] copied bastion key to { key_path } and set permissions 0600" ,
313+ flush = True ,
314+ )
315+ except Exception as exc :
316+ print (
317+ f"[fedmed-pl-superlink] ERROR: failed to copy/chmod bastion key: { exc } " ,
318+ flush = True ,
319+ )
320+ return None
321+
322+ # Optional: handle known_hosts similarly
323+ known_hosts_path = None
324+ if options .bastion_known_hosts :
325+ orig_known = _resolve_input_file (inputdir , options .bastion_known_hosts )
326+ if orig_known and orig_known .is_file ():
327+ try :
328+ known_hosts_path = keys_dir / "known_hosts"
329+ shutil .copy2 (orig_known , known_hosts_path )
330+ print (
331+ f"[fedmed-pl-superlink] copied known_hosts to { known_hosts_path } " ,
332+ flush = True ,
333+ )
334+ except Exception as exc :
335+ print (
336+ f"[fedmed-pl-superlink] WARNING: failed to copy known_hosts: { exc } " ,
337+ flush = True ,
338+ )
339+ known_hosts_path = None
340+
341+ ssh_opts : list [str ] = [
342+ "-o" , "ServerAliveInterval=30" ,
343+ "-o" , "ServerAliveCountMax=3" ,
344+ "-o" , "ExitOnForwardFailure=yes" ,
345+ ]
346+
347+ if known_hosts_path and known_hosts_path .exists ():
348+ ssh_opts .extend ([
349+ "-o" , f"UserKnownHostsFile={ known_hosts_path } " ,
350+ "-o" , "StrictHostKeyChecking=yes" ,
351+ ])
352+ else :
353+ ssh_opts .extend (["-o" , "StrictHostKeyChecking=no" ])
354+
355+ cmd : list [str ] = [
356+ "ssh" ,
357+ "-vv" ,
358+ "-N" ,
359+ * ssh_opts ,
360+ "-p" , str (options .bastion_port ),
361+ "-i" , str (key_path ),
362+ "-R" , f"0.0.0.0:{ options .bastion_fleet_port } :{ fleet_local } " ,
363+ "-R" , f"0.0.0.0:{ options .bastion_control_port } :{ control_local } " ,
364+ "-R" , f"0.0.0.0:{ options .bastion_serverapp_port } :{ serverapp_local } " ,
365+ f"{ bastion_user } @{ bastion_host } " ,
366+ ]
367+
368+ #_check_superlink_reachable(options.superlink_host, options.bastion_port)
369+
370+ print (f"[fedmed-pl-superlink] opening reverse tunnels: { ' ' .join (cmd )} " , flush = True )
371+ proc = subprocess .Popen (
372+ cmd ,
373+ stdout = subprocess .PIPE ,
374+ stderr = subprocess .PIPE ,
375+ text = True ,
376+ )
377+ _register_child (proc )
378+ threading .Thread (target = _stream_lines , args = (proc .stdout , "ssh" ), daemon = True ).start ()
379+ threading .Thread (target = _stream_lines , args = (proc .stderr , "ssh" ), daemon = True ).start ()
380+ return proc
381+
174382
175383def handle_signals () -> None :
176384 def _handle (signum , _frame ): # type: ignore[override]
@@ -289,9 +497,21 @@ def main(options: Namespace, inputdir: Path, outputdir: Path) -> None:
289497 "===============================\n " ,
290498 flush = True ,
291499 )
292- del inputdir
293500 handle_signals ()
294501
502+ # DEBUG: show what pl-dircopy actually put into /incoming
503+ print (f"[fedmed-pl-superlink] DEBUG: inputdir = { inputdir } " , flush = True )
504+ root = inputdir
505+ if not root .exists ():
506+ print (f"[fedmed-pl-superlink] DEBUG: { root } does not exist" , flush = True )
507+ else :
508+ for p in root .rglob ("*" ):
509+ try :
510+ rel = p .relative_to (root )
511+ except ValueError :
512+ rel = p
513+ print (f" { root } /{ rel } " , flush = True )
514+
295515 if getattr (options , "json" , False ):
296516 emit_plugin_json ()
297517 return
@@ -330,11 +550,27 @@ def main(options: Namespace, inputdir: Path, outputdir: Path) -> None:
330550 + ", " .join (reachable_ips ),
331551 flush = True ,
332552 )
553+ # Use the first container IP as the target for SSH -R
554+ tunnel_ip = reachable_ips [0 ]
555+ print (
556+ f"[fedmed-pl-superlink] using { tunnel_ip } as SSH -R backend target" ,
557+ flush = True ,
558+ )
333559 else :
334560 print (
335- "[fedmed-pl-superlink] unable to auto-detect host IPs." ,
561+ "[fedmed-pl-superlink] unable to auto-detect host IPs; "
562+ "falling back to 127.0.0.1 for SSH -R backend (may fail on host)" ,
336563 flush = True ,
337564 )
565+ tunnel_ip = "127.0.0.1"
566+
567+ # Local targets *from the EC2 host's perspective* for SSH -R
568+ fleet_local = f"{ tunnel_ip } :{ options .fleet_port } "
569+ control_local = f"{ tunnel_ip } :{ options .control_port } "
570+ serverapp_local = f"{ tunnel_ip } :{ options .serverapp_port } "
571+
572+ # Open reverse tunnels to bastion (no-op if bastion_* not set)
573+ _maybe_open_reverse_tunnels (options , inputdir , fleet_local , control_local , serverapp_local )
338574
339575 superlink = _launch_superlink (addresses , env )
340576 time .sleep (max (0 , options .startup_delay ))
0 commit comments