browserctl 0.12.0 → 0.13.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/CHANGELOG.md +17 -0
- data/README.md +3 -3
- data/bin/browserctl +39 -32
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +0 -27
- data/lib/browserctl/commands/cli_output.rb +17 -3
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +56 -8
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +40 -11
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +33 -294
- data/lib/browserctl/server/command_dispatcher.rb +25 -16
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +20 -47
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +61 -237
- metadata +11 -8
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
data/examples/session_reuse.rb
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Demonstrates the authenticate-once, reuse-forever pattern.
|
|
4
|
-
#
|
|
5
|
-
# The first run (no saved session) invokes `login_once` to authenticate,
|
|
6
|
-
# which saves the session. Every subsequent run loads the saved session
|
|
7
|
-
# directly — no re-authentication needed.
|
|
8
|
-
#
|
|
9
|
-
# `expired_if:` detects when the saved session exists but server-side auth
|
|
10
|
-
# has lapsed (rotated cookie, token TTL), and automatically re-authenticates.
|
|
11
|
-
#
|
|
12
|
-
# Run:
|
|
13
|
-
# browserctl workflow run examples/session_reuse.rb \
|
|
14
|
-
# --app_url https://the-internet.herokuapp.com \
|
|
15
|
-
# --username tomsmith \
|
|
16
|
-
# --password "SuperSecretPassword!"
|
|
17
|
-
#
|
|
18
|
-
# On the first run: authenticates and saves the session.
|
|
19
|
-
# On subsequent runs: loads the session and skips the login page entirely.
|
|
20
|
-
|
|
21
|
-
# --- Step 1: define the login workflow (run once, triggered automatically on missing/expired session) ---
|
|
22
|
-
|
|
23
|
-
Browserctl.workflow "session_reuse/login_once" do
|
|
24
|
-
desc "Authenticate and save session — called automatically by session_reuse when needed"
|
|
25
|
-
|
|
26
|
-
param :app_url, required: true
|
|
27
|
-
param :username, required: true
|
|
28
|
-
param :password, required: true, secret: true
|
|
29
|
-
|
|
30
|
-
step "open login page" do
|
|
31
|
-
open_page(:main, url: "#{app_url}/login")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
step "fill and submit credentials" do
|
|
35
|
-
page(:main).fill("input#username", username)
|
|
36
|
-
page(:main).fill("input#password", password)
|
|
37
|
-
page(:main).click("button[type=submit]")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
step "verify login succeeded" do
|
|
41
|
-
assert page(:main).url.include?("/secure"), "login failed — still on login page"
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
step "save authenticated session" do
|
|
45
|
-
save_session("session_reuse_demo")
|
|
46
|
-
puts " ✓ Session saved — future runs will skip this step"
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# --- Step 2: the main workflow that reuses the saved session ---
|
|
51
|
-
|
|
52
|
-
Browserctl.workflow "session_reuse" do
|
|
53
|
-
desc "Authenticate once, reuse forever — demonstrates load_session with fallback and expired_if"
|
|
54
|
-
|
|
55
|
-
param :app_url, default: "https://the-internet.herokuapp.com"
|
|
56
|
-
param :username, default: "tomsmith"
|
|
57
|
-
param :password, default: "SuperSecretPassword!", secret: true
|
|
58
|
-
|
|
59
|
-
step "restore session or log in" do
|
|
60
|
-
load_session("session_reuse_demo",
|
|
61
|
-
fallback: "session_reuse/login_once",
|
|
62
|
-
expired_if: lambda {
|
|
63
|
-
page(:main).navigate("#{app_url}/secure")
|
|
64
|
-
!page(:main).url.include?("/secure")
|
|
65
|
-
})
|
|
66
|
-
puts " ✓ Session ready — authenticated as #{username}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
step "do authenticated work" do
|
|
70
|
-
page(:main).navigate("#{app_url}/secure")
|
|
71
|
-
heading = page(:main).evaluate("document.querySelector('h2')?.textContent?.trim()")
|
|
72
|
-
assert heading&.include?("Secure Area"), "expected to be in secure area, got: #{heading.inspect}"
|
|
73
|
-
puts " ✓ Landed in secure area without re-authenticating"
|
|
74
|
-
end
|
|
75
|
-
end
|
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "io/console"
|
|
5
|
-
require "json"
|
|
6
|
-
require "open3"
|
|
7
|
-
require "openssl"
|
|
8
|
-
require "securerandom"
|
|
9
|
-
require "tmpdir"
|
|
10
|
-
require_relative "cli_output"
|
|
11
|
-
|
|
12
|
-
module Browserctl
|
|
13
|
-
module Commands
|
|
14
|
-
module Session
|
|
15
|
-
extend CliOutput
|
|
16
|
-
|
|
17
|
-
USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
|
|
18
|
-
|
|
19
|
-
PBKDF2_ITERATIONS = 100_000
|
|
20
|
-
PBKDF2_KEY_LEN = 32
|
|
21
|
-
SALT_LEN = 16
|
|
22
|
-
NONCE_LEN = 12
|
|
23
|
-
|
|
24
|
-
SENSITIVE_BASENAMES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
25
|
-
|
|
26
|
-
def self.run(client, args)
|
|
27
|
-
sub = args.shift or abort USAGE
|
|
28
|
-
case sub
|
|
29
|
-
when "save" then run_save(client, args)
|
|
30
|
-
when "load" then run_load(client, args)
|
|
31
|
-
when "list" then run_list(client)
|
|
32
|
-
when "delete" then run_delete(client, args)
|
|
33
|
-
when "export" then run_export(args)
|
|
34
|
-
when "import" then run_import(args)
|
|
35
|
-
else abort "unknown session subcommand '#{sub}'\n#{USAGE}"
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.run_save(client, args)
|
|
40
|
-
encrypt = args.delete("--encrypt")
|
|
41
|
-
name = args.shift or abort "usage: browserctl session save <name> [--encrypt]"
|
|
42
|
-
print_result(client.session_save(name, encrypt: !!encrypt))
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.run_load(client, args)
|
|
46
|
-
name = args.shift or abort "usage: browserctl session load <name>"
|
|
47
|
-
print_result(client.session_load(name))
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def self.run_list(client)
|
|
51
|
-
print_result(client.session_list)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def self.run_delete(client, args)
|
|
55
|
-
name = args.shift or abort "usage: browserctl session delete <name>"
|
|
56
|
-
print_result(client.session_delete(name))
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def self.run_export(args)
|
|
60
|
-
encrypt = args.delete("--encrypt")
|
|
61
|
-
name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
62
|
-
dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
63
|
-
|
|
64
|
-
Browserctl::Session.validate_name!(name)
|
|
65
|
-
session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
|
|
66
|
-
abort "session '#{name}' not found" unless Dir.exist?(session_dir)
|
|
67
|
-
|
|
68
|
-
dest = File.expand_path(dest)
|
|
69
|
-
|
|
70
|
-
if encrypt
|
|
71
|
-
passphrase = prompt_passphrase(confirm: true)
|
|
72
|
-
encrypt_export(session_dir, name, dest, passphrase)
|
|
73
|
-
else
|
|
74
|
-
pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
|
|
75
|
-
Process.wait(pid)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
puts({ ok: true, path: dest }.to_json)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def self.run_import(args)
|
|
82
|
-
zip_path = args.shift or abort "usage: browserctl session import <path>"
|
|
83
|
-
zip_path = File.expand_path(zip_path)
|
|
84
|
-
abort "zip file not found: #{zip_path}" unless File.exist?(zip_path)
|
|
85
|
-
|
|
86
|
-
sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
|
|
87
|
-
FileUtils.mkdir_p(sessions_dir)
|
|
88
|
-
|
|
89
|
-
before = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
|
|
90
|
-
|
|
91
|
-
if encrypted_zip?(zip_path)
|
|
92
|
-
passphrase = prompt_passphrase
|
|
93
|
-
decrypt_import(zip_path, sessions_dir, passphrase)
|
|
94
|
-
else
|
|
95
|
-
pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
|
|
96
|
-
Process.wait(pid)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
after = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
|
|
100
|
-
name = (after - before).first
|
|
101
|
-
|
|
102
|
-
puts({ ok: true, name: name }.to_json)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# --- private helpers ---
|
|
106
|
-
|
|
107
|
-
def self.prompt_passphrase(confirm: false)
|
|
108
|
-
return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
|
|
109
|
-
|
|
110
|
-
$stderr.print "Passphrase: "
|
|
111
|
-
pass = $stdin.noecho(&:gets).to_s.chomp
|
|
112
|
-
$stderr.puts
|
|
113
|
-
|
|
114
|
-
if confirm
|
|
115
|
-
$stderr.print "Confirm passphrase: "
|
|
116
|
-
confirm_pass = $stdin.noecho(&:gets).to_s.chomp
|
|
117
|
-
$stderr.puts
|
|
118
|
-
abort "Passphrases do not match." unless pass == confirm_pass
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
pass
|
|
122
|
-
end
|
|
123
|
-
private_class_method :prompt_passphrase
|
|
124
|
-
|
|
125
|
-
def self.derive_key(passphrase, salt)
|
|
126
|
-
OpenSSL::PKCS5.pbkdf2_hmac(
|
|
127
|
-
passphrase,
|
|
128
|
-
salt,
|
|
129
|
-
PBKDF2_ITERATIONS,
|
|
130
|
-
PBKDF2_KEY_LEN,
|
|
131
|
-
OpenSSL::Digest.new("SHA256")
|
|
132
|
-
)
|
|
133
|
-
end
|
|
134
|
-
private_class_method :derive_key
|
|
135
|
-
|
|
136
|
-
def self.encrypt_blob(plaintext, key)
|
|
137
|
-
nonce = SecureRandom.bytes(NONCE_LEN)
|
|
138
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
139
|
-
cipher.encrypt
|
|
140
|
-
cipher.key = key
|
|
141
|
-
cipher.iv = nonce
|
|
142
|
-
ct = cipher.update(plaintext) + cipher.final
|
|
143
|
-
tag = cipher.auth_tag
|
|
144
|
-
nonce + ct + tag
|
|
145
|
-
end
|
|
146
|
-
private_class_method :encrypt_blob
|
|
147
|
-
|
|
148
|
-
def self.decrypt_blob(blob, key)
|
|
149
|
-
nonce = blob.byteslice(0, NONCE_LEN)
|
|
150
|
-
tag = blob.byteslice(-16, 16)
|
|
151
|
-
ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
|
|
152
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
153
|
-
cipher.decrypt
|
|
154
|
-
cipher.key = key
|
|
155
|
-
cipher.iv = nonce
|
|
156
|
-
cipher.auth_tag = tag
|
|
157
|
-
cipher.update(ciphertext) + cipher.final
|
|
158
|
-
rescue OpenSSL::Cipher::CipherError => e
|
|
159
|
-
raise Browserctl::Error, "export decryption failed: #{e.message}"
|
|
160
|
-
end
|
|
161
|
-
private_class_method :decrypt_blob
|
|
162
|
-
|
|
163
|
-
# Build an encrypted zip using the system zip command.
|
|
164
|
-
# Plaintext files are staged in a tmpdir; sensitive ones are encrypted
|
|
165
|
-
# before staging. An _encryption_manifest.json is added at the zip root.
|
|
166
|
-
def self.encrypt_export(session_dir, session_name, dest, passphrase)
|
|
167
|
-
salt = SecureRandom.bytes(SALT_LEN)
|
|
168
|
-
key = derive_key(passphrase, salt)
|
|
169
|
-
manifest = JSON.generate({
|
|
170
|
-
encrypted: true, kdf: "pbkdf2-sha256",
|
|
171
|
-
iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
|
|
172
|
-
})
|
|
173
|
-
Dir.mktmpdir do |tmpdir|
|
|
174
|
-
File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
|
|
175
|
-
stage_session_files(session_dir, session_name, tmpdir, key)
|
|
176
|
-
pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
|
|
177
|
-
Process.wait(pid)
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
private_class_method :encrypt_export
|
|
181
|
-
|
|
182
|
-
def self.stage_session_files(session_dir, session_name, tmpdir, key)
|
|
183
|
-
staged_session = File.join(tmpdir, session_name)
|
|
184
|
-
FileUtils.mkdir_p(staged_session)
|
|
185
|
-
Dir[File.join(session_dir, "**", "*")].each do |file|
|
|
186
|
-
next if File.directory?(file)
|
|
187
|
-
|
|
188
|
-
relative = file.delete_prefix("#{session_dir}/")
|
|
189
|
-
staged_path = File.join(staged_session, relative)
|
|
190
|
-
FileUtils.mkdir_p(File.dirname(staged_path))
|
|
191
|
-
raw = File.binread(file)
|
|
192
|
-
if SENSITIVE_BASENAMES.include?(File.basename(file))
|
|
193
|
-
File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
|
|
194
|
-
else
|
|
195
|
-
File.binwrite(staged_path, raw)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
private_class_method :stage_session_files
|
|
200
|
-
|
|
201
|
-
# Returns true if the zip contains _encryption_manifest.json.
|
|
202
|
-
def self.encrypted_zip?(zip_path)
|
|
203
|
-
out, status = Open3.capture2("unzip", "-l", zip_path)
|
|
204
|
-
status.success? && out.include?("_encryption_manifest.json")
|
|
205
|
-
end
|
|
206
|
-
private_class_method :encrypted_zip?
|
|
207
|
-
|
|
208
|
-
# Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
|
|
209
|
-
def self.decrypt_import(zip_path, sessions_dir, passphrase)
|
|
210
|
-
Dir.mktmpdir do |tmpdir|
|
|
211
|
-
pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
|
|
212
|
-
Process.wait(pid)
|
|
213
|
-
manifest_path = File.join(tmpdir, "_encryption_manifest.json")
|
|
214
|
-
raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
|
|
215
|
-
|
|
216
|
-
manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
217
|
-
key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
|
|
218
|
-
copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
private_class_method :decrypt_import
|
|
222
|
-
|
|
223
|
-
def self.copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
224
|
-
Dir[File.join(tmpdir, "**", "*")].each do |file|
|
|
225
|
-
next if File.directory?(file)
|
|
226
|
-
next if File.basename(file) == "_encryption_manifest.json"
|
|
227
|
-
|
|
228
|
-
relative = file.delete_prefix("#{tmpdir}/")
|
|
229
|
-
dest_path = File.join(sessions_dir, relative)
|
|
230
|
-
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
231
|
-
if file.end_with?(".enc")
|
|
232
|
-
File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
|
|
233
|
-
f.write(decrypt_blob(File.binread(file), key))
|
|
234
|
-
end
|
|
235
|
-
else
|
|
236
|
-
FileUtils.cp(file, dest_path)
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
private_class_method :copy_decrypted_files
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
end
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Browserctl
|
|
4
|
-
module Driver
|
|
5
|
-
class Base
|
|
6
|
-
def create_page = raise NotImplementedError, "#{self.class.name}#create_page not implemented"
|
|
7
|
-
def quit = raise NotImplementedError, "#{self.class.name}#quit not implemented"
|
|
8
|
-
def headed? = raise NotImplementedError, "#{self.class.name}#headed? not implemented"
|
|
9
|
-
def supports?(_) = false
|
|
10
|
-
def devtools_info(_page) = raise NotImplementedError, "#{self.class.name}#devtools_info not implemented"
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
data/lib/browserctl/driver.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../../session"
|
|
4
|
-
|
|
5
|
-
module Browserctl
|
|
6
|
-
class CommandDispatcher
|
|
7
|
-
module Handlers
|
|
8
|
-
module Session
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def cmd_session_save(req)
|
|
12
|
-
first_session = @global_mutex.synchronize { @pages.values.first }
|
|
13
|
-
return { error: "no open pages — open a page before saving a session" } unless first_session
|
|
14
|
-
|
|
15
|
-
cookies = first_session.page.cookies.all.values.map(&:to_h)
|
|
16
|
-
|
|
17
|
-
pages_meta = {}
|
|
18
|
-
local_storage = {}
|
|
19
|
-
@global_mutex.synchronize { @pages.dup }.each do |page_name, session|
|
|
20
|
-
session.mutex.synchronize do
|
|
21
|
-
origin = session.page.evaluate("location.origin")
|
|
22
|
-
local_str = session.page.evaluate("JSON.stringify({...localStorage})")
|
|
23
|
-
pages_meta[page_name] = { url: session.page.current_url, title: session.page.title }
|
|
24
|
-
local_storage[origin] = JSON.parse(local_str)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
now = Time.now.iso8601
|
|
29
|
-
Browserctl::Session.save(
|
|
30
|
-
req[:session_name],
|
|
31
|
-
metadata: { version: 1, name: req[:session_name],
|
|
32
|
-
created_at: now, updated_at: now, pages: pages_meta },
|
|
33
|
-
cookies: cookies,
|
|
34
|
-
local_storage: local_storage,
|
|
35
|
-
session_storage: {},
|
|
36
|
-
encrypt: req[:encrypt] || false
|
|
37
|
-
)
|
|
38
|
-
{ ok: true, path: Browserctl::Session.path(req[:session_name]),
|
|
39
|
-
pages: pages_meta.length, cookies: cookies.length }
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def cmd_session_load(req)
|
|
43
|
-
data = Browserctl::Session.load(req[:session_name])
|
|
44
|
-
|
|
45
|
-
data[:metadata][:pages].each do |page_name, page_data|
|
|
46
|
-
existing = @global_mutex.synchronize { @pages[page_name.to_s] }
|
|
47
|
-
if existing
|
|
48
|
-
existing.page.go_to(page_data[:url])
|
|
49
|
-
else
|
|
50
|
-
new_page = @driver.create_page
|
|
51
|
-
new_page.go_to(page_data[:url])
|
|
52
|
-
@global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
seed_session = @global_mutex.synchronize { @pages.values.first }
|
|
57
|
-
cookie_count = data[:cookies].length
|
|
58
|
-
data[:cookies].each { |c| seed_session.page.cookies.set(**c.slice(:name, :value, :domain, :path)) }
|
|
59
|
-
|
|
60
|
-
ls_key_count = 0
|
|
61
|
-
data[:local_storage].each do |origin, keys|
|
|
62
|
-
next if keys.empty?
|
|
63
|
-
|
|
64
|
-
tmp_page = @driver.create_page
|
|
65
|
-
begin
|
|
66
|
-
tmp_page.go_to(origin)
|
|
67
|
-
keys.each do |k, v|
|
|
68
|
-
tmp_page.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
|
|
69
|
-
ls_key_count += 1
|
|
70
|
-
end
|
|
71
|
-
ensure
|
|
72
|
-
tmp_page.close
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
{ ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
|
|
77
|
-
local_storage_keys: ls_key_count }
|
|
78
|
-
rescue Browserctl::Error, ArgumentError, JSON::ParserError, RuntimeError => e
|
|
79
|
-
{ error: e.message }
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def cmd_session_list(_req)
|
|
83
|
-
sessions = Browserctl::Session.all
|
|
84
|
-
{ ok: true, sessions: sessions }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def cmd_session_delete(req)
|
|
88
|
-
Browserctl::Session.delete(req[:session_name])
|
|
89
|
-
{ ok: true }
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
data/lib/browserctl/session.rb
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
require "openssl"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
require "open3"
|
|
8
|
-
require_relative "constants"
|
|
9
|
-
|
|
10
|
-
module Browserctl
|
|
11
|
-
class Session
|
|
12
|
-
BASE_DIR = File.join(BROWSERCTL_DIR, "sessions")
|
|
13
|
-
|
|
14
|
-
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
15
|
-
|
|
16
|
-
SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
17
|
-
|
|
18
|
-
def self.path(name) = File.join(BASE_DIR, name)
|
|
19
|
-
def self.exist?(name) = Dir.exist?(path(name))
|
|
20
|
-
|
|
21
|
-
def self.delete(name)
|
|
22
|
-
validate_name!(name)
|
|
23
|
-
FileUtils.rm_rf(path(name))
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.all
|
|
27
|
-
Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
|
|
31
|
-
validate_name!(session_name)
|
|
32
|
-
key, metadata = prepare_encryption(session_name, metadata, encrypt)
|
|
33
|
-
dir = path(session_name)
|
|
34
|
-
FileUtils.mkdir_p(dir)
|
|
35
|
-
write_json(File.join(dir, "metadata.json"), metadata)
|
|
36
|
-
write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.prepare_encryption(session_name, metadata, encrypt)
|
|
40
|
-
return [nil, metadata] unless encrypt
|
|
41
|
-
|
|
42
|
-
unless keychain_available?
|
|
43
|
-
raise Browserctl::Error,
|
|
44
|
-
"session encryption requires macOS Keychain (darwin only)\n " \
|
|
45
|
-
"For Linux/CI, omit --encrypt and rely on 0o600 file permissions,\n " \
|
|
46
|
-
"or use BROWSERCTL_EXPORT_PASSPHRASE with session export --encrypt for portable archives."
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
key = SecureRandom.bytes(32)
|
|
50
|
-
keychain_store(session_name, key)
|
|
51
|
-
[key, metadata.merge(encrypted: true)]
|
|
52
|
-
end
|
|
53
|
-
private_class_method :prepare_encryption
|
|
54
|
-
|
|
55
|
-
def self.write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
56
|
-
if key
|
|
57
|
-
write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
|
|
58
|
-
write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
|
|
59
|
-
unless session_storage.empty?
|
|
60
|
-
write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
|
|
61
|
-
key)
|
|
62
|
-
end
|
|
63
|
-
else
|
|
64
|
-
write_secret(File.join(dir, "cookies.json"), cookies)
|
|
65
|
-
write_secret(File.join(dir, "local_storage.json"), local_storage)
|
|
66
|
-
write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
private_class_method :write_session_files
|
|
70
|
-
|
|
71
|
-
def self.load(session_name)
|
|
72
|
-
validate_name!(session_name)
|
|
73
|
-
dir = path(session_name)
|
|
74
|
-
raise Browserctl::Error, "session '#{session_name}' not found" unless Dir.exist?(dir)
|
|
75
|
-
|
|
76
|
-
meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
|
|
77
|
-
|
|
78
|
-
if meta[:encrypted]
|
|
79
|
-
key = keychain_fetch(session_name)
|
|
80
|
-
{
|
|
81
|
-
metadata: meta,
|
|
82
|
-
cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
|
|
83
|
-
local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
|
|
84
|
-
session_storage: load_session_storage_encrypted(dir, key)
|
|
85
|
-
}
|
|
86
|
-
else
|
|
87
|
-
{
|
|
88
|
-
metadata: meta,
|
|
89
|
-
cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
|
|
90
|
-
local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
|
|
91
|
-
session_storage: load_session_storage(dir)
|
|
92
|
-
}
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def self.load_meta(path)
|
|
97
|
-
JSON.parse(File.read(path), symbolize_names: true)
|
|
98
|
-
rescue JSON::ParserError
|
|
99
|
-
nil
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def self.validate_name!(name)
|
|
103
|
-
return if SAFE_NAME.match?(name.to_s)
|
|
104
|
-
|
|
105
|
-
raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
|
|
109
|
-
def self.encrypt_file(data, key)
|
|
110
|
-
nonce = SecureRandom.bytes(12)
|
|
111
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
112
|
-
cipher.encrypt
|
|
113
|
-
cipher.key = key
|
|
114
|
-
cipher.iv = nonce
|
|
115
|
-
ciphertext = cipher.update(data) + cipher.final
|
|
116
|
-
tag = cipher.auth_tag
|
|
117
|
-
nonce + ciphertext + tag
|
|
118
|
-
end
|
|
119
|
-
private_class_method :encrypt_file
|
|
120
|
-
|
|
121
|
-
# Decrypt binary blob produced by encrypt_file; returns original plaintext string.
|
|
122
|
-
def self.decrypt_file(blob, key)
|
|
123
|
-
nonce = blob.byteslice(0, 12)
|
|
124
|
-
tag = blob.byteslice(-16, 16)
|
|
125
|
-
ciphertext = blob.byteslice(12, blob.bytesize - 28)
|
|
126
|
-
|
|
127
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
128
|
-
cipher.decrypt
|
|
129
|
-
cipher.key = key
|
|
130
|
-
cipher.iv = nonce
|
|
131
|
-
cipher.auth_tag = tag
|
|
132
|
-
cipher.update(ciphertext) + cipher.final
|
|
133
|
-
rescue OpenSSL::Cipher::CipherError => e
|
|
134
|
-
raise Browserctl::Error, "decryption failed: #{e.message}"
|
|
135
|
-
end
|
|
136
|
-
private_class_method :decrypt_file
|
|
137
|
-
|
|
138
|
-
def self.keychain_store(name, key)
|
|
139
|
-
hex_key = key.unpack1("H*")
|
|
140
|
-
out, status = Open3.capture2(
|
|
141
|
-
"security", "add-generic-password",
|
|
142
|
-
"-a", name,
|
|
143
|
-
"-s", "browserctl",
|
|
144
|
-
"-w", hex_key,
|
|
145
|
-
"-U"
|
|
146
|
-
)
|
|
147
|
-
raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
|
|
148
|
-
end
|
|
149
|
-
private_class_method :keychain_store
|
|
150
|
-
|
|
151
|
-
def self.keychain_fetch(name)
|
|
152
|
-
hex_key, status = Open3.capture2(
|
|
153
|
-
"security", "find-generic-password",
|
|
154
|
-
"-a", name,
|
|
155
|
-
"-s", "browserctl",
|
|
156
|
-
"-w"
|
|
157
|
-
)
|
|
158
|
-
raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
|
|
159
|
-
|
|
160
|
-
[hex_key.strip].pack("H*")
|
|
161
|
-
end
|
|
162
|
-
private_class_method :keychain_fetch
|
|
163
|
-
|
|
164
|
-
def self.keychain_available?
|
|
165
|
-
RUBY_PLATFORM.include?("darwin") && system("which", "security", out: File::NULL, err: File::NULL)
|
|
166
|
-
end
|
|
167
|
-
private_class_method :keychain_available?
|
|
168
|
-
|
|
169
|
-
def self.load_session_storage(dir)
|
|
170
|
-
ss_path = File.join(dir, "session_storage.json")
|
|
171
|
-
File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
|
|
172
|
-
end
|
|
173
|
-
private_class_method :load_session_storage
|
|
174
|
-
|
|
175
|
-
def self.load_session_storage_encrypted(dir, key)
|
|
176
|
-
ss_path = File.join(dir, "session_storage.json.enc")
|
|
177
|
-
return {} unless File.exist?(ss_path)
|
|
178
|
-
|
|
179
|
-
decrypt_json(ss_path, key, symbolize_names: false)
|
|
180
|
-
end
|
|
181
|
-
private_class_method :load_session_storage_encrypted
|
|
182
|
-
|
|
183
|
-
def self.decrypt_json(path, key, symbolize_names:)
|
|
184
|
-
blob = File.binread(path)
|
|
185
|
-
JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
|
|
186
|
-
end
|
|
187
|
-
private_class_method :decrypt_json
|
|
188
|
-
|
|
189
|
-
def self.write_json(path, data)
|
|
190
|
-
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
191
|
-
end
|
|
192
|
-
private_class_method :write_json
|
|
193
|
-
|
|
194
|
-
# Cookies and storage contain secrets — restrict to owner read/write only.
|
|
195
|
-
def self.write_secret(path, data)
|
|
196
|
-
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
197
|
-
end
|
|
198
|
-
private_class_method :write_secret
|
|
199
|
-
|
|
200
|
-
def self.write_encrypted_secret(path, data, key)
|
|
201
|
-
blob = encrypt_file(JSON.generate(data), key)
|
|
202
|
-
File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
203
|
-
end
|
|
204
|
-
private_class_method :write_encrypted_secret
|
|
205
|
-
end
|
|
206
|
-
end
|