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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +118 -0
- data/app/assets/rails_console/rails_console.css +230 -0
- data/app/assets/rails_console/rails_console.js +11 -0
- data/app/assets/rails_console/src/application.css +12 -0
- data/app/assets/rails_console/src/console.js +135 -0
- data/app/channels/rails_console/console_channel.rb +70 -0
- data/app/controllers/rails_console/consoles_controller.rb +19 -0
- data/app/models/rails_console/application_record.rb +7 -0
- data/app/models/rails_console/log_line.rb +13 -0
- data/app/models/rails_console/session.rb +21 -0
- data/app/views/layouts/rails_console/application.html.erb +16 -0
- data/app/views/rails_console/consoles/show.html.erb +1 -0
- data/bin/rails_console +8 -0
- data/config/routes.rb +5 -0
- data/lib/generators/rails_console/install_generator.rb +55 -0
- data/lib/generators/rails_console/templates/db/migrate/create_rails_console_log_lines.rb +14 -0
- data/lib/generators/rails_console/templates/db/migrate/create_rails_console_sessions.rb +16 -0
- data/lib/generators/rails_console/templates/rails_console.rb +14 -0
- data/lib/rails-console/broker.rb +336 -0
- data/lib/rails-console/config.rb +41 -0
- data/lib/rails-console/engine.rb +26 -0
- data/lib/rails-console/logging.rb +15 -0
- data/lib/rails-console/pty_session.rb +46 -0
- data/lib/rails-console/unsafe.rb +11 -0
- data/lib/rails-console/user.rb +13 -0
- data/lib/rails-console/version.rb +5 -0
- data/lib/rails-console.rb +14 -0
- metadata +129 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createConsumer } from '@rails/actioncable';
|
|
2
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
3
|
+
import { Terminal } from '@xterm/xterm';
|
|
4
|
+
|
|
5
|
+
const RELOAD_MARKER = '\x00CONSOLE_RELOAD\x00';
|
|
6
|
+
const RETRY_DELAY = 3000;
|
|
7
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
8
|
+
const SPINNER_INTERVAL = 90;
|
|
9
|
+
|
|
10
|
+
class RailsConsoleTerminal {
|
|
11
|
+
constructor(element) {
|
|
12
|
+
this.element = element;
|
|
13
|
+
this.consumer = createConsumer();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
connect() {
|
|
17
|
+
this.openTerminal();
|
|
18
|
+
this.subscribe();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
disconnect() {
|
|
22
|
+
window.removeEventListener('resize', this.onResize);
|
|
23
|
+
|
|
24
|
+
clearTimeout(this.retryTimeout);
|
|
25
|
+
this.stopSpinner();
|
|
26
|
+
|
|
27
|
+
this.subscription?.unsubscribe();
|
|
28
|
+
this.terminal?.dispose();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
onData(data) {
|
|
32
|
+
const bytes = new TextEncoder().encode(data);
|
|
33
|
+
const binary = String.fromCharCode(...bytes);
|
|
34
|
+
|
|
35
|
+
this.subscription.perform('receive', { bytes: btoa(binary) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
openTerminal() {
|
|
39
|
+
this.fitAddon = new FitAddon();
|
|
40
|
+
this.terminal = new Terminal({ cursorBlink: true });
|
|
41
|
+
|
|
42
|
+
this.terminal.loadAddon(this.fitAddon);
|
|
43
|
+
this.terminal.open(this.element);
|
|
44
|
+
this.fitAddon.fit();
|
|
45
|
+
this.terminal.focus();
|
|
46
|
+
|
|
47
|
+
this.onResize = () => this.fitAddon.fit();
|
|
48
|
+
|
|
49
|
+
window.addEventListener('resize', this.onResize);
|
|
50
|
+
|
|
51
|
+
this.terminal.onData((data) => this.onData(data));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
received(data) {
|
|
55
|
+
if (data.error) {
|
|
56
|
+
this.stopSpinner();
|
|
57
|
+
this.showError(data.error);
|
|
58
|
+
} else if (data.bytes) {
|
|
59
|
+
this.writeBytes(data.bytes);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
showError(message) {
|
|
64
|
+
this.terminal.write(`\r\n${message}\r\n`);
|
|
65
|
+
|
|
66
|
+
this.subscription.unsubscribe();
|
|
67
|
+
|
|
68
|
+
this.retryTimeout = setTimeout(() => this.subscribe(), RETRY_DELAY);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
startSpinner() {
|
|
72
|
+
let frame = 0;
|
|
73
|
+
|
|
74
|
+
this.spinner = setInterval(() => {
|
|
75
|
+
this.terminal.write(`\r\x1b[K\x1b[33m${SPINNER_FRAMES[frame]}\x1b[0m Booting console…`);
|
|
76
|
+
|
|
77
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
78
|
+
}, SPINNER_INTERVAL);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stopSpinner() {
|
|
82
|
+
if (!this.spinner) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clearInterval(this.spinner);
|
|
87
|
+
|
|
88
|
+
this.spinner = null;
|
|
89
|
+
|
|
90
|
+
this.terminal.write('\r\x1b[K');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
subscribe() {
|
|
94
|
+
this.startSpinner();
|
|
95
|
+
|
|
96
|
+
this.subscription = this.consumer.subscriptions.create('RailsConsole::ConsoleChannel', {
|
|
97
|
+
connected: () => {},
|
|
98
|
+
disconnected: () => {},
|
|
99
|
+
received: (data) => this.received(data),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
writeBytes(encoded) {
|
|
104
|
+
let binary = atob(encoded);
|
|
105
|
+
|
|
106
|
+
// The broker sends this marker when it swaps the session (e.g. on unsafe!). Reset the
|
|
107
|
+
// screen and show the booting spinner again until the fresh console prints its output.
|
|
108
|
+
if (binary.includes(RELOAD_MARKER)) {
|
|
109
|
+
binary = binary.replaceAll(RELOAD_MARKER, '');
|
|
110
|
+
|
|
111
|
+
this.terminal.reset();
|
|
112
|
+
this.startSpinner();
|
|
113
|
+
} else {
|
|
114
|
+
this.stopSpinner();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
118
|
+
|
|
119
|
+
this.terminal.write(bytes);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
124
|
+
const element = document.querySelector('[data-rails-console]');
|
|
125
|
+
|
|
126
|
+
if (!element) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const terminal = new RailsConsoleTerminal(element);
|
|
131
|
+
|
|
132
|
+
terminal.connect();
|
|
133
|
+
|
|
134
|
+
window.addEventListener('beforeunload', () => terminal.disconnect());
|
|
135
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module RailsConsole
|
|
7
|
+
class ConsoleChannel < ActionCable::Channel::Base
|
|
8
|
+
# Keystrokes from the browser: decode and forward them to the broker.
|
|
9
|
+
def receive(data)
|
|
10
|
+
bytes = Base64.strict_decode64(data.fetch('bytes'))
|
|
11
|
+
|
|
12
|
+
@socket&.write(bytes)
|
|
13
|
+
rescue ArgumentError, Errno::EPIPE, IOError
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Connects an authorized user to the broker and starts streaming its output back.
|
|
18
|
+
def subscribed
|
|
19
|
+
return reject if !RailsConsole.authorized?(connection)
|
|
20
|
+
|
|
21
|
+
return reject_unavailable if !RailsConsole::Broker.boot
|
|
22
|
+
|
|
23
|
+
@socket = connect_to_broker
|
|
24
|
+
@reader_thread = Thread.new { relay_broker_output }
|
|
25
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT
|
|
26
|
+
reject_unavailable
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Closes the broker connection when the browser leaves.
|
|
30
|
+
def unsubscribed
|
|
31
|
+
@reader_thread&.kill
|
|
32
|
+
@socket&.close
|
|
33
|
+
rescue IOError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Opens the broker socket and sends who is connecting.
|
|
40
|
+
def connect_to_broker
|
|
41
|
+
socket = UNIXSocket.new(RailsConsole.socket_path.to_s)
|
|
42
|
+
identity = RailsConsole.config.current_user.call(RailsConsole.user_from(connection))
|
|
43
|
+
ip_address = Rack::Request.new(connection.env).ip
|
|
44
|
+
label = identity[:label] || identity['label']
|
|
45
|
+
user_id = identity[:id] || identity['id']
|
|
46
|
+
|
|
47
|
+
socket.puts(JSON.generate(ip_address:, label:, user_id:))
|
|
48
|
+
|
|
49
|
+
socket
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Tells the browser the console could not be reached and drops the subscription.
|
|
53
|
+
def reject_unavailable
|
|
54
|
+
transmit({ error: 'Console is unavailable at the moment.' })
|
|
55
|
+
|
|
56
|
+
reject
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Streams broker output to the browser, encoded so binary bytes survive JSON.
|
|
60
|
+
def relay_broker_output
|
|
61
|
+
loop do
|
|
62
|
+
bytes = @socket.readpartial(RailsConsole::PtySession::READ_SIZE)
|
|
63
|
+
|
|
64
|
+
transmit({ bytes: Base64.strict_encode64(bytes) })
|
|
65
|
+
end
|
|
66
|
+
rescue IOError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsole
|
|
4
|
+
class ConsolesController < ActionController::Base
|
|
5
|
+
layout 'rails_console/application'
|
|
6
|
+
|
|
7
|
+
before_action :authorize!
|
|
8
|
+
|
|
9
|
+
def show; end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def authorize!
|
|
14
|
+
return if RailsConsole.authorized?(request)
|
|
15
|
+
|
|
16
|
+
head(403)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsole
|
|
4
|
+
class LogLine < ApplicationRecord
|
|
5
|
+
self.table_name = 'rails_console_log_lines'
|
|
6
|
+
|
|
7
|
+
enum :direction, { input: 0, output: 1, transition: 2 }
|
|
8
|
+
|
|
9
|
+
belongs_to :session, class_name: 'RailsConsole::Session', inverse_of: :log_lines, optional: false
|
|
10
|
+
|
|
11
|
+
validates :content, presence: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsole
|
|
4
|
+
class Session < ApplicationRecord
|
|
5
|
+
self.table_name = 'rails_console_sessions'
|
|
6
|
+
|
|
7
|
+
has_many :log_lines, class_name: 'RailsConsole::LogLine', dependent: :destroy, inverse_of: :session
|
|
8
|
+
|
|
9
|
+
validates :started_at, presence: true
|
|
10
|
+
|
|
11
|
+
def user
|
|
12
|
+
return if RailsConsole.user_class.blank?
|
|
13
|
+
|
|
14
|
+
model = RailsConsole.user_class.safe_constantize
|
|
15
|
+
|
|
16
|
+
return if model.nil?
|
|
17
|
+
|
|
18
|
+
model.find_by(id: user_id)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Rails Console</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= action_cable_meta_tag %>
|
|
8
|
+
|
|
9
|
+
<%= stylesheet_link_tag 'rails_console', nonce: true %>
|
|
10
|
+
<%= javascript_include_tag 'rails_console', nonce: true %>
|
|
11
|
+
</head>
|
|
12
|
+
|
|
13
|
+
<body>
|
|
14
|
+
<%= yield %>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div id="rails-console" data-rails-console></div>
|
data/bin/rails_console
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsConsole
|
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
|
5
|
+
source_root File.expand_path('templates', __dir__)
|
|
6
|
+
|
|
7
|
+
desc 'Installs RailsConsole migrations and initializer'
|
|
8
|
+
|
|
9
|
+
def create_initializer
|
|
10
|
+
template('rails_console.rb', 'config/initializers/rails_console.rb')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create_migrations
|
|
14
|
+
template(
|
|
15
|
+
'db/migrate/create_rails_console_sessions.rb',
|
|
16
|
+
"db/migrate/#{timestamp(0)}_create_rails_console_sessions.rb"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
template(
|
|
20
|
+
'db/migrate/create_rails_console_log_lines.rb',
|
|
21
|
+
"db/migrate/#{timestamp(1)}_create_rails_console_log_lines.rb"
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def link_sprockets_assets
|
|
26
|
+
manifest = 'app/assets/config/manifest.js'
|
|
27
|
+
|
|
28
|
+
return if !File.exist?(manifest)
|
|
29
|
+
|
|
30
|
+
contents = File.read(manifest)
|
|
31
|
+
|
|
32
|
+
return if contents.include?('rails_console.css')
|
|
33
|
+
|
|
34
|
+
append_to_file(manifest) do
|
|
35
|
+
<<~JS
|
|
36
|
+
|
|
37
|
+
//= link rails_console.css
|
|
38
|
+
//= link rails_console.js
|
|
39
|
+
JS
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def migration_version
|
|
46
|
+
ActiveRecord::Migration.current_version
|
|
47
|
+
rescue StandardError
|
|
48
|
+
'8.1'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def timestamp(offset)
|
|
52
|
+
Time.current.utc.strftime('%Y%m%d%H%M%S').to_i + offset
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsConsoleLogLines < ActiveRecord::Migration[<%= migration_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_console_log_lines do |t|
|
|
6
|
+
t.integer :direction, null: false, default: 0
|
|
7
|
+
t.text :content, null: false
|
|
8
|
+
|
|
9
|
+
t.references :session, foreign_key: { to_table: :rails_console_sessions }, null: false
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsConsoleSessions < ActiveRecord::Migration[<%= migration_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_console_sessions do |t|
|
|
6
|
+
t.integer :user_id
|
|
7
|
+
t.string :user_label
|
|
8
|
+
t.string :ip_address
|
|
9
|
+
t.datetime :started_at, null: false
|
|
10
|
+
t.datetime :ended_at
|
|
11
|
+
t.datetime :unsafe_at
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsConsole.configure do |config|
|
|
4
|
+
config.audit = true
|
|
5
|
+
|
|
6
|
+
config.authorize = ->(user) { user&.devops? }
|
|
7
|
+
|
|
8
|
+
config.command = 'bundle exec rails console'
|
|
9
|
+
|
|
10
|
+
config.current_user = ->(user) { { id: user&.id, label: user.try(:email) || 'unknown' } }
|
|
11
|
+
|
|
12
|
+
config.idle_timeout = 10.minutes
|
|
13
|
+
config.sandbox_command = 'bundle exec rails console --sandbox'
|
|
14
|
+
end
|