tunnel-manager 1.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunnel-manager might be problematic. Click here for more details.

tests/test_tunnel.py CHANGED
@@ -14,7 +14,6 @@ def test_password_authentication():
14
14
  remote_host="10.0.0.11",
15
15
  username=username,
16
16
  password=password,
17
- log_file="tunnel_password.log",
18
17
  )
19
18
 
20
19
  # Connect to the remote host
@@ -29,10 +28,11 @@ def test_password_authentication():
29
28
  print(f"Command output: {out}")
30
29
  # Example file transfer (uncomment to test, ensure files exist)
31
30
  tunnel.send_file(
32
- "./tests/local_test.txt", "/home/genius/Downloads/remote_test.txt"
31
+ "/home/genius/Development/inventory/inventory.yml",
32
+ "/home/genius/Downloads/remote_test.txt",
33
33
  )
34
34
  tunnel.receive_file(
35
- "/home/genius/Downloads/remote_test.txt", "./tests/downloaded_test.txt"
35
+ "/home/genius/Downloads/remote_test.txt", "./tests/downloaded_inventory.txt"
36
36
  )
37
37
 
38
38
  tunnel.close()
@@ -49,7 +49,6 @@ def test_key_authentication():
49
49
  remote_host="10.0.0.11",
50
50
  username=username,
51
51
  identity_file=os.path.expanduser("~/.ssh/id_rsa"),
52
- log_file="tunnel_key.log",
53
52
  )
54
53
 
55
54
  # Connect to the remote host
@@ -68,9 +68,6 @@ class Tunnel:
68
68
  raise ValueError("Either identity_file or password must be provided.")
69
69
 
70
70
  def connect(self):
71
- """
72
- Establish the SSH connection if not already connected.
73
- """
74
71
  if (
75
72
  self.ssh_client
76
73
  and self.ssh_client.get_transport()
@@ -88,7 +85,18 @@ class Tunnel:
88
85
 
89
86
  try:
90
87
  if self.identity_file:
91
- private_key = paramiko.RSAKey.from_private_key_file(self.identity_file)
88
+ # Try loading as ED25519 key first
89
+ try:
90
+ private_key = paramiko.Ed25519Key.from_private_key_file(
91
+ self.identity_file
92
+ )
93
+ self.logger.info(f"Loaded ED25519 key from: {self.identity_file}")
94
+ except paramiko.ssh_exception.SSHException:
95
+ # Fallback to RSA key
96
+ private_key = paramiko.RSAKey.from_private_key_file(
97
+ self.identity_file
98
+ )
99
+ self.logger.info(f"Loaded RSA key from: {self.identity_file}")
92
100
  if self.certificate_file:
93
101
  private_key.load_certificate(self.certificate_file)
94
102
  self.logger.info(f"Loaded certificate: {self.certificate_file}")
@@ -141,18 +149,60 @@ class Tunnel:
141
149
  def send_file(self, local_path, remote_path):
142
150
  """
143
151
  Send (upload) a file to the remote host.
144
-
145
152
  :param local_path: Path to the local file.
146
153
  :param remote_path: Path on the remote host.
147
154
  """
148
155
  self.connect()
149
156
  try:
157
+ # Normalize paths for consistency
158
+ local_path = os.path.abspath(os.path.expanduser(local_path))
159
+ remote_path = os.path.expanduser(
160
+ remote_path
161
+ ) # ~ expansion for remote, but paramiko handles it
162
+
163
+ self.logger.debug(
164
+ f"send_file: local_path='{local_path}', remote_path='{remote_path}'"
165
+ )
166
+ self.logger.debug(f"send_file: CWD={os.getcwd()}")
167
+
168
+ # Explicit checks before SFTP
169
+ if not os.path.exists(local_path):
170
+ err_msg = f"Local file does not exist: {local_path}"
171
+ self.logger.error(err_msg)
172
+ raise IOError(err_msg)
173
+ if not os.path.isfile(local_path):
174
+ err_msg = (
175
+ f"Local path is not a regular file (dir/symlink?): {local_path}"
176
+ )
177
+ self.logger.error(err_msg)
178
+ raise IOError(err_msg)
179
+ if not os.access(local_path, os.R_OK):
180
+ err_msg = f"No read permission for local file: {local_path}"
181
+ self.logger.error(err_msg)
182
+ raise PermissionError(err_msg)
183
+
184
+ # Test binary open (mimics what sftp.put does)
185
+ try:
186
+ with open(local_path, "rb") as f:
187
+ sample = f.read(1024) # Read a chunk to simulate transfer
188
+ self.logger.debug(
189
+ f"Binary open successful for {local_path}, sample size: {len(sample)} bytes"
190
+ )
191
+ except Exception as open_err:
192
+ err_msg = f"Failed to open {local_path} in binary mode: {str(open_err)}"
193
+ self.logger.error(err_msg)
194
+ raise IOError(err_msg)
195
+
150
196
  if not self.sftp:
151
197
  self.sftp = self.ssh_client.open_sftp()
198
+ self.logger.debug(f"Opening SFTP for put: {local_path} -> {remote_path}")
152
199
  self.sftp.put(local_path, remote_path)
153
200
  self.logger.info(f"File sent: {local_path} -> {remote_path}")
154
201
  except Exception as e:
155
- self.logger.error(f"File send failed: {str(e)}")
202
+ self.logger.error(f"File send failed: {str(e)} (type: {type(e).__name__})")
203
+ import traceback
204
+
205
+ self.logger.error(traceback.format_exc())
156
206
  raise
157
207
  finally:
158
208
  if self.sftp:
@@ -234,13 +284,14 @@ class Tunnel:
234
284
  self.ssh_client = None
235
285
 
236
286
  def setup_passwordless_ssh(
237
- self, local_key_path=os.path.expanduser("~/.ssh/id_rsa")
287
+ self, local_key_path=os.path.expanduser("~/.ssh/id_rsa"), key_type="ed25519"
238
288
  ):
239
289
  """
240
290
  Set up passwordless SSH by copying a public key to the remote host.
241
291
  Requires password-based authentication to be configured.
242
292
 
243
293
  :param local_key_path: Path to the local private key (public key is assumed to be .pub).
294
+ :param key_type: Type of key to generate ('rsa' or 'ed25519', default: 'rsa').
244
295
  """
245
296
  if not self.password:
246
297
  raise ValueError("Password-based authentication required for setup.")
@@ -248,9 +299,17 @@ class Tunnel:
248
299
  local_key_path = os.path.expanduser(local_key_path)
249
300
  pub_key_path = local_key_path + ".pub"
250
301
 
302
+ if key_type not in ["rsa", "ed25519"]:
303
+ raise ValueError("key_type must be 'rsa' or 'ed25519'")
304
+
251
305
  if not os.path.exists(pub_key_path):
252
- os.system(f"ssh-keygen -t rsa -b 4096 -f {local_key_path} -N ''")
253
- self.logger.info(f"Generated key pair: {local_key_path}, {pub_key_path}")
306
+ if key_type == "rsa":
307
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {local_key_path} -N ''")
308
+ else: # ed25519
309
+ os.system(f"ssh-keygen -t ed25519 -f {local_key_path} -N ''")
310
+ self.logger.info(
311
+ f"Generated {key_type} key pair: {local_key_path}, {pub_key_path}"
312
+ )
254
313
 
255
314
  with open(pub_key_path, "r") as f:
256
315
  pub_key = f.read().strip()
@@ -261,7 +320,7 @@ class Tunnel:
261
320
  self.run_command(f"echo '{pub_key}' >> ~/.ssh/authorized_keys")
262
321
  self.run_command("chmod 600 ~/.ssh/authorized_keys")
263
322
  self.logger.info(
264
- f"Set up passwordless SSH for {self.username}@{self.remote_host}"
323
+ f"Set up passwordless SSH for {self.username}@{self.remote_host} with {key_type} key"
265
324
  )
266
325
  except Exception as e:
267
326
  self.logger.error(f"Failed to set up passwordless SSH: {str(e)}")
@@ -270,7 +329,9 @@ class Tunnel:
270
329
  self.close()
271
330
 
272
331
  @staticmethod
273
- def execute_on_all(inventory, func, group="all", parallel=False, max_threads=5):
332
+ def execute_on_inventory(
333
+ inventory, func, group="all", parallel=False, max_threads=5
334
+ ):
274
335
  """
275
336
  Execute a function on all hosts in the specified group of the YAML inventory, sequentially or in parallel.
276
337
  :param inventory: Path to the YAML inventory file.
@@ -400,16 +461,23 @@ class Tunnel:
400
461
  f"Copied SSH config to {remote_config_path} on {self.remote_host}"
401
462
  )
402
463
 
403
- def rotate_ssh_key(self, new_key_path):
464
+ def rotate_ssh_key(self, new_key_path, key_type="ed25519"):
404
465
  """
405
466
  Rotate the SSH key by generating a new pair and updating authorized_keys.
406
467
  :param new_key_path: Path for the new private key.
468
+ :param key_type: Type of key to generate ('rsa' or 'ed25519', default: 'rsa').
407
469
  """
408
470
  new_key_path = os.path.expanduser(new_key_path)
409
471
  new_pub_path = new_key_path + ".pub"
472
+ if key_type not in ["rsa", "ed25519"]:
473
+ raise ValueError("key_type must be 'rsa' or 'ed25519'")
474
+
410
475
  if not os.path.exists(new_key_path):
411
- os.system(f"ssh-keygen -t rsa -b 4096 -f {new_key_path} -N ''")
412
- self.logger.info(f"Generated new key pair: {new_key_path}")
476
+ if key_type == "rsa":
477
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {new_key_path} -N ''")
478
+ else: # ed25519
479
+ os.system(f"ssh-keygen -t ed25519 -f {new_key_path} -N ''")
480
+ self.logger.info(f"Generated new {key_type} key pair: {new_key_path}")
413
481
 
414
482
  with open(new_pub_path, "r") as f:
415
483
  new_pub = f.read().strip()
@@ -433,7 +501,6 @@ class Tunnel:
433
501
  new_auth.append(new_pub)
434
502
 
435
503
  temp_file = "/tmp/authorized_keys.new"
436
- # Construct the command string without escape sequences in f-string
437
504
  new_auth_joined = "\n".join(new_auth)
438
505
  self.run_command(f"echo '{new_auth_joined}' > {temp_file}")
439
506
  self.run_command(f"mv {temp_file} ~/.ssh/authorized_keys")
@@ -441,7 +508,9 @@ class Tunnel:
441
508
 
442
509
  self.identity_file = new_key_path
443
510
  self.password = None
444
- self.logger.info(f"Rotated key to {new_key_path} on {self.remote_host}")
511
+ self.logger.info(
512
+ f"Rotated {key_type} key to {new_key_path} on {self.remote_host}"
513
+ )
445
514
  logging.info(
446
515
  f"Please update SSH config for {self.remote_host} IdentityFile to {new_key_path}"
447
516
  )
@@ -450,6 +519,7 @@ class Tunnel:
450
519
  def setup_all_passwordless_ssh(
451
520
  inventory,
452
521
  shared_key_path=os.path.expanduser("~/.ssh/id_shared"),
522
+ key_type="ed25519",
453
523
  group="all",
454
524
  parallel=False,
455
525
  max_threads=5,
@@ -458,16 +528,23 @@ class Tunnel:
458
528
  Set up passwordless SSH for all hosts in the specified group of the YAML inventory.
459
529
  :param inventory: Path to the YAML inventory file.
460
530
  :param shared_key_path: Path to a shared private key (optional, generates if missing).
531
+ :param key_type: Type of key to generate ('rsa' or 'ed25519', default: 'rsa').
461
532
  :param group: Inventory group to target (default: 'all').
462
533
  :param parallel: Run in parallel.
463
534
  :param max_threads: Max threads for parallel.
464
535
  """
465
536
  shared_key_path = os.path.expanduser(shared_key_path)
466
537
  shared_pub_key_path = shared_key_path + ".pub"
538
+ if key_type not in ["rsa", "ed25519"]:
539
+ raise ValueError("key_type must be 'rsa' or 'ed25519'")
540
+
467
541
  if not os.path.exists(shared_key_path):
468
- os.system(f"ssh-keygen -t rsa -b 4096 -f {shared_key_path} -N ''")
542
+ if key_type == "rsa":
543
+ os.system(f"ssh-keygen -t rsa -b 4096 -f {shared_key_path} -N ''")
544
+ else: # ed25519
545
+ os.system(f"ssh-keygen -t ed25519 -f {shared_key_path} -N ''")
469
546
  logging.info(
470
- f"Generated shared key pair: {shared_key_path}, {shared_pub_key_path}"
547
+ f"Generated shared {key_type} key pair: {shared_key_path}, {shared_pub_key_path}"
471
548
  )
472
549
 
473
550
  with open(shared_pub_key_path, "r") as f:
@@ -487,13 +564,13 @@ class Tunnel:
487
564
  password=password,
488
565
  )
489
566
  tunnel.remove_host_key()
490
- tunnel.setup_passwordless_ssh(local_key_path=key_path)
567
+ tunnel.setup_passwordless_ssh(local_key_path=key_path, key_type=key_type)
491
568
 
492
569
  try:
493
570
  tunnel.connect()
494
571
  tunnel.run_command(f"echo '{shared_pub_key}' >> ~/.ssh/authorized_keys")
495
572
  tunnel.run_command("chmod 600 ~/.ssh/authorized_keys")
496
- logging.info(f"Added shared key to {username}@{hostname}")
573
+ logging.info(f"Added shared {key_type} key to {username}@{hostname}")
497
574
  except Exception as e:
498
575
  logging.error(
499
576
  f"Failed to add shared key to {username}@{hostname}: {str(e)}"
@@ -501,13 +578,13 @@ class Tunnel:
501
578
  finally:
502
579
  tunnel.close()
503
580
 
504
- result, msg = tunnel.test_key_auth(shared_key_path)
581
+ result, msg = tunnel.test_key_auth(key_path)
505
582
  logging.info(f"Key auth test for {username}@{hostname}: {msg}")
506
583
 
507
- Tunnel.execute_on_all(inventory, setup_host, group, parallel, max_threads)
584
+ Tunnel.execute_on_inventory(inventory, setup_host, group, parallel, max_threads)
508
585
 
509
586
  @staticmethod
510
- def run_command_on_all(
587
+ def run_command_on_inventory(
511
588
  inventory, command, group="all", parallel=False, max_threads=5
512
589
  ):
513
590
  """
@@ -543,7 +620,9 @@ class Tunnel:
543
620
  print(f"Error on {host['hostname']}: {str(e)}", file=sys.stderr)
544
621
 
545
622
  try:
546
- Tunnel.execute_on_all(inventory, run_host, group, parallel, max_threads)
623
+ Tunnel.execute_on_inventory(
624
+ inventory, run_host, group, parallel, max_threads
625
+ )
547
626
  print(f"Completed command execution on group '{group}'")
548
627
  except Exception as e:
549
628
  logger.error(f"Failed to execute command on group '{group}': {str(e)}")
@@ -553,7 +632,7 @@ class Tunnel:
553
632
  raise
554
633
 
555
634
  @staticmethod
556
- def copy_ssh_config_on_all(
635
+ def copy_ssh_config_on_inventory(
557
636
  inventory,
558
637
  local_config_path,
559
638
  remote_config_path=os.path.expanduser("~/.ssh/config"),
@@ -581,12 +660,13 @@ class Tunnel:
581
660
  tunnel.copy_ssh_config(local_config_path, remote_config_path)
582
661
  tunnel.close()
583
662
 
584
- Tunnel.execute_on_all(inventory, copy_host, group, parallel, max_threads)
663
+ Tunnel.execute_on_inventory(inventory, copy_host, group, parallel, max_threads)
585
664
 
586
665
  @staticmethod
587
- def rotate_ssh_key_on_all(
666
+ def rotate_ssh_key_on_inventory(
588
667
  inventory,
589
668
  key_prefix=os.path.expanduser("~/.ssh/id_"),
669
+ key_type="ed25519",
590
670
  group="all",
591
671
  parallel=False,
592
672
  max_threads=5,
@@ -595,6 +675,7 @@ class Tunnel:
595
675
  Rotate SSH keys for all hosts in the specified group of the YAML inventory.
596
676
  :param inventory: Path to the YAML inventory file.
597
677
  :param key_prefix: Prefix for new key paths (appends hostname).
678
+ :param key_type: Type of key to generate ('rsa' or 'ed25519', default: 'rsa').
598
679
  :param group: Inventory group to target (default: 'all').
599
680
  :param parallel: Run in parallel.
600
681
  :param max_threads: Max threads for parallel.
@@ -608,16 +689,18 @@ class Tunnel:
608
689
  password=host.get("password"),
609
690
  identity_file=host.get("key_path"),
610
691
  )
611
- tunnel.rotate_ssh_key(new_key_path)
692
+ tunnel.rotate_ssh_key(new_key_path, key_type=key_type)
612
693
  logging.info(
613
- f"Rotated key for {host['hostname']}. Update inventory key_path to {new_key_path} if needed."
694
+ f"Rotated {key_type} key for {host['hostname']}. Update inventory key_path to {new_key_path} if needed."
614
695
  )
615
696
  tunnel.close()
616
697
 
617
- Tunnel.execute_on_all(inventory, rotate_host, group, parallel, max_threads)
698
+ Tunnel.execute_on_inventory(
699
+ inventory, rotate_host, group, parallel, max_threads
700
+ )
618
701
 
619
702
  @staticmethod
620
- def send_file_on_all(
703
+ def send_file_on_inventory(
621
704
  inventory,
622
705
  local_path,
623
706
  remote_path,
@@ -649,10 +732,10 @@ class Tunnel:
649
732
  if not os.path.exists(local_path):
650
733
  raise ValueError(f"Local file does not exist: {local_path}")
651
734
 
652
- Tunnel.execute_on_all(inventory, send_host, group, parallel, max_threads)
735
+ Tunnel.execute_on_inventory(inventory, send_host, group, parallel, max_threads)
653
736
 
654
737
  @staticmethod
655
- def receive_file_on_all(
738
+ def receive_file_on_inventory(
656
739
  inventory,
657
740
  remote_path: str,
658
741
  local_path_prefix,
@@ -685,7 +768,9 @@ class Tunnel:
685
768
  tunnel.close()
686
769
 
687
770
  os.makedirs(local_path_prefix, exist_ok=True)
688
- Tunnel.execute_on_all(inventory, receive_host, group, parallel, max_threads)
771
+ Tunnel.execute_on_inventory(
772
+ inventory, receive_host, group, parallel, max_threads
773
+ )
689
774
 
690
775
 
691
776
  def tunnel_manager():
@@ -702,6 +787,12 @@ def tunnel_manager():
702
787
  default="~/.ssh/id_shared",
703
788
  help="Path to shared private key",
704
789
  )
790
+ setup_parser.add_argument(
791
+ "--key-type",
792
+ choices=["rsa", "ed25519"],
793
+ default="ed25519",
794
+ help="Key type to generate (rsa or ed25519, default: ed25519)",
795
+ )
705
796
  setup_parser.add_argument(
706
797
  "--group", default="all", help="Inventory group to target (default: all)"
707
798
  )
@@ -749,6 +840,12 @@ def tunnel_manager():
749
840
  default="~/.ssh/id_",
750
841
  help="Prefix for new key paths (appends hostname)",
751
842
  )
843
+ rotate_parser.add_argument(
844
+ "--key-type",
845
+ choices=["rsa", "ed25519"],
846
+ default="ed25519",
847
+ help="Key type to generate (rsa or ed25519, default: ed25519)",
848
+ )
752
849
  rotate_parser.add_argument(
753
850
  "--group", default="all", help="Inventory group to target (default: all)"
754
851
  )
@@ -832,12 +929,13 @@ def tunnel_manager():
832
929
  Tunnel.setup_all_passwordless_ssh(
833
930
  args.inventory,
834
931
  args.shared_key_path,
932
+ args.key_type,
835
933
  args.group,
836
934
  args.parallel,
837
935
  args.max_threads,
838
936
  )
839
937
  elif args.command == "run-command":
840
- Tunnel.run_command_on_all(
938
+ Tunnel.run_command_on_inventory(
841
939
  args.inventory,
842
940
  args.remote_command,
843
941
  args.group,
@@ -845,7 +943,7 @@ def tunnel_manager():
845
943
  args.max_threads,
846
944
  )
847
945
  elif args.command == "copy-config":
848
- Tunnel.copy_ssh_config_on_all(
946
+ Tunnel.copy_ssh_config_on_inventory(
849
947
  args.inventory,
850
948
  args.local_config_path,
851
949
  args.remote_config_path,
@@ -854,15 +952,16 @@ def tunnel_manager():
854
952
  args.max_threads,
855
953
  )
856
954
  elif args.command == "rotate-key":
857
- Tunnel.rotate_ssh_key_on_all(
955
+ Tunnel.rotate_ssh_key_on_inventory(
858
956
  args.inventory,
859
957
  args.key_prefix,
958
+ args.key_type,
860
959
  args.group,
861
960
  args.parallel,
862
961
  args.max_threads,
863
962
  )
864
963
  elif args.command == "send-file":
865
- Tunnel.send_file_on_all(
964
+ Tunnel.send_file_on_inventory(
866
965
  args.inventory,
867
966
  args.local_path,
868
967
  args.remote_path,
@@ -871,7 +970,7 @@ def tunnel_manager():
871
970
  args.max_threads,
872
971
  )
873
972
  elif args.command == "receive-file":
874
- Tunnel.receive_file_on_all(
973
+ Tunnel.receive_file_on_inventory(
875
974
  args.inventory,
876
975
  args.remote_path,
877
976
  args.local_path_prefix,