ssh_tunnels 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bcdb9b758ed9d27d0e81ba9bafbf8e50273fa38c2c954b332b916fccb7cc699
4
- data.tar.gz: 42d49d0040704dbe2ca4c51040ccfffc797eb2f5cf4dbdc7c322aa51a9f26c27
3
+ metadata.gz: b54b2afe8325a674248fec24386b471caa39c91584568a2bf8c05f7534b6636b
4
+ data.tar.gz: 2a18a9f5a2125f7684a250387e132c2299303ff2f02125875d289dd212a4c49e
5
5
  SHA512:
6
- metadata.gz: 109da60813a282a22c54b91075112e9a2bf5408e2490abe7c625aae87ef7c45ef3633ab698cdfd5f82fa24cf2a0fae04e32a6c86ba9a66b275b2eba97621e4c3
7
- data.tar.gz: 117a4e60567f1763cf7c55ca024e4a1a9fc00e4b187209aff3e57edb288b209dd18ab217e958d505a4837b5daab1a8bd9502e098d1640080c091633b60b6f282
6
+ metadata.gz: 75475d0e47eb73d9ffb8db14abb060fbc83319d1b207d6e0e9081580d43c5d1b834141a7fa39d3dfadd21b04791939d24f7c90faef9589fec45f35a153ff9c8f
7
+ data.tar.gz: d7133ea6c66c25221137a12bfe7969c19555e51bd9e1931ed0c365fbb4bfa9c8b653c0c5b14467e53e1de27bf38631dd30ac86cd6e4a95a98320b80030821992
data/.rubocop.yml CHANGED
@@ -2,3 +2,7 @@ AllCops:
2
2
  NewCops: enable
3
3
  TargetRubyVersion: 3.3
4
4
  SuggestExtensions: false
5
+
6
+ Metrics/BlockLength:
7
+ Exclude:
8
+ - 'spec/**/*'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ssh_tunnels (0.3.0)
4
+ ssh_tunnels (0.4.0)
5
5
  bcrypt_pbkdf (~> 1.1)
6
6
  curses (~> 1.4)
7
7
  ed25519 (~> 1.3)
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
- default_gateway = config.fetch('default_gateway', nil)
34
- default_local_ip = config.fetch('default_local_ip', nil)
35
- unless config.key?('tunnels')
36
- warn('Configuration file must provide `tunnels` section. Exiting.')
37
- exit 1
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
- tunnels = tunnels.map do |name, tunnel_config|
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.shutdown
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
@@ -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,17 +47,46 @@ 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
35
- @session = Net::SSH.start(@gateway.fetch('host'), @gateway.fetch('user', @user), options)
36
- forward_local
81
+ apply_pending
82
+ @error = nil
83
+ connect
37
84
  @active = true
38
- @thread = Thread.new { @session.loop(0.001) { @active } }
39
- rescue StandardError
40
- shutdown
85
+ @thread = Thread.new { run_loop }
86
+ rescue StandardError => e
87
+ @error = e
88
+ @active = false
89
+ close_session
41
90
  raise
42
91
  end
43
92
 
@@ -48,13 +97,62 @@ module SshTunnels
48
97
  def shutdown
49
98
  @active = false
50
99
  @thread&.join
51
- @session&.close
52
- @session = nil
53
100
  @thread = nil
101
+ @session = nil
102
+ apply_pending
54
103
  end
55
104
 
56
105
  private
57
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
+
118
+ def connect
119
+ @session = Net::SSH.start(@gateway.fetch('host'), @gateway.fetch('user', @user), options)
120
+ forward_local
121
+ end
122
+
123
+ # Runs in the background thread. When the loop ends — whether because the
124
+ # user disconnected (@active set to false) or the connection dropped (the
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".
128
+ def run_loop
129
+ @session.loop(0.001) { @active }
130
+ rescue StandardError => e
131
+ @error = e
132
+ ensure
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
142
+ @session&.close
143
+ @session = nil
144
+ end
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
+
58
156
  def forward_local
59
157
  args = [local_port, remote_host, remote_port]
60
158
  args.unshift(local_host) if local_host
@@ -86,4 +184,5 @@ module SshTunnels
86
184
  }
87
185
  end
88
186
  end
187
+ # rubocop:enable Metrics/ClassLength
89
188
  end
@@ -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
- def initialize(tunnels)
10
- @tunnels = tunnels
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
- Curses.init_pair(1, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
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
- message = "[1-#{@tunnels.size}] to connect/disconnect. Press 'q' to quit."
84
- window.addstr(message)
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
- tunnel = @tunnels[IDENTIFIERS.index { |value| value == input }]
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
- def status(message)
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.addstr(' ' * (Curses.cols - x))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SshTunnels
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
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.3.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-05-29 00:00:00.000000000 Z
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