ssh_tunnels 0.3.1 → 0.4.0
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/Gemfile.lock +1 -1
- data/README.md +10 -0
- data/bin/ssh_tunnels +7 -43
- data/lib/ssh_tunnels/config.rb +96 -0
- data/lib/ssh_tunnels/tunnel.rb +83 -6
- data/lib/ssh_tunnels/ui.rb +82 -23
- data/lib/ssh_tunnels/version.rb +1 -1
- data/lib/ssh_tunnels.rb +3 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b54b2afe8325a674248fec24386b471caa39c91584568a2bf8c05f7534b6636b
|
|
4
|
+
data.tar.gz: 2a18a9f5a2125f7684a250387e132c2299303ff2f02125875d289dd212a4c49e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75475d0e47eb73d9ffb8db14abb060fbc83319d1b207d6e0e9081580d43c5d1b834141a7fa39d3dfadd21b04791939d24f7c90faef9589fec45f35a153ff9c8f
|
|
7
|
+
data.tar.gz: d7133ea6c66c25221137a12bfe7969c19555e51bd9e1931ed0c365fbb4bfa9c8b653c0c5b14467e53e1de27bf38631dd30ac86cd6e4a95a98320b80030821992
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -103,6 +103,16 @@ tunnels:
|
|
|
103
103
|
remote: 8080
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
## Live reload
|
|
107
|
+
|
|
108
|
+
The configuration file is checked once a second while the app is running. Saving a change updates the tunnel list in place, so there is no need to quit and reconnect:
|
|
109
|
+
|
|
110
|
+
* New tunnels appear and can be connected straight away.
|
|
111
|
+
* Tunnels that are disconnected take their new settings immediately.
|
|
112
|
+
* Tunnels that are connected keep running on their current settings. They are marked `config changed, reconnect to apply` and pick up the new settings when next disconnected.
|
|
113
|
+
* Tunnels deleted from the file are removed from the list if disconnected. If connected they stay listed, marked `removed from config`, until disconnected.
|
|
114
|
+
* An invalid or half-saved file is reported in the status line and leaves the current tunnels untouched until the next successful reload.
|
|
115
|
+
|
|
106
116
|
## Contributing
|
|
107
117
|
|
|
108
118
|
Pull requests are welcome.
|
data/bin/ssh_tunnels
CHANGED
|
@@ -22,39 +22,12 @@ end.parse!
|
|
|
22
22
|
|
|
23
23
|
config_path = options.fetch(:config_path, File.expand_path('~/.ssh_tunnels.yml'))
|
|
24
24
|
|
|
25
|
-
unless File.exist?(config_path)
|
|
26
|
-
warn("Unable to locate configuration file: #{config_path}")
|
|
27
|
-
exit 1
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
config = YAML.safe_load_file(config_path)
|
|
31
|
-
|
|
32
25
|
begin
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
gateways = config.fetch('gateways', {})
|
|
41
|
-
|
|
42
|
-
tunnels = config.fetch('tunnels')
|
|
43
|
-
error = false
|
|
44
|
-
tunnels.each do |key, tunnel|
|
|
45
|
-
if tunnel.key?('gateway') && !gateways.key?(tunnel.fetch('gateway'))
|
|
46
|
-
error = true
|
|
47
|
-
warn("Tunnel `#{key}` references unknown gateway `#{tunnel.fetch('gateway')}`")
|
|
48
|
-
elsif !tunnel.key?('gateway') && default_gateway.nil?
|
|
49
|
-
error = true
|
|
50
|
-
warn("Tunnel `#{key}` must provide `gateway` key or define a top-level `default_gateway` configuration.")
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
if error
|
|
55
|
-
warn('Configuration errors detected. Exiting.')
|
|
56
|
-
exit 1
|
|
57
|
-
end
|
|
26
|
+
config = SshTunnels::Config.load(config_path)
|
|
27
|
+
rescue SshTunnels::ConfigError => e
|
|
28
|
+
warn(e.message)
|
|
29
|
+
warn('Configuration errors detected. Exiting.')
|
|
30
|
+
exit 1
|
|
58
31
|
end
|
|
59
32
|
|
|
60
33
|
user = ENV.fetch('USER')
|
|
@@ -63,18 +36,9 @@ passphrase = $stdin.noecho(&:gets).chomp
|
|
|
63
36
|
puts
|
|
64
37
|
|
|
65
38
|
begin
|
|
66
|
-
|
|
67
|
-
gateway = if tunnel_config.key?('gateway')
|
|
68
|
-
gateways.fetch(tunnel_config.fetch('gateway'))
|
|
69
|
-
else
|
|
70
|
-
default_gateway
|
|
71
|
-
end
|
|
72
|
-
config = { 'local_ip' => default_local_ip }.merge(tunnel_config)
|
|
73
|
-
SshTunnels::Tunnel.new(name, user, config, gateway, passphrase)
|
|
74
|
-
end
|
|
75
|
-
ui = SshTunnels::UI.new(tunnels)
|
|
39
|
+
ui = SshTunnels::UI.new(config, user, passphrase)
|
|
76
40
|
ui.run
|
|
77
41
|
rescue Interrupt
|
|
78
42
|
puts 'Interrupt detected.'
|
|
79
|
-
ui
|
|
43
|
+
ui&.shutdown
|
|
80
44
|
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SshTunnels
|
|
4
|
+
# Loads and validates a YAML configuration file and builds Tunnel objects
|
|
5
|
+
# from it. Invalid configuration raises ConfigError rather than exiting so
|
|
6
|
+
# that a live reload can report the problem and carry on unchanged.
|
|
7
|
+
class Config
|
|
8
|
+
REQUIRED_TUNNEL_KEYS = %w[host remote_port].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :path
|
|
11
|
+
|
|
12
|
+
def self.load(path)
|
|
13
|
+
raise ConfigError, "Unable to locate configuration file: #{path}" unless File.exist?(path)
|
|
14
|
+
|
|
15
|
+
new(path, YAML.safe_load_file(path))
|
|
16
|
+
rescue Psych::SyntaxError => e
|
|
17
|
+
raise ConfigError, "Invalid YAML: #{e.problem} (line #{e.line}, column #{e.column})"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(path, data)
|
|
21
|
+
@path = path
|
|
22
|
+
@data = data
|
|
23
|
+
validate
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mtime
|
|
27
|
+
File.mtime(@path)
|
|
28
|
+
rescue Errno::ENOENT
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tunnels(user, passphrase)
|
|
33
|
+
tunnel_configs.map do |name, tunnel_config|
|
|
34
|
+
settings = { 'local_ip' => default_local_ip }.merge(tunnel_config)
|
|
35
|
+
Tunnel.new(name, user, settings, gateway_for(tunnel_config), passphrase)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def validate
|
|
42
|
+
validate_structure
|
|
43
|
+
errors = tunnel_configs.flat_map { |name, tunnel_config| tunnel_errors(name, tunnel_config) }
|
|
44
|
+
raise ConfigError, errors.join("\n") unless errors.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_structure
|
|
48
|
+
raise ConfigError, 'Configuration file must contain a YAML map.' unless @data.is_a?(Hash)
|
|
49
|
+
raise ConfigError, 'Configuration file must provide `tunnels` section.' unless @data.key?('tunnels')
|
|
50
|
+
raise ConfigError, '`tunnels` section must be a map.' unless tunnel_configs.is_a?(Hash)
|
|
51
|
+
raise ConfigError, '`gateways` section must be a map.' unless gateways.is_a?(Hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tunnel_errors(name, tunnel_config)
|
|
55
|
+
return ["Tunnel `#{name}` must be a map."] unless tunnel_config.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
missing = REQUIRED_TUNNEL_KEYS.reject { |key| tunnel_config.key?(key) }
|
|
58
|
+
missing.map { |key| "Tunnel `#{name}` must provide `#{key}`." } + gateway_errors(name, tunnel_config)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def gateway_errors(name, tunnel_config)
|
|
62
|
+
if tunnel_config.key?('gateway')
|
|
63
|
+
gateway = tunnel_config.fetch('gateway')
|
|
64
|
+
return [] if gateways.key?(gateway)
|
|
65
|
+
|
|
66
|
+
["Tunnel `#{name}` references unknown gateway `#{gateway}`."]
|
|
67
|
+
elsif default_gateway.nil?
|
|
68
|
+
["Tunnel `#{name}` must provide `gateway` key or define a top-level `default_gateway` configuration."]
|
|
69
|
+
else
|
|
70
|
+
[]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def gateway_for(tunnel_config)
|
|
75
|
+
return default_gateway unless tunnel_config.key?('gateway')
|
|
76
|
+
|
|
77
|
+
gateways.fetch(tunnel_config.fetch('gateway'))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tunnel_configs
|
|
81
|
+
@data.fetch('tunnels')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def gateways
|
|
85
|
+
@data.fetch('gateways', {})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_gateway
|
|
89
|
+
@data.fetch('default_gateway', nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def default_local_ip
|
|
93
|
+
@data.fetch('default_local_ip', nil)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/ssh_tunnels/tunnel.rb
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SshTunnels
|
|
4
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
5
|
# SSH Tunnel
|
|
5
6
|
class Tunnel
|
|
6
|
-
attr_reader :name, :error
|
|
7
|
+
attr_reader :name, :error, :config, :gateway
|
|
8
|
+
|
|
9
|
+
# Merges freshly-loaded tunnels into the current list. Tunnels that share a
|
|
10
|
+
# name keep their existing object (and therefore any live connection) and
|
|
11
|
+
# receive the new settings via #update. Tunnels no longer present in the
|
|
12
|
+
# configuration are dropped if inactive, or kept and flagged as removed
|
|
13
|
+
# while still connected so a file edit never interrupts a live session.
|
|
14
|
+
def self.reconcile(current, incoming)
|
|
15
|
+
existing = current.to_h { |tunnel| [tunnel.name, tunnel] }
|
|
16
|
+
merged = incoming.map do |tunnel|
|
|
17
|
+
match = existing.delete(tunnel.name)
|
|
18
|
+
next tunnel if match.nil?
|
|
19
|
+
|
|
20
|
+
match.update(tunnel.config, tunnel.gateway)
|
|
21
|
+
match
|
|
22
|
+
end
|
|
23
|
+
merged + existing.values.select(&:active?).each(&:remove)
|
|
24
|
+
end
|
|
7
25
|
|
|
8
26
|
def initialize(name, user, config, gateway, passphrase)
|
|
9
27
|
@name = name
|
|
@@ -14,6 +32,8 @@ module SshTunnels
|
|
|
14
32
|
@session = nil
|
|
15
33
|
@thread = nil
|
|
16
34
|
@active = false
|
|
35
|
+
@pending = nil
|
|
36
|
+
@removed = false
|
|
17
37
|
end
|
|
18
38
|
|
|
19
39
|
def to_s
|
|
@@ -27,11 +47,38 @@ module SshTunnels
|
|
|
27
47
|
"#{base} (#{@error})"
|
|
28
48
|
end
|
|
29
49
|
|
|
50
|
+
# Called on configuration reload. Inactive tunnels take the new settings
|
|
51
|
+
# immediately; active tunnels keep running on their current settings and
|
|
52
|
+
# apply the new ones when next disconnected.
|
|
53
|
+
def update(config, gateway)
|
|
54
|
+
@removed = false
|
|
55
|
+
if config == @config && gateway == @gateway
|
|
56
|
+
@pending = nil
|
|
57
|
+
elsif active?
|
|
58
|
+
@pending = [config, gateway]
|
|
59
|
+
else
|
|
60
|
+
apply(config, gateway)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def remove
|
|
65
|
+
@removed = true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def removed?
|
|
69
|
+
@removed
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def changed?
|
|
73
|
+
!@pending.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
30
76
|
def toggle
|
|
31
77
|
active? ? shutdown : open
|
|
32
78
|
end
|
|
33
79
|
|
|
34
80
|
def open
|
|
81
|
+
apply_pending
|
|
35
82
|
@error = nil
|
|
36
83
|
connect
|
|
37
84
|
@active = true
|
|
@@ -39,8 +86,7 @@ module SshTunnels
|
|
|
39
86
|
rescue StandardError => e
|
|
40
87
|
@error = e
|
|
41
88
|
@active = false
|
|
42
|
-
|
|
43
|
-
@session = nil
|
|
89
|
+
close_session
|
|
44
90
|
raise
|
|
45
91
|
end
|
|
46
92
|
|
|
@@ -53,10 +99,22 @@ module SshTunnels
|
|
|
53
99
|
@thread&.join
|
|
54
100
|
@thread = nil
|
|
55
101
|
@session = nil
|
|
102
|
+
apply_pending
|
|
56
103
|
end
|
|
57
104
|
|
|
58
105
|
private
|
|
59
106
|
|
|
107
|
+
def apply_pending
|
|
108
|
+
apply(*@pending) unless @pending.nil?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def apply(config, gateway)
|
|
112
|
+
@config = config
|
|
113
|
+
@gateway = gateway
|
|
114
|
+
@pending = nil
|
|
115
|
+
@error = nil
|
|
116
|
+
end
|
|
117
|
+
|
|
60
118
|
def connect
|
|
61
119
|
@session = Net::SSH.start(@gateway.fetch('host'), @gateway.fetch('user', @user), options)
|
|
62
120
|
forward_local
|
|
@@ -64,19 +122,37 @@ module SshTunnels
|
|
|
64
122
|
|
|
65
123
|
# Runs in the background thread. When the loop ends — whether because the
|
|
66
124
|
# user disconnected (@active set to false) or the connection dropped (the
|
|
67
|
-
# loop raises) —
|
|
68
|
-
#
|
|
69
|
-
#
|
|
125
|
+
# loop raises) — the local forwarded port must be released here. Otherwise
|
|
126
|
+
# the orphaned listener keeps the port bound, causing connecting apps to
|
|
127
|
+
# hang and reconnects to fail with "address in use".
|
|
70
128
|
def run_loop
|
|
71
129
|
@session.loop(0.001) { @active }
|
|
72
130
|
rescue StandardError => e
|
|
73
131
|
@error = e
|
|
74
132
|
ensure
|
|
75
133
|
@active = false
|
|
134
|
+
close_session
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Net::SSH::Connection::Session#close only closes channels and the
|
|
138
|
+
# transport socket; the TCPServer bound by forward.local is only released
|
|
139
|
+
# by forward.cancel_local, so cancel before closing.
|
|
140
|
+
def close_session
|
|
141
|
+
release_port
|
|
76
142
|
@session&.close
|
|
77
143
|
@session = nil
|
|
78
144
|
end
|
|
79
145
|
|
|
146
|
+
def release_port
|
|
147
|
+
return if @session.nil?
|
|
148
|
+
|
|
149
|
+
args = [local_port]
|
|
150
|
+
args << local_host if local_host
|
|
151
|
+
@session.forward.cancel_local(*args)
|
|
152
|
+
rescue StandardError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
80
156
|
def forward_local
|
|
81
157
|
args = [local_port, remote_host, remote_port]
|
|
82
158
|
args.unshift(local_host) if local_host
|
|
@@ -108,4 +184,5 @@ module SshTunnels
|
|
|
108
184
|
}
|
|
109
185
|
end
|
|
110
186
|
end
|
|
187
|
+
# rubocop:enable Metrics/ClassLength
|
|
111
188
|
end
|
data/lib/ssh_tunnels/ui.rb
CHANGED
|
@@ -5,19 +5,27 @@ module SshTunnels
|
|
|
5
5
|
# User Interface
|
|
6
6
|
class UI
|
|
7
7
|
IDENTIFIERS = ['1'..'9', 'a'..'z', 'A'..'Z'].map(&:to_a).flatten
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
COLORS = {
|
|
9
|
+
white: Curses::COLOR_WHITE,
|
|
10
|
+
blue: Curses::COLOR_BLUE,
|
|
11
|
+
green: Curses::COLOR_GREEN,
|
|
12
|
+
cyan: Curses::COLOR_CYAN,
|
|
13
|
+
red: Curses::COLOR_RED,
|
|
14
|
+
yellow: Curses::COLOR_YELLOW
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(config, user, passphrase)
|
|
18
|
+
@config = config
|
|
19
|
+
@user = user
|
|
20
|
+
@passphrase = passphrase
|
|
21
|
+
@tunnels = config.tunnels(user, passphrase)
|
|
22
|
+
@config_mtime = config.mtime
|
|
11
23
|
end
|
|
12
24
|
|
|
13
25
|
def setup
|
|
14
26
|
Curses.init_screen
|
|
15
27
|
Curses.start_color
|
|
16
|
-
|
|
17
|
-
Curses.init_pair(2, Curses::COLOR_BLUE, Curses::COLOR_BLACK)
|
|
18
|
-
Curses.init_pair(3, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
|
|
19
|
-
Curses.init_pair(4, Curses::COLOR_CYAN, Curses::COLOR_BLACK)
|
|
20
|
-
Curses.init_pair(5, Curses::COLOR_RED, Curses::COLOR_BLACK)
|
|
28
|
+
COLORS.each_value.with_index(1) { |value, pair| Curses.init_pair(pair, value, Curses::COLOR_BLACK) }
|
|
21
29
|
Curses.timeout = 1000
|
|
22
30
|
Curses.curs_set(0)
|
|
23
31
|
Curses.noecho
|
|
@@ -44,6 +52,8 @@ module SshTunnels
|
|
|
44
52
|
|
|
45
53
|
def monitor
|
|
46
54
|
loop do
|
|
55
|
+
reload_if_changed
|
|
56
|
+
prune_removed
|
|
47
57
|
@tunnels.each_with_index do |tunnel, index|
|
|
48
58
|
display_tunnel(tunnel, index)
|
|
49
59
|
end
|
|
@@ -61,6 +71,37 @@ module SshTunnels
|
|
|
61
71
|
clean_status if @status_time && Time.now.utc - @status_time > 2.5
|
|
62
72
|
end
|
|
63
73
|
|
|
74
|
+
# The configuration file is polled once per tick (see Curses.timeout). A
|
|
75
|
+
# change triggers a reload; an unreadable or invalid file leaves the
|
|
76
|
+
# current tunnels untouched and reports the problem until the next
|
|
77
|
+
# successful reload.
|
|
78
|
+
def reload_if_changed
|
|
79
|
+
mtime = @config.mtime
|
|
80
|
+
return if mtime == @config_mtime
|
|
81
|
+
|
|
82
|
+
@config_mtime = mtime
|
|
83
|
+
reload
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def reload
|
|
87
|
+
@config = Config.load(@config.path)
|
|
88
|
+
@tunnels = Tunnel.reconcile(@tunnels, @config.tunnels(@user, @passphrase))
|
|
89
|
+
window.erase
|
|
90
|
+
status('Configuration reloaded.')
|
|
91
|
+
rescue ConfigError => e
|
|
92
|
+
status("Configuration error: #{e.message.lines.first.chomp}", sticky: true)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Tunnels removed from the configuration stay listed while connected and
|
|
96
|
+
# disappear once they are no longer active.
|
|
97
|
+
def prune_removed
|
|
98
|
+
orphans = @tunnels.select { |tunnel| tunnel.removed? && !tunnel.active? }
|
|
99
|
+
return if orphans.empty?
|
|
100
|
+
|
|
101
|
+
@tunnels -= orphans
|
|
102
|
+
window.erase
|
|
103
|
+
end
|
|
104
|
+
|
|
64
105
|
def window
|
|
65
106
|
@window ||= Curses.stdscr
|
|
66
107
|
end
|
|
@@ -74,21 +115,45 @@ module SshTunnels
|
|
|
74
115
|
window.addstr("#{tunnel.name} ")
|
|
75
116
|
window.attrset(color(:cyan))
|
|
76
117
|
window.addstr(tunnel.to_s)
|
|
118
|
+
display_marker(tunnel)
|
|
119
|
+
window.clrtoeol
|
|
77
120
|
end
|
|
78
121
|
# rubocop:enable Metrics/AbcSize
|
|
79
122
|
|
|
123
|
+
def display_marker(tunnel)
|
|
124
|
+
marker = tunnel_marker(tunnel)
|
|
125
|
+
return if marker.nil?
|
|
126
|
+
|
|
127
|
+
window.attrset(color(:yellow))
|
|
128
|
+
window.addstr(" [#{marker}]")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def tunnel_marker(tunnel)
|
|
132
|
+
return 'removed from config, disconnect to clear' if tunnel.removed?
|
|
133
|
+
return 'config changed, reconnect to apply' if tunnel.changed?
|
|
134
|
+
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
80
138
|
def display_usage
|
|
81
139
|
window.setpos(@tunnels.size + 3, 2)
|
|
82
140
|
window.attrset(color(:cyan))
|
|
83
|
-
|
|
84
|
-
window.
|
|
141
|
+
window.addstr(usage_message)
|
|
142
|
+
window.clrtoeol
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def usage_message
|
|
146
|
+
return "No tunnels configured. Press 'q' to quit." if @tunnels.empty?
|
|
147
|
+
|
|
148
|
+
"[1-#{IDENTIFIERS[@tunnels.size - 1]}] to connect/disconnect. Press 'q' to quit."
|
|
85
149
|
end
|
|
86
150
|
|
|
87
151
|
def process_input(input)
|
|
88
152
|
raise UserQuit if input == 'q'
|
|
89
153
|
return status("Unrecognized input: #{input}") unless input.is_a?(String) && input =~ /\A[0-9a-zA-Z]\Z/
|
|
90
154
|
|
|
91
|
-
|
|
155
|
+
index = IDENTIFIERS.index(input)
|
|
156
|
+
tunnel = index && @tunnels[index]
|
|
92
157
|
return status("Unrecognized tunnel: #{input}") if tunnel.nil?
|
|
93
158
|
|
|
94
159
|
toggle_tunnel(tunnel)
|
|
@@ -107,9 +172,11 @@ module SshTunnels
|
|
|
107
172
|
tunnel.active? ? color(:green) : color(:blue)
|
|
108
173
|
end
|
|
109
174
|
|
|
110
|
-
|
|
175
|
+
# A sticky status stays until replaced by the next status message rather
|
|
176
|
+
# than being cleared after a few seconds.
|
|
177
|
+
def status(message, sticky: false)
|
|
111
178
|
clean_status
|
|
112
|
-
@status_time = Time.now.utc
|
|
179
|
+
@status_time = sticky ? nil : Time.now.utc
|
|
113
180
|
window.setpos(*status_coordinates)
|
|
114
181
|
window.attrset(color(:white))
|
|
115
182
|
window.addstr(message)
|
|
@@ -120,7 +187,7 @@ module SshTunnels
|
|
|
120
187
|
y, x = status_coordinates
|
|
121
188
|
window.setpos(y, x)
|
|
122
189
|
window.attrset(color(:white))
|
|
123
|
-
window.
|
|
190
|
+
window.clrtoeol
|
|
124
191
|
end
|
|
125
192
|
|
|
126
193
|
def status_coordinates
|
|
@@ -128,15 +195,7 @@ module SshTunnels
|
|
|
128
195
|
end
|
|
129
196
|
|
|
130
197
|
def color(name)
|
|
131
|
-
Curses.color_pair(
|
|
132
|
-
{
|
|
133
|
-
white: 1,
|
|
134
|
-
blue: 2,
|
|
135
|
-
green: 3,
|
|
136
|
-
cyan: 4,
|
|
137
|
-
red: 5
|
|
138
|
-
}.fetch(name)
|
|
139
|
-
)
|
|
198
|
+
Curses.color_pair(COLORS.keys.index(name) + 1)
|
|
140
199
|
end
|
|
141
200
|
end
|
|
142
201
|
# rubocop:enable Metrics/ClassLength
|
data/lib/ssh_tunnels/version.rb
CHANGED
data/lib/ssh_tunnels.rb
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require 'curses'
|
|
4
4
|
require 'net/ssh'
|
|
5
|
+
require 'yaml'
|
|
5
6
|
|
|
6
7
|
require 'ssh_tunnels/version'
|
|
8
|
+
require 'ssh_tunnels/config'
|
|
7
9
|
require 'ssh_tunnels/tunnel'
|
|
8
10
|
require 'ssh_tunnels/ui'
|
|
9
11
|
|
|
10
12
|
module SshTunnels
|
|
11
13
|
class Error < StandardError; end
|
|
12
14
|
class UserQuit < Error; end
|
|
15
|
+
class ConfigError < Error; end
|
|
13
16
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ssh_tunnels
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bob Farrell
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bcrypt_pbkdf
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- bin/ssh_tunnels
|
|
90
90
|
- doc/demo.png
|
|
91
91
|
- lib/ssh_tunnels.rb
|
|
92
|
+
- lib/ssh_tunnels/config.rb
|
|
92
93
|
- lib/ssh_tunnels/tunnel.rb
|
|
93
94
|
- lib/ssh_tunnels/ui.rb
|
|
94
95
|
- lib/ssh_tunnels/version.rb
|