rails-console 0.1.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.
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'socket'
6
+
7
+ module RailsConsole
8
+ # Owns the single PTY console session per container. Puma workers reach it over a local
9
+ # Unix socket, so any worker can serve the browser's WebSocket and hit the same session.
10
+ class Broker
11
+ include Logging
12
+
13
+ # Ctrl-L: tells the console to redraw its prompt so a just-attached client sees it.
14
+ REDRAW = "\f"
15
+
16
+ # Sent to clients when the session is replaced (safe!/unsafe!), so the frontend resets the
17
+ # screen and shows the booting spinner again. NUL-delimited so it never collides with real
18
+ # terminal output.
19
+ RELOAD_MARKER = "\x00CONSOLE_RELOAD\x00"
20
+
21
+ # The console prints this to ask for sandbox (safe) mode. NUL bytes never appear in terminal
22
+ # output, so it can't be triggered by accident.
23
+ SAFE_MARKER = "\x00CONSOLE_SAFE_REQUEST\x00"
24
+
25
+ # Seconds to wait for a freshly spawned broker to start listening.
26
+ SPAWN_TIMEOUT = 15
27
+
28
+ # The console prints this to ask for write (unsafe) mode. NUL bytes never appear in terminal
29
+ # output, so it can't be triggered by accident.
30
+ UNSAFE_MARKER = "\x00CONSOLE_UNSAFE_REQUEST\x00"
31
+
32
+ attr_reader :console_session, :owner_label
33
+
34
+ def self.boot(socket_path: nil) # rubocop:disable Naming/PredicateMethod
35
+ socket_path ||= RailsConsole.socket_path
36
+
37
+ return true if running?(socket_path:)
38
+
39
+ broker_bin = Gem.bin_path('rails-console', 'rails_console')
40
+
41
+ Process.spawn('bundle', 'exec', broker_bin, chdir: Rails.root.to_s)
42
+
43
+ SPAWN_TIMEOUT.times do
44
+ return true if running?(socket_path:)
45
+
46
+ sleep(1)
47
+ end
48
+
49
+ false
50
+ end
51
+
52
+ def self.running?(socket_path: nil)
53
+ socket_path ||= RailsConsole.socket_path
54
+
55
+ UNIXSocket.new(socket_path.to_s).close
56
+
57
+ true
58
+ rescue Errno::ENOENT, Errno::ECONNREFUSED
59
+ false
60
+ end
61
+
62
+ def initialize(safe_command: nil, socket_path: nil, unsafe_command: nil)
63
+ @clients = []
64
+ @console_session = nil
65
+ @idle_timer = nil
66
+ @mutex = Mutex.new
67
+ @owner_label = nil
68
+ @pty_session = nil
69
+ @safe_command = safe_command
70
+ @server = nil
71
+ @shutting_down = false
72
+ @socket_path = socket_path || RailsConsole.socket_path
73
+ @unsafe_command = unsafe_command
74
+ end
75
+
76
+ def run
77
+ return log("Already listening on #{@socket_path}, aborting.") if already_running?
78
+
79
+ prepare_socket_path
80
+
81
+ @server = UNIXServer.new(@socket_path)
82
+
83
+ log("Listening on #{@socket_path}")
84
+
85
+ loop { Thread.new(@server.accept) { |client| handle_client(client) } }
86
+ rescue IOError
87
+ nil
88
+ ensure
89
+ @server&.close
90
+ end
91
+
92
+ def shutdown
93
+ @shutting_down = true
94
+
95
+ @mutex.synchronize do
96
+ @idle_timer&.kill
97
+ @idle_timer = nil
98
+
99
+ @clients.each do |item|
100
+ item.close
101
+ rescue IOError
102
+ nil
103
+ end
104
+
105
+ @clients.clear
106
+
107
+ @pty_session&.kill!
108
+ end
109
+
110
+ @server&.close
111
+ @server = nil
112
+ end
113
+
114
+ private
115
+
116
+ def already_running?
117
+ self.class.running?(socket_path: @socket_path)
118
+ end
119
+
120
+ def attach_client(client)
121
+ @mutex.synchronize do
122
+ @idle_timer&.kill
123
+ @idle_timer = nil
124
+
125
+ @clients << client
126
+ end
127
+ end
128
+
129
+ def broadcast(bytes)
130
+ @mutex.synchronize do
131
+ @clients.each { |item| item.write(bytes) }
132
+ rescue IOError, Errno::EPIPE
133
+ nil
134
+ end
135
+ end
136
+
137
+ def claim(label)
138
+ @mutex.synchronize do
139
+ if !@pty_session&.alive?
140
+ @owner_label = label
141
+
142
+ :start
143
+ elsif owner_label == label
144
+ :attach
145
+ else
146
+ :reject
147
+ end
148
+ end
149
+ end
150
+
151
+ def detach_client(client)
152
+ @mutex.synchronize do
153
+ @clients.delete(client)
154
+
155
+ schedule_idle_shutdown if @clients.empty?
156
+ end
157
+
158
+ client.close
159
+ rescue IOError
160
+ nil
161
+ end
162
+
163
+ def end_session
164
+ @mutex.synchronize do
165
+ next if @clients.any?
166
+
167
+ log("Ending idle session for #{owner_label}")
168
+
169
+ @pty_session&.kill!
170
+ console_session&.update(ended_at: Time.current)
171
+
172
+ @console_session = nil
173
+ @owner_label = nil
174
+ end
175
+ end
176
+
177
+ def handle_client(client)
178
+ start_message = read_start_message(client)
179
+
180
+ return if start_message.nil?
181
+
182
+ action = claim(start_message.fetch('label'))
183
+
184
+ return reject_client(client) if action == :reject
185
+
186
+ if action == :start
187
+ start_session(
188
+ ip_address: start_message['ip_address'],
189
+ user_id: start_message.fetch('user_id'),
190
+ user_label: start_message.fetch('label')
191
+ )
192
+ end
193
+
194
+ attach_client(client)
195
+
196
+ # Only redraw when joining a console that's already up; on :start the boot output
197
+ # itself fills the screen, and a Ctrl-L would just be echoed as "^L" mid-boot.
198
+ wake_pty if action == :attach
199
+
200
+ relay_client_input(client)
201
+ ensure
202
+ detach_client(client)
203
+ end
204
+
205
+ def idle_timeout
206
+ RailsConsole.idle_timeout
207
+ end
208
+
209
+ def prepare_socket_path
210
+ FileUtils.mkdir_p(File.dirname(@socket_path))
211
+ FileUtils.rm_f(@socket_path)
212
+ end
213
+
214
+ def read_loop(pty_session)
215
+ loop do
216
+ break if !pty_session.equal?(@pty_session)
217
+ break if !pty_session.alive?
218
+
219
+ bytes = pty_session.read_nonblock
220
+
221
+ next sleep(0.02) if bytes.nil?
222
+
223
+ requested = requested_mode(bytes)
224
+
225
+ relay_pty_output(bytes.gsub(SAFE_MARKER, '').gsub(UNSAFE_MARKER, ''))
226
+
227
+ next if requested.nil?
228
+
229
+ switch_mode(sandbox: requested == :safe)
230
+
231
+ break
232
+ end
233
+ end
234
+
235
+ def read_start_message(client)
236
+ line = client.gets&.chomp
237
+
238
+ return if line.nil?
239
+
240
+ JSON.parse(line)
241
+ rescue IOError, JSON::ParserError
242
+ nil
243
+ end
244
+
245
+ def record_log_line(content:, direction:)
246
+ return if !RailsConsole.audit
247
+ return if console_session.nil?
248
+ return if content.blank?
249
+
250
+ RailsConsole::LogLine.create(content:, direction:, session: console_session)
251
+ end
252
+
253
+ def reject_client(client)
254
+ client.write("Session in use by #{owner_label}, please try again later.\n")
255
+ client.close
256
+ end
257
+
258
+ def relay_client_input(client)
259
+ loop do
260
+ bytes = client.readpartial(RailsConsole::PtySession::READ_SIZE)
261
+
262
+ @pty_session&.write(bytes)
263
+
264
+ record_log_line(content: bytes, direction: :input)
265
+ end
266
+ rescue IOError, Errno::ECONNRESET
267
+ nil
268
+ end
269
+
270
+ def relay_pty_output(bytes)
271
+ broadcast(bytes)
272
+ record_log_line(content: bytes, direction: :output)
273
+ end
274
+
275
+ # Which mode the console asked to switch to, or nil when the output has no marker.
276
+ def requested_mode(bytes)
277
+ return :safe if bytes.include?(SAFE_MARKER)
278
+ return :unsafe if bytes.include?(UNSAFE_MARKER)
279
+
280
+ nil
281
+ end
282
+
283
+ def schedule_idle_shutdown
284
+ return if @shutting_down
285
+ return if @idle_timer&.alive?
286
+
287
+ @idle_timer = Thread.new do
288
+ sleep(idle_timeout)
289
+
290
+ end_session unless @shutting_down
291
+ end
292
+ rescue ThreadError
293
+ nil
294
+ end
295
+
296
+ def start_session(ip_address:, user_id:, user_label:)
297
+ @console_session = RailsConsole::Session.create!(
298
+ ip_address:,
299
+ started_at: Time.current,
300
+ user_id:,
301
+ user_label:
302
+ )
303
+ @pty_session = RailsConsole::PtySession.new(command: @safe_command, sandbox: true)
304
+
305
+ log("Session started for #{owner_label}, pid=#{@pty_session.pid}, sandbox=true")
306
+
307
+ Thread.new { read_loop(@pty_session) }
308
+ end
309
+
310
+ def switch_mode(sandbox:)
311
+ mode = sandbox ? 'SAFE' : 'UNSAFE'
312
+ command = sandbox ? @safe_command : @unsafe_command
313
+
314
+ log_warn("Switching to #{mode} mode for #{owner_label}")
315
+
316
+ console_session.update(unsafe_at: Time.current) if !sandbox
317
+ record_log_line(content: "Switched to #{mode} mode", direction: :transition)
318
+
319
+ broadcast(RELOAD_MARKER)
320
+
321
+ old_session = @pty_session
322
+ @pty_session = RailsConsole::PtySession.new(command:, sandbox:)
323
+
324
+ old_session.kill!
325
+
326
+ log_warn("Session is now #{mode}, pid=#{@pty_session.pid}")
327
+
328
+ Thread.new { read_loop(@pty_session) }
329
+ end
330
+
331
+ # Nudges the console to redraw so a just-attached client isn't left staring at a blank screen.
332
+ def wake_pty
333
+ @pty_session&.write(REDRAW)
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/numeric/time'
4
+
5
+ module RailsConsole
6
+ class << self
7
+ attr_accessor :audit,
8
+ :authorize,
9
+ :command,
10
+ :current_user,
11
+ :idle_timeout,
12
+ :sandbox_command,
13
+ :user_class
14
+
15
+ attr_writer :socket_path
16
+
17
+ def config
18
+ self
19
+ end
20
+
21
+ def configure
22
+ yield(self)
23
+ end
24
+
25
+ def default_socket_path
26
+ Rails.root.join('tmp/sockets/rails_console.sock')
27
+ end
28
+
29
+ def socket_path
30
+ @socket_path || default_socket_path
31
+ end
32
+ end
33
+
34
+ self.audit = true
35
+ self.authorize = ->(_user) { false }
36
+ self.command = 'bundle exec rails console'
37
+ self.current_user = ->(user) { { id: user&.id, label: user.try(:email) || 'unknown' } }
38
+ self.idle_timeout = 10.minutes
39
+ self.sandbox_command = 'bundle exec rails console --sandbox'
40
+ self.user_class = 'User'
41
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace RailsConsole
6
+
7
+ # Pagers hang inside the web console PTY, so disable them on whichever REPL the host app uses.
8
+ console do
9
+ IRB.conf[:USE_PAGER] = false if defined?(IRB)
10
+
11
+ begin
12
+ require 'pry'
13
+
14
+ Pry.config.pager = false
15
+ rescue LoadError
16
+ nil
17
+ end
18
+ end
19
+
20
+ initializer 'rails_console.assets' do |app|
21
+ next if !app.config.respond_to?(:assets)
22
+
23
+ app.config.assets.precompile += %w[rails_console.css rails_console.js]
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ module Logging
5
+ PREFIX = '[RailsConsole]'
6
+
7
+ def log(message)
8
+ Rails.logger.info("#{PREFIX} #{message}")
9
+ end
10
+
11
+ def log_warn(message)
12
+ Rails.logger.warn("#{PREFIX} #{message}")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+
5
+ module RailsConsole
6
+ class PtySession
7
+ READ_SIZE = 8192
8
+
9
+ attr_reader :pid
10
+
11
+ delegate :write, to: :@input
12
+
13
+ def initialize(command: nil, sandbox: true)
14
+ command ||= sandbox ? RailsConsole.sandbox_command : RailsConsole.command
15
+
16
+ @output, @input, @pid = PTY.spawn(self.class.spawn_env, command)
17
+ end
18
+
19
+ # Pagers hang inside the web terminal, so force a pass-through one.
20
+ def self.spawn_env
21
+ {
22
+ 'PAGER' => 'cat',
23
+ 'TERM' => 'xterm-256color',
24
+ }
25
+ end
26
+
27
+ def alive?
28
+ Process.waitpid(pid, Process::WNOHANG).nil?
29
+ rescue Errno::ECHILD
30
+ false
31
+ end
32
+
33
+ def kill!
34
+ Process.kill('TERM', pid)
35
+ Process.waitpid(pid)
36
+ rescue Errno::ESRCH, Errno::ECHILD
37
+ nil
38
+ end
39
+
40
+ def read_nonblock(size = READ_SIZE)
41
+ @output.read_nonblock(size)
42
+ rescue IO::WaitReadable, EOFError
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ def safe!
4
+ $stdout.puts(RailsConsole::Broker::SAFE_MARKER)
5
+ $stdout.flush
6
+ end
7
+
8
+ def unsafe!
9
+ $stdout.puts(RailsConsole::Broker::UNSAFE_MARKER)
10
+ $stdout.flush
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ def self.authorized?(context)
5
+ config.authorize.call(user_from(context))
6
+ end
7
+
8
+ def self.user_from(context)
9
+ return context.current_user if context.respond_to?(:current_user)
10
+
11
+ context.env['warden']&.user
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ end
5
+
6
+ require 'rails-console/version'
7
+ require 'rails-console/config'
8
+ require 'rails-console/user'
9
+ require 'rails-console/logging'
10
+ require 'rails-console/unsafe'
11
+ require 'rails-console/pty_session'
12
+ require 'rails-console/broker'
13
+
14
+ require 'rails-console/engine' if defined?(::Rails::Engine)
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-console
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Washington Botelho
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actioncable
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: railties
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: A safe, browser-based Rails console you mount on your app.
69
+ email: wbotelhos@gmail.com
70
+ executables:
71
+ - rails_console
72
+ extensions: []
73
+ extra_rdoc_files:
74
+ - CHANGELOG.md
75
+ - LICENSE
76
+ - README.md
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - app/assets/rails_console/rails_console.css
82
+ - app/assets/rails_console/rails_console.js
83
+ - app/assets/rails_console/src/application.css
84
+ - app/assets/rails_console/src/console.js
85
+ - app/channels/rails_console/console_channel.rb
86
+ - app/controllers/rails_console/consoles_controller.rb
87
+ - app/models/rails_console/application_record.rb
88
+ - app/models/rails_console/log_line.rb
89
+ - app/models/rails_console/session.rb
90
+ - app/views/layouts/rails_console/application.html.erb
91
+ - app/views/rails_console/consoles/show.html.erb
92
+ - bin/rails_console
93
+ - config/routes.rb
94
+ - lib/generators/rails_console/install_generator.rb
95
+ - lib/generators/rails_console/templates/db/migrate/create_rails_console_log_lines.rb
96
+ - lib/generators/rails_console/templates/db/migrate/create_rails_console_sessions.rb
97
+ - lib/generators/rails_console/templates/rails_console.rb
98
+ - lib/rails-console.rb
99
+ - lib/rails-console/broker.rb
100
+ - lib/rails-console/config.rb
101
+ - lib/rails-console/engine.rb
102
+ - lib/rails-console/logging.rb
103
+ - lib/rails-console/pty_session.rb
104
+ - lib/rails-console/unsafe.rb
105
+ - lib/rails-console/user.rb
106
+ - lib/rails-console/version.rb
107
+ homepage: https://github.com/wbotelhos/rails-console
108
+ licenses:
109
+ - MIT
110
+ metadata:
111
+ rubygems_mfa_required: 'true'
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: '3.3'
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.6.9
127
+ specification_version: 4
128
+ summary: A safe, browser-based Rails console you mount on your app.
129
+ test_files: []