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 list networks <endpoint-id> List networks
portainer-cli create stack --endpoint ID --name NAME --file FILE 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 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) --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) portainer-cli exec <endpoint-id> <container> Open interactive shell (manual endpoint)
--shell SHELL Shell to use (default: /bin/sh) --shell SHELL Shell to use (default: /bin/sh)
Config file: ~/.portainer-cli/config.yml Config file: ~/.config/portainer-cli/config.yml
HELP HELP
def self.run(argv) def self.run(argv)
debug = argv.delete('--debug')
config = Config.load config = Config.load
cmd = argv.shift&.downcase cmd = argv.shift&.downcase
@@ -56,11 +58,11 @@ module PortainerCli
when 'console' when 'console'
require_config!(config) require_config!(config)
client = Client.new(config) client = Client.new(config)
Commands::Console.new(config, client).run(argv) Commands::Console.new(config, client, debug: debug).run(argv)
when 'exec', 'shell' when 'exec', 'shell'
require_config!(config) require_config!(config)
client = Client.new(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' when 'version', '--version', '-v'
puts "portainer-cli #{PortainerCli::VERSION}" puts "portainer-cli #{PortainerCli::VERSION}"
when 'help', '--help', '-h', nil when 'help', '--help', '-h', nil
+1 -1
View File
@@ -60,7 +60,7 @@ module PortainerCli
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https' if uri.scheme == 'https'
http.use_ssl = true 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 end
http.open_timeout = 10 http.open_timeout = 10
http.read_timeout = 30 http.read_timeout = 30
+5
View File
@@ -32,6 +32,11 @@ module PortainerCli
@config.api_key = prompt_secret("API key", @config.api_key) @config.api_key = prompt_secret("API key", @config.api_key)
end 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 @config.save
puts puts
puts green("Configuration saved to #{Config::CONFIG_FILE}") puts green("Configuration saved to #{Config::CONFIG_FILE}")
+58 -26
View File
@@ -13,38 +13,41 @@ module PortainerCli
class Console class Console
include Base include Base
def initialize(config, client) def initialize(config, client, debug: false)
@config = config @config = config
@client = client @client = client
@debug = debug
end end
def run(args) def run(args)
opts = { shell: '/bin/sh' } opts = { shell: '/bin/sh' }
OptionParser.new do |o| 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) end.parse!(args)
stack_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 or error("Usage: portainer-cli console <stack> <container> [--shell /bin/bash]") container_name = args.shift || stack_name
stack = resolve_stack(stack_name) stack = resolve_stack(stack_name)
endpoint_id = stack['EndpointId'] 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]) exec_id = create_exec(endpoint_id, container_id, opts[:shell], opts[:run])
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? send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { 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 rescue PortainerCli::Client::ApiError => e
error(e.message) error(e.message)
rescue Interrupt rescue Interrupt
@@ -53,6 +56,10 @@ module PortainerCli
private private
def status(msg, quiet)
$stderr.puts dim(msg) unless quiet
end
def resolve_stack(name_or_id) def resolve_stack(name_or_id)
stacks = @client.get('/api/stacks') stacks = @client.get('/api/stacks')
match = stacks.find do |s| match = stacks.find do |s|
@@ -65,27 +72,39 @@ module PortainerCli
match match
end 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) 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 # 1. Exact match: full name or ID prefix
exact = containers.find do |c| exact = containers.find do |c|
c['Id'].start_with?(name_or_id) || 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 end
return exact['Id'] if exact return exact['Id'] if exact
# 2. Fuzzy: container name contains the search term as a word segment # 2. Compose-style match: "<stack>-<service>-<replica>"
# Prefers "<stack>-<name>-<n>" pattern, falls back to any substring match # This is the most precise fuzzy match when we know the stack name.
names_for = ->(c) { Array(c['Names']).map { |n| n.delete_prefix('/') } } if stack_name
composed = containers.select do |c|
fuzzy = 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/) }
names_for.(c).any? { |n| n.include?(name_or_id) } 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 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 if fuzzy.size == 1
matched_name = names_for.(fuzzy.first).first 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'] return fuzzy.first['Id']
end end
@@ -94,11 +113,24 @@ module PortainerCli
error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}") error("Ambiguous container '#{name_or_id}'. Did you mean one of:\n#{list}")
end end
available = containers.map { |c| " #{c['Id'][0, 12]} #{names_for.(c).first}" }.join("\n") # 4. Try 'app' as a last resort when the implicit default didn't match
error("Container '#{name_or_id}' not found on endpoint #{endpoint_id}. Available:\n#{available}") 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 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( result = @client.post(
"/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec", "/api/endpoints/#{endpoint_id}/docker/containers/#{container_id}/exec",
{ {
@@ -106,15 +138,15 @@ module PortainerCli
'AttachStdout' => true, 'AttachStdout' => true,
'AttachStderr' => true, 'AttachStderr' => true,
'Tty' => true, 'Tty' => true,
'Cmd' => [shell] 'Cmd' => cmd
} }
) )
result['Id'] result['Id']
end end
def exec_websocket_url(exec_id) def exec_websocket_url(exec_id, endpoint_id)
token = @config.token || @config.api_key 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 end
def send_resize(endpoint_id, exec_id) def send_resize(endpoint_id, exec_id)
+8 -2
View File
@@ -2,6 +2,7 @@
require_relative 'base' require_relative 'base'
require 'optparse' require 'optparse'
require 'shellwords'
module PortainerCli module PortainerCli
module Commands module Commands
@@ -55,6 +56,7 @@ module PortainerCli
end end
env_vars = opts[:env].map do |pair| env_vars = opts[:env].map do |pair|
error("Invalid --env '#{pair}': expected KEY=VALUE") unless pair.include?('=')
k, v = pair.split('=', 2) k, v = pair.split('=', 2)
{ 'name' => k, 'value' => v.to_s } { 'name' => k, 'value' => v.to_s }
end end
@@ -90,7 +92,11 @@ module PortainerCli
port_bindings = {} port_bindings = {}
exposed_ports = {} exposed_ports = {}
opts[:ports].each do |mapping| 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" cont_key = "#{cont_port}/tcp"
exposed_ports[cont_key] = {} exposed_ports[cont_key] = {}
port_bindings[cont_key] = [{ 'HostPort' => host_port }] port_bindings[cont_key] = [{ 'HostPort' => host_port }]
@@ -102,7 +108,7 @@ module PortainerCli
'ExposedPorts' => exposed_ports, 'ExposedPorts' => exposed_ports,
'HostConfig' => { 'PortBindings' => port_bindings } '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])}" : '' query = opts[:name] ? "?name=#{URI.encode_www_form_component(opts[:name])}" : ''
result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body) result = @client.post("/api/endpoints/#{opts[:endpoint]}/docker/containers/create#{query}", body)
+6 -6
View File
@@ -9,9 +9,10 @@ module PortainerCli
class Exec class Exec
include Base include Base
def initialize(config, client) def initialize(config, client, debug: false)
@config = config @config = config
@client = client @client = client
@debug = debug
end end
# portainer-cli exec <endpoint-id> <container-name-or-id> [-- cmd args...] # 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]})...") $stderr.puts dim("Connecting to #{container_name} (#{container_id[0, 12]})...")
exec_id = create_exec(endpoint_id, container_id, opts[:shell]) 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? send_resize(endpoint_id, exec_id) if $stdout.tty?
trap('SIGWINCH') { 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.") $stderr.puts dim("\r\nSession ended.")
rescue PortainerCli::Client::ApiError => e rescue PortainerCli::Client::ApiError => e
@@ -77,10 +78,9 @@ module PortainerCli
result['Id'] result['Id']
end end
def exec_websocket_url(exec_id) def exec_websocket_url(exec_id, endpoint_id)
# Portainer uses JWT for websocket auth (not API key), so we need to include token in query
token = @config.token || @config.api_key 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 end
def send_resize(endpoint_id, exec_id) def send_resize(endpoint_id, exec_id)
+10 -8
View File
@@ -5,10 +5,10 @@ require 'fileutils'
module PortainerCli module PortainerCli
class Config 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 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 def self.load
new.tap(&:read) new.tap(&:read)
@@ -18,17 +18,19 @@ module PortainerCli
return unless File.exist?(CONFIG_FILE) return unless File.exist?(CONFIG_FILE)
data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {} data = YAML.safe_load(File.read(CONFIG_FILE), permitted_classes: [], symbolize_names: false) || {}
@url = data['url'] @url = data['url']
@token = data['token'] @token = data['token']
@api_key = data['api_key'] @api_key = data['api_key']
@ssl_verify = data.fetch('ssl_verify', true)
end end
def save def save
FileUtils.mkdir_p(CONFIG_DIR) FileUtils.mkdir_p(CONFIG_DIR)
data = {} data = {}
data['url'] = @url if @url data['url'] = @url if @url
data['token'] = @token if @token data['token'] = @token if @token
data['api_key'] = @api_key if @api_key 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.write(CONFIG_FILE, YAML.dump(data))
File.chmod(0o600, CONFIG_FILE) File.chmod(0o600, CONFIG_FILE)
end end
+36 -6
View File
@@ -10,17 +10,20 @@ module PortainerCli
class WebsocketExec class WebsocketExec
READ_SIZE = 4096 READ_SIZE = 4096
def initialize(url, auth_header) def initialize(url, auth_header, ssl_verify: true, debug: false)
@url = URI.parse(url) @url = URI.parse(url)
@auth_header = auth_header @auth_header = auth_header
@ssl_verify = ssl_verify
@debug = debug
end end
def run def run
debug "WS URL: #{@url}"
debug "Auth headers: #{@auth_header.map { |k, v| "#{k}: #{v[0, 8]}..." }.join(', ')}"
socket = open_socket socket = open_socket
handshake = perform_handshake(socket) perform_handshake(socket)
raise "WebSocket handshake failed: #{handshake.error}" unless handshake.valid?
run_io_loop(socket) run_io_loop(socket)
ensure ensure
socket&.close rescue nil socket&.close rescue nil
@@ -33,15 +36,23 @@ module PortainerCli
if @url.scheme == 'wss' if @url.scheme == 'wss'
ctx = OpenSSL::SSL::SSLContext.new 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 = OpenSSL::SSL::SSLSocket.new(raw, ctx)
ssl.hostname = @url.host ssl.hostname = @url.host
ssl.sync_close = true 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 ssl
else else
raw raw
end end
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot connect to #{@url.host}:#{@url.port}#{e.message}"
end end
def perform_handshake(socket) def perform_handshake(socket)
@@ -49,18 +60,37 @@ module PortainerCli
url: @url.to_s, url: @url.to_s,
headers: @auth_header headers: @auth_header
) )
debug "Handshake request:\n#{handshake.to_s.chomp}"
socket.write(handshake.to_s) socket.write(handshake.to_s)
header_bytes = +''
loop do loop do
byte = socket.read(1) byte = socket.read(1)
break if byte.nil? break if byte.nil?
header_bytes << byte
handshake << byte handshake << byte
break if handshake.finished? break if handshake.finished?
end 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 handshake
end end
def debug(msg)
return unless @debug
$stderr.puts "\e[2m[debug] #{msg}\e[0m"
end
def run_io_loop(socket) def run_io_loop(socket)
incoming = WebSocket::Frame::Incoming::Client.new incoming = WebSocket::Frame::Incoming::Client.new