rosh 0.9.6 → 0.9.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1399835146cfcd6615a17868694c426b4c06b25649fab1c02192bf8f4534fe1d
4
- data.tar.gz: 835a84fe3a697e322fb28aa6c67e16f0ad1cbf832616b23991c65f0333fde8f9
3
+ metadata.gz: 5e3423833dad99eaf3db27ca753520ab865385a1dff3326f3dffd612297cccb7
4
+ data.tar.gz: cb28364217707106482da121b7842f5f4d6894c501330e10861e6c89a3062e35
5
5
  SHA512:
6
- metadata.gz: ecaffa32659f87db955a586bced2a742f15735cc75c5d76b00cc7113a9378673cfb54294b7d33ffa29eefdda0213cee56ea09f07de4caae68b3239875f4d9d7c
7
- data.tar.gz: 66a075c156779a0d33907187ada1195f8d7ebce66fb59de8136e585f7e3b4ce1d0e9f641961d3be12c6b956ae8215f0071e60e16a2a46ea153f0b5c8f377fbd0
6
+ metadata.gz: 201f582429c6e1b15bf5516db2abc236e6977d117b579faae4fdb2f243b965979a3cf6be69e19c0df19a72b705c541c45e87bc11430f04633a3725769040951f
7
+ data.tar.gz: e2fdf6bfb3d9861e72a47bbda8fa5a5b94e3747fa0e77a30f5678c6ad484f06257ff5aaaa839b8c2fccc5772326f3f5b1469acd3cfb0f14779cdbdeff021345e
data/.gitignore CHANGED
@@ -15,3 +15,4 @@ spec/reports
15
15
  test/tmp
16
16
  test/version_tmp
17
17
  tmp
18
+ vendor/bundle
data/README.md CHANGED
@@ -30,6 +30,14 @@ Or install it yourself as:
30
30
 
31
31
  If ~/.ssh/config contains LocalForward or RemoteForward for the host, the same
32
32
  forwarding options are passed to `ssh` automatically.
33
+
34
+ If the host uses `ProxyJump` or `ProxyCommand`, rosh also carries that proxy
35
+ setting over to the spawned `ssh` command.
36
+
37
+ If a `LocalForward` is skipped because the local port is actually in use,
38
+ rosh retries that forwarding on later reconnect attempts instead of dropping it
39
+ for the rest of the process lifetime.
40
+
33
41
  To detach the outer screen session,
34
42
 
35
43
  ^t d
data/lib/rosh/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Rosh
2
- VERSION = '0.9.6'
2
+ VERSION = '0.9.8'
3
3
  end
data/lib/rosh.rb CHANGED
@@ -2,6 +2,7 @@ require 'uri'
2
2
  require 'resolv'
3
3
  require 'net/ssh/config'
4
4
  require 'optparse'
5
+ require 'shellwords'
5
6
  require 'socket'
6
7
  require File.join(File.dirname(__FILE__), %w[rosh version])
7
8
 
@@ -9,6 +10,9 @@ class Rosh
9
10
  def initialize(*args)
10
11
  @interval = 3
11
12
  @ssh_opts = []
13
+ @base_ssh_opts = []
14
+ @local_forward_specs = []
15
+ @remote_forward_specs = []
12
16
  alive_interval = 5
13
17
  @escape = '^t'
14
18
  @tmux_socket_name = nil
@@ -23,8 +27,8 @@ class Rosh
23
27
  end.parse! args
24
28
  @host, @name = *args, :default
25
29
  abort 'hostname is required' if @host == :default
26
- @ssh_opts << "-o ServerAliveInterval=#{alive_interval}"
27
- @ssh_opts << "-o ServerAliveCountMax=1"
30
+ @base_ssh_opts << "-o ServerAliveInterval=#{alive_interval}"
31
+ @base_ssh_opts << "-o ServerAliveCountMax=1"
28
32
 
29
33
  # check ~/.ssh/config to resolve alias name
30
34
  alias_name = @host
@@ -34,19 +38,18 @@ class Rosh
34
38
  end
35
39
  @oom_reported = false
36
40
  @last_exit_status = nil
37
- local_forwards(alias_name).each do |f|
38
- add_forward_option(:local, f)
39
- end
40
- remote_forwards(alias_name).each do |f|
41
- add_forward_option(:remote, f)
42
- end
41
+ @local_forward_specs = local_forwards(alias_name)
42
+ @remote_forward_specs = remote_forwards(alias_name)
43
43
  @host = config[:host_name] if config[:host_name]
44
- @ssh_opts << "-l #{config[:user]}" if config[:user]
45
- @ssh_opts << "-p #{config[:port]}" if config[:port]
46
- @ssh_opts << "-J #{config[:proxy].jump_proxies}" if config[:proxy]
44
+ @base_ssh_opts << "-l #{config[:user]}" if config[:user]
45
+ @base_ssh_opts << "-p #{config[:port]}" if config[:port]
46
+ if proxy_option = ssh_proxy_option(config[:proxy])
47
+ @base_ssh_opts << proxy_option
48
+ end
47
49
  if keys = config[:keys]
48
- keys.each{|k| @ssh_opts << "-i #{k}"}
50
+ keys.each{|k| @base_ssh_opts << "-i #{k}"}
49
51
  end
52
+ refresh_ssh_opts!
50
53
  if @verbose
51
54
  puts "host: #{@host}"
52
55
  puts "name: #{@name}"
@@ -58,25 +61,20 @@ class Rosh
58
61
  end
59
62
 
60
63
  def connect
61
- cmd = if @screen
62
- ["ssh", *@ssh_opts, resolv,
63
- '-t', "'screen -rx #{@name}'", '2>/dev/null']*' '
64
- else
65
- remote_cmd = single_quote(tmux_attach_command)
66
- ["ssh", *@ssh_opts, resolv,
67
- '-t', remote_cmd, '2>/dev/null']*' '
68
- end
69
64
  if @verbose
70
65
  puts "connecting to #{@host}..."
71
- puts cmd
72
66
  end
67
+ cmd = nil
73
68
  begin
74
69
  reconnect
70
+ cmd = attach_command
71
+ puts cmd if @verbose
75
72
  end until execute_attach(cmd)
76
73
  report_session_end(cmd)
77
74
  end
78
75
 
79
76
  def reconnect
77
+ refresh_ssh_opts!
80
78
  if @first_try
81
79
  session_exists = if @screen
82
80
  sh('-p 0 -X echo ok', '2>&1 >/dev/null')
@@ -107,6 +105,26 @@ class Rosh
107
105
  end
108
106
 
109
107
  private
108
+ def ssh_proxy_option(proxy)
109
+ return nil unless proxy
110
+
111
+ if proxy.respond_to?(:jump_proxies)
112
+ "-J #{proxy.jump_proxies}"
113
+ elsif proxy.respond_to?(:command_line_template)
114
+ "-o ProxyCommand=#{Shellwords.escape(proxy.command_line_template)}"
115
+ end
116
+ end
117
+
118
+ def refresh_ssh_opts!
119
+ @ssh_opts = @base_ssh_opts.dup
120
+ @local_forward_specs.each do |spec|
121
+ add_forward_option(:local, spec)
122
+ end
123
+ @remote_forward_specs.each do |spec|
124
+ add_forward_option(:remote, spec)
125
+ end
126
+ end
127
+
110
128
  def execute_attach(cmd)
111
129
  result = system cmd
112
130
  @last_exit_status = $?
@@ -238,6 +256,17 @@ private
238
256
  system cmd
239
257
  end
240
258
 
259
+ def attach_command
260
+ if @screen
261
+ ["ssh", *@ssh_opts, resolv,
262
+ '-t', "'screen -rx #{@name}'", '2>/dev/null']*' '
263
+ else
264
+ remote_cmd = single_quote(tmux_attach_command)
265
+ ["ssh", *@ssh_opts, resolv,
266
+ '-t', remote_cmd, '2>/dev/null']*' '
267
+ end
268
+ end
269
+
241
270
  def local_forwards(host)
242
271
  file = File.expand_path("~/.ssh/config")
243
272
  return [] unless File.readable?(file)
@@ -1,10 +1,27 @@
1
1
  require 'minitest/autorun'
2
2
  require 'tmpdir'
3
3
  require 'fileutils'
4
+ require 'shellwords'
4
5
 
5
6
  require_relative '../lib/rosh'
6
7
 
7
8
  class RoshForwardingTest < Minitest::Test
9
+ def with_stub(obj, method_name, callable)
10
+ singleton = class << obj; self; end
11
+ original_defined = obj.respond_to?(method_name, true)
12
+ original_name = :"__orig_#{method_name}_for_test"
13
+
14
+ singleton.alias_method(original_name, method_name) if original_defined
15
+ singleton.define_method(method_name, &callable)
16
+ yield
17
+ ensure
18
+ singleton.remove_method(method_name) rescue nil
19
+ if original_defined
20
+ singleton.alias_method(method_name, original_name)
21
+ singleton.remove_method(original_name) rescue nil
22
+ end
23
+ end
24
+
8
25
  def setup
9
26
  @original_home = ENV['HOME']
10
27
  @tmp_home = Dir.mktmpdir('rosh-test-home-')
@@ -14,6 +31,14 @@ class RoshForwardingTest < Minitest::Test
14
31
  Host grav
15
32
  LocalForward 127.0.0.1 3131 localhost 3131
16
33
  RemoteForward 8082 localhost 8082
34
+
35
+ Host grav-jump
36
+ HostName grav.example.com
37
+ ProxyJump bastion.example.com
38
+
39
+ Host grav-command
40
+ HostName grav.example.com
41
+ ProxyCommand ssh bastion.example.com -W %h:%p
17
42
  CONFIG
18
43
  ENV['HOME'] = @tmp_home
19
44
  end
@@ -47,6 +72,21 @@ class RoshForwardingTest < Minitest::Test
47
72
  assert_includes ssh_opts, '-R 8082:localhost:8082'
48
73
  end
49
74
 
75
+ def test_ssh_opts_include_proxy_jump_from_ssh_config
76
+ rosh = Rosh.new('grav-jump')
77
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
78
+
79
+ assert_includes ssh_opts, '-J bastion.example.com'
80
+ end
81
+
82
+ def test_ssh_opts_include_proxy_command_from_ssh_config
83
+ rosh = Rosh.new('grav-command')
84
+ ssh_opts = rosh.instance_variable_get(:@ssh_opts)
85
+
86
+ expected = "-o ProxyCommand=#{Shellwords.escape('ssh bastion.example.com -W %h:%p')}"
87
+ assert_includes ssh_opts, expected
88
+ end
89
+
50
90
  def test_forwarding_is_skipped_when_local_port_is_in_use
51
91
  klass = Class.new(Rosh) do
52
92
  def local_forward_available?(_spec)
@@ -60,6 +100,55 @@ class RoshForwardingTest < Minitest::Test
60
100
  assert_includes ssh_opts, '-R 8082:localhost:8082'
61
101
  end
62
102
 
103
+ def test_reconnect_retries_local_forwarding_when_port_becomes_available
104
+ klass = Class.new(Rosh) do
105
+ def initialize(*args)
106
+ @availability_checks = [false, true]
107
+ super
108
+ end
109
+
110
+ def local_forward_available?(_spec)
111
+ @availability_checks.empty? ? true : @availability_checks.shift
112
+ end
113
+ end
114
+
115
+ rosh = klass.new('grav')
116
+ refute_includes rosh.instance_variable_get(:@ssh_opts), '-L 127.0.0.1:3131:localhost:3131'
117
+
118
+ rosh.instance_variable_set(:@first_try, false)
119
+ capture_io { rosh.reconnect }
120
+
121
+ assert_includes rosh.instance_variable_get(:@ssh_opts), '-L 127.0.0.1:3131:localhost:3131'
122
+ end
123
+
124
+ def test_connect_rebuilds_attach_command_after_reconnect_refreshes_forwarding
125
+ klass = Class.new(Rosh) do
126
+ def initialize(*args)
127
+ @availability_checks = [false, false, true]
128
+ super
129
+ end
130
+
131
+ def local_forward_available?(_spec)
132
+ @availability_checks.empty? ? true : @availability_checks.shift
133
+ end
134
+ end
135
+
136
+ rosh = klass.new('grav')
137
+ rosh.instance_variable_set(:@first_try, false)
138
+
139
+ commands = []
140
+ results = [false, true]
141
+
142
+ with_stub(rosh, :execute_attach, ->(cmd) { commands << cmd; results.shift }) do
143
+ with_stub(rosh, :report_session_end, ->(_cmd) { nil }) do
144
+ capture_io { rosh.connect }
145
+ end
146
+ end
147
+
148
+ refute_includes commands.first, '-L 127.0.0.1:3131:localhost:3131'
149
+ assert_includes commands.last, '-L 127.0.0.1:3131:localhost:3131'
150
+ end
151
+
63
152
  def test_verbose_logs_when_session_is_missing
64
153
  rosh = Rosh.new('grav')
65
154
  rosh.instance_variable_set(:@verbose, true)
@@ -69,7 +158,7 @@ class RoshForwardingTest < Minitest::Test
69
158
  rosh.instance_variable_set(:@last_exit_status, status)
70
159
 
71
160
  out, _ = capture_io do
72
- rosh.stub(:sh_has_session?, false) do
161
+ with_stub(rosh, :sh_has_session?, -> { false }) do
73
162
  rosh.send(:log_session_end, 'ssh grav')
74
163
  end
75
164
  end
@@ -98,7 +187,7 @@ class RoshForwardingTest < Minitest::Test
98
187
  def test_tmux_new_session_disables_destroy_unattached
99
188
  rosh = Rosh.new('grav', 'grav')
100
189
  commands = []
101
- rosh.stub(:system, ->(cmd) { commands << cmd; true }) do
190
+ with_stub(rosh, :system, ->(cmd) { commands << cmd; true }) do
102
191
  assert rosh.send(:sh_new_session?)
103
192
  end
104
193
 
@@ -111,7 +200,7 @@ class RoshForwardingTest < Minitest::Test
111
200
  commands = []
112
201
  results = [false, true].each
113
202
 
114
- rosh.stub(:system, ->(cmd) { commands << cmd; results.next rescue true }) do
203
+ with_stub(rosh, :system, ->(cmd) { commands << cmd; results.next rescue true }) do
115
204
  assert rosh.send(:sh_new_session?)
116
205
  end
117
206
 
@@ -127,7 +216,7 @@ class RoshForwardingTest < Minitest::Test
127
216
  assert_includes rosh.send(:tmux_attach_command), "-L #{socket_name}"
128
217
 
129
218
  commands = []
130
- rosh.stub(:system, ->(cmd) { commands << cmd; true }) do
219
+ with_stub(rosh, :system, ->(cmd) { commands << cmd; true }) do
131
220
  rosh.send(:sh_has_session?)
132
221
  end
133
222
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rosh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Genki Takiuchi
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-12-28 00:00:00.000000000 Z
11
+ date: 2026-04-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-ssh
@@ -107,7 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
107
  - !ruby/object:Gem::Version
108
108
  version: '0'
109
109
  requirements: []
110
- rubygems_version: 3.4.15
110
+ rubygems_version: 3.4.20
111
111
  signing_key:
112
112
  specification_version: 4
113
113
  summary: Rosh is roaming shell