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 +4 -4
- data/.gitignore +1 -0
- data/README.md +8 -0
- data/lib/rosh/version.rb +1 -1
- data/lib/rosh.rb +50 -21
- data/test/rosh_forwarding_test.rb +93 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e3423833dad99eaf3db27ca753520ab865385a1dff3326f3dffd612297cccb7
|
|
4
|
+
data.tar.gz: cb28364217707106482da121b7842f5f4d6894c501330e10861e6c89a3062e35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 201f582429c6e1b15bf5516db2abc236e6977d117b579faae4fdb2f243b965979a3cf6be69e19c0df19a72b705c541c45e87bc11430f04633a3725769040951f
|
|
7
|
+
data.tar.gz: e2fdf6bfb3d9861e72a47bbda8fa5a5b94e3747fa0e77a30f5678c6ad484f06257ff5aaaa839b8c2fccc5772326f3f5b1469acd3cfb0f14779cdbdeff021345e
|
data/.gitignore
CHANGED
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
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
|
-
@
|
|
27
|
-
@
|
|
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)
|
|
38
|
-
|
|
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
|
-
@
|
|
45
|
-
@
|
|
46
|
-
|
|
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| @
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
110
|
+
rubygems_version: 3.4.20
|
|
111
111
|
signing_key:
|
|
112
112
|
specification_version: 4
|
|
113
113
|
summary: Rosh is roaming shell
|