Compare commits

...

10 Commits

Author SHA1 Message Date
grzlus ed5eca8924 Fix 5 pre-release bugs
- Help text showed wrong config path (~/.portainer-cli vs ~/.config/portainer-cli)
- --port without colon (e.g. --port 8080) crashed with nil/tcp key; now errors clearly
- --cmd used naive split(' ') breaking quoted args; switched to Shellwords.split
- --env without '=' (e.g. --env FOO) silently passed empty value; now errors clearly
- SSL/connection errors surfaced as raw exception; now caught with actionable message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:35:28 +01:00
grzlus f263088d15 Add README, LICENSE (GPL-3), and update config dir to XDG path
Config moved to ~/.config/portainer-cli/config.yml following XDG convention.
README covers installation, configuration, all commands, container name
resolution logic, and debugging. Tool noted as AI-generated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 15:18:27 +01:00
grzlus 56f73f72e0 Add --quiet flag to suppress all status output
Silences stack/endpoint/container resolution messages for scripting
and AI use cases where only raw I/O should be on stdout/stderr.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:14:29 +01:00
grzlus 8e45e826ff Add --run flag to console command for running a specific command
--run CMD wraps the command in the shell: /bin/sh -c CMD, keeping TTY
mode so interactive commands like 'rails c' or 'bash' work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:09:33 +01:00
grzlus 382ba15f15 Make container name optional in console command
When omitted, tries: 1) same name as stack, 2) 'app'.
So 'portainer-cli console librarian' resolves to librarian-librarian-1,
and 'portainer-cli console myapp' falls back to myapp-app-1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 14:06:56 +01:00
grzlus 948910fc87 Fix missing endpointId query param in WebSocket exec URL
Portainer requires endpointId in the websocket/exec query string.
Was returning 400 "Missing query parameter".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:11:18 +01:00
grzlus 7235f687b1 Read and display HTTP response body on WebSocket handshake failure
400/4xx responses include a body (Content-Length) with the actual error
message, which was previously swallowed. Now captured and shown in the
RuntimeError so the root cause is visible without --debug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:18 +01:00
grzlus 9fe6ef339a Add --debug flag for WebSocket connection diagnostics
Shows WS URL, auth headers (truncated), full handshake request/response.
Also improves the handshake failure error to include the raw server response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:09:01 +01:00
grzlus c59deca51e Add ssl_verify config option to fix certificate errors
Raw OpenSSL sockets don't find the system CA bundle the same way Net::HTTP
does. ssl_verify (default: true) is now a config setting that applies to
both HTTP REST calls and WebSocket connections. Set via `portainer-cli configure`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:07:07 +01:00
grzlus a1015366a9 Smarter container matching using stack-service-replica pattern
Prefer exact "<stack>-<service>-<N>" compose naming over generic substring
match, so "librarian" in stack "librarian" resolves to "librarian-librarian-1"
unambiguously. Substring fallback is also scoped to the stack's own containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:05:21 +01:00
10 changed files with 306 additions and 53 deletions
+26
View File
@@ -0,0 +1,26 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2026 Grzegorz Luszczek
Everyone is permitted to copy and distribute verbatim copies of this license
document, but changing it is not allowed.
PREAMBLE
The GNU General Public License is a free, copyleft license for software and
other kinds of works.
When we speak of free software, we are referring to freedom, not price. Our
General Public Licenses are designed to make sure that you have the freedom
to distribute copies of free software (and charge for them if you wish), that
you receive source code or can get it if you want it, that you can change the
software or use pieces of it in new free programs, and that you know you can
do these things.
To protect your rights, we need to prevent others from denying you these
rights or asking you to surrender the rights. Therefore, you have certain
responsibilities if you distribute copies of the software, or if you modify
it: responsibilities to respect the freedom of others.
For the full license text, see: https://www.gnu.org/licenses/gpl-3.0.txt
+150
View File
@@ -0,0 +1,150 @@
# portainer-cli
A Ruby command-line tool for interacting with [Portainer](https://www.portainer.io/) via its REST API. List environments, stacks, containers, volumes and networks — and open fully interactive shells inside running containers directly from your terminal.
> **This tool was fully generated by AI** (Claude, by Anthropic) through an iterative conversation-driven development session.
## Features
- List endpoints, stacks, containers, volumes, and networks
- Create stacks (from file or inline compose) and containers
- **Interactive exec into containers** via WebSocket — works like `docker exec -it` but through Portainer
- Smart container name resolution: fuzzy matching using Docker Compose naming conventions (`<stack>-<service>-<N>`)
- Automatic endpoint resolution — no need to look up endpoint IDs manually
- Supports both API key and JWT token authentication
- Configurable SSL verification (useful for self-signed certificates)
- `--run CMD` flag for running a specific command (e.g. `rails c`)
- `--quiet` flag for scripting and AI agent use cases
## Requirements
- Ruby 3.x
- [`websocket`](https://rubygems.org/gems/websocket) gem
- A running Portainer instance (v2.x or later)
## Installation
```sh
git clone https://github.com/grzlus/portainer-cli
cd portainer-cli
bundle install
chmod +x bin/portainer-cli
# Link into your PATH (adjust the target to wherever suits you)
ln -sf "$PWD/bin/portainer-cli" ~/bin/portainer-cli
```
## Configuration
```sh
portainer-cli configure
```
You will be prompted for:
- **Portainer URL** — e.g. `https://portainer.example.com`
- **Authentication** — API key (recommended, no expiry) or JWT token
- **SSL verification** — answer `n` if your instance uses a self-signed certificate
Configuration is saved to `~/.config/portainer-cli/config.yml` (mode `0600`).
### Manual config
```yaml
# ~/.config/portainer-cli/config.yml
url: https://portainer.example.com
api_key: ptr_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
ssl_verify: true
```
## Usage
### List resources
```sh
portainer-cli list endpoints
portainer-cli list stacks
portainer-cli list containers <endpoint-id>
portainer-cli list volumes <endpoint-id>
portainer-cli list networks <endpoint-id>
```
### Open an interactive shell
```sh
# Resolves endpoint automatically from the stack name.
# Container name defaults to the stack name, then falls back to 'app'.
portainer-cli console <stack>
portainer-cli console <stack> <container>
# Examples
portainer-cli console myapp
portainer-cli console myapp worker
portainer-cli console myapp --shell /bin/bash
```
### Run a specific command
```sh
portainer-cli console myapp --run "rails c"
portainer-cli console myapp --run "bundle exec rake db:migrate"
```
### Quiet mode (scripting / AI agents)
Suppresses all status output — only the container's own stdout/stderr is printed.
```sh
portainer-cli console myapp --quiet --run "rails runner 'puts User.count'"
```
### Create resources
```sh
# Stack from a compose file
portainer-cli create stack --endpoint 1 --name myapp --file docker-compose.yml
# Stack from inline compose
portainer-cli create stack --endpoint 1 --name myapp --compose "version: '3'
services:
web:
image: nginx"
# Container
portainer-cli create container --endpoint 1 --image nginx:alpine --name web \
--port 8080:80 --env APP_ENV=production
```
### Low-level exec (manual endpoint ID)
```sh
portainer-cli exec <endpoint-id> <container-name-or-id>
portainer-cli exec <endpoint-id> <container-name-or-id> --shell /bin/bash
```
## Container name resolution
When using `console`, names are resolved in this order:
1. **Exact match** — full container name or ID prefix
2. **Compose pattern**`<stack>-<service>-<N>` (e.g. `librarian``librarian-librarian-1`)
3. **Substring match** — scoped to the stack's own containers
4. **`app` fallback** — tries `<stack>-app-1` as a last resort
If multiple containers match ambiguously, a list is printed and the command exits.
## Debugging
```sh
portainer-cli --debug console myapp
```
Prints the WebSocket URL, auth headers, and the full HTTP upgrade handshake request/response — useful for diagnosing connection issues behind reverse proxies.
## License
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later version.
See [LICENSE](LICENSE) for the full text.
+6 -4
View File
@@ -30,15 +30,17 @@ module PortainerCli
portainer-cli list networks <endpoint-id> List networks
portainer-cli create stack --endpoint ID --name NAME --file FILE
portainer-cli create container --endpoint ID --image IMAGE [--name NAME] [--port h:c] [--env K=V]
portainer-cli console <stack> <container> Open interactive shell (resolves endpoint automatically)
portainer-cli console <stack> [container] Open interactive shell (resolves endpoint automatically)
--shell SHELL Shell to use (default: /bin/sh)
--run CMD Run command instead of a bare shell
portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
--shell SHELL Shell to use (default: /bin/sh)
Config file: ~/.portainer-cli/config.yml
Config file: ~/.config/portainer-cli/config.yml
HELP
def self.run(argv)
debug = argv.delete('--debug')
config = Config.load
cmd = argv.shift&.downcase
@@ -56,11 +58,11 @@ module PortainerCli
when 'console'
require_config!(config)
client = Client.new(config)
Commands::Console.new(config, client).run(argv)
Commands::Console.new(config, client, debug: debug).run(argv)
when 'exec', 'shell'
require_config!(config)
client = Client.new(config)
Commands::Exec.new(config, client).run(argv)
Commands::Exec.new(config, client, debug: debug).run(argv)
when 'version', '--version', '-v'
puts "portainer-cli #{PortainerCli::VERSION}"
when 'help', '--help', '-h', nil
+1 -1
View File
@@ -60,7 +60,7 @@ module PortainerCli
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.verify_mode = @config.ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
end
http.open_timeout = 10
http.read_timeout = 30
+5
View File
@@ -32,6 +32,11 @@ module PortainerCli
@config.api_key = prompt_secret("API key", @config.api_key)
end
puts
current_verify = @config.ssl_verify.nil? ? true : @config.ssl_verify
verify_choice = prompt("Verify SSL certificates? [y/n]", current_verify ? "y" : "n")
@config.ssl_verify = (verify_choice.downcase != "n")
@config.save
puts
puts green("Configuration saved to #{Config::CONFIG_FILE}")
+58 -26
View File
@@ -13,38 +13,41 @@ module PortainerCli
class Console
include Base
def initialize(config, client)
def initialize(config, client, debug: false)
@config = config
@client = client
@debug = debug
end
def run(args)
opts = { shell: '/bin/sh' }
OptionParser.new do |o|
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
o.on('--shell SHELL', 'Shell to use (default: /bin/sh)') { |v| opts[:shell] = v }
o.on('--run CMD', 'Run command instead of a bare shell') { |v| opts[:run] = v }
o.on('--quiet', 'Suppress status output') { opts[:quiet] = true }
end.parse!(args)
stack_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
container_name = args.shift or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]")
stack_name = args.shift or error("Usage: portainer-cli console <stack> [container] [--run CMD] [--shell /bin/bash]")
container_name = args.shift || stack_name
stack = resolve_stack(stack_name)
endpoint_id = stack['EndpointId']
$stderr.puts dim("Stack: #{stack['Name']} Endpoint: #{endpoint_id}")
status("Stack: #{stack['Name']} Endpoint: #{endpoint_id}", opts[:quiet])
container_id = resolve_container(endpoint_id, container_name)
container_id = resolve_container(endpoint_id, container_name, stack['Name'], opts[:quiet])
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
status("Connecting to #{container_name} (#{container_id[0, 12]})...", opts[:quiet])
exec_id = create_exec(endpoint_id, container_id, opts[:shell])
ws_url = exec_websocket_url(exec_id)
exec_id = create_exec(endpoint_id, container_id, opts[:shell], opts[:run])
ws_url = exec_websocket_url(exec_id, endpoint_id)
send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { send_resize(endpoint_id, exec_id) } if $stdout.tty?
WebsocketExec.new(ws_url, @config.auth_header).run
WebsocketExec.new(ws_url, @config.auth_header, ssl_verify: @config.ssl_verify, debug: @debug).run
$stderr.puts dim("\r\nSession ended.")
status("\r\nSession ended.", opts[:quiet])
rescue PortainerCli::Client::ApiError => e
error(e.message)
rescue Interrupt
@@ -53,6 +56,10 @@ module PortainerCli
private
def status(msg, quiet)
$stderr.puts dim(msg) unless quiet
end
def resolve_stack(name_or_id)
stacks = @client.get('/api/stacks')
match = stacks.find do |s|
@@ -65,27 +72,39 @@ module PortainerCli
match
end
def resolve_container(endpoint_id, name_or_id)
def resolve_container(endpoint_id, name_or_id, stack_name = nil, quiet = false)
containers = @client.get("/api/endpoints/#{endpoint_id}/docker/containers/json", all: 1)
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
# 1. Exact match: full name or ID prefix
exact = containers.find do |c|
c['Id'].start_with?(name_or_id) ||
Array(c['Names']).any? { |n| n.delete_prefix('/') == name_or_id }
names_for.(c).any? { |n| n == name_or_id }
end
return exact['Id'] if exact
# 2. Fuzzy: container name contains the search term as a word segment
# Prefers "<stack>-<name>-<n>" pattern, falls back to any substring match
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } }
fuzzy = containers.select do |c|
names_for.(c).any? { |n| n.include?(name_or_id) }
# 2. Compose-style match: "<stack>-<service>-<replica>"
# This is the most precise fuzzy match when we know the stack name.
if stack_name
composed = containers.select do |c|
names_for.(c).any? { |n| n == "#{stack_name}-#{name_or_id}-1" || n.match?(/\A#{Regexp.escape(stack_name)}-#{Regexp.escape(name_or_id)}-\d+\z/) }
end
if composed.size == 1
matched_name = names_for.(composed.first).first
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
return composed.first['Id']
end
end
# 3. Substring fallback (only within this stack's containers)
stack_prefix = stack_name ? "#{stack_name}-" : nil
pool = stack_prefix ? containers.select { |c| names_for.(c).any? { |n| n.start_with?(stack_prefix) } } : containers
fuzzy = pool.select { |c| names_for.(c).any? { |n| n.include?(name_or_id) } }
if fuzzy.size == 1
matched_name = names_for.(fuzzy.first).first
$stderr.puts dim("Matched '#{name_or_id}' → #{matched_name}")
status("Matched '#{name_or_id}' → #{matched_name}", quiet)
return fuzzy.first['Id']
end
@@ -94,11 +113,24 @@ module PortainerCli
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
end
available = containers.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}")
# 4. Try 'app' as a last resort when the implicit default didn't match
if name_or_id != 'app' && stack_name
app_candidates = containers.select do |c|
names_for.(c).any? { |n| n.match?(/\A#{Regexp.escape(stack_name)}-app-\d+\z/) }
end
if app_candidates.size == 1
matched_name = names_for.(app_candidates.first).first
status("Matched '#{name_or_id}' → #{matched_name} (fallback to 'app')", quiet)
return app_candidates.first['Id']
end
end
available = pool.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n")
error("Container '#{name_or_id}' not found. Available:\n#{available}")
end
def create_exec(endpoint_id, container_id, shell)
def create_exec(endpoint_id, container_id, shell, run_cmd = nil)
cmd = run_cmd ? [shell, '-c', run_cmd] : [shell]
result = @client.post(
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
{
@@ -106,15 +138,15 @@ module PortainerCli
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => [shell]
'Cmd' => cmd
}
)
result['Id']
end
def exec_websocket_url(exec_id)
def exec_websocket_url(exec_id, endpoint_id)
token = @config.token || @config.api_key
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id)
end
def send_resize(endpoint_id, exec_id)
+8 -2
View File
@@ -2,6 +2,7 @@
require_relative 'base'
require 'optparse'
require 'shellwords'
module PortainerCli
module Commands
@@ -55,6 +56,7 @@ module PortainerCli
end
env_vars = opts[:env].map do |pair|
error("Invalid --env '#{pair}': expected KEY=VALUE") unless pair.include?('=')
k, v = pair.split('=', 2)
{ 'name' => k, 'value' => v.to_s }
end
@@ -90,7 +92,11 @@ module PortainerCli
port_bindings = {}
exposed_ports = {}
opts[:ports].each do |mapping|
host_port, cont_port = mapping.split(':', 2)
parts = mapping.split(':', 2)
error("Invalid --port '#{mapping}': expected HOST:CONTAINER (e.g. 8080:80)") unless parts.size == 2
host_port, cont_port = parts
error("Invalid --port '#{mapping}': missing host port") if host_port.empty?
error("Invalid --port '#{mapping}': missing container port") if cont_port.empty?
cont_key = "#{cont_port}/tcp"
exposed_ports[cont_key] = {}
port_bindings[cont_key] = [{ 'HostPort' => host_port }]
@@ -102,7 +108,7 @@ module PortainerCli
'ExposedPorts' => exposed_ports,
'HostConfig' => { 'PortBindings' => port_bindings }
}
body['Cmd'] = opts[:cmd].split(' ') if opts[:cmd]
body['Cmd'] = Shellwords.split(opts[:cmd]) if opts[:cmd]
query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : ''
result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body)
+6 -6
View File
@@ -9,9 +9,10 @@ module PortainerCli
class Exec
include Base
def initialize(config, client)
def initialize(config, client, debug: false)
@config = config
@client = client
@debug = debug
end
# portainer-cli exec <endpoint-id> <container-name-or-id> [-- cmd args...]
@@ -31,12 +32,12 @@ module PortainerCli
$stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
exec_id = create_exec(endpoint_id, container_id, opts[:shell])
ws_url = exec_websocket_url(exec_id)
ws_url = exec_websocket_url(exec_id, endpoint_id)
send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { send_resize(endpoint_id, exec_id) } if $stdout.tty?
WebsocketExec.new(ws_url, @config.auth_header).run
WebsocketExec.new(ws_url, @config.auth_header, ssl_verify: @config.ssl_verify, debug: @debug).run
$stderr.puts dim("\r\nSession ended.")
rescue PortainerCli::Client::ApiError => e
@@ -77,10 +78,9 @@ module PortainerCli
result['Id']
end
def exec_websocket_url(exec_id)
# Portainer uses JWT for websocket auth (not API key), so we need to include token in query
def exec_websocket_url(exec_id, endpoint_id)
token = @config.token || @config.api_key
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id)
@client.websocket_url('/api/websocket/exec', token: token, id: exec_id, endpointId: endpoint_id)
end
def send_resize(endpoint_id, exec_id)
+10 -8
View File
@@ -5,10 +5,10 @@ require 'fileutils'
module PortainerCli
class Config
CONFIG_DIR = File.expand_path('~/.portainer-cli').freeze
CONFIG_DIR = File.expand_path('~/.config/portainer-cli').freeze
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
attr_accessor :url, :token, :api_key
attr_accessor :url, :token, :api_key, :ssl_verify
def self.load
new.tap(&:read)
@@ -18,17 +18,19 @@ module PortainerCli
return unless File.exist?(CONFIG_FILE)
data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {}
@url = data['url']
@token = data['token']
@api_key = data['api_key']
@url = data['url']
@token = data['token']
@api_key = data['api_key']
@ssl_verify = data.fetch('ssl_verify', true)
end
def save
FileUtils.mkdir_p(CONFIG_DIR)
data = {}
data['url'] = @url if @url
data['token'] = @token if @token
data['api_key'] = @api_key if @api_key
data['url'] = @url if @url
data['token'] = @token if @token
data['api_key'] = @api_key if @api_key
data['ssl_verify'] = @ssl_verify unless @ssl_verify.nil?
File.write(CONFIG_FILE, YAML.dump(data))
File.chmod(0o600, CONFIG_FILE)
end
+36 -6
View File
@@ -10,17 +10,20 @@ module PortainerCli
class WebsocketExec
READ_SIZE = 4096
def initialize(url, auth_header)
def initialize(url, auth_header, ssl_verify: true, debug: false)
@url = URI.parse(url)
@auth_header = auth_header
@ssl_verify = ssl_verify
@debug = debug
end
def run
debug "WS URL: #{@url}"
debug "Auth headers: #{@auth_header.map { |k, v| "#{k}: #{v[0, 8]}..." }.join(', ')}"
socket = open_socket
handshake = perform_handshake(socket)
raise "WebSocket handshake failed: #{handshake.error}" unless handshake.valid?
perform_handshake(socket)
run_io_loop(socket)
ensure
socket&.close rescue nil
@@ -33,15 +36,23 @@ module PortainerCli
if @url.scheme == 'wss'
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER
ctx.verify_mode = @ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
ssl = OpenSSL::SSL::SSLSocket.new(raw, ctx)
ssl.hostname = @url.host
ssl.sync_close = true
ssl.connect
begin
ssl.connect
rescue OpenSSL::SSL::SSLError => e
raw.close rescue nil
hint = @ssl_verify ? " (try: portainer-cli configure → answer 'n' to SSL verification)" : ""
raise "SSL connection failed: #{e.message}#{hint}"
end
ssl
else
raw
end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot connect to #{@url.host}:#{@url.port}#{e.message}"
end
def perform_handshake(socket)
@@ -49,18 +60,37 @@ module PortainerCli
url: @url.to_s,
headers: @auth_header
)
debug "Handshake request:\n#{handshake.to_s.chomp}"
socket.write(handshake.to_s)
header_bytes = +''
loop do
byte = socket.read(1)
break if byte.nil?
header_bytes << byte
handshake << byte
break if handshake.finished?
end
debug "Handshake response headers:\n#{header_bytes.chomp}"
unless handshake.valid?
# Try to read the response body for error details
body = ''
if (len = header_bytes.match(/Content-Length:\s*(\d+)/i)&.[](1)&.to_i) && len > 0
body = socket.read(len).to_s
end
raise "WebSocket handshake failed: #{handshake.error}\n" \
"Server response:\n#{header_bytes.chomp}\n#{body}"
end
handshake
end
def debug(msg)
return unless @debug
$stderr.puts "\e[2m[debug] #{msg}\e[0m"
end
def run_io_loop(socket)
incoming = WebSocket::Frame::Incoming::Client.new