Files
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

143 lines
4.0 KiB
Ruby

# frozen_string_literal: true
require 'socket'
require 'openssl'
require 'base64'
require 'io/console'
require 'websocket'
module PortainerCli
class WebsocketExec
READ_SIZE = 4096
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
perform_handshake(socket)
run_io_loop(socket)
ensure
socket&.close rescue nil
end
private
def open_socket
raw = TCPSocket.new(@url.host, @url.port)
if @url.scheme == 'wss'
ctx = OpenSSL::SSL::SSLContext.new
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
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)
handshake = WebSocket::Handshake::Client.new(
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
# Put terminal in raw mode so all keypresses go directly to the container
$stdin.raw do
loop do
readable, = IO.select([$stdin, socket], nil, nil, nil)
readable.each do |io|
if io == $stdin
data = io.readpartial(READ_SIZE) rescue nil
break if data.nil?
frame = WebSocket::Frame::Outgoing::Client.new(
data: data,
type: :binary,
version: 13
)
socket.write(frame.to_s)
elsif io == socket
data = io.readpartial(READ_SIZE) rescue nil
if data.nil?
# Server closed the connection
return
end
incoming << data
while (frame = incoming.next)
case frame.type
when :binary, :text
$stdout.write(frame.data)
$stdout.flush
when :close
return
when :ping
pong = WebSocket::Frame::Outgoing::Client.new(
data: frame.data,
type: :pong,
version: 13
)
socket.write(pong.to_s)
end
end
end
end
end
end
end
end
end