Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed5eca8924 | |||
| f263088d15 | |||
| 56f73f72e0 | |||
| 8e45e826ff | |||
| 382ba15f15 | |||
| 948910fc87 | |||
| 7235f687b1 | |||
| 9fe6ef339a | |||
| c59deca51e | |||
| a1015366a9 |
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user