- Implement dynamic prompt settle detection and context block recalculation in gRPC server during Copilot execution. - Enable multi-turn streaming interaction loop in NodeStub for remote Copilot and autonomous missions. - Fix non-mission chat history logging in terminal UI. - Add unit test coverage for remote Copilot command execution lifecycle. - Regenerate HTML documentation in docs/ via pdoc. - Bump version to v6.3.0.
4227 lines
202 KiB
HTML
4227 lines
202 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||
<meta name="generator" content="pdoc3 0.11.6">
|
||
<title>connpy API documentation</title>
|
||
<meta name="description" content="<p align="center">
|
||
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
|
||
</p> …">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
|
||
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
|
||
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
|
||
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
|
||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
|
||
<script>window.addEventListener('DOMContentLoaded', () => {
|
||
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
|
||
hljs.highlightAll();
|
||
/* Collapse source docstrings */
|
||
setTimeout(() => {
|
||
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
|
||
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
|
||
.forEach(el => {
|
||
let d = document.createElement('details');
|
||
d.classList.add('hljs-string');
|
||
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
|
||
el.replaceWith(d);
|
||
});
|
||
}, 100);
|
||
})</script>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<article id="content">
|
||
<header>
|
||
<h1 class="title">Package <code>connpy</code></h1>
|
||
</header>
|
||
<section id="section-intro">
|
||
<p align="center">
|
||
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
|
||
</p>
|
||
<h1 id="connpy-v630">Connpy (v6.3.0)</h1>
|
||
<p><a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/v/connpy.svg?style=flat-square"></a>
|
||
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square"></a>
|
||
<a href="https://pypi.org/pypi/connpy/"><img alt="" src="https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400"></a>
|
||
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20docker-blue?style=flat-square"></a>
|
||
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/backend-gRPC-blue?style=flat-square"></a>
|
||
<a href="https://github.com/fluzzi/connpy"><img alt="" src="https://img.shields.io/badge/AI%20Core-LiteLLM-green?style=flat-square"></a>
|
||
<a href="https://modelcontextprotocol.io"><img alt="" src="https://img.shields.io/badge/MCP-compatible-orange?style=flat-square"></a>
|
||
<a href="https://github.com/fluzzi/connpy/blob/main/LICENSE"><img alt="" src="https://img.shields.io/pypi/l/connpy.svg?style=flat-square"></a></p>
|
||
<p><strong>Connpy</strong> is a powerful Connection Manager and Network Automation Platform for Linux, Mac, and Docker. It provides a unified interface for <strong>SSH, SFTP, Telnet, kubectl, Docker pods, and AWS SSM</strong>.</p>
|
||
<p>The v6 release introduces a comprehensive <strong>AI Copilot</strong> and <strong>AI Playbook Engine</strong>, transforming your terminal into an interactive network assistant that understands your device outputs, configures parameters safely, and runs simulations.</p>
|
||
<hr>
|
||
<h2 id="1-ai-system">1. 🤖 AI System</h2>
|
||
<h3 id="1a-terminal-copilot-ctrlspace">1a. Terminal Copilot (Ctrl+Space)</h3>
|
||
<p>Invoke the context-aware AI Copilot directly inside any active terminal session by pressing <strong><code>Ctrl + Space</code></strong>.
|
||
* <strong>Context Modes</strong>: Cycles through <code>LINES</code> (sends raw scroll buffer), <code>SINGLE</code> (captures exactly one command + output block), and <code>RANGE</code> (logical group of recent commands) using <strong><code>Ctrl+Up/Down</code></strong>.
|
||
* <strong>Slash Commands (<code>/</code>)</strong>: Control the AI persona, safety settings, and mission mode:
|
||
* <code>/mission [objective]</code>: Start an autonomous multi-step investigation mission with human approval.
|
||
* <code>/cancel</code>: Abort active mission.
|
||
* <code>/architect</code> / <code>/engineer</code>: Swaps the agent between high-level strategist and technical executor.
|
||
* <code>/trust</code> / <code>/untrust</code>: Configures auto-run behavior for suggested non-destructive commands.
|
||
* <code>/os [system]</code>: Manually overrides target OS parsing rules (e.g. <code>/os cisco_ios</code>).
|
||
* <code>/prompt [regex]</code>: Overrides command prompt detection bounds.
|
||
* <code>/clear</code>: Clear context history.</p>
|
||
<h3 id="1b-ai-chat-conn-ai">1b. AI Chat (conn ai)</h3>
|
||
<p>Start a standalone persistent session with the AI Copilot. Manage sessions using <code>--list</code>, <code>--resume</code>, <code>--session <id></code> (to restore a specific history), <code>--delete <id></code>, or send a quick single-shot question directly from the terminal prompt:</p>
|
||
<pre><code class="language-bash">conn ai "how do i check bgp summary on cisco?"
|
||
</code></pre>
|
||
<h3 id="1c-mcp-integration">1c. MCP Integration</h3>
|
||
<p>Connect to external data sources and tools dynamically via the Model Context Protocol (MCP). Use the interactive wizard or command actions to configure MCP servers:</p>
|
||
<pre><code class="language-bash">conn ai --mcp
|
||
</code></pre>
|
||
<h3 id="1d-local-interactive-shell-conn-shell">1d. Local Interactive Shell (conn shell)</h3>
|
||
<p>Launch a local interactive shell with AI Copilot support enabled directly on your host machine:</p>
|
||
<pre><code class="language-bash">conn shell # Start local shell (default: $SHELL or /bin/bash)
|
||
conn shell -c /bin/zsh # Override shell executable
|
||
conn shell --capture session.log # Log session output to file
|
||
</code></pre>
|
||
<ul>
|
||
<li><strong>Nested Sessions & Passthrough</strong>: Supports running nested <code>conn</code> / <code><a title="connpy" href="#connpy">connpy</a></code> connections inside <code>conn shell</code>. Automatically detects foreground <code>conn</code> processes and forwards <code>Ctrl+Space</code> down to the active device connection instead of triggering the local Copilot.</li>
|
||
<li><strong>Shell Configuration</strong>: Configure default shell command, prompt regex, or OS type via <code>conn config</code>:</li>
|
||
</ul>
|
||
<pre><code class="language-bash">conn config --shell-command /bin/zsh
|
||
conn config --shell-prompt "\$\s*$"
|
||
conn config --shell-os ubuntu
|
||
</code></pre>
|
||
<hr>
|
||
<h2 id="2-automation-playbooks">2. ⚙️ Automation & Playbooks</h2>
|
||
<h3 id="2a-quick-run-conn-run">2a. Quick Run (conn run)</h3>
|
||
<p>Run commands in parallel directly on target nodes or folder structures:</p>
|
||
<pre><code class="language-bash">conn run router1 "show interface"
|
||
</code></pre>
|
||
<h3 id="2b-yaml-playbook-engine">2b. YAML Playbook Engine</h3>
|
||
<p>Execute complex structured automation playbooks defined in YAML configuration files. Supports multi-task execution, variables (using global, per-node, or regex matching definitions), timeouts, and variable parallel execution bounds.</p>
|
||
<pre><code class="language-yaml"># example_playbook.yaml
|
||
- name: Verify Network Operations
|
||
hosts: "@office"
|
||
parallel: true
|
||
tasks:
|
||
- name: Get interface brief
|
||
run: "show ip interface brief"
|
||
- name: Check OSPF state
|
||
run: "show ip ospf neighbor"
|
||
test: "FULL"
|
||
</code></pre>
|
||
<p>Execute using the playbooks runner:</p>
|
||
<pre><code class="language-bash">conn run example_playbook.yaml
|
||
</code></pre>
|
||
<h3 id="2c-ai-assisted-automation">2c. AI-Assisted Automation</h3>
|
||
<p>Leverage AI to generate playbook templates (<code>--generate-ai</code>), simulate command changes before execution (<code>--preflight-ai</code>), or analyze consolidated execution logs post-run (<code>--analyze</code>). Use <code>--test "expected text1" "expected text2"</code> to specify assert-style output validations.
|
||
* <em>To generate an empty template:</em> <code>conn run --generate</code></p>
|
||
<hr>
|
||
<h2 id="3-inventory-management">3. 📂 Inventory Management</h2>
|
||
<h3 id="3a-nodes">3a. Nodes</h3>
|
||
<p>Manage connections using standard commands: add (<code>conn --add node1</code>), edit (<code>conn --mod node1</code>), delete (<code>conn --del node1</code>), show configuration (<code>conn --show node1</code>), or connect (<code>conn node1</code>).</p>
|
||
<h3 id="3b-profiles">3b. Profiles</h3>
|
||
<p>Define credentials and templates globally and reference them inside node fields using the <code>@profile_name</code> placeholder. Manage profiles interactively or via commands:</p>
|
||
<pre><code class="language-bash">conn profile -a profile_name
|
||
# Or equivalently:
|
||
conn -a profile profile_name
|
||
</code></pre>
|
||
<p>During the interactive <code>conn --add</code> prompt, you can input <code>@profile_name</code> in the <strong>username</strong> or <strong>password</strong> fields to reference it.</p>
|
||
<h3 id="3c-folders-move-copy-list">3c. Folders, Move, Copy, List</h3>
|
||
<p>Organize nodes into logical folder hierarchies (<code>@office</code>, <code>@datacenter@office</code>). Move items (<code>conn move [src] [dst]</code>), copy (<code>conn copy [src] [dst]</code>), or list items with custom filters and formatting:</p>
|
||
<pre><code class="language-bash">conn list nodes --filter ".*-prod" --format "{name} ({host}) runs {protocol}"
|
||
</code></pre>
|
||
<h3 id="3d-bulk-export-import">3d. Bulk, Export, Import</h3>
|
||
<p>Bulk import connections from formatted text files (<code>conn bulk -f nodes.txt</code>), or export/import connection folders using YAML configurations (<code>conn export @folder > backup.yaml</code> / <code>conn import backup.yaml</code>).</p>
|
||
<h3 id="3e-tags-system">3e. Tags System</h3>
|
||
<p>Customize connection settings dynamically using tags. Configure per-node settings like custom OS types (<code>os</code>), prompt regex rules (<code>prompt</code>), and page length triggers (<code>screen_length_command</code>).</p>
|
||
<pre><code class="language-yaml"># Custom tags dictionary (YANG / VSR context)
|
||
tags: { "os": "cisco_ios", "prompt": ".*#", "screen_length_command": "terminal length 0" }
|
||
</code></pre>
|
||
<hr>
|
||
<h2 id="4-protocols-connection-features">4. 🔌 Protocols & Connection Features</h2>
|
||
<h3 id="4a-ssh-sftp-telnet-kubectl-docker-aws-ssm">4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM</h3>
|
||
<p>Connect to various architectures using native protocols:
|
||
* <strong>SSH / Telnet</strong>: Standard CLI protocols.
|
||
* <strong>SFTP</strong>: Transfer files securely (<code>conn --sftp node</code>).
|
||
* <strong>Docker</strong>: Connect directly to local container names (host set to container name/ID).
|
||
* <strong>Kubernetes (kubectl)</strong>: Connect to pods (namespace customizable via options).
|
||
* <strong>AWS SSM</strong>: Connect to EC2 instances using Instance IDs as hosts.</p>
|
||
<h3 id="4b-jumphosts">4b. Jumphosts</h3>
|
||
<p>Support for single or chained intermediate gateway nodes (SSH, SSM, kubectl, or docker jumphosts) to tunnel traffic safely into target environments.</p>
|
||
<h3 id="4c-debug-mode-keepalive-logging">4c. Debug Mode, Keepalive, Logging</h3>
|
||
<p>Track connection steps (<code>conn --debug node</code>), set idle keepalive intervals (<code>conn config --keepalive <seconds></code>), or define dynamic output log files using variables like <code>${unique}</code>, <code>${host}</code>, <code>${port}</code>, <code>${user}</code>, <code>${protocol}</code>, or <code>${date 'format'}</code>.</p>
|
||
<hr>
|
||
<h2 id="5-remote-capture-conn-capture-core-plugin">5. 🖥️ Remote Capture (conn capture - Core Plugin)</h2>
|
||
<p>Perform remote packet capture (<code>tcpdump</code>) on hosts over secure SSH reverse tunnels and stream packets live into your local Wireshark GUI:</p>
|
||
<pre><code class="language-bash">conn capture router1 eth0 -w -f "port 80"
|
||
</code></pre>
|
||
<ul>
|
||
<li><strong>Requirements</strong>: Local installation of Wireshark or <code>tshark</code> is required for live piping (<code>-w</code>).</li>
|
||
<li><strong>Advanced flags</strong>: Specify network namespaces (<code>--ns <name></code>), custom filters (<code>-f <filter></code>), or configure the Wireshark local path (<code>--set-wireshark-path</code>).</li>
|
||
</ul>
|
||
<hr>
|
||
<h2 id="6-context-filtering">6. 🛡️ Context Filtering</h2>
|
||
<p>Prevent accidental command execution in production by setting active regex contexts. This hides non-matching inventory items and restricts execution scope:</p>
|
||
<pre><code class="language-bash">conn context production -a --regex ".*-prod"
|
||
conn context production --set
|
||
</code></pre>
|
||
<ul>
|
||
<li><strong>Manage Contexts</strong>: List defined filters (<code>conn context --ls</code>), show context details (<code>conn context production -s</code>), or delete contexts (<code>conn context production -r</code>).</li>
|
||
</ul>
|
||
<hr>
|
||
<h2 id="7-plugin-system">7. 🔌 Plugin System</h2>
|
||
<p>Extend <code><a title="connpy" href="#connpy">connpy</a></code> features and hook into core execution events (pre/post hooks) by writing Python scripts. Add, update, delete, or list plugins locally, or execute them on remote instances:</p>
|
||
<pre><code class="language-bash">conn plugin --add my_plugin script.py
|
||
conn plugin --update my_plugin script.py
|
||
conn plugin --remote --sync
|
||
</code></pre>
|
||
<hr>
|
||
<h2 id="8-grpc-client-server-architecture">8. ⚙️ gRPC Client-Server Architecture</h2>
|
||
<h3 id="8a-server-startstoprestartdebug">8a. Server (start/stop/restart/debug)</h3>
|
||
<p>Execute tasks on a centralized remote host. Start gRPC server (<code>conn api -s 50051</code>), stop (<code>conn api -x</code>), restart (<code>conn api -r</code>), or debug in the foreground (<code>conn api -d</code>).</p>
|
||
<h3 id="8b-client-config">8b. Client Config</h3>
|
||
<p>Shift the local CLI to communicate with a remote server instance:</p>
|
||
<pre><code class="language-bash">conn config --service-mode remote
|
||
conn config --remote localhost:50051
|
||
</code></pre>
|
||
<h3 id="8c-user-management-api-tokens">8c. User Management & API Tokens</h3>
|
||
<p>Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:</p>
|
||
<pre><code class="language-bash">conn user --add username
|
||
conn user --list
|
||
conn user --regen-password username
|
||
|
||
# Personal Access Tokens (PAT) for non-interactive API access
|
||
conn user --create-token "CI/CD Token" --expires-in 30
|
||
conn user --list-tokens
|
||
conn user --revoke-token <token_id>
|
||
</code></pre>
|
||
<p>Use <code>--path</code> to specify custom configuration folders in server Mode B. Pass API tokens via <code>CONNPY_TOKEN</code> environment variable.</p>
|
||
<h3 id="8d-sso-oidc">8d. SSO / OIDC</h3>
|
||
<p>Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:</p>
|
||
<pre><code class="language-bash">conn sso --add provider_name
|
||
</code></pre>
|
||
<h3 id="8e-login-logout">8e. Login / Logout</h3>
|
||
<p>Authenticate client sessions (<code>conn login [username]</code>), check connection status (<code>conn login --status</code>), or close sessions (<code>conn logout</code>).</p>
|
||
<hr>
|
||
<h2 id="9-installation-configuration">9. ⚡ Installation & Configuration</h2>
|
||
<h3 id="9a-pip-install">9a. pip install</h3>
|
||
<pre><code class="language-bash">pip install connpy
|
||
</code></pre>
|
||
<h3 id="9b-shell-completion-fzf">9b. Shell Completion + FZF</h3>
|
||
<p>Install autocompletions and fuzzy-search wrappers into your shell profile:</p>
|
||
<pre><code class="language-bash">eval "$(conn config --completion bash)"
|
||
eval "$(conn config --fzf-wrapper bash)"
|
||
</code></pre>
|
||
<h3 id="9c-conn-config-options">9c. conn config options</h3>
|
||
<p>View configuration details (<code>conn config</code>) or customize variables like case sensitivity (<code>--allow-uppercase</code>), FZF list picker (<code>--fzf true</code>), configurations directory (<code>--configfolder</code>), or persistent AI API keys and models (<code>--engineer-model</code>).</p>
|
||
<h3 id="9d-theming">9d. Theming</h3>
|
||
<p>Customize CLI panel styles and colors by pointing to built-in presets or external YAML styles:</p>
|
||
<pre><code class="language-bash">conn config --theme /path/to/theme.yaml
|
||
</code></pre>
|
||
<hr>
|
||
<h2 id="10-privacy-security-synchronization-conn-sync">10. 🔒 Privacy, Security & Synchronization (conn sync)</h2>
|
||
<p>Encrypts inventory and profiles locally via RSA/OAEP. Backup and sync configurations to Google Drive manually (<code>conn sync --once</code>, <code>--list</code>, <code>--restore</code>) or schedule auto-sync. Segregate restores (<code>--nodes</code> / <code>--config</code>) or sync remote nodes with <code>--sync-remote</code>.</p>
|
||
<hr>
|
||
<h2 id="11-python-api">11. 🐍 Python API</h2>
|
||
<p>Embed connection and automation routines programmatically in Python:</p>
|
||
<pre><code class="language-python">import connpy
|
||
|
||
# 1. Direct single node interaction
|
||
router = connpy.node("router1", "1.1.1.1", user="admin")
|
||
router.run(["show ip int brief"])
|
||
print(router.output)
|
||
|
||
# 2. Parallel nodes execution with variables
|
||
config = connpy.configfile()
|
||
nodes_info = config.getitem("@office", ["router1", "router2"])
|
||
routers = connpy.nodes(nodes_info, config=config)
|
||
variables = {
|
||
"router1@office": {"id": "1"},
|
||
"__global__": {"mask": "255.255.255.0"}
|
||
}
|
||
routers.run(["interface lo{id}", "ip address 10.0.0.{id} {mask}"], variables)
|
||
|
||
# 3. AI Copilot prompts
|
||
myai = connpy.ai(connpy.configfile())
|
||
response = myai.ask("Show BGP status.")
|
||
print(response)
|
||
</code></pre>
|
||
<p><em>Supports additional programmatic features like <code><a title="connpy.node.test" href="#connpy.node.test">node.test()</a></code>, <code><a title="connpy.node.interact" href="#connpy.node.interact">node.interact()</a></code>, <code><a title="connpy.configfile.encrypt" href="#connpy.configfile.encrypt">configfile.encrypt()</a></code>, <code>connapp</code> embeds, and <code>ClassHook</code> / <code>MethodHook</code> plugin hooks.</em></p>
|
||
<hr>
|
||
<h2 id="12-docker-deployment">12. 🐳 Docker Deployment</h2>
|
||
<p>Run <code><a title="connpy" href="#connpy">connpy</a></code> containerized and silent:</p>
|
||
<pre><code class="language-bash">docker compose run --rm connpy-app [command]
|
||
</code></pre>
|
||
<p>Add <code>alias conn='docker compose run --rm connpy-app'</code> for a transparent container experience.</p>
|
||
<hr>
|
||
<h2 id="13-license">13. 📜 License</h2>
|
||
<p><a href="LICENSE">PolyForm Noncommercial 1.0.0</a></p>
|
||
</section>
|
||
<section>
|
||
<h2 class="section-title" id="header-submodules">Sub-modules</h2>
|
||
<dl>
|
||
<dt><code class="name"><a title="connpy.ai" href="ai.html">connpy.ai</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.cli" href="cli/index.html">connpy.cli</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.grpc_layer" href="grpc_layer/index.html">connpy.grpc_layer</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.mcp_client" href="mcp_client.html">connpy.mcp_client</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.proto" href="proto/index.html">connpy.proto</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.services" href="services/index.html">connpy.services</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.tunnels" href="tunnels.html">connpy.tunnels</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt><code class="name"><a title="connpy.utils" href="utils.html">connpy.utils</a></code></dt>
|
||
<dd>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
</dl>
|
||
</section>
|
||
<section>
|
||
</section>
|
||
<section>
|
||
</section>
|
||
<section>
|
||
<h2 class="section-title" id="header-classes">Classes</h2>
|
||
<dl>
|
||
<dt id="connpy.Plugins"><code class="flex name class">
|
||
<span>class <span class="ident">Plugins</span></span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">class Plugins:
|
||
def __init__(self):
|
||
self.plugins = {}
|
||
self.plugin_parsers = {}
|
||
self.preloads = {}
|
||
self.remote_plugins = {}
|
||
self.preferences = {}
|
||
|
||
def _load_preferences(self, config_dir):
|
||
import json
|
||
path = os.path.join(config_dir, "plugin_preferences.json")
|
||
try:
|
||
with open(path) as f:
|
||
self.preferences = json.load(f)
|
||
except (FileNotFoundError, json.JSONDecodeError):
|
||
self.preferences = {}
|
||
|
||
def _save_preferences(self, config_dir):
|
||
import json
|
||
path = os.path.join(config_dir, "plugin_preferences.json")
|
||
try:
|
||
with open(path, "w") as f:
|
||
json.dump(self.preferences, f, indent=4)
|
||
except OSError as e:
|
||
printer.error(f"Failed to save plugin preferences: {e}")
|
||
|
||
|
||
def verify_script(self, file_path):
|
||
"""
|
||
Verifies that a given Python script meets specific structural requirements.
|
||
|
||
This function checks a Python script for compliance with predefined structural
|
||
rules. It ensures that the script contains only allowed top-level elements
|
||
(functions, classes, imports, pass statements, and a specific if __name__ block)
|
||
and that it includes mandatory classes with specific attributes and methods.
|
||
|
||
### Arguments:
|
||
- file_path (str): The file path of the Python script to be verified.
|
||
|
||
### Returns:
|
||
- str: A message indicating the type of violation if the script doesn't meet
|
||
the requirements, or False if all requirements are met.
|
||
|
||
### Verifications:
|
||
- The presence of only allowed top-level elements.
|
||
- The existence of two specific classes: 'Parser' and 'Entrypoint'. and/or specific class: Preload.
|
||
- 'Parser' class must only have an '__init__' method and must assign 'self.parser'.
|
||
- 'Entrypoint' class must have an '__init__' method accepting specific arguments.
|
||
|
||
If any of these checks fail, the function returns an error message indicating
|
||
the reason. If the script passes all checks, the function returns False,
|
||
indicating successful verification.
|
||
|
||
### Exceptions:
|
||
- SyntaxError: If the script contains a syntax error, it is caught and
|
||
returned as a part of the error message.
|
||
"""
|
||
with open(file_path, 'r') as file:
|
||
source_code = file.read()
|
||
|
||
try:
|
||
tree = ast.parse(source_code)
|
||
except SyntaxError as e:
|
||
return f"Syntax error in file: {e}"
|
||
|
||
|
||
has_parser = False
|
||
has_entrypoint = False
|
||
has_preload = False
|
||
|
||
for node in tree.body:
|
||
# Allow only function definitions, class definitions, and pass statements at top-level
|
||
if isinstance(node, ast.If):
|
||
# Check for the 'if __name__ == "__main__":' block
|
||
if not (isinstance(node.test, ast.Compare) and
|
||
isinstance(node.test.left, ast.Name) and
|
||
node.test.left.id == '__name__' and
|
||
((hasattr(ast, 'Str') and isinstance(node.test.comparators[0], getattr(ast, 'Str')) and node.test.comparators[0].s == '__main__') or
|
||
(hasattr(ast, 'Constant') and isinstance(node.test.comparators[0], getattr(ast, 'Constant')) and node.test.comparators[0].value == '__main__'))):
|
||
return "Only __name__ == __main__ If is allowed"
|
||
|
||
elif not isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Import, ast.ImportFrom, ast.Pass)):
|
||
return f"Plugin can only have pass, functions, classes and imports. {node} is not allowed" # Reject any other AST types
|
||
|
||
if isinstance(node, ast.ClassDef):
|
||
|
||
if node.name == 'Parser':
|
||
has_parser = True
|
||
# Ensure Parser class has only the __init__ method and assigns self.parser
|
||
if not all(isinstance(method, ast.FunctionDef) and method.name == '__init__' for method in node.body):
|
||
return "Parser class should only have __init__ method"
|
||
|
||
# Check if 'self.parser' is assigned in __init__ method
|
||
init_method = node.body[0]
|
||
assigned_attrs = [target.attr for expr in init_method.body if isinstance(expr, ast.Assign) for target in expr.targets if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == 'self']
|
||
if 'parser' not in assigned_attrs:
|
||
return "Parser class should set self.parser"
|
||
|
||
|
||
elif node.name == 'Entrypoint':
|
||
has_entrypoint = True
|
||
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == '__init__'), None)
|
||
if not init_method or len(init_method.args.args) != 4: # self, args, parser, conapp
|
||
return "Entrypoint class should have method __init__ and accept only arguments: args, parser and connapp" # 'Entrypoint' __init__ does not have correct signature
|
||
|
||
elif node.name == 'Preload':
|
||
has_preload = True
|
||
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == '__init__'), None)
|
||
if not init_method or len(init_method.args.args) != 2: # self, connapp
|
||
return "Preload class should have method __init__ and accept only argument: connapp" # 'Preload' __init__ does not have correct signature
|
||
|
||
# Applying the combination logic based on class presence
|
||
if has_parser and not has_entrypoint:
|
||
return "Parser requires Entrypoint class to be present."
|
||
elif has_entrypoint and not has_parser:
|
||
return "Entrypoint requires Parser class to be present."
|
||
|
||
if not (has_parser or has_entrypoint or has_preload):
|
||
return "No valid class (Parser, Entrypoint, or Preload) found."
|
||
|
||
return False # All requirements met, no error
|
||
|
||
def _import_from_path(self, path):
|
||
spec = importlib.util.spec_from_file_location("module.name", path)
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules["module.name"] = module
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
def _import_plugins_to_argparse(self, directory, subparsers, remote_enabled=False):
|
||
if not os.path.exists(directory):
|
||
return
|
||
for filename in os.listdir(directory):
|
||
commands = subparsers.choices.keys()
|
||
if filename.endswith(".py"):
|
||
root_filename = os.path.splitext(filename)[0]
|
||
if root_filename in commands:
|
||
continue
|
||
|
||
# Check preferences: if remote is preferred AND remote is enabled, skip local loading
|
||
if remote_enabled and self.preferences.get(root_filename) == "remote":
|
||
continue
|
||
|
||
# Construct the full path
|
||
filepath = os.path.join(directory, filename)
|
||
check_file = self.verify_script(filepath)
|
||
if check_file:
|
||
printer.error(f"Failed to load plugin: {filename}. Reason: {check_file}")
|
||
continue
|
||
else:
|
||
self.plugins[root_filename] = self._import_from_path(filepath)
|
||
if hasattr(self.plugins[root_filename], "Parser"):
|
||
self.plugin_parsers[root_filename] = self.plugins[root_filename].Parser()
|
||
plugin = self.plugin_parsers[root_filename]
|
||
# Default to RichHelpFormatter if plugin doesn't set one
|
||
try:
|
||
from rich_argparse import RichHelpFormatter as _RHF
|
||
fmt = plugin.parser.formatter_class
|
||
if fmt is argparse.HelpFormatter or fmt is argparse.RawTextHelpFormatter or fmt is argparse.RawDescriptionHelpFormatter:
|
||
fmt = _RHF
|
||
except ImportError:
|
||
fmt = plugin.parser.formatter_class
|
||
subparsers.add_parser(root_filename, parents=[self.plugin_parsers[root_filename].parser], add_help=False, help=plugin.parser.description, usage=plugin.parser.usage, description=plugin.parser.description, epilog=plugin.parser.epilog, formatter_class=fmt)
|
||
if hasattr(self.plugins[root_filename], "Preload"):
|
||
self.preloads[root_filename] = self.plugins[root_filename]
|
||
|
||
def _import_remote_plugins_to_argparse(self, plugin_stub, subparsers, cache_dir, force_sync=False):
|
||
import hashlib
|
||
os.makedirs(cache_dir, exist_ok=True)
|
||
|
||
try:
|
||
remote_plugins_info = plugin_stub.list_plugins()
|
||
except Exception:
|
||
return
|
||
|
||
# Pruning: Remove local cached files that are no longer on the server
|
||
for local_file in os.listdir(cache_dir):
|
||
if local_file.endswith(".py"):
|
||
name = local_file[:-3]
|
||
if name not in remote_plugins_info:
|
||
try:
|
||
os.remove(os.path.join(cache_dir, local_file))
|
||
except Exception:
|
||
pass
|
||
|
||
for name, info in remote_plugins_info.items():
|
||
if not info.get("enabled", True):
|
||
continue
|
||
|
||
pref = self.preferences.get(name, "local")
|
||
if pref != "remote" and name in self.plugins:
|
||
continue
|
||
if not force_sync and name in subparsers.choices:
|
||
continue
|
||
|
||
cache_path = os.path.join(cache_dir, f"{name}.py")
|
||
|
||
# Hash comparison
|
||
remote_hash = info.get("hash", "")
|
||
local_hash = ""
|
||
if os.path.exists(cache_path):
|
||
try:
|
||
with open(cache_path, "rb") as f:
|
||
local_hash = hashlib.md5(f.read()).hexdigest()
|
||
except Exception:
|
||
pass
|
||
|
||
# Update only if hash differs or force_sync is True
|
||
if force_sync or remote_hash != local_hash or not os.path.exists(cache_path):
|
||
try:
|
||
source = plugin_stub.get_plugin_source(name)
|
||
with open(cache_path, "w") as f:
|
||
f.write(source)
|
||
except Exception as e:
|
||
printer.warning(f"Failed to sync remote plugin {name}: {e}")
|
||
continue
|
||
|
||
# Verify and load
|
||
check_file = self.verify_script(cache_path)
|
||
if check_file:
|
||
printer.warning(f"Remote plugin {name} failed verification: {check_file}")
|
||
continue
|
||
|
||
module = self._import_from_path(cache_path)
|
||
if hasattr(module, "Parser"):
|
||
self.plugin_parsers[name] = module.Parser()
|
||
self.remote_plugins[name] = True
|
||
plugin = self.plugin_parsers[name]
|
||
try:
|
||
from rich_argparse import RichHelpFormatter as _RHF
|
||
fmt = plugin.parser.formatter_class
|
||
if fmt is argparse.HelpFormatter or fmt is argparse.RawTextHelpFormatter or fmt is argparse.RawDescriptionHelpFormatter:
|
||
fmt = _RHF
|
||
except ImportError:
|
||
fmt = plugin.parser.formatter_class
|
||
|
||
# If force_sync, we might be re-registering, but argparse subparsers.add_parser
|
||
# might fail if it exists. We check if it's already there.
|
||
if name not in subparsers.choices:
|
||
subparsers.add_parser(
|
||
name,
|
||
parents=[plugin.parser],
|
||
add_help=False,
|
||
help=f"[remote] {plugin.parser.description}",
|
||
usage=plugin.parser.usage,
|
||
description=plugin.parser.description,
|
||
epilog=plugin.parser.epilog,
|
||
formatter_class=fmt
|
||
)</code></pre>
|
||
</details>
|
||
<div class="desc"></div>
|
||
<h3>Methods</h3>
|
||
<dl>
|
||
<dt id="connpy.Plugins.verify_script"><code class="name flex">
|
||
<span>def <span class="ident">verify_script</span></span>(<span>self, file_path)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">def verify_script(self, file_path):
|
||
"""
|
||
Verifies that a given Python script meets specific structural requirements.
|
||
|
||
This function checks a Python script for compliance with predefined structural
|
||
rules. It ensures that the script contains only allowed top-level elements
|
||
(functions, classes, imports, pass statements, and a specific if __name__ block)
|
||
and that it includes mandatory classes with specific attributes and methods.
|
||
|
||
### Arguments:
|
||
- file_path (str): The file path of the Python script to be verified.
|
||
|
||
### Returns:
|
||
- str: A message indicating the type of violation if the script doesn't meet
|
||
the requirements, or False if all requirements are met.
|
||
|
||
### Verifications:
|
||
- The presence of only allowed top-level elements.
|
||
- The existence of two specific classes: 'Parser' and 'Entrypoint'. and/or specific class: Preload.
|
||
- 'Parser' class must only have an '__init__' method and must assign 'self.parser'.
|
||
- 'Entrypoint' class must have an '__init__' method accepting specific arguments.
|
||
|
||
If any of these checks fail, the function returns an error message indicating
|
||
the reason. If the script passes all checks, the function returns False,
|
||
indicating successful verification.
|
||
|
||
### Exceptions:
|
||
- SyntaxError: If the script contains a syntax error, it is caught and
|
||
returned as a part of the error message.
|
||
"""
|
||
with open(file_path, 'r') as file:
|
||
source_code = file.read()
|
||
|
||
try:
|
||
tree = ast.parse(source_code)
|
||
except SyntaxError as e:
|
||
return f"Syntax error in file: {e}"
|
||
|
||
|
||
has_parser = False
|
||
has_entrypoint = False
|
||
has_preload = False
|
||
|
||
for node in tree.body:
|
||
# Allow only function definitions, class definitions, and pass statements at top-level
|
||
if isinstance(node, ast.If):
|
||
# Check for the 'if __name__ == "__main__":' block
|
||
if not (isinstance(node.test, ast.Compare) and
|
||
isinstance(node.test.left, ast.Name) and
|
||
node.test.left.id == '__name__' and
|
||
((hasattr(ast, 'Str') and isinstance(node.test.comparators[0], getattr(ast, 'Str')) and node.test.comparators[0].s == '__main__') or
|
||
(hasattr(ast, 'Constant') and isinstance(node.test.comparators[0], getattr(ast, 'Constant')) and node.test.comparators[0].value == '__main__'))):
|
||
return "Only __name__ == __main__ If is allowed"
|
||
|
||
elif not isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Import, ast.ImportFrom, ast.Pass)):
|
||
return f"Plugin can only have pass, functions, classes and imports. {node} is not allowed" # Reject any other AST types
|
||
|
||
if isinstance(node, ast.ClassDef):
|
||
|
||
if node.name == 'Parser':
|
||
has_parser = True
|
||
# Ensure Parser class has only the __init__ method and assigns self.parser
|
||
if not all(isinstance(method, ast.FunctionDef) and method.name == '__init__' for method in node.body):
|
||
return "Parser class should only have __init__ method"
|
||
|
||
# Check if 'self.parser' is assigned in __init__ method
|
||
init_method = node.body[0]
|
||
assigned_attrs = [target.attr for expr in init_method.body if isinstance(expr, ast.Assign) for target in expr.targets if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == 'self']
|
||
if 'parser' not in assigned_attrs:
|
||
return "Parser class should set self.parser"
|
||
|
||
|
||
elif node.name == 'Entrypoint':
|
||
has_entrypoint = True
|
||
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == '__init__'), None)
|
||
if not init_method or len(init_method.args.args) != 4: # self, args, parser, conapp
|
||
return "Entrypoint class should have method __init__ and accept only arguments: args, parser and connapp" # 'Entrypoint' __init__ does not have correct signature
|
||
|
||
elif node.name == 'Preload':
|
||
has_preload = True
|
||
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == '__init__'), None)
|
||
if not init_method or len(init_method.args.args) != 2: # self, connapp
|
||
return "Preload class should have method __init__ and accept only argument: connapp" # 'Preload' __init__ does not have correct signature
|
||
|
||
# Applying the combination logic based on class presence
|
||
if has_parser and not has_entrypoint:
|
||
return "Parser requires Entrypoint class to be present."
|
||
elif has_entrypoint and not has_parser:
|
||
return "Entrypoint requires Parser class to be present."
|
||
|
||
if not (has_parser or has_entrypoint or has_preload):
|
||
return "No valid class (Parser, Entrypoint, or Preload) found."
|
||
|
||
return False # All requirements met, no error</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Verifies that a given Python script meets specific structural requirements.</p>
|
||
<p>This function checks a Python script for compliance with predefined structural
|
||
rules. It ensures that the script contains only allowed top-level elements
|
||
(functions, classes, imports, pass statements, and a specific if <strong>name</strong> block)
|
||
and that it includes mandatory classes with specific attributes and methods.</p>
|
||
<h3 id="arguments">Arguments:</h3>
|
||
<pre><code>- file_path (str): The file path of the Python script to be verified.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>- str: A message indicating the type of violation if the script doesn't meet
|
||
the requirements, or False if all requirements are met.
|
||
</code></pre>
|
||
<h3 id="verifications">Verifications:</h3>
|
||
<pre><code>- The presence of only allowed top-level elements.
|
||
- The existence of two specific classes: 'Parser' and 'Entrypoint'. and/or specific class: Preload.
|
||
- 'Parser' class must only have an '__init__' method and must assign 'self.parser'.
|
||
- 'Entrypoint' class must have an '__init__' method accepting specific arguments.
|
||
</code></pre>
|
||
<p>If any of these checks fail, the function returns an error message indicating
|
||
the reason. If the script passes all checks, the function returns False,
|
||
indicating successful verification.</p>
|
||
<h3 id="exceptions">Exceptions:</h3>
|
||
<pre><code> - SyntaxError: If the script contains a syntax error, it is caught and
|
||
returned as a part of the error message.
|
||
</code></pre></div>
|
||
</dd>
|
||
</dl>
|
||
</dd>
|
||
<dt id="connpy.configfile"><code class="flex name class">
|
||
<span>class <span class="ident">configfile</span></span>
|
||
<span>(</span><span>conf=None, key=None, shared_config=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@ClassHook
|
||
class configfile:
|
||
''' This class generates a configfile object. Containts a dictionary storing, config, nodes and profiles, normaly used by connection manager.
|
||
|
||
### Attributes:
|
||
|
||
- file (str): Path/file to config file.
|
||
|
||
- key (str): Path/file to RSA key file.
|
||
|
||
- config (dict): Dictionary containing information of connection
|
||
manager configuration.
|
||
|
||
- connections (dict): Dictionary containing all the nodes added to
|
||
connection manager.
|
||
|
||
- profiles (dict): Dictionary containing all the profiles added to
|
||
connection manager.
|
||
|
||
- privatekey (obj): Object containing the private key to encrypt
|
||
passwords.
|
||
|
||
- publickey (obj): Object containing the public key to decrypt
|
||
passwords.
|
||
'''
|
||
|
||
def __init__(self, conf = None, key = None, shared_config = None):
|
||
self._shared_config = shared_config
|
||
'''
|
||
|
||
### Optional Parameters:
|
||
|
||
- conf (str): Path/file to config file. If left empty default
|
||
path is ~/.config/conn/config.yaml
|
||
|
||
- key (str): Path/file to RSA key file. If left empty default
|
||
path is ~/.config/conn/.osk
|
||
|
||
'''
|
||
home = os.path.expanduser("~")
|
||
defaultdir = home + '/.config/conn'
|
||
|
||
if conf is None:
|
||
# Standard path: use ~/.config/conn and respect .folder redirection
|
||
self.anchor_path = defaultdir
|
||
self.defaultdir = defaultdir
|
||
Path(defaultdir).mkdir(parents=True, exist_ok=True)
|
||
|
||
pathfile = defaultdir + '/.folder'
|
||
try:
|
||
with open(pathfile, "r") as f:
|
||
configdir = f.read().strip()
|
||
except (FileNotFoundError, IOError):
|
||
with open(pathfile, "w") as f:
|
||
f.write(str(defaultdir))
|
||
configdir = defaultdir
|
||
|
||
self.defaultdir = configdir
|
||
self.file = configdir + '/config.yaml'
|
||
self.key = key or (configdir + '/.osk')
|
||
|
||
# Ensure redirected directories exist
|
||
Path(configdir).mkdir(parents=True, exist_ok=True)
|
||
Path(f"{configdir}/plugins").mkdir(parents=True, exist_ok=True)
|
||
|
||
# Backwards compatibility: Migrate from JSON to YAML only for default path
|
||
legacy_json = configdir + '/config.json'
|
||
legacy_noext = configdir + '/config'
|
||
legacy_file = None
|
||
if os.path.exists(legacy_json): legacy_file = legacy_json
|
||
elif os.path.exists(legacy_noext): legacy_file = legacy_noext
|
||
|
||
if not os.path.exists(self.file) and legacy_file:
|
||
try:
|
||
with open(legacy_file, 'r') as f:
|
||
old_data = json.load(f)
|
||
if not self._validate_config(old_data):
|
||
printer.warning(f"Legacy config {legacy_file} has invalid structure, skipping migration.")
|
||
else:
|
||
with open(self.file, 'w') as f:
|
||
yaml.dump(old_data, f, Dumper=NoAliasDumper, default_flow_style=False, sort_keys=False)
|
||
# Verify the written YAML can be read back correctly
|
||
with open(self.file, 'r') as f:
|
||
verify = yaml.safe_load(f)
|
||
if not self._validate_config(verify):
|
||
os.remove(self.file)
|
||
printer.warning("YAML verification failed after migration, keeping legacy config.")
|
||
else:
|
||
# Note: cachefile is derived later, we use temp one for migration sync
|
||
temp_cache = configdir + '/.config.cache.json'
|
||
with open(temp_cache, 'w') as f:
|
||
json.dump(old_data, f)
|
||
shutil.move(legacy_file, legacy_file + ".backup")
|
||
printer.success(f"Migrated legacy config ({len(old_data.get('connections',{}))} folders/nodes) into YAML and Cache successfully!")
|
||
except Exception as e:
|
||
if os.path.exists(self.file):
|
||
try: os.remove(self.file)
|
||
except OSError: pass
|
||
printer.warning(f"Failed to migrate legacy config: {e}")
|
||
else:
|
||
# Custom path (common in tests): isolate everything to the conf parent directory
|
||
self.file = os.path.abspath(conf)
|
||
configdir = os.path.dirname(self.file)
|
||
self.anchor_path = configdir
|
||
self.defaultdir = configdir
|
||
self.key = os.path.abspath(key) if key else (configdir + '/.osk')
|
||
|
||
# Sidecar files always live next to the config file (or in the redirected configdir)
|
||
self.cachefile = configdir + '/.config.cache.json'
|
||
self.fzf_cachefile = configdir + '/.fzf_nodes_cache.txt'
|
||
self.folders_cachefile = configdir + '/.folders_cache.txt'
|
||
self.profiles_cachefile = configdir + '/.profiles_cache.txt'
|
||
|
||
if os.path.exists(self.file):
|
||
config = self._loadconfig(self.file)
|
||
else:
|
||
config = self._createconfig(self.file)
|
||
|
||
self.config = config["config"]
|
||
self.connections = config["connections"]
|
||
self.profiles = config["profiles"]
|
||
|
||
self._privatekey_obj = None
|
||
self._publickey_obj = None
|
||
if not os.path.exists(self.key):
|
||
self._createkey(self.key)
|
||
|
||
# Self-heal text caches if they are missing
|
||
if not os.path.exists(self.fzf_cachefile) or not os.path.exists(self.folders_cachefile) or not os.path.exists(self.profiles_cachefile):
|
||
self._generate_nodes_cache()
|
||
|
||
@property
|
||
def privatekey(self):
|
||
if getattr(self, '_privatekey_obj', None) is None:
|
||
from Crypto.PublicKey import RSA
|
||
if not os.path.exists(self.key):
|
||
self._createkey(self.key)
|
||
with open(self.key) as f:
|
||
self._privatekey_obj = RSA.import_key(f.read())
|
||
return self._privatekey_obj
|
||
|
||
@privatekey.setter
|
||
def privatekey(self, value):
|
||
self._privatekey_obj = value
|
||
|
||
@property
|
||
def publickey(self):
|
||
if getattr(self, '_publickey_obj', None) is None:
|
||
self._publickey_obj = self.privatekey.publickey()
|
||
return self._publickey_obj
|
||
|
||
@publickey.setter
|
||
def publickey(self, value):
|
||
self._publickey_obj = value
|
||
|
||
|
||
def get_effective_setting(self, key, default=None):
|
||
"""Get config setting with shared fallback for inheritable keys."""
|
||
val = self.config.get(key)
|
||
if key == "ai":
|
||
if val is not None:
|
||
if self._shared_config:
|
||
import copy
|
||
# Deep merge: shared as base, user overrides
|
||
base = copy.deepcopy(self._shared_config.config.get(key, {}))
|
||
if isinstance(base, dict) and isinstance(val, dict):
|
||
# Credential isolation:
|
||
# If user defines engineer credentials, discard shared ones
|
||
if "engineer_api_key" in val or "engineer_auth" in val:
|
||
base.pop("engineer_api_key", None)
|
||
base.pop("engineer_auth", None)
|
||
# If user defines architect credentials, discard shared ones
|
||
if "architect_api_key" in val or "architect_auth" in val:
|
||
base.pop("architect_api_key", None)
|
||
base.pop("architect_auth", None)
|
||
|
||
# Recursive update for inner dictionaries (like mcp_servers or model details)
|
||
def deep_merge(d1, d2):
|
||
for k, v in d2.items():
|
||
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
|
||
deep_merge(d1[k], v)
|
||
else:
|
||
d1[k] = copy.deepcopy(v)
|
||
deep_merge(base, val)
|
||
return base
|
||
return val
|
||
elif self._shared_config:
|
||
return self._shared_config.config.get(key, default)
|
||
|
||
return val if val is not None else default
|
||
|
||
|
||
def _validate_config(self, data):
|
||
"""Verify config data has the required structure."""
|
||
if not isinstance(data, dict):
|
||
return False
|
||
required = {"config", "connections", "profiles"}
|
||
return required.issubset(data.keys())
|
||
|
||
def _loadconfig(self, conf):
|
||
#Loads config file using dual cache
|
||
cache_exists = os.path.exists(self.cachefile)
|
||
yaml_time = os.path.getmtime(conf) if os.path.exists(conf) else 0
|
||
cache_time = os.path.getmtime(self.cachefile) if cache_exists else 0
|
||
|
||
if not cache_exists or yaml_time > cache_time:
|
||
with open(conf, 'r') as f:
|
||
data = yaml.safe_load(f)
|
||
if not self._validate_config(data):
|
||
# YAML is broken, try to recover from cache
|
||
if cache_exists:
|
||
printer.warning("Config file appears corrupt, recovering from cache...")
|
||
with open(self.cachefile, 'r') as f:
|
||
data = json.load(f)
|
||
if self._validate_config(data):
|
||
# Re-write the YAML from good cache
|
||
with open(conf, 'w') as f:
|
||
yaml.dump(data, f, Dumper=NoAliasDumper, default_flow_style=False, sort_keys=False)
|
||
return data
|
||
# Both broken or no cache - create fresh
|
||
printer.error("Config file is corrupt and no valid cache exists. Creating default config.")
|
||
return self._createconfig(conf)
|
||
try:
|
||
with open(self.cachefile, 'w') as f:
|
||
json.dump(data, f)
|
||
except Exception:
|
||
pass
|
||
return data
|
||
else:
|
||
with open(self.cachefile, 'r') as f:
|
||
data = json.load(f)
|
||
if not self._validate_config(data):
|
||
# Cache broken, try yaml
|
||
with open(conf, 'r') as f:
|
||
data = yaml.safe_load(f)
|
||
if self._validate_config(data):
|
||
return data
|
||
# Both broken
|
||
printer.error("Both config and cache are corrupt. Creating default config.")
|
||
return self._createconfig(conf)
|
||
return data
|
||
|
||
def _createconfig(self, conf):
|
||
#Create config file (always writes defaults, safe for recovery)
|
||
defaultconfig = {'config': {'case': False, 'idletime': 30, 'fzf': False}, 'connections': {}, 'profiles': { "default": { "host":"", "protocol":"ssh", "port":"", "user":"", "password":"", "options":"", "logs":"", "tags": "", "jumphost":""}}}
|
||
with open(conf, "w") as f:
|
||
yaml.dump(defaultconfig, f, Dumper=NoAliasDumper, default_flow_style=False, sort_keys=False)
|
||
os.chmod(conf, 0o600)
|
||
try:
|
||
with open(self.cachefile, 'w') as f:
|
||
json.dump(defaultconfig, f)
|
||
except Exception:
|
||
pass
|
||
return defaultconfig
|
||
|
||
@MethodHook
|
||
def _saveconfig(self, conf):
|
||
#Save config file atomically to prevent corruption
|
||
newconfig = {"config":{}, "connections": {}, "profiles": {}}
|
||
newconfig["config"] = self.config
|
||
newconfig["connections"] = self.connections
|
||
newconfig["profiles"] = self.profiles
|
||
tmpfile = conf + '.tmp'
|
||
try:
|
||
with open(tmpfile, "w") as f:
|
||
yaml.dump(newconfig, f, Dumper=NoAliasDumper, default_flow_style=False, sort_keys=False)
|
||
# Atomic replace: only overwrite original if write succeeded
|
||
shutil.move(tmpfile, conf)
|
||
with open(self.cachefile, "w") as f:
|
||
json.dump(newconfig, f)
|
||
self._generate_nodes_cache()
|
||
except (IOError, OSError) as e:
|
||
printer.error(f"Failed to save config: {e}")
|
||
# Clean up temp file if it exists
|
||
if os.path.exists(tmpfile):
|
||
try:
|
||
os.remove(tmpfile)
|
||
except OSError:
|
||
pass
|
||
return 1
|
||
return 0
|
||
|
||
def _generate_nodes_cache(self, nodes=None, folders=None, profiles=None):
|
||
try:
|
||
if nodes is None:
|
||
nodes = self._getallnodes()
|
||
if folders is None:
|
||
folders = self._getallfolders()
|
||
if profiles is None:
|
||
profiles = list(self.profiles.keys())
|
||
|
||
with open(self.fzf_cachefile, "w") as f:
|
||
f.write("\n".join(nodes))
|
||
with open(self.folders_cachefile, "w") as f:
|
||
f.write("\n".join(folders))
|
||
with open(self.profiles_cachefile, "w") as f:
|
||
f.write("\n".join(profiles))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _createkey(self, keyfile):
|
||
#Create key file
|
||
from Crypto.PublicKey import RSA
|
||
key = RSA.generate(2048)
|
||
with open(keyfile,'wb') as f:
|
||
f.write(key.export_key('PEM'))
|
||
f.close()
|
||
os.chmod(keyfile, 0o600)
|
||
return key
|
||
|
||
@MethodHook
|
||
def _explode_unique(self, unique):
|
||
#Divide unique name into folder, subfolder and id
|
||
uniques = unique.split("@")
|
||
if not unique.startswith("@"):
|
||
result = {"id": uniques[0]}
|
||
else:
|
||
result = {}
|
||
if len(uniques) == 2:
|
||
result["folder"] = uniques[1]
|
||
if result["folder"] == "":
|
||
return False
|
||
elif len(uniques) == 3:
|
||
result["folder"] = uniques[2]
|
||
result["subfolder"] = uniques[1]
|
||
if result["folder"] == "" or result["subfolder"] == "":
|
||
return False
|
||
elif len(uniques) > 3:
|
||
return False
|
||
return result
|
||
|
||
@MethodHook
|
||
def getitem(self, unique, keys = None, extract = False):
|
||
'''
|
||
Get an node or a group of nodes from configfile which can be passed to node/nodes class
|
||
|
||
### Parameters:
|
||
|
||
- unique (str): Unique name of the node or folder in config using
|
||
connection manager style: node[@subfolder][@folder]
|
||
or [@subfolder]@folder
|
||
|
||
### Optional Parameters:
|
||
|
||
- keys (list): In case you pass a folder as unique, you can filter
|
||
nodes inside the folder passing a list.
|
||
- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
|
||
'''
|
||
uniques = self._explode_unique(unique)
|
||
if unique.startswith("@"):
|
||
if uniques.keys() >= {"folder", "subfolder"}:
|
||
folder = self.connections[uniques["folder"]][uniques["subfolder"]]
|
||
else:
|
||
folder = self.connections[uniques["folder"]]
|
||
newfolder = deepcopy(folder)
|
||
newfolder.pop("type")
|
||
for node_name in folder.keys():
|
||
if node_name == "type":
|
||
continue
|
||
if "type" in newfolder[node_name].keys():
|
||
if newfolder[node_name]["type"] == "subfolder":
|
||
newfolder.pop(node_name)
|
||
else:
|
||
newfolder[node_name].pop("type")
|
||
|
||
if keys != None:
|
||
newfolder = dict((k, newfolder[k]) for k in keys)
|
||
|
||
if extract:
|
||
for node_name, node_keys in newfolder.items():
|
||
for key, value in node_keys.items():
|
||
profile = re.search("^@(.*)", str(value))
|
||
if profile:
|
||
try:
|
||
newfolder[node_name][key] = self.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
newfolder[node_name][key] = ""
|
||
elif value == '' and key == "protocol":
|
||
try:
|
||
newfolder[node_name][key] = self.profiles["default"][key]
|
||
except KeyError:
|
||
newfolder[node_name][key] = "ssh"
|
||
|
||
newfolder = {"{}{}".format(k,unique):v for k,v in newfolder.items()}
|
||
return newfolder
|
||
else:
|
||
if uniques.keys() >= {"folder", "subfolder"}:
|
||
node = self.connections[uniques["folder"]][uniques["subfolder"]][uniques["id"]]
|
||
elif "folder" in uniques.keys():
|
||
node = self.connections[uniques["folder"]][uniques["id"]]
|
||
else:
|
||
node = self.connections[uniques["id"]]
|
||
newnode = deepcopy(node)
|
||
newnode.pop("type")
|
||
|
||
if extract:
|
||
for key, value in newnode.items():
|
||
profile = re.search("^@(.*)", str(value))
|
||
if profile:
|
||
try:
|
||
newnode[key] = self.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
newnode[key] = ""
|
||
elif value == '' and key == "protocol":
|
||
try:
|
||
newnode[key] = self.profiles["default"][key]
|
||
except KeyError:
|
||
newnode[key] = "ssh"
|
||
return newnode
|
||
|
||
@MethodHook
|
||
def getitems(self, uniques, extract = False):
|
||
'''
|
||
Get a group of nodes from configfile which can be passed to node/nodes class
|
||
|
||
### Parameters:
|
||
|
||
- uniques (str/list): String name that will match hostnames
|
||
from the connection manager. It can be a
|
||
list of strings.
|
||
|
||
### Optional Parameters:
|
||
|
||
- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
|
||
'''
|
||
nodes = {}
|
||
if isinstance(uniques, str):
|
||
uniques = [uniques]
|
||
for i in uniques:
|
||
if i.startswith("@"):
|
||
if not self.config["case"]:
|
||
i = i.lower()
|
||
this = self.getitem(i, extract = extract)
|
||
nodes.update(this)
|
||
else:
|
||
if not self.config["case"]:
|
||
i = i.lower()
|
||
this = self.getitem(i, extract = extract)
|
||
nodes[i] = this
|
||
return nodes
|
||
|
||
|
||
@MethodHook
|
||
def _connections_add(self,*, id, host, folder='', subfolder='', options='', logs='', password='', port='', protocol='', user='', tags='', jumphost='', type = "connection" ):
|
||
#Add connection from config
|
||
if folder == '':
|
||
self.connections[id] = {"host": host, "options": options, "logs": logs, "password": password, "port": port, "protocol": protocol, "user": user, "tags": tags,"jumphost": jumphost,"type": type}
|
||
elif folder != '' and subfolder == '':
|
||
self.connections[folder][id] = {"host": host, "options": options, "logs": logs, "password": password, "port": port, "protocol": protocol, "user": user, "tags": tags, "jumphost": jumphost, "type": type}
|
||
elif folder != '' and subfolder != '':
|
||
self.connections[folder][subfolder][id] = {"host": host, "options": options, "logs": logs, "password": password, "port": port, "protocol": protocol, "user": user, "tags": tags, "jumphost": jumphost, "type": type}
|
||
|
||
|
||
@MethodHook
|
||
def _connections_del(self,*, id, folder='', subfolder=''):
|
||
#Delete connection from config
|
||
if folder == '':
|
||
del self.connections[id]
|
||
elif folder != '' and subfolder == '':
|
||
del self.connections[folder][id]
|
||
elif folder != '' and subfolder != '':
|
||
del self.connections[folder][subfolder][id]
|
||
|
||
@MethodHook
|
||
def _folder_add(self,*, folder, subfolder = ''):
|
||
#Add Folder from config
|
||
if subfolder == '':
|
||
if folder not in self.connections:
|
||
self.connections[folder] = {"type": "folder"}
|
||
else:
|
||
if subfolder not in self.connections[folder]:
|
||
self.connections[folder][subfolder] = {"type": "subfolder"}
|
||
|
||
@MethodHook
|
||
def _folder_del(self,*, folder, subfolder=''):
|
||
#Delete folder from config
|
||
if subfolder == '':
|
||
del self.connections[folder]
|
||
else:
|
||
del self.connections[folder][subfolder]
|
||
|
||
|
||
@MethodHook
|
||
def _profiles_add(self,*, id, host = '', options='', logs='', password='', port='', protocol='', user='', tags='', jumphost='' ):
|
||
#Add profile from config
|
||
self.profiles[id] = {"host": host, "options": options, "logs": logs, "password": password, "port": port, "protocol": protocol, "user": user, "tags": tags, "jumphost": jumphost}
|
||
|
||
|
||
@MethodHook
|
||
def _profiles_del(self,*, id ):
|
||
#Delete profile from config
|
||
del self.profiles[id]
|
||
|
||
@MethodHook
|
||
def _getallnodes(self, filter = None):
|
||
#get all nodes on configfile
|
||
nodes = []
|
||
layer1 = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "connection"]
|
||
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "folder"]
|
||
nodes.extend(layer1)
|
||
for f in folders:
|
||
layer2 = [k + "@" + f for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "connection"]
|
||
nodes.extend(layer2)
|
||
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "subfolder"]
|
||
for s in subfolders:
|
||
layer3 = [k + "@" + s + "@" + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"]
|
||
nodes.extend(layer3)
|
||
if filter:
|
||
flat_filter = []
|
||
if isinstance(filter, str):
|
||
flat_filter = [filter]
|
||
elif isinstance(filter, list):
|
||
for item in filter:
|
||
if isinstance(item, str):
|
||
flat_filter.append(item)
|
||
else:
|
||
printer.error("Filter must be a string or a list of strings")
|
||
sys.exit(1)
|
||
flags = re.IGNORECASE if not self.config.get("case", False) else 0
|
||
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
|
||
return nodes
|
||
|
||
@MethodHook
|
||
def _getallnodesfull(self, filter = None, extract = True):
|
||
#get all nodes on configfile with all their attributes.
|
||
nodes = {}
|
||
layer1 = {k:v for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "connection"}
|
||
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "folder"]
|
||
nodes.update(layer1)
|
||
for f in folders:
|
||
layer2 = {k + "@" + f:v for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "connection"}
|
||
nodes.update(layer2)
|
||
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "subfolder"]
|
||
for s in subfolders:
|
||
layer3 = {k + "@" + s + "@" + f:v for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection"}
|
||
nodes.update(layer3)
|
||
if filter:
|
||
flat_filter = []
|
||
if isinstance(filter, str):
|
||
flat_filter = [filter]
|
||
elif isinstance(filter, list):
|
||
for item in filter:
|
||
if isinstance(item, str):
|
||
flat_filter.append(item)
|
||
else:
|
||
printer.error("Filter must be a string or a list of strings")
|
||
sys.exit(1)
|
||
flat_filter = ["^(?!.*@).+$" if item == "@" else item for item in flat_filter]
|
||
nodes = {k: v for k, v in nodes.items() if any(re.search(pattern, k) for pattern in flat_filter)}
|
||
if extract:
|
||
for node, keys in nodes.items():
|
||
for key, value in keys.items():
|
||
profile = re.search("^@(.*)", str(value))
|
||
if profile:
|
||
try:
|
||
nodes[node][key] = self.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
nodes[node][key] = ""
|
||
elif value == '' and key == "protocol":
|
||
try:
|
||
nodes[node][key] = self.profiles["default"][key]
|
||
except KeyError:
|
||
nodes[node][key] = "ssh"
|
||
return nodes
|
||
|
||
|
||
@MethodHook
|
||
def _getallfolders(self):
|
||
#get all folders on configfile
|
||
folders = ["@" + k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "folder"]
|
||
subfolders = []
|
||
for f in folders:
|
||
s = ["@" + k + f for k,v in self.connections[f[1:]].items() if isinstance(v, dict) and v.get("type") == "subfolder"]
|
||
subfolders.extend(s)
|
||
folders.extend(subfolders)
|
||
return folders
|
||
|
||
@MethodHook
|
||
def _profileused(self, profile):
|
||
#Return all the nodes that uses this profile.
|
||
nodes = []
|
||
layer1 = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "connection" and ("@" + profile in v.values() or ( isinstance(v.get("password"),list) and "@" + profile in v.get("password")))]
|
||
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get("type") == "folder"]
|
||
nodes.extend(layer1)
|
||
for f in folders:
|
||
layer2 = [k + "@" + f for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "connection" and ("@" + profile in v.values() or ( isinstance(v.get("password"),list) and "@" + profile in v.get("password")))]
|
||
nodes.extend(layer2)
|
||
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get("type") == "subfolder"]
|
||
for s in subfolders:
|
||
layer3 = [k + "@" + s + "@" + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get("type") == "connection" and ("@" + profile in v.values() or ( isinstance(v.get("password"),list) and "@" + profile in v.get("password")))]
|
||
nodes.extend(layer3)
|
||
return nodes
|
||
|
||
@MethodHook
|
||
def encrypt(self, password, keyfile=None):
|
||
'''
|
||
Encrypts password using RSA keyfile
|
||
|
||
### Parameters:
|
||
|
||
- password (str): Plaintext password to encrypt.
|
||
|
||
### Optional Parameters:
|
||
|
||
- keyfile (str): Path/file to keyfile. Default is config keyfile.
|
||
|
||
|
||
### Returns:
|
||
|
||
str: Encrypted password.
|
||
|
||
'''
|
||
if keyfile is None:
|
||
keyfile = self.key
|
||
with open(keyfile) as f:
|
||
from Crypto.PublicKey import RSA
|
||
from Crypto.Cipher import PKCS1_OAEP
|
||
key = RSA.import_key(f.read())
|
||
f.close()
|
||
publickey = key.publickey()
|
||
encryptor = PKCS1_OAEP.new(publickey)
|
||
password = encryptor.encrypt(password.encode("utf-8"))
|
||
return str(password)</code></pre>
|
||
</details>
|
||
<div class="desc"><p>This class generates a configfile object. Containts a dictionary storing, config, nodes and profiles, normaly used by connection manager.</p>
|
||
<h3 id="attributes">Attributes:</h3>
|
||
<pre><code>- file (str): Path/file to config file.
|
||
|
||
- key (str): Path/file to RSA key file.
|
||
|
||
- config (dict): Dictionary containing information of connection
|
||
manager configuration.
|
||
|
||
- connections (dict): Dictionary containing all the nodes added to
|
||
connection manager.
|
||
|
||
- profiles (dict): Dictionary containing all the profiles added to
|
||
connection manager.
|
||
|
||
- privatekey (obj): Object containing the private key to encrypt
|
||
passwords.
|
||
|
||
- publickey (obj): Object containing the public key to decrypt
|
||
passwords.
|
||
</code></pre></div>
|
||
<h3>Instance variables</h3>
|
||
<dl>
|
||
<dt id="connpy.configfile.privatekey"><code class="name">prop <span class="ident">privatekey</span></code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@property
|
||
def privatekey(self):
|
||
if getattr(self, '_privatekey_obj', None) is None:
|
||
from Crypto.PublicKey import RSA
|
||
if not os.path.exists(self.key):
|
||
self._createkey(self.key)
|
||
with open(self.key) as f:
|
||
self._privatekey_obj = RSA.import_key(f.read())
|
||
return self._privatekey_obj</code></pre>
|
||
</details>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
<dt id="connpy.configfile.publickey"><code class="name">prop <span class="ident">publickey</span></code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@property
|
||
def publickey(self):
|
||
if getattr(self, '_publickey_obj', None) is None:
|
||
self._publickey_obj = self.privatekey.publickey()
|
||
return self._publickey_obj</code></pre>
|
||
</details>
|
||
<div class="desc"></div>
|
||
</dd>
|
||
</dl>
|
||
<h3>Methods</h3>
|
||
<dl>
|
||
<dt id="connpy.configfile.encrypt"><code class="name flex">
|
||
<span>def <span class="ident">encrypt</span></span>(<span>self, password, keyfile=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def encrypt(self, password, keyfile=None):
|
||
'''
|
||
Encrypts password using RSA keyfile
|
||
|
||
### Parameters:
|
||
|
||
- password (str): Plaintext password to encrypt.
|
||
|
||
### Optional Parameters:
|
||
|
||
- keyfile (str): Path/file to keyfile. Default is config keyfile.
|
||
|
||
|
||
### Returns:
|
||
|
||
str: Encrypted password.
|
||
|
||
'''
|
||
if keyfile is None:
|
||
keyfile = self.key
|
||
with open(keyfile) as f:
|
||
from Crypto.PublicKey import RSA
|
||
from Crypto.Cipher import PKCS1_OAEP
|
||
key = RSA.import_key(f.read())
|
||
f.close()
|
||
publickey = key.publickey()
|
||
encryptor = PKCS1_OAEP.new(publickey)
|
||
password = encryptor.encrypt(password.encode("utf-8"))
|
||
return str(password)</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Encrypts password using RSA keyfile</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- password (str): Plaintext password to encrypt.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- keyfile (str): Path/file to keyfile. Default is config keyfile.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>str: Encrypted password.
|
||
</code></pre></div>
|
||
</dd>
|
||
<dt id="connpy.configfile.get_effective_setting"><code class="name flex">
|
||
<span>def <span class="ident">get_effective_setting</span></span>(<span>self, key, default=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">def get_effective_setting(self, key, default=None):
|
||
"""Get config setting with shared fallback for inheritable keys."""
|
||
val = self.config.get(key)
|
||
if key == "ai":
|
||
if val is not None:
|
||
if self._shared_config:
|
||
import copy
|
||
# Deep merge: shared as base, user overrides
|
||
base = copy.deepcopy(self._shared_config.config.get(key, {}))
|
||
if isinstance(base, dict) and isinstance(val, dict):
|
||
# Credential isolation:
|
||
# If user defines engineer credentials, discard shared ones
|
||
if "engineer_api_key" in val or "engineer_auth" in val:
|
||
base.pop("engineer_api_key", None)
|
||
base.pop("engineer_auth", None)
|
||
# If user defines architect credentials, discard shared ones
|
||
if "architect_api_key" in val or "architect_auth" in val:
|
||
base.pop("architect_api_key", None)
|
||
base.pop("architect_auth", None)
|
||
|
||
# Recursive update for inner dictionaries (like mcp_servers or model details)
|
||
def deep_merge(d1, d2):
|
||
for k, v in d2.items():
|
||
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
|
||
deep_merge(d1[k], v)
|
||
else:
|
||
d1[k] = copy.deepcopy(v)
|
||
deep_merge(base, val)
|
||
return base
|
||
return val
|
||
elif self._shared_config:
|
||
return self._shared_config.config.get(key, default)
|
||
|
||
return val if val is not None else default</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Get config setting with shared fallback for inheritable keys.</p></div>
|
||
</dd>
|
||
<dt id="connpy.configfile.getitem"><code class="name flex">
|
||
<span>def <span class="ident">getitem</span></span>(<span>self, unique, keys=None, extract=False)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def getitem(self, unique, keys = None, extract = False):
|
||
'''
|
||
Get an node or a group of nodes from configfile which can be passed to node/nodes class
|
||
|
||
### Parameters:
|
||
|
||
- unique (str): Unique name of the node or folder in config using
|
||
connection manager style: node[@subfolder][@folder]
|
||
or [@subfolder]@folder
|
||
|
||
### Optional Parameters:
|
||
|
||
- keys (list): In case you pass a folder as unique, you can filter
|
||
nodes inside the folder passing a list.
|
||
- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
|
||
'''
|
||
uniques = self._explode_unique(unique)
|
||
if unique.startswith("@"):
|
||
if uniques.keys() >= {"folder", "subfolder"}:
|
||
folder = self.connections[uniques["folder"]][uniques["subfolder"]]
|
||
else:
|
||
folder = self.connections[uniques["folder"]]
|
||
newfolder = deepcopy(folder)
|
||
newfolder.pop("type")
|
||
for node_name in folder.keys():
|
||
if node_name == "type":
|
||
continue
|
||
if "type" in newfolder[node_name].keys():
|
||
if newfolder[node_name]["type"] == "subfolder":
|
||
newfolder.pop(node_name)
|
||
else:
|
||
newfolder[node_name].pop("type")
|
||
|
||
if keys != None:
|
||
newfolder = dict((k, newfolder[k]) for k in keys)
|
||
|
||
if extract:
|
||
for node_name, node_keys in newfolder.items():
|
||
for key, value in node_keys.items():
|
||
profile = re.search("^@(.*)", str(value))
|
||
if profile:
|
||
try:
|
||
newfolder[node_name][key] = self.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
newfolder[node_name][key] = ""
|
||
elif value == '' and key == "protocol":
|
||
try:
|
||
newfolder[node_name][key] = self.profiles["default"][key]
|
||
except KeyError:
|
||
newfolder[node_name][key] = "ssh"
|
||
|
||
newfolder = {"{}{}".format(k,unique):v for k,v in newfolder.items()}
|
||
return newfolder
|
||
else:
|
||
if uniques.keys() >= {"folder", "subfolder"}:
|
||
node = self.connections[uniques["folder"]][uniques["subfolder"]][uniques["id"]]
|
||
elif "folder" in uniques.keys():
|
||
node = self.connections[uniques["folder"]][uniques["id"]]
|
||
else:
|
||
node = self.connections[uniques["id"]]
|
||
newnode = deepcopy(node)
|
||
newnode.pop("type")
|
||
|
||
if extract:
|
||
for key, value in newnode.items():
|
||
profile = re.search("^@(.*)", str(value))
|
||
if profile:
|
||
try:
|
||
newnode[key] = self.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
newnode[key] = ""
|
||
elif value == '' and key == "protocol":
|
||
try:
|
||
newnode[key] = self.profiles["default"][key]
|
||
except KeyError:
|
||
newnode[key] = "ssh"
|
||
return newnode</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Get an node or a group of nodes from configfile which can be passed to node/nodes class</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- unique (str): Unique name of the node or folder in config using
|
||
connection manager style: node[@subfolder][@folder]
|
||
or [@subfolder]@folder
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- keys (list): In case you pass a folder as unique, you can filter
|
||
nodes inside the folder passing a list.
|
||
- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
</code></pre></div>
|
||
</dd>
|
||
<dt id="connpy.configfile.getitems"><code class="name flex">
|
||
<span>def <span class="ident">getitems</span></span>(<span>self, uniques, extract=False)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def getitems(self, uniques, extract = False):
|
||
'''
|
||
Get a group of nodes from configfile which can be passed to node/nodes class
|
||
|
||
### Parameters:
|
||
|
||
- uniques (str/list): String name that will match hostnames
|
||
from the connection manager. It can be a
|
||
list of strings.
|
||
|
||
### Optional Parameters:
|
||
|
||
- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
|
||
'''
|
||
nodes = {}
|
||
if isinstance(uniques, str):
|
||
uniques = [uniques]
|
||
for i in uniques:
|
||
if i.startswith("@"):
|
||
if not self.config["case"]:
|
||
i = i.lower()
|
||
this = self.getitem(i, extract = extract)
|
||
nodes.update(this)
|
||
else:
|
||
if not self.config["case"]:
|
||
i = i.lower()
|
||
this = self.getitem(i, extract = extract)
|
||
nodes[i] = this
|
||
return nodes</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Get a group of nodes from configfile which can be passed to node/nodes class</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- uniques (str/list): String name that will match hostnames
|
||
from the connection manager. It can be a
|
||
list of strings.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- extract (bool): If True, extract information from profiles.
|
||
Default False.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>dict: Dictionary containing information of node or multiple
|
||
dictionaries of multiple nodes.
|
||
</code></pre></div>
|
||
</dd>
|
||
</dl>
|
||
</dd>
|
||
<dt id="connpy.node"><code class="flex name class">
|
||
<span>class <span class="ident">node</span></span>
|
||
<span>(</span><span>unique,<br>host,<br>options='',<br>logs='',<br>password='',<br>port='',<br>protocol='',<br>user='',<br>config='',<br>tags='',<br>jumphost='')</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@ClassHook
|
||
class node:
|
||
''' This class generates a node object. Containts all the information and methods to connect and interact with a device using ssh or telnet.
|
||
|
||
### Attributes:
|
||
|
||
- output (str): Output of the commands you ran with run or test
|
||
method.
|
||
|
||
- result(bool): True if expected value is found after running
|
||
the commands using test method.
|
||
|
||
- status (int): 0 if the method run or test run successfully.
|
||
1 if connection failed.
|
||
2 if expect timeouts without prompt or EOF.
|
||
|
||
'''
|
||
|
||
def __init__(self, unique, host, options='', logs='', password='', port='', protocol='', user='', config='', tags='', jumphost=''):
|
||
'''
|
||
|
||
### Parameters:
|
||
|
||
- unique (str): Unique name to assign to the node.
|
||
|
||
- host (str): IP address or hostname of the node.
|
||
|
||
### Optional Parameters:
|
||
|
||
- options (str): Additional options to pass the ssh/telnet for
|
||
connection.
|
||
|
||
- logs (str): Path/file for storing the logs. You can use
|
||
${unique},${host}, ${port}, ${user}, ${protocol}
|
||
as variables.
|
||
|
||
- password (str): Encrypted or plaintext password.
|
||
|
||
- port (str): Port to connect to node, default 22 for ssh and 23
|
||
for telnet.
|
||
|
||
- protocol (str): Select ssh, telnet, kubectl or docker. Default is ssh.
|
||
|
||
- user (str): Username to of the node.
|
||
|
||
- config (obj): Pass the object created with class configfile with
|
||
key for decryption and extra configuration if you
|
||
are using connection manager.
|
||
|
||
- tags (dict) : Tags useful for automation and personal porpuse
|
||
like "os", "prompt" and "screenleght_command"
|
||
|
||
- jumphost (str): Reference another node to be used as a jumphost
|
||
'''
|
||
self.config = config
|
||
if config == '':
|
||
self.idletime = 0
|
||
self.key = None
|
||
else:
|
||
self.idletime = config.config["idletime"]
|
||
self.key = config.key
|
||
self.unique = unique
|
||
attr = {"host": host, "logs": logs, "options":options, "port": port, "protocol": protocol, "user": user, "tags": tags, "jumphost": jumphost}
|
||
for key in attr:
|
||
profile = re.search("^@(.*)", str(attr[key]))
|
||
if profile and config != '':
|
||
try:
|
||
setattr(self,key,config.profiles[profile.group(1)][key])
|
||
except KeyError:
|
||
setattr(self,key,"")
|
||
elif attr[key] == '' and key == "protocol":
|
||
try:
|
||
setattr(self,key,config.profiles["default"][key])
|
||
except (KeyError, AttributeError):
|
||
setattr(self,key,"ssh")
|
||
else:
|
||
setattr(self,key,attr[key])
|
||
if isinstance(password,list):
|
||
self.password = []
|
||
for i, s in enumerate(password):
|
||
profile = re.search("^@(.*)", password[i])
|
||
if profile and config != '':
|
||
self.password.append(config.profiles[profile.group(1)]["password"])
|
||
else:
|
||
self.password.append(password[i])
|
||
else:
|
||
self.password = [password]
|
||
if self.jumphost != "" and config != '':
|
||
raw_cmd, jh_passwords = self._build_jumphost_chain(self.jumphost, config)
|
||
if jh_passwords:
|
||
self.password = jh_passwords + self.password
|
||
if raw_cmd:
|
||
escaped = raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||
self.jumphost = f'-o ProxyCommand="{escaped}"'
|
||
else:
|
||
self.jumphost = ""
|
||
|
||
self.output = ""
|
||
self.status = 1
|
||
self.result = {}
|
||
self.cmd_byte_positions = [(0, None)]
|
||
|
||
@staticmethod
|
||
def _resolve_jumphost_data(jh_dict, config):
|
||
'''Resolve @profile references and normalize passwords in a jumphost dict.'''
|
||
for key in jh_dict:
|
||
profile = re.search("^@(.*)", str(jh_dict[key]))
|
||
if profile:
|
||
try:
|
||
jh_dict[key] = config.profiles[profile.group(1)][key]
|
||
except KeyError:
|
||
jh_dict[key] = ""
|
||
elif jh_dict[key] == '' and key == "protocol":
|
||
try:
|
||
jh_dict[key] = config.profiles["default"][key]
|
||
except KeyError:
|
||
jh_dict[key] = "ssh"
|
||
if isinstance(jh_dict["password"], list):
|
||
resolved = []
|
||
for p in jh_dict["password"]:
|
||
profile = re.search("^@(.*)", p)
|
||
if profile:
|
||
resolved.append(config.profiles[profile.group(1)]["password"])
|
||
else:
|
||
resolved.append(p)
|
||
jh_dict["password"] = resolved
|
||
else:
|
||
jh_dict["password"] = [jh_dict["password"]]
|
||
return jh_dict
|
||
|
||
def _build_jumphost_chain(self, jumphost_name, config, visited=None, depth=0, target_host="%h", target_port="%p"):
|
||
'''Recursively build ProxyCommand for chained jumphosts.
|
||
|
||
Returns:
|
||
tuple: (raw_proxy_command, passwords_list)
|
||
- raw_proxy_command: Command string to embed in ProxyCommand
|
||
- passwords_list: Ordered passwords (innermost first)
|
||
|
||
Raises:
|
||
ValueError: On circular references or exceeding max depth (5).
|
||
'''
|
||
if depth >= 5:
|
||
raise ValueError("Jumphost chain exceeds maximum depth of 5 hops")
|
||
if visited is None:
|
||
visited = []
|
||
if jumphost_name in visited:
|
||
cycle = " -> ".join(visited + [jumphost_name])
|
||
raise ValueError(f"Circular jumphost reference detected: {cycle}")
|
||
visited = visited + [jumphost_name]
|
||
|
||
jh = config.getitem(jumphost_name)
|
||
jh = self._resolve_jumphost_data(jh, config)
|
||
|
||
passwords = []
|
||
inner_proxy_opt = ""
|
||
|
||
# Recursively resolve inner jumphost
|
||
if jh.get("jumphost", "") != "":
|
||
if jh["protocol"] not in ["ssh"]:
|
||
raise ValueError(
|
||
f"Jumphost '{jumphost_name}' uses protocol '{jh['protocol']}' "
|
||
f"which does not support chained jumphosts. "
|
||
f"Only SSH jumphosts can have their own jumphosts."
|
||
)
|
||
parent_port = jh["port"] if jh["port"] != "" else "22"
|
||
inner_raw_cmd, inner_passwords = self._build_jumphost_chain(
|
||
jh["jumphost"], config, visited, depth + 1, target_host=jh["host"], target_port=parent_port
|
||
)
|
||
passwords = inner_passwords
|
||
escaped = inner_raw_cmd.replace('\\', '\\\\').replace('"', '\\"')
|
||
inner_proxy_opt = f'-o ProxyCommand="{escaped}"'
|
||
|
||
# Collect this hop's passwords
|
||
if jh["password"] != [""]:
|
||
passwords = passwords + jh["password"]
|
||
|
||
t_port = target_port if target_port != "" else "22"
|
||
|
||
# Build raw command based on protocol
|
||
if jh["protocol"] == "ssh":
|
||
cmd = f"ssh -W {target_host}:{t_port}"
|
||
if inner_proxy_opt:
|
||
cmd += f" {inner_proxy_opt}"
|
||
if jh["port"] != '':
|
||
cmd += f" -p {jh['port']}"
|
||
if jh["options"] != '':
|
||
cmd += f" {jh['options']}"
|
||
user_host = f"{jh['user']}@{jh['host']}" if jh['user'] != '' else jh['host']
|
||
cmd += f" {user_host}"
|
||
elif jh["protocol"] == "ssm":
|
||
ssm_target = jh["host"]
|
||
ssm_cmd = f"aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters 'portNumber=22'"
|
||
if isinstance(jh.get("tags"), dict):
|
||
if "profile" in jh["tags"]:
|
||
ssm_cmd += f" --profile {jh['tags']['profile']}"
|
||
if "region" in jh["tags"]:
|
||
ssm_cmd += f" --region {jh['tags']['region']}"
|
||
if jh["options"] != '':
|
||
ssm_cmd += f" {jh['options']}"
|
||
bastion_user_part = f"{jh['user']}@{ssm_target}" if jh['user'] else ssm_target
|
||
ssh_opts = ""
|
||
if isinstance(jh.get("tags"), dict) and "ssh_options" in jh["tags"]:
|
||
ssh_opts = f" {jh['tags']['ssh_options']}"
|
||
cmd = f"ssh{ssh_opts} -o ProxyCommand='{ssm_cmd}' -W {target_host}:{t_port} {bastion_user_part}"
|
||
elif jh["protocol"] in ["kubectl", "docker"]:
|
||
nc_cmd = "nc"
|
||
if isinstance(jh.get("tags"), dict) and "nc_command" in jh["tags"]:
|
||
nc_cmd = jh["tags"]["nc_command"]
|
||
if jh["protocol"] == "kubectl":
|
||
cmd = "kubectl exec "
|
||
if jh["options"] != '':
|
||
cmd += f"{jh['options']} "
|
||
cmd += f"{jh['host']} -i -- {nc_cmd} {target_host} {t_port}"
|
||
else:
|
||
cmd = "docker "
|
||
if jh["options"] != '':
|
||
cmd += f"{jh['options']} "
|
||
cmd += f"exec -i {jh['host']} {nc_cmd} {target_host} {t_port}"
|
||
else:
|
||
return "", passwords
|
||
|
||
return cmd, passwords
|
||
|
||
@MethodHook
|
||
def _passtx(self, passwords, *, keyfile=None):
|
||
# decrypts passwords, used by other methdos.
|
||
dpass = []
|
||
if keyfile is None:
|
||
keyfile = self.key
|
||
if keyfile is not None:
|
||
from Crypto.PublicKey import RSA
|
||
from Crypto.Cipher import PKCS1_OAEP
|
||
with open(keyfile) as f:
|
||
key = RSA.import_key(f.read())
|
||
decryptor = PKCS1_OAEP.new(key)
|
||
for passwd in passwords:
|
||
if not re.match('^b[\"\'].+[\"\']$', passwd):
|
||
dpass.append(passwd)
|
||
else:
|
||
try:
|
||
decrypted = decryptor.decrypt(ast.literal_eval(passwd)).decode("utf-8")
|
||
dpass.append(decrypted)
|
||
except Exception:
|
||
printer.error("Decryption failed: Missing or corrupted key.")
|
||
printer.info("Verify your RSA key and configuration settings.")
|
||
sys.exit(1)
|
||
return dpass
|
||
|
||
|
||
|
||
@MethodHook
|
||
def _logfile(self, logfile = None):
|
||
# translate logs variables and generate logs path.
|
||
if logfile == None:
|
||
logfile = self.logs
|
||
logfile = logfile.replace("${unique}", self.unique)
|
||
logfile = logfile.replace("${host}", self.host)
|
||
logfile = logfile.replace("${port}", self.port)
|
||
logfile = logfile.replace("${user}", self.user)
|
||
logfile = logfile.replace("${protocol}", self.protocol)
|
||
now = datetime.datetime.now()
|
||
dateconf = re.search(r'\$\{date \'(.*)\'}', logfile)
|
||
if dateconf:
|
||
logfile = re.sub(r'\$\{date (.*)}',now.strftime(dateconf.group(1)), logfile)
|
||
return logfile
|
||
|
||
@MethodHook
|
||
def _logclean(self, logfile, var = False):
|
||
"""Remove special ascii characters and process terminal cursor movements to clean logs."""
|
||
from .utils import log_cleaner
|
||
|
||
if var == False:
|
||
try:
|
||
with open(logfile, "r") as f:
|
||
t = f.read()
|
||
except:
|
||
return
|
||
else:
|
||
t = logfile
|
||
|
||
result = log_cleaner(t)
|
||
|
||
if var == False:
|
||
try:
|
||
with open(logfile, "w") as f:
|
||
f.write(result)
|
||
except:
|
||
pass
|
||
return
|
||
else:
|
||
return result
|
||
|
||
@MethodHook
|
||
def _savelog(self):
|
||
'''Save the log buffer to the file at regular intervals if there are changes.'''
|
||
t = threading.current_thread()
|
||
prev_size = 0 # Store the previous size of the buffer
|
||
|
||
while getattr(t, "do_run", True): # Check if thread is signaled to stop
|
||
current_size = self.mylog.tell() # Current size of the buffer
|
||
|
||
# Only save if the buffer size has changed
|
||
if current_size != prev_size:
|
||
with open(self.logfile, "w") as f: # Use "w" to overwrite the file
|
||
f.write(self._logclean(self.mylog.getvalue().decode(), True))
|
||
prev_size = current_size # Update the previous size
|
||
sleep(5)
|
||
|
||
@MethodHook
|
||
def _filter(self, a):
|
||
#Set time for last input when using interact
|
||
self.lastinput = time()
|
||
return a
|
||
|
||
@MethodHook
|
||
def _keepalive(self):
|
||
#Send keepalive ctrl+e when idletime passed without new inputs on interact
|
||
self.lastinput = time()
|
||
t = threading.current_thread()
|
||
while True:
|
||
if time() - self.lastinput >= self.idletime:
|
||
self.child.sendcontrol("e")
|
||
self.lastinput = time()
|
||
sleep(1)
|
||
|
||
|
||
def _setup_interact_environment(self, debug=False, logger=None, async_mode=False):
|
||
try:
|
||
size = re.search('columns=([0-9]+).*lines=([0-9]+)',str(os.get_terminal_size()))
|
||
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
|
||
except OSError:
|
||
pass
|
||
if logger and self.protocol != "local":
|
||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||
|
||
# Always initialize self.mylog to capture terminal context for the AI Copilot
|
||
if not hasattr(self, 'mylog'):
|
||
self.mylog = io.BytesIO()
|
||
|
||
if not async_mode:
|
||
self.child.logfile_read = self.mylog
|
||
|
||
# Only start disk-logging tasks if logfile is configured
|
||
if 'logfile' in dir(self):
|
||
if not async_mode:
|
||
# Start the _savelog thread (sync mode)
|
||
log_thread = threading.Thread(target=self._savelog)
|
||
log_thread.daemon = True
|
||
log_thread.start()
|
||
if 'missingtext' in dir(self):
|
||
print(self.child.after.decode(), end='')
|
||
if self.idletime > 0 and not async_mode:
|
||
x = threading.Thread(target=self._keepalive)
|
||
x.daemon = True
|
||
x.start()
|
||
if debug:
|
||
if 'mylog' in dir(self):
|
||
if not async_mode:
|
||
print(self.mylog.getvalue().decode())
|
||
|
||
def _teardown_interact_environment(self):
|
||
if 'logfile' in dir(self) and hasattr(self, 'mylog'):
|
||
with open(self.logfile, "w") as f:
|
||
f.write(self._logclean(self.mylog.getvalue().decode(), True))
|
||
|
||
def _is_child_connpy_active(self, child_fd: int) -> bool:
|
||
if self.protocol != "local":
|
||
return False
|
||
try:
|
||
fg_pgid = os.tcgetpgrp(child_fd)
|
||
if fg_pgid <= 0:
|
||
return False
|
||
cmdline_path = f"/proc/{fg_pgid}/cmdline"
|
||
if os.path.exists(cmdline_path):
|
||
with open(cmdline_path, "rb") as f:
|
||
raw_args = f.read().split(b"\x00")
|
||
cmdline_str = " ".join([arg.decode(errors="ignore") for arg in raw_args if arg])
|
||
|
||
is_active = False
|
||
for raw_arg in raw_args:
|
||
arg_str = raw_arg.decode(errors="ignore")
|
||
if not arg_str:
|
||
continue
|
||
base_name = os.path.basename(arg_str)
|
||
if base_name in ["conn", "connpy", "connapp"] or "connpy" in arg_str or "connapp" in arg_str:
|
||
is_active = True
|
||
break
|
||
|
||
return is_active
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
|
||
local_stream.setup(resize_callback=resize_callback)
|
||
self.current_local_stream = local_stream
|
||
try:
|
||
child_fd = self.child.child_fd
|
||
|
||
# 1. Flush ghost buffer (Clean UX)
|
||
ghost_buffer = b''
|
||
if getattr(self, 'missingtext', False):
|
||
# If we are missing the password, we MUST show the password prompt
|
||
ghost_buffer = (self.child.after or b'') + (self.child.buffer or b'')
|
||
else:
|
||
# We auto-logged in. Hide the messy password negotiation and just keep any pending live stream.
|
||
ghost_buffer = self.child.buffer or b''
|
||
|
||
# Fix user's pet peeve: Strip leading newlines to avoid the empty lines
|
||
# the router echoes after receiving the password or blank line.
|
||
if not getattr(self, 'missingtext', False):
|
||
ghost_buffer = ghost_buffer.lstrip(b'\r\n ')
|
||
|
||
if ghost_buffer:
|
||
# Add a single clean newline so it doesn't merge with the Connected message
|
||
await local_stream.write(b'\r\n' + ghost_buffer)
|
||
if hasattr(self, 'mylog'):
|
||
self.mylog.write(b'\n' + ghost_buffer)
|
||
|
||
self.child.buffer = b''
|
||
self.child.before = b''
|
||
|
||
# 2. Set child fd non-blocking
|
||
flags = fcntl.fcntl(child_fd, fcntl.F_GETFL)
|
||
fcntl.fcntl(child_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
child_reader_queue = asyncio.Queue()
|
||
|
||
# Reset and track command byte positions for copilot context navigation
|
||
# Each entry is (byte_position, command_text_or_None)
|
||
self.cmd_byte_positions = [(self.mylog.tell() if hasattr(self, 'mylog') else 0, None)]
|
||
|
||
def _child_read_ready():
|
||
try:
|
||
# Increase buffer to 64KB for better high-speed handling
|
||
data = os.read(child_fd, 65536)
|
||
if data:
|
||
child_reader_queue.put_nowait(data)
|
||
else:
|
||
child_reader_queue.put_nowait(b'')
|
||
except BlockingIOError:
|
||
pass
|
||
except OSError:
|
||
child_reader_queue.put_nowait(b'')
|
||
|
||
loop.add_reader(child_fd, _child_read_ready)
|
||
self.lastinput = time()
|
||
|
||
async def ingress_task():
|
||
while True:
|
||
data = await local_stream.read()
|
||
if not data:
|
||
break
|
||
|
||
# Copilot interception
|
||
if copilot_handler and b'\x00' in data:
|
||
if self._is_child_connpy_active(child_fd):
|
||
try:
|
||
os.write(child_fd, data)
|
||
except OSError:
|
||
break
|
||
self.lastinput = time()
|
||
continue
|
||
|
||
# Build node info from available metadata and ensure values are strings (not bytes)
|
||
def to_str(val):
|
||
if isinstance(val, bytes):
|
||
return val.decode(errors='replace')
|
||
return str(val) if val is not None else "unknown"
|
||
|
||
node_info = {
|
||
"name": to_str(getattr(self, 'unique', 'unknown')),
|
||
"host": to_str(getattr(self, 'host', 'unknown'))
|
||
}
|
||
if isinstance(getattr(self, 'tags', None), dict):
|
||
node_info["os"] = to_str(self.tags.get("os", "unknown"))
|
||
node_info["prompt"] = to_str(self.tags.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$'))
|
||
|
||
# Invoke copilot (async callback handles UI)
|
||
await copilot_handler(self.mylog.getvalue(), node_info, local_stream, child_fd, self.cmd_byte_positions)
|
||
continue
|
||
|
||
# Remove any stray \x00 bytes and forward normally
|
||
clean_data = data.replace(b'\x00', b'')
|
||
if clean_data:
|
||
# Track command boundaries when user hits Enter or presses Ctrl+C
|
||
if hasattr(self, 'mylog') and (b'\r' in clean_data or b'\n' in clean_data or b'\x03' in clean_data):
|
||
pos = self.mylog.tell()
|
||
marker_cmd = "CANCELLED" if b'\x03' in clean_data else None
|
||
self.cmd_byte_positions.append((pos, marker_cmd))
|
||
if hasattr(self, 'current_local_stream') and self.current_local_stream is not None:
|
||
try:
|
||
await self.current_local_stream.write(f'\x1b]133;B;{pos}\x07'.encode())
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
os.write(child_fd, clean_data)
|
||
except OSError:
|
||
break
|
||
self.lastinput = time()
|
||
|
||
async def egress_task():
|
||
# Continue stripping newlines from the live stream until we hit real text
|
||
skip_newlines = not getattr(self, 'missingtext', False) and not ghost_buffer
|
||
while True:
|
||
data = await child_reader_queue.get()
|
||
if not data:
|
||
break
|
||
|
||
# Batching Optimization: Drain the queue to batch writes during high-volume bursts
|
||
# Helps the terminal parse ANSI faster and reduces syscalls.
|
||
chunks = [data]
|
||
while not child_reader_queue.empty():
|
||
try:
|
||
extra = child_reader_queue.get_nowait()
|
||
if not extra:
|
||
chunks.append(b'') # Re-put EOF later or handle it
|
||
break
|
||
chunks.append(extra)
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
|
||
has_eof = chunks[-1] == b''
|
||
if has_eof:
|
||
chunks.pop()
|
||
|
||
if chunks:
|
||
combined_data = b''.join(chunks)
|
||
if skip_newlines:
|
||
stripped = combined_data.lstrip(b'\r\n')
|
||
if stripped:
|
||
skip_newlines = False
|
||
combined_data = stripped
|
||
else:
|
||
if has_eof: break
|
||
continue
|
||
|
||
await local_stream.write(combined_data)
|
||
if hasattr(self, 'mylog'):
|
||
self.mylog.write(combined_data)
|
||
|
||
if has_eof:
|
||
break
|
||
|
||
async def keepalive_task():
|
||
while True:
|
||
await asyncio.sleep(1)
|
||
if time() - self.lastinput >= self.idletime:
|
||
try:
|
||
self.child.sendcontrol("e")
|
||
self.lastinput = time()
|
||
except Exception:
|
||
pass
|
||
|
||
async def savelog_task():
|
||
prev_size = 0
|
||
while True:
|
||
await asyncio.sleep(5)
|
||
current_size = self.mylog.tell()
|
||
if current_size != prev_size:
|
||
try:
|
||
# Move heavy log cleaning to a thread to avoid freezing the interaction loop
|
||
raw_log = self.mylog.getvalue().decode(errors='replace')
|
||
cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
|
||
with open(self.logfile, "w") as f:
|
||
f.write(cleaned_log)
|
||
prev_size = current_size
|
||
except Exception:
|
||
pass
|
||
|
||
async def pwd_tracker_task():
|
||
import socket
|
||
hostname = socket.gethostname()
|
||
last_cwd = None
|
||
while True:
|
||
await asyncio.sleep(0.3)
|
||
try:
|
||
child_pid = getattr(self.child, 'pid', None)
|
||
if child_pid:
|
||
new_cwd = os.readlink(f"/proc/{child_pid}/cwd")
|
||
if new_cwd != last_cwd:
|
||
last_cwd = new_cwd
|
||
try:
|
||
os.chdir(new_cwd)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
await local_stream.write(f"\033]7;file://{hostname}{new_cwd}\007".encode())
|
||
except Exception:
|
||
pass
|
||
if isinstance(self.tags, dict):
|
||
self.tags["cwd"] = new_cwd
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# We wait for either the user (ingress) or the child (egress) to finish
|
||
tasks = [
|
||
asyncio.create_task(ingress_task()),
|
||
asyncio.create_task(egress_task())
|
||
]
|
||
if self.protocol == "local":
|
||
tasks.append(asyncio.create_task(pwd_tracker_task()))
|
||
if self.idletime > 0:
|
||
tasks.append(asyncio.create_task(keepalive_task()))
|
||
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
|
||
tasks.append(asyncio.create_task(savelog_task()))
|
||
|
||
done, pending = await asyncio.wait(
|
||
[tasks[0], tasks[1]],
|
||
return_when=asyncio.FIRST_COMPLETED
|
||
)
|
||
|
||
# If ingress finished first (user quit), give egress a small window to catch up
|
||
# on the remaining output in the queue.
|
||
if tasks[0] in done and tasks[1] not in done:
|
||
try:
|
||
await asyncio.wait_for(tasks[1], timeout=0.2)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
pass
|
||
|
||
for t in tasks:
|
||
if t not in done:
|
||
t.cancel()
|
||
|
||
# Final log sync on thread to avoid losing last lines
|
||
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
|
||
try:
|
||
raw_log = self.mylog.getvalue().decode(errors='replace')
|
||
cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
|
||
with open(self.logfile, "w") as f:
|
||
f.write(cleaned_log)
|
||
except Exception:
|
||
pass
|
||
|
||
finally:
|
||
loop.remove_reader(child_fd)
|
||
try:
|
||
flags = fcntl.fcntl(child_fd, fcntl.F_GETFL)
|
||
fcntl.fcntl(child_fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self.current_local_stream = None
|
||
local_stream.teardown()
|
||
|
||
@MethodHook
|
||
async def inject_commands(self, commands, child_fd, on_inject=None):
|
||
"""
|
||
Inject a list of commands into the node's PTY.
|
||
Handles screen_length_command, history tracking and delays.
|
||
"""
|
||
if not commands:
|
||
return
|
||
|
||
# 0. Clear line
|
||
os.write(child_fd, b'\x15')
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 1. Prepare list (prepend screen_length if exists)
|
||
slc = self.tags.get("screen_length_command") if hasattr(self, 'tags') and isinstance(self.tags, dict) else None
|
||
|
||
to_send = list(commands)
|
||
if slc and slc not in to_send: # avoid duplicates if already there
|
||
to_send.insert(0, slc)
|
||
|
||
# 2. Inject one by one
|
||
for cmd in to_send:
|
||
# Register in node's official history (SKIP if it's the administrative screen length command)
|
||
if cmd != slc and hasattr(self, 'cmd_byte_positions') and self.cmd_byte_positions is not None:
|
||
log_pos = self.mylog.tell() if hasattr(self, 'mylog') else 0
|
||
self.cmd_byte_positions.append((log_pos, cmd))
|
||
if hasattr(self, 'current_local_stream') and self.current_local_stream is not None:
|
||
try:
|
||
await self.current_local_stream.write(f'\x1b]133;B;{log_pos}\x07'.encode())
|
||
except Exception:
|
||
pass
|
||
|
||
# Write physically to PTY
|
||
os.write(child_fd, (cmd + "\n").encode())
|
||
|
||
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
|
||
if on_inject and cmd != slc:
|
||
if asyncio.iscoroutinefunction(on_inject):
|
||
await on_inject(cmd)
|
||
else:
|
||
on_inject(cmd)
|
||
|
||
# Delay to avoid overwhelming the router
|
||
await asyncio.sleep(0.8)
|
||
|
||
@MethodHook
|
||
def interact(self, debug=False, logger=None):
|
||
'''
|
||
Asynchronous interactive session using Smart Tunnel architecture.
|
||
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.
|
||
'''
|
||
connect = self._connect(debug=debug, logger=logger)
|
||
if connect == True:
|
||
try:
|
||
self._setup_interact_environment(debug=debug, logger=logger, async_mode=True)
|
||
|
||
local_stream = LocalStream()
|
||
|
||
def resize_callback(rows, cols):
|
||
try:
|
||
self.child.setwinsize(rows, cols)
|
||
except Exception:
|
||
pass
|
||
|
||
# Build local copilot handler
|
||
copilot_handler = self._build_local_copilot_handler()
|
||
|
||
asyncio.run(self._async_interact_loop(local_stream, resize_callback, copilot_handler=copilot_handler))
|
||
finally:
|
||
self._teardown_interact_environment()
|
||
else:
|
||
if logger:
|
||
logger("error", str(connect))
|
||
else:
|
||
printer.error(f"Connection failed: {str(connect)}")
|
||
sys.exit(1)
|
||
|
||
def _build_local_copilot_handler(self):
|
||
"""Build copilot handler for local CLI sessions using rich for rendering."""
|
||
config = getattr(self, 'config', None) if hasattr(self, 'config') else None
|
||
return self._copilot_handler(config)
|
||
|
||
def _copilot_handler(self, config):
|
||
"""Unified copilot handler for local session."""
|
||
import asyncio
|
||
import os
|
||
|
||
async def handler(buffer, node_info, stream, child_fd, cmd_byte_positions=None):
|
||
try:
|
||
from .cli.terminal_ui import CopilotInterface
|
||
from .services.ai_service import AIService
|
||
|
||
interface = CopilotInterface(
|
||
config,
|
||
history=getattr(stream, 'copilot_history', None),
|
||
session_state=getattr(stream, 'copilot_state', None)
|
||
)
|
||
# Save history back to stream for persistence in current session
|
||
stream.copilot_history = interface.history
|
||
stream.copilot_state = interface.session_state
|
||
interface.session_state['banner_shown'] = False
|
||
|
||
ai_service = AIService(config)
|
||
|
||
async def on_ai_call(active_buffer, question, chunk_callback, merged_node_info):
|
||
return await ai_service.aask_copilot(
|
||
active_buffer,
|
||
question,
|
||
node_info=merged_node_info,
|
||
chunk_callback=chunk_callback
|
||
)
|
||
# Get raw bytes from BytesIO
|
||
raw_bytes = self.mylog.getvalue()
|
||
|
||
# Stop terminal reading so prompt_toolkit (in run_session)
|
||
# has exclusive control of stdin without LocalStream interference.
|
||
if hasattr(stream, 'stop_reading'):
|
||
stream.stop_reading()
|
||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||
# Fallback if the method is missing (in LocalStream)
|
||
stream._loop.remove_reader(stream.stdin_fd)
|
||
|
||
try:
|
||
with copilot_terminal_mode():
|
||
while True:
|
||
action, commands, custom_cmd = await interface.run_session(
|
||
raw_bytes=raw_bytes,
|
||
cmd_byte_positions=self.cmd_byte_positions,
|
||
node_info=node_info,
|
||
on_ai_call=on_ai_call
|
||
)
|
||
if action in ("send_all", "custom"):
|
||
cmds_to_send = commands if action == "send_all" else custom_cmd
|
||
await self.inject_commands(cmds_to_send, child_fd)
|
||
|
||
# Dynamic Wait for Device Prompt Settle
|
||
prompt_pattern = node_info.get("prompt", r'>$|#$|\$$|>.$|#.$|\$.$')
|
||
start_t = time()
|
||
last_len = 0
|
||
quiet_count = 0
|
||
while time() - start_t < 60:
|
||
await asyncio.sleep(0.15)
|
||
cur_bytes = self.mylog.getvalue()
|
||
if len(cur_bytes) == last_len:
|
||
quiet_count += 1
|
||
else:
|
||
quiet_count = 0
|
||
last_len = len(cur_bytes)
|
||
|
||
clean_txt = self._logclean(cur_bytes.decode(errors='replace'), True).strip()
|
||
lines = [l.strip() for l in clean_txt.split('\n') if l.strip()]
|
||
last_line = lines[-1] if lines else ""
|
||
if quiet_count >= 2 and re.search(prompt_pattern, last_line):
|
||
break
|
||
|
||
raw_bytes = self.mylog.getvalue()
|
||
continue
|
||
if action == "continue":
|
||
continue
|
||
break
|
||
finally:
|
||
print("\033[2m Returning to session...\033[0m", flush=True)
|
||
# Restart terminal reading to return to interactive SSH/Telnet mode
|
||
if hasattr(stream, 'start_reading'):
|
||
stream.start_reading()
|
||
elif hasattr(stream, '_loop') and hasattr(stream, 'stdin_fd'):
|
||
stream._loop.add_reader(stream.stdin_fd, stream._read_ready)
|
||
|
||
os.write(child_fd, b'\x15\r')
|
||
except Exception as e:
|
||
import traceback
|
||
print(f"\n[ERROR in Copilot Handler] {e}", flush=True)
|
||
traceback.print_exc()
|
||
os.write(child_fd, b'\x15\r')
|
||
|
||
return handler
|
||
|
||
@MethodHook
|
||
def run(self, commands, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', stdout = False, timeout = 10, logger = None):
|
||
'''
|
||
Run a command or list of commands on the node and return the output.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables
|
||
used in commands parameter.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave
|
||
empty to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- stdout (bool):Set True to send the command output to stdout.
|
||
default False.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
### Returns:
|
||
|
||
str: Output of the commands you ran on the node.
|
||
|
||
'''
|
||
connect = self._connect(timeout = timeout, logger = logger)
|
||
now = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')
|
||
if connect == True:
|
||
if logger:
|
||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||
|
||
if "prompt" in self.tags:
|
||
prompt = self.tags["prompt"]
|
||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||
output = ''
|
||
status = ''
|
||
if not isinstance(commands, list):
|
||
commands = [commands]
|
||
if "screen_length_command" in self.tags:
|
||
commands.insert(0, self.tags["screen_length_command"])
|
||
self.mylog = io.BytesIO()
|
||
self.child.logfile_read = self.mylog
|
||
for c in commands:
|
||
if vars is not None:
|
||
try:
|
||
c = c.format(**vars)
|
||
except KeyError as e:
|
||
self.output = f"Error: Variable {e} not defined in task or inventory"
|
||
self.status = 1
|
||
return self.output
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
# Only set terminal size on devices without a
|
||
# screen_length_command (e.g. Linux/bash servers).
|
||
# Routers already disable pagination via that command.
|
||
# After setwinsize, consume any SIGWINCH re-render
|
||
# prompt (~40ms on bash) with a short timeout.
|
||
if c == commands[0] and "screen_length_command" not in self.tags:
|
||
try:
|
||
self.child.setwinsize(65535, 65535)
|
||
except Exception:
|
||
try:
|
||
self.child.setwinsize(10000, 10000)
|
||
except Exception:
|
||
pass
|
||
self.child.expect(expects, timeout = 1)
|
||
self.child.sendline(c)
|
||
if result == 2:
|
||
break
|
||
if not result == 2:
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
self.child.close()
|
||
output = self._logclean(self.mylog.getvalue().decode(), True)
|
||
if logger:
|
||
logger("output", output)
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(output)
|
||
f.close()
|
||
self.output = output
|
||
if result == 2:
|
||
self.status = 2
|
||
else:
|
||
self.status = 0
|
||
return output
|
||
else:
|
||
self.output = connect
|
||
self.status = 1
|
||
if logger:
|
||
logger("error", f"Connection failed: {connect}")
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(connect)
|
||
|
||
f.close()
|
||
return connect
|
||
|
||
@MethodHook
|
||
def test(self, commands, expected, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', timeout = 10, logger = None):
|
||
'''
|
||
Run a command or list of commands on the node, then check if expected value appears on the output after the last command.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
|
||
- expected (str) : Expected text to appear after running
|
||
all the commands on the node.You can use
|
||
variables as {varname} and defining them
|
||
in optional parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables
|
||
used in commands and expected parameters.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave
|
||
empty to not store logs.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
### Returns:
|
||
bool: true if expected value is found after running the commands
|
||
false if prompt is found before.
|
||
|
||
'''
|
||
now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||
connect = self._connect(timeout = timeout, logger = logger)
|
||
if connect == True:
|
||
if logger:
|
||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||
|
||
if "prompt" in self.tags:
|
||
prompt = self.tags["prompt"]
|
||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||
output = ''
|
||
if not isinstance(commands, list):
|
||
commands = [commands]
|
||
if not isinstance(expected, list):
|
||
expected = [expected]
|
||
if "screen_length_command" in self.tags:
|
||
commands.insert(0, self.tags["screen_length_command"])
|
||
self.mylog = io.BytesIO()
|
||
self.child.logfile_read = self.mylog
|
||
for c in commands:
|
||
if vars is not None:
|
||
try:
|
||
c = c.format(**vars)
|
||
except KeyError as e:
|
||
self.output = f"Error: Variable {e} not defined in task or inventory"
|
||
self.status = 1
|
||
return self.output
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
if c == commands[0] and "screen_length_command" not in self.tags:
|
||
try:
|
||
self.child.setwinsize(65535, 65535)
|
||
except Exception:
|
||
try:
|
||
self.child.setwinsize(10000, 10000)
|
||
except Exception:
|
||
pass
|
||
self.child.expect(expects, timeout = 1)
|
||
self.child.sendline(c)
|
||
if result == 2:
|
||
break
|
||
if not result == 2:
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
self.child.close()
|
||
output = self._logclean(self.mylog.getvalue().decode(), True)
|
||
if logger:
|
||
logger("output", output)
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(output)
|
||
f.close()
|
||
self.output = output
|
||
if result in [0, 1]:
|
||
# lastcommand = commands[-1]
|
||
# if vars is not None:
|
||
# lastcommand = lastcommand.format(**vars)
|
||
# last_command_index = output.rfind(lastcommand)
|
||
# cleaned_output = output[last_command_index + len(lastcommand):].strip()
|
||
self.result = {}
|
||
for e in expected:
|
||
if vars is not None:
|
||
e = e.format(**vars)
|
||
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
||
cleaned_output = output
|
||
try:
|
||
newpattern = f".*({updatedprompt}).*{e}.*"
|
||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||
except re.error:
|
||
try:
|
||
escaped_e = re.escape(e)
|
||
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
|
||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||
except re.error:
|
||
pass
|
||
|
||
if e in cleaned_output:
|
||
self.result[e] = True
|
||
else:
|
||
try:
|
||
if re.search(e, cleaned_output):
|
||
self.result[e] = True
|
||
else:
|
||
self.result[e] = False
|
||
except re.error:
|
||
self.result[e] = False
|
||
self.status = 0
|
||
return self.result
|
||
if result == 2:
|
||
self.result = None
|
||
self.status = 2
|
||
return output
|
||
else:
|
||
self.result = None
|
||
self.output = connect
|
||
self.status = 1
|
||
return connect
|
||
|
||
@MethodHook
|
||
def _generate_ssh_sftp_cmd(self):
|
||
cmd = self.protocol
|
||
if self.port:
|
||
if self.protocol == "ssh":
|
||
cmd += " -p " + self.port
|
||
elif self.protocol == "sftp":
|
||
cmd += " -P " + self.port
|
||
if self.options:
|
||
opts = self.options
|
||
if self.protocol == "sftp":
|
||
# Strip SSH-only flags that sftp doesn't support
|
||
opts = re.sub(r'(?<!\S)-[XxtTAaNf]\b', '', opts).strip()
|
||
if opts:
|
||
cmd += " " + opts
|
||
if self.jumphost:
|
||
cmd += " " + self.jumphost
|
||
user_host = f"{self.user}@{self.host}" if self.user else self.host
|
||
cmd += f" {user_host}"
|
||
return cmd
|
||
|
||
@MethodHook
|
||
def _generate_telnet_cmd(self):
|
||
cmd = f"telnet {self.host}"
|
||
if self.port:
|
||
cmd += f" {self.port}"
|
||
if self.options:
|
||
cmd += f" {self.options}"
|
||
return cmd
|
||
|
||
@MethodHook
|
||
def _generate_kube_cmd(self):
|
||
cmd = f"kubectl exec {self.options} {self.host} -it --"
|
||
kube_command = self.tags.get("kube_command", "/bin/bash") if isinstance(self.tags, dict) else "/bin/bash"
|
||
cmd += f" {kube_command}"
|
||
return cmd
|
||
|
||
@MethodHook
|
||
def _generate_docker_cmd(self):
|
||
cmd = f"docker {self.options} exec -it {self.host}"
|
||
docker_command = self.tags.get("docker_command", "/bin/bash") if isinstance(self.tags, dict) else "/bin/bash"
|
||
cmd += f" {docker_command}"
|
||
return cmd
|
||
|
||
@MethodHook
|
||
def _generate_ssm_cmd(self):
|
||
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
|
||
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
|
||
cmd = f"aws ssm start-session --target {self.host}"
|
||
if region:
|
||
cmd += f" --region {region}"
|
||
if profile:
|
||
cmd += f" --profile {profile}"
|
||
if self.options:
|
||
cmd += f" {self.options}"
|
||
return cmd
|
||
|
||
|
||
@MethodHook
|
||
def _get_cmd(self):
|
||
if self.protocol in ["ssh", "sftp"]:
|
||
return self._generate_ssh_sftp_cmd()
|
||
elif self.protocol == "telnet":
|
||
return self._generate_telnet_cmd()
|
||
elif self.protocol == "kubectl":
|
||
return self._generate_kube_cmd()
|
||
elif self.protocol == "docker":
|
||
return self._generate_docker_cmd()
|
||
elif self.protocol == "ssm":
|
||
return self._generate_ssm_cmd()
|
||
elif self.protocol == "local":
|
||
return self.host
|
||
else:
|
||
printer.error(f"Invalid protocol: {self.protocol}")
|
||
sys.exit(1)
|
||
|
||
@MethodHook
|
||
def _connect(self, debug=False, timeout=10, max_attempts=3, logger=None):
|
||
if self.protocol == "local":
|
||
cmd = self._get_cmd()
|
||
args = shlex.split(cmd)
|
||
self.child = pexpect.spawn(args[0], args[1:], env=os.environ.copy())
|
||
from pexpect import fdpexpect
|
||
self.raw_child = fdpexpect.fdspawn(self.child.child_fd)
|
||
if self.logs != '':
|
||
self.logfile = self._logfile()
|
||
return True
|
||
|
||
cmd = self._get_cmd()
|
||
passwords = self._passtx(self.password) if self.password and any(self.password) else []
|
||
if self.logs != '':
|
||
self.logfile = self._logfile()
|
||
default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
|
||
prompt = self.tags.get("prompt", default_prompt) if isinstance(self.tags, dict) else default_prompt
|
||
password_prompt = '[p|P]assword:|[u|U]sername:' if self.protocol != 'telnet' else '[p|P]assword:'
|
||
|
||
expects = {
|
||
"ssh": ['yes/no', 'refused', 'supported', 'Invalid|[u|U]sage: ssh', 'ssh-keygen.*\"', 'timeout|timed.out', 'unavailable', 'closed', password_prompt, prompt, 'suspend', pexpect.EOF, pexpect.TIMEOUT, "No route to host", "resolve hostname", "no matching", "[b|B]ad (owner|permissions)"],
|
||
"sftp": ['yes/no', 'refused', 'supported', 'Invalid|[u|U]sage: sftp', 'ssh-keygen.*\"', 'timeout|timed.out', 'unavailable', 'closed', password_prompt, prompt, 'suspend', pexpect.EOF, pexpect.TIMEOUT, "No route to host", "resolve hostname", "no matching", "[b|B]ad (owner|permissions)"],
|
||
"telnet": ['[u|U]sername:', 'refused', 'supported', 'invalid|unrecognized option', 'ssh-keygen.*\"', 'timeout|timed.out', 'unavailable', 'closed', password_prompt, prompt, 'suspend', pexpect.EOF, pexpect.TIMEOUT, "No route to host", "resolve hostname", "no matching", "[b|B]ad (owner|permissions)"],
|
||
"kubectl": ['[u|U]sername:', '[r|R]efused', '[E|e]rror', 'DEPRECATED', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF, "expired|invalid"],
|
||
"docker": ['[u|U]sername:', 'Cannot', '[E|e]rror', 'failed', 'not a docker command', 'unknown', 'unable to resolve', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF],
|
||
"ssm": ['[u|U]sername:', 'Cannot', '[E|e]rror', 'failed', 'SessionManagerPlugin', '[u|U]nknown', 'unable to resolve', pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF]
|
||
}
|
||
|
||
error_indices = {
|
||
"ssh": [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
|
||
"sftp": [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
|
||
"telnet": [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
|
||
"kubectl": [1, 2, 3, 4, 8], # Define error indices for kube
|
||
"docker": [1, 2, 3, 4, 5, 6, 7], # Define error indices for docker
|
||
"ssm": [1, 2, 3, 4, 5, 6, 7]
|
||
}
|
||
|
||
eof_indices = {
|
||
"ssh": [8, 9, 10, 11],
|
||
"sftp": [8, 9, 10, 11],
|
||
"telnet": [8, 9, 10, 11],
|
||
"kubectl": [5, 6, 7], # Define eof indices for kube
|
||
"docker": [8, 9, 10], # Define eof indices for docker
|
||
"ssm": [8, 9, 10]
|
||
}
|
||
|
||
initial_indices = {
|
||
"ssh": [0],
|
||
"sftp": [0],
|
||
"telnet": [0],
|
||
"kubectl": [0], # Define special indices for kube
|
||
"docker": [0], # Define special indices for docker
|
||
"ssm": [0]
|
||
}
|
||
|
||
attempts = 1
|
||
while attempts <= max_attempts:
|
||
args = shlex.split(cmd)
|
||
child = pexpect.spawn(args[0], args[1:])
|
||
if isinstance(self.tags, dict) and self.tags.get("console"):
|
||
child.sendline()
|
||
if debug:
|
||
if logger:
|
||
logger("debug", f"Command:\n{cmd}")
|
||
self.mylog = io.BytesIO()
|
||
self.mylog.write(f"[i] [DEBUG] Command:\r\n {cmd}\r\n".encode())
|
||
child.logfile_read = self.mylog
|
||
|
||
|
||
endloop = False
|
||
for i in range(len(passwords) if passwords else 1):
|
||
while True:
|
||
results = child.expect(expects[self.protocol], timeout=timeout)
|
||
results_value = expects[self.protocol][results]
|
||
|
||
if results in initial_indices[self.protocol]:
|
||
if self.protocol in ["ssh", "sftp"]:
|
||
child.sendline('yes')
|
||
elif self.protocol in ["telnet", "kubectl", "docker", "ssm"]:
|
||
if self.user:
|
||
child.sendline(self.user)
|
||
else:
|
||
self.missingtext = True
|
||
break
|
||
|
||
elif results in error_indices[self.protocol]:
|
||
child.terminate()
|
||
if results_value == pexpect.TIMEOUT and attempts != max_attempts:
|
||
attempts += 1
|
||
endloop = True
|
||
break
|
||
else:
|
||
after = "Connection timeout" if results_value == pexpect.TIMEOUT else child.after.decode()
|
||
return f"Connection failed code: {results}\n{child.before.decode().lstrip()}{after}{child.readline().decode()}".rstrip()
|
||
|
||
elif results in eof_indices[self.protocol]:
|
||
if results_value == password_prompt:
|
||
if passwords:
|
||
child.sendline(passwords[i])
|
||
else:
|
||
self.missingtext = True
|
||
break
|
||
elif results_value == "suspend":
|
||
child.sendline("\r")
|
||
sleep(2)
|
||
else:
|
||
endloop = True
|
||
child.sendline()
|
||
break
|
||
|
||
if endloop:
|
||
break
|
||
if results_value == pexpect.TIMEOUT:
|
||
continue
|
||
else:
|
||
break
|
||
|
||
if isinstance(self.tags, dict) and self.tags.get("post_connect_commands"):
|
||
cmds = self.tags.get("post_connect_commands")
|
||
commands = [cmds] if isinstance(cmds, str) else cmds
|
||
for command in commands:
|
||
child.sendline(command)
|
||
sleep(1)
|
||
child.readline(0)
|
||
self.child = child
|
||
from pexpect import fdpexpect
|
||
self.raw_child = fdpexpect.fdspawn(self.child.child_fd)
|
||
return True</code></pre>
|
||
</details>
|
||
<div class="desc"><p>This class generates a node object. Containts all the information and methods to connect and interact with a device using ssh or telnet.</p>
|
||
<h3 id="attributes">Attributes:</h3>
|
||
<pre><code>- output (str): Output of the commands you ran with run or test
|
||
method.
|
||
|
||
- result(bool): True if expected value is found after running
|
||
the commands using test method.
|
||
|
||
- status (int): 0 if the method run or test run successfully.
|
||
1 if connection failed.
|
||
2 if expect timeouts without prompt or EOF.
|
||
</code></pre>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- unique (str): Unique name to assign to the node.
|
||
|
||
- host (str): IP address or hostname of the node.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- options (str): Additional options to pass the ssh/telnet for
|
||
connection.
|
||
|
||
- logs (str): Path/file for storing the logs. You can use
|
||
${unique},${host}, ${port}, ${user}, ${protocol}
|
||
as variables.
|
||
|
||
- password (str): Encrypted or plaintext password.
|
||
|
||
- port (str): Port to connect to node, default 22 for ssh and 23
|
||
for telnet.
|
||
|
||
- protocol (str): Select ssh, telnet, kubectl or docker. Default is ssh.
|
||
|
||
- user (str): Username to of the node.
|
||
|
||
- config (obj): Pass the object created with class configfile with
|
||
key for decryption and extra configuration if you
|
||
are using connection manager.
|
||
|
||
- tags (dict) : Tags useful for automation and personal porpuse
|
||
like "os", "prompt" and "screenleght_command"
|
||
|
||
- jumphost (str): Reference another node to be used as a jumphost
|
||
</code></pre></div>
|
||
<h3>Methods</h3>
|
||
<dl>
|
||
<dt id="connpy.node.inject_commands"><code class="name flex">
|
||
<span>async def <span class="ident">inject_commands</span></span>(<span>self, commands, child_fd, on_inject=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
async def inject_commands(self, commands, child_fd, on_inject=None):
|
||
"""
|
||
Inject a list of commands into the node's PTY.
|
||
Handles screen_length_command, history tracking and delays.
|
||
"""
|
||
if not commands:
|
||
return
|
||
|
||
# 0. Clear line
|
||
os.write(child_fd, b'\x15')
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 1. Prepare list (prepend screen_length if exists)
|
||
slc = self.tags.get("screen_length_command") if hasattr(self, 'tags') and isinstance(self.tags, dict) else None
|
||
|
||
to_send = list(commands)
|
||
if slc and slc not in to_send: # avoid duplicates if already there
|
||
to_send.insert(0, slc)
|
||
|
||
# 2. Inject one by one
|
||
for cmd in to_send:
|
||
# Register in node's official history (SKIP if it's the administrative screen length command)
|
||
if cmd != slc and hasattr(self, 'cmd_byte_positions') and self.cmd_byte_positions is not None:
|
||
log_pos = self.mylog.tell() if hasattr(self, 'mylog') else 0
|
||
self.cmd_byte_positions.append((log_pos, cmd))
|
||
if hasattr(self, 'current_local_stream') and self.current_local_stream is not None:
|
||
try:
|
||
await self.current_local_stream.write(f'\x1b]133;B;{log_pos}\x07'.encode())
|
||
except Exception:
|
||
pass
|
||
|
||
# Write physically to PTY
|
||
os.write(child_fd, (cmd + "\n").encode())
|
||
|
||
# Notify (e.g., for gRPC or logs) - SKIP for administrative SLC
|
||
if on_inject and cmd != slc:
|
||
if asyncio.iscoroutinefunction(on_inject):
|
||
await on_inject(cmd)
|
||
else:
|
||
on_inject(cmd)
|
||
|
||
# Delay to avoid overwhelming the router
|
||
await asyncio.sleep(0.8)</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Inject a list of commands into the node's PTY.
|
||
Handles screen_length_command, history tracking and delays.</p></div>
|
||
</dd>
|
||
<dt id="connpy.node.interact"><code class="name flex">
|
||
<span>def <span class="ident">interact</span></span>(<span>self, debug=False, logger=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def interact(self, debug=False, logger=None):
|
||
'''
|
||
Asynchronous interactive session using Smart Tunnel architecture.
|
||
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.
|
||
'''
|
||
connect = self._connect(debug=debug, logger=logger)
|
||
if connect == True:
|
||
try:
|
||
self._setup_interact_environment(debug=debug, logger=logger, async_mode=True)
|
||
|
||
local_stream = LocalStream()
|
||
|
||
def resize_callback(rows, cols):
|
||
try:
|
||
self.child.setwinsize(rows, cols)
|
||
except Exception:
|
||
pass
|
||
|
||
# Build local copilot handler
|
||
copilot_handler = self._build_local_copilot_handler()
|
||
|
||
asyncio.run(self._async_interact_loop(local_stream, resize_callback, copilot_handler=copilot_handler))
|
||
finally:
|
||
self._teardown_interact_environment()
|
||
else:
|
||
if logger:
|
||
logger("error", str(connect))
|
||
else:
|
||
printer.error(f"Connection failed: {str(connect)}")
|
||
sys.exit(1)</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Asynchronous interactive session using Smart Tunnel architecture.
|
||
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.</p></div>
|
||
</dd>
|
||
<dt id="connpy.node.run"><code class="name flex">
|
||
<span>def <span class="ident">run</span></span>(<span>self,<br>commands,<br>vars=None,<br>*,<br>folder='',<br>prompt='>$|#$|\\$$|>.$|#.$|\\$.$',<br>stdout=False,<br>timeout=10,<br>logger=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def run(self, commands, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', stdout = False, timeout = 10, logger = None):
|
||
'''
|
||
Run a command or list of commands on the node and return the output.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables
|
||
used in commands parameter.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave
|
||
empty to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- stdout (bool):Set True to send the command output to stdout.
|
||
default False.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
### Returns:
|
||
|
||
str: Output of the commands you ran on the node.
|
||
|
||
'''
|
||
connect = self._connect(timeout = timeout, logger = logger)
|
||
now = datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')
|
||
if connect == True:
|
||
if logger:
|
||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||
|
||
if "prompt" in self.tags:
|
||
prompt = self.tags["prompt"]
|
||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||
output = ''
|
||
status = ''
|
||
if not isinstance(commands, list):
|
||
commands = [commands]
|
||
if "screen_length_command" in self.tags:
|
||
commands.insert(0, self.tags["screen_length_command"])
|
||
self.mylog = io.BytesIO()
|
||
self.child.logfile_read = self.mylog
|
||
for c in commands:
|
||
if vars is not None:
|
||
try:
|
||
c = c.format(**vars)
|
||
except KeyError as e:
|
||
self.output = f"Error: Variable {e} not defined in task or inventory"
|
||
self.status = 1
|
||
return self.output
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
# Only set terminal size on devices without a
|
||
# screen_length_command (e.g. Linux/bash servers).
|
||
# Routers already disable pagination via that command.
|
||
# After setwinsize, consume any SIGWINCH re-render
|
||
# prompt (~40ms on bash) with a short timeout.
|
||
if c == commands[0] and "screen_length_command" not in self.tags:
|
||
try:
|
||
self.child.setwinsize(65535, 65535)
|
||
except Exception:
|
||
try:
|
||
self.child.setwinsize(10000, 10000)
|
||
except Exception:
|
||
pass
|
||
self.child.expect(expects, timeout = 1)
|
||
self.child.sendline(c)
|
||
if result == 2:
|
||
break
|
||
if not result == 2:
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
self.child.close()
|
||
output = self._logclean(self.mylog.getvalue().decode(), True)
|
||
if logger:
|
||
logger("output", output)
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(output)
|
||
f.close()
|
||
self.output = output
|
||
if result == 2:
|
||
self.status = 2
|
||
else:
|
||
self.status = 0
|
||
return output
|
||
else:
|
||
self.output = connect
|
||
self.status = 1
|
||
if logger:
|
||
logger("error", f"Connection failed: {connect}")
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(connect)
|
||
|
||
f.close()
|
||
return connect</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Run a command or list of commands on the node and return the output.</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- vars (dict): Dictionary containing the definition of variables
|
||
used in commands parameter.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
</code></pre>
|
||
<h3 id="optional-named-parameters">Optional Named Parameters:</h3>
|
||
<pre><code>- folder (str): Path where output log should be stored, leave
|
||
empty to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- stdout (bool):Set True to send the command output to stdout.
|
||
default False.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>str: Output of the commands you ran on the node.
|
||
</code></pre></div>
|
||
</dd>
|
||
<dt id="connpy.node.test"><code class="name flex">
|
||
<span>def <span class="ident">test</span></span>(<span>self,<br>commands,<br>expected,<br>vars=None,<br>*,<br>folder='',<br>prompt='>$|#$|\\$$|>.$|#.$|\\$.$',<br>timeout=10,<br>logger=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def test(self, commands, expected, vars = None,*, folder = '', prompt = r'>$|#$|\$$|>.$|#.$|\$.$', timeout = 10, logger = None):
|
||
'''
|
||
Run a command or list of commands on the node, then check if expected value appears on the output after the last command.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
|
||
- expected (str) : Expected text to appear after running
|
||
all the commands on the node.You can use
|
||
variables as {varname} and defining them
|
||
in optional parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables
|
||
used in commands and expected parameters.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave
|
||
empty to not store logs.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
### Returns:
|
||
bool: true if expected value is found after running the commands
|
||
false if prompt is found before.
|
||
|
||
'''
|
||
now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||
connect = self._connect(timeout = timeout, logger = logger)
|
||
if connect == True:
|
||
if logger:
|
||
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
|
||
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
|
||
|
||
if "prompt" in self.tags:
|
||
prompt = self.tags["prompt"]
|
||
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
|
||
output = ''
|
||
if not isinstance(commands, list):
|
||
commands = [commands]
|
||
if not isinstance(expected, list):
|
||
expected = [expected]
|
||
if "screen_length_command" in self.tags:
|
||
commands.insert(0, self.tags["screen_length_command"])
|
||
self.mylog = io.BytesIO()
|
||
self.child.logfile_read = self.mylog
|
||
for c in commands:
|
||
if vars is not None:
|
||
try:
|
||
c = c.format(**vars)
|
||
except KeyError as e:
|
||
self.output = f"Error: Variable {e} not defined in task or inventory"
|
||
self.status = 1
|
||
return self.output
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
if c == commands[0] and "screen_length_command" not in self.tags:
|
||
try:
|
||
self.child.setwinsize(65535, 65535)
|
||
except Exception:
|
||
try:
|
||
self.child.setwinsize(10000, 10000)
|
||
except Exception:
|
||
pass
|
||
self.child.expect(expects, timeout = 1)
|
||
self.child.sendline(c)
|
||
if result == 2:
|
||
break
|
||
if not result == 2:
|
||
result = self.child.expect(expects, timeout = timeout)
|
||
self.child.close()
|
||
output = self._logclean(self.mylog.getvalue().decode(), True)
|
||
if logger:
|
||
logger("output", output)
|
||
if folder != '':
|
||
with open(folder + "/" + self.unique + "_" + now + ".txt", "w") as f:
|
||
f.write(output)
|
||
f.close()
|
||
self.output = output
|
||
if result in [0, 1]:
|
||
# lastcommand = commands[-1]
|
||
# if vars is not None:
|
||
# lastcommand = lastcommand.format(**vars)
|
||
# last_command_index = output.rfind(lastcommand)
|
||
# cleaned_output = output[last_command_index + len(lastcommand):].strip()
|
||
self.result = {}
|
||
for e in expected:
|
||
if vars is not None:
|
||
e = e.format(**vars)
|
||
updatedprompt = re.sub(r'(?<!\\)\$', '', prompt)
|
||
cleaned_output = output
|
||
try:
|
||
newpattern = f".*({updatedprompt}).*{e}.*"
|
||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||
except re.error:
|
||
try:
|
||
escaped_e = re.escape(e)
|
||
newpattern = f".*({updatedprompt}).*{escaped_e}.*"
|
||
cleaned_output = re.sub(newpattern, '', cleaned_output)
|
||
except re.error:
|
||
pass
|
||
|
||
if e in cleaned_output:
|
||
self.result[e] = True
|
||
else:
|
||
try:
|
||
if re.search(e, cleaned_output):
|
||
self.result[e] = True
|
||
else:
|
||
self.result[e] = False
|
||
except re.error:
|
||
self.result[e] = False
|
||
self.status = 0
|
||
return self.result
|
||
if result == 2:
|
||
self.result = None
|
||
self.status = 2
|
||
return output
|
||
else:
|
||
self.result = None
|
||
self.output = connect
|
||
self.status = 1
|
||
return connect</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Run a command or list of commands on the node, then check if expected value appears on the output after the last command.</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- commands (str/list): Commands to run on the node. Should be
|
||
str or a list of str. You can use variables
|
||
as {varname} and defining them in optional
|
||
parameter vars.
|
||
|
||
- expected (str) : Expected text to appear after running
|
||
all the commands on the node.You can use
|
||
variables as {varname} and defining them
|
||
in optional parameter vars.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- vars (dict): Dictionary containing the definition of variables
|
||
used in commands and expected parameters.
|
||
Keys: Variable names.
|
||
Values: strings.
|
||
</code></pre>
|
||
<h3 id="optional-named-parameters">Optional Named Parameters:</h3>
|
||
<pre><code>- folder (str): Path where output log should be stored, leave
|
||
empty to not store logs.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
- timeout (int):Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>bool: true if expected value is found after running the commands
|
||
false if prompt is found before.
|
||
</code></pre></div>
|
||
</dd>
|
||
</dl>
|
||
</dd>
|
||
<dt id="connpy.nodes"><code class="flex name class">
|
||
<span>class <span class="ident">nodes</span></span>
|
||
<span>(</span><span>nodes: dict, config='')</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@ClassHook
|
||
class nodes:
|
||
''' This class generates a nodes object. Contains a list of node class objects and methods to run multiple tasks on nodes simultaneously.
|
||
|
||
### Attributes:
|
||
|
||
- nodelist (list): List of node class objects passed to the init
|
||
function.
|
||
|
||
- output (dict): Dictionary formed by nodes unique as keys,
|
||
output of the commands you ran on the node as
|
||
value. Created after running methods run or test.
|
||
|
||
- result (dict): Dictionary formed by nodes unique as keys, value
|
||
is True if expected value is found after running
|
||
the commands, False if prompt is found before.
|
||
Created after running method test.
|
||
|
||
- status (dict): Dictionary formed by nodes unique as keys, value:
|
||
0 if method run or test ended successfully.
|
||
1 if connection failed.
|
||
2 if expect timeouts without prompt or EOF.
|
||
|
||
- <unique> (obj): For each item in nodelist, there is an attribute
|
||
generated with the node unique.
|
||
'''
|
||
|
||
def __init__(self, nodes: dict, config = ''):
|
||
'''
|
||
### Parameters:
|
||
|
||
- nodes (dict): Dictionary formed by node information:
|
||
Keys: Unique name for each node.
|
||
Mandatory Subkeys: host(str).
|
||
Optional Subkeys: options(str), logs(str), password(str),
|
||
port(str), protocol(str), user(str).
|
||
For reference on subkeys check node class.
|
||
|
||
### Optional Parameters:
|
||
|
||
- config (obj): Pass the object created with class configfile with key
|
||
for decryption and extra configuration if you are using
|
||
connection manager.
|
||
'''
|
||
self.nodelist = []
|
||
self.config = config
|
||
for n in nodes:
|
||
this = node(n, **nodes[n], config = config)
|
||
self.nodelist.append(this)
|
||
setattr(self,n,this)
|
||
|
||
|
||
@MethodHook
|
||
def _splitlist(self, lst, n):
|
||
#split a list in lists of n members.
|
||
for i in range(0, len(lst), n):
|
||
yield lst[i:i + n]
|
||
|
||
|
||
@MethodHook
|
||
def run(self, commands, vars = None,*, folder = None, prompt = None, stdout = None, parallel = 10, timeout = None, on_complete = None, logger = None):
|
||
'''
|
||
Run a command or list of commands on all the nodes in nodelist.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the nodes. Should be str or
|
||
list of str. You can use variables as {varname}
|
||
and defining them in optional parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands parameter.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave empty
|
||
to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while routers
|
||
use ">" or "#". The default value should work for
|
||
most nodes. Change it if your connection need some
|
||
special symbol.
|
||
|
||
- stdout (bool): Set True to send the command output to stdout.
|
||
Default False.
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
|
||
###Returns:
|
||
|
||
dict: Dictionary formed by nodes unique as keys, Output of the
|
||
commands you ran on the node as value.
|
||
|
||
'''
|
||
args = {}
|
||
nodesargs = {}
|
||
args["commands"] = commands
|
||
if folder != None:
|
||
args["folder"] = folder
|
||
Path(folder).mkdir(parents=True, exist_ok=True)
|
||
if prompt != None:
|
||
args["prompt"] = prompt
|
||
if stdout != None and on_complete is None:
|
||
args["stdout"] = stdout
|
||
if timeout != None:
|
||
args["timeout"] = timeout
|
||
output = {}
|
||
status = {}
|
||
tasks = []
|
||
|
||
def _run_node(node_obj, node_args, callback):
|
||
"""Wrapper that runs a node and fires the callback on completion."""
|
||
node_obj.run(**node_args)
|
||
if callback:
|
||
callback(node_obj.unique, node_obj.output, node_obj.status)
|
||
|
||
for n in self.nodelist:
|
||
nodesargs[n.unique] = deepcopy(args)
|
||
if vars != None:
|
||
nodesargs[n.unique]["vars"] = {}
|
||
if "__global__" in vars.keys():
|
||
nodesargs[n.unique]["vars"].update(vars["__global__"])
|
||
for var_key, var_val in vars.items():
|
||
if var_key == "__global__":
|
||
continue
|
||
try:
|
||
if re.search(var_key, n.unique, re.IGNORECASE):
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
except re.error:
|
||
if var_key == n.unique:
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
|
||
# Pass the logger to the node
|
||
nodesargs[n.unique]["logger"] = logger
|
||
|
||
if on_complete:
|
||
tasks.append(threading.Thread(target=_run_node, args=(n, nodesargs[n.unique], on_complete)))
|
||
else:
|
||
tasks.append(threading.Thread(target=n.run, kwargs=nodesargs[n.unique]))
|
||
|
||
taskslist = list(self._splitlist(tasks, parallel))
|
||
|
||
for t in taskslist:
|
||
for i in t:
|
||
i.start()
|
||
for i in t:
|
||
i.join()
|
||
for i in self.nodelist:
|
||
output[i.unique] = i.output
|
||
status[i.unique] = i.status
|
||
self.output = output
|
||
self.status = status
|
||
return output
|
||
|
||
@MethodHook
|
||
def test(self, commands, expected, vars = None,*, folder = None, prompt = None, parallel = 10, timeout = None, on_complete = None, logger = None):
|
||
'''
|
||
Run a command or list of commands on all the nodes in nodelist, then check if expected value appears on the output after the last command.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be str or
|
||
list of str.
|
||
|
||
- expected (str) : Expected text to appear after running all the
|
||
commands on the node.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands and expected parameters.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary formed by nodes unique as keys, value is True if
|
||
expected value is found after running the commands, False
|
||
if prompt is found before.
|
||
|
||
'''
|
||
args = {}
|
||
nodesargs = {}
|
||
args["commands"] = commands
|
||
args["expected"] = expected
|
||
if folder != None:
|
||
args["folder"] = folder
|
||
Path(folder).mkdir(parents=True, exist_ok=True)
|
||
if prompt != None:
|
||
args["prompt"] = prompt
|
||
if timeout != None:
|
||
args["timeout"] = timeout
|
||
output = {}
|
||
result = {}
|
||
status = {}
|
||
tasks = []
|
||
|
||
def _test_node(node_obj, node_args, callback):
|
||
"""Wrapper that runs a node test and fires the callback on completion."""
|
||
node_obj.test(**node_args)
|
||
if callback:
|
||
callback(node_obj.unique, node_obj.output, node_obj.status, node_obj.result)
|
||
|
||
for n in self.nodelist:
|
||
nodesargs[n.unique] = deepcopy(args)
|
||
if vars != None:
|
||
nodesargs[n.unique]["vars"] = {}
|
||
if "__global__" in vars.keys():
|
||
nodesargs[n.unique]["vars"].update(vars["__global__"])
|
||
for var_key, var_val in vars.items():
|
||
if var_key == "__global__":
|
||
continue
|
||
try:
|
||
if re.search(var_key, n.unique, re.IGNORECASE):
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
except re.error:
|
||
if var_key == n.unique:
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
nodesargs[n.unique]["logger"] = logger
|
||
|
||
if on_complete:
|
||
tasks.append(threading.Thread(target=_test_node, args=(n, nodesargs[n.unique], on_complete)))
|
||
else:
|
||
tasks.append(threading.Thread(target=n.test, kwargs=nodesargs[n.unique]))
|
||
|
||
taskslist = list(self._splitlist(tasks, parallel))
|
||
for t in taskslist:
|
||
for i in t:
|
||
i.start()
|
||
for i in t:
|
||
i.join()
|
||
for i in self.nodelist:
|
||
result[i.unique] = i.result
|
||
output[i.unique] = i.output
|
||
status[i.unique] = i.status
|
||
self.output = output
|
||
self.result = result
|
||
self.status = status
|
||
return result</code></pre>
|
||
</details>
|
||
<div class="desc"><p>This class generates a nodes object. Contains a list of node class objects and methods to run multiple tasks on nodes simultaneously.</p>
|
||
<h3 id="attributes">Attributes:</h3>
|
||
<pre><code>- nodelist (list): List of node class objects passed to the init
|
||
function.
|
||
|
||
- output (dict): Dictionary formed by nodes unique as keys,
|
||
output of the commands you ran on the node as
|
||
value. Created after running methods run or test.
|
||
|
||
- result (dict): Dictionary formed by nodes unique as keys, value
|
||
is True if expected value is found after running
|
||
the commands, False if prompt is found before.
|
||
Created after running method test.
|
||
|
||
- status (dict): Dictionary formed by nodes unique as keys, value:
|
||
0 if method run or test ended successfully.
|
||
1 if connection failed.
|
||
2 if expect timeouts without prompt or EOF.
|
||
|
||
- <unique> (obj): For each item in nodelist, there is an attribute
|
||
generated with the node unique.
|
||
</code></pre>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- nodes (dict): Dictionary formed by node information:
|
||
Keys: Unique name for each node.
|
||
Mandatory Subkeys: host(str).
|
||
Optional Subkeys: options(str), logs(str), password(str),
|
||
port(str), protocol(str), user(str).
|
||
For reference on subkeys check node class.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- config (obj): Pass the object created with class configfile with key
|
||
for decryption and extra configuration if you are using
|
||
connection manager.
|
||
</code></pre></div>
|
||
<h3>Methods</h3>
|
||
<dl>
|
||
<dt id="connpy.nodes.run"><code class="name flex">
|
||
<span>def <span class="ident">run</span></span>(<span>self,<br>commands,<br>vars=None,<br>*,<br>folder=None,<br>prompt=None,<br>stdout=None,<br>parallel=10,<br>timeout=None,<br>on_complete=None,<br>logger=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def run(self, commands, vars = None,*, folder = None, prompt = None, stdout = None, parallel = 10, timeout = None, on_complete = None, logger = None):
|
||
'''
|
||
Run a command or list of commands on all the nodes in nodelist.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the nodes. Should be str or
|
||
list of str. You can use variables as {varname}
|
||
and defining them in optional parameter vars.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands parameter.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- folder (str): Path where output log should be stored, leave empty
|
||
to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while routers
|
||
use ">" or "#". The default value should work for
|
||
most nodes. Change it if your connection need some
|
||
special symbol.
|
||
|
||
- stdout (bool): Set True to send the command output to stdout.
|
||
Default False.
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
|
||
###Returns:
|
||
|
||
dict: Dictionary formed by nodes unique as keys, Output of the
|
||
commands you ran on the node as value.
|
||
|
||
'''
|
||
args = {}
|
||
nodesargs = {}
|
||
args["commands"] = commands
|
||
if folder != None:
|
||
args["folder"] = folder
|
||
Path(folder).mkdir(parents=True, exist_ok=True)
|
||
if prompt != None:
|
||
args["prompt"] = prompt
|
||
if stdout != None and on_complete is None:
|
||
args["stdout"] = stdout
|
||
if timeout != None:
|
||
args["timeout"] = timeout
|
||
output = {}
|
||
status = {}
|
||
tasks = []
|
||
|
||
def _run_node(node_obj, node_args, callback):
|
||
"""Wrapper that runs a node and fires the callback on completion."""
|
||
node_obj.run(**node_args)
|
||
if callback:
|
||
callback(node_obj.unique, node_obj.output, node_obj.status)
|
||
|
||
for n in self.nodelist:
|
||
nodesargs[n.unique] = deepcopy(args)
|
||
if vars != None:
|
||
nodesargs[n.unique]["vars"] = {}
|
||
if "__global__" in vars.keys():
|
||
nodesargs[n.unique]["vars"].update(vars["__global__"])
|
||
for var_key, var_val in vars.items():
|
||
if var_key == "__global__":
|
||
continue
|
||
try:
|
||
if re.search(var_key, n.unique, re.IGNORECASE):
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
except re.error:
|
||
if var_key == n.unique:
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
|
||
# Pass the logger to the node
|
||
nodesargs[n.unique]["logger"] = logger
|
||
|
||
if on_complete:
|
||
tasks.append(threading.Thread(target=_run_node, args=(n, nodesargs[n.unique], on_complete)))
|
||
else:
|
||
tasks.append(threading.Thread(target=n.run, kwargs=nodesargs[n.unique]))
|
||
|
||
taskslist = list(self._splitlist(tasks, parallel))
|
||
|
||
for t in taskslist:
|
||
for i in t:
|
||
i.start()
|
||
for i in t:
|
||
i.join()
|
||
for i in self.nodelist:
|
||
output[i.unique] = i.output
|
||
status[i.unique] = i.status
|
||
self.output = output
|
||
self.status = status
|
||
return output</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Run a command or list of commands on all the nodes in nodelist.</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- commands (str/list): Commands to run on the nodes. Should be str or
|
||
list of str. You can use variables as {varname}
|
||
and defining them in optional parameter vars.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands parameter.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
</code></pre>
|
||
<h3 id="optional-named-parameters">Optional Named Parameters:</h3>
|
||
<pre><code>- folder (str): Path where output log should be stored, leave empty
|
||
to disable logging.
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while routers
|
||
use ">" or "#". The default value should work for
|
||
most nodes. Change it if your connection need some
|
||
special symbol.
|
||
|
||
- stdout (bool): Set True to send the command output to stdout.
|
||
Default False.
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>dict: Dictionary formed by nodes unique as keys, Output of the
|
||
commands you ran on the node as value.
|
||
</code></pre></div>
|
||
</dd>
|
||
<dt id="connpy.nodes.test"><code class="name flex">
|
||
<span>def <span class="ident">test</span></span>(<span>self,<br>commands,<br>expected,<br>vars=None,<br>*,<br>folder=None,<br>prompt=None,<br>parallel=10,<br>timeout=None,<br>on_complete=None,<br>logger=None)</span>
|
||
</code></dt>
|
||
<dd>
|
||
<details class="source">
|
||
<summary>
|
||
<span>Expand source code</span>
|
||
</summary>
|
||
<pre><code class="python">@MethodHook
|
||
def test(self, commands, expected, vars = None,*, folder = None, prompt = None, parallel = 10, timeout = None, on_complete = None, logger = None):
|
||
'''
|
||
Run a command or list of commands on all the nodes in nodelist, then check if expected value appears on the output after the last command.
|
||
|
||
|
||
### Parameters:
|
||
|
||
- commands (str/list): Commands to run on the node. Should be str or
|
||
list of str.
|
||
|
||
- expected (str) : Expected text to appear after running all the
|
||
commands on the node.
|
||
|
||
### Optional Parameters:
|
||
|
||
- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands and expected parameters.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
|
||
### Optional Named Parameters:
|
||
|
||
- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
|
||
### Returns:
|
||
|
||
dict: Dictionary formed by nodes unique as keys, value is True if
|
||
expected value is found after running the commands, False
|
||
if prompt is found before.
|
||
|
||
'''
|
||
args = {}
|
||
nodesargs = {}
|
||
args["commands"] = commands
|
||
args["expected"] = expected
|
||
if folder != None:
|
||
args["folder"] = folder
|
||
Path(folder).mkdir(parents=True, exist_ok=True)
|
||
if prompt != None:
|
||
args["prompt"] = prompt
|
||
if timeout != None:
|
||
args["timeout"] = timeout
|
||
output = {}
|
||
result = {}
|
||
status = {}
|
||
tasks = []
|
||
|
||
def _test_node(node_obj, node_args, callback):
|
||
"""Wrapper that runs a node test and fires the callback on completion."""
|
||
node_obj.test(**node_args)
|
||
if callback:
|
||
callback(node_obj.unique, node_obj.output, node_obj.status, node_obj.result)
|
||
|
||
for n in self.nodelist:
|
||
nodesargs[n.unique] = deepcopy(args)
|
||
if vars != None:
|
||
nodesargs[n.unique]["vars"] = {}
|
||
if "__global__" in vars.keys():
|
||
nodesargs[n.unique]["vars"].update(vars["__global__"])
|
||
for var_key, var_val in vars.items():
|
||
if var_key == "__global__":
|
||
continue
|
||
try:
|
||
if re.search(var_key, n.unique, re.IGNORECASE):
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
except re.error:
|
||
if var_key == n.unique:
|
||
nodesargs[n.unique]["vars"].update(var_val)
|
||
nodesargs[n.unique]["logger"] = logger
|
||
|
||
if on_complete:
|
||
tasks.append(threading.Thread(target=_test_node, args=(n, nodesargs[n.unique], on_complete)))
|
||
else:
|
||
tasks.append(threading.Thread(target=n.test, kwargs=nodesargs[n.unique]))
|
||
|
||
taskslist = list(self._splitlist(tasks, parallel))
|
||
for t in taskslist:
|
||
for i in t:
|
||
i.start()
|
||
for i in t:
|
||
i.join()
|
||
for i in self.nodelist:
|
||
result[i.unique] = i.result
|
||
output[i.unique] = i.output
|
||
status[i.unique] = i.status
|
||
self.output = output
|
||
self.result = result
|
||
self.status = status
|
||
return result</code></pre>
|
||
</details>
|
||
<div class="desc"><p>Run a command or list of commands on all the nodes in nodelist, then check if expected value appears on the output after the last command.</p>
|
||
<h3 id="parameters">Parameters:</h3>
|
||
<pre><code>- commands (str/list): Commands to run on the node. Should be str or
|
||
list of str.
|
||
|
||
- expected (str) : Expected text to appear after running all the
|
||
commands on the node.
|
||
</code></pre>
|
||
<h3 id="optional-parameters">Optional Parameters:</h3>
|
||
<pre><code>- vars (dict): Dictionary containing the definition of variables for
|
||
each node, used in commands and expected parameters.
|
||
Keys should be formed by nodes unique names. Use
|
||
special key name __global__ for global variables.
|
||
Subkeys: Variable names.
|
||
Values: strings.
|
||
</code></pre>
|
||
<h3 id="optional-named-parameters">Optional Named Parameters:</h3>
|
||
<pre><code>- prompt (str): Prompt to be expected after a command is finished
|
||
running. Usually linux uses ">" or EOF while
|
||
routers use ">" or "#". The default value should
|
||
work for most nodes. Change it if your connection
|
||
need some special symbol.
|
||
|
||
|
||
- parallel (int): Number of nodes to run the commands simultaneously.
|
||
Default is 10, if there are more nodes that this
|
||
value, nodes are groups in groups with max this
|
||
number of members.
|
||
|
||
- timeout (int): Time in seconds for expect to wait for prompt/EOF.
|
||
default 10.
|
||
|
||
- on_complete (callable): Optional callback called when each node
|
||
finishes. Receives (unique, output, status).
|
||
Called from the node's thread so it must
|
||
be thread-safe.
|
||
</code></pre>
|
||
<h3 id="returns">Returns:</h3>
|
||
<pre><code>dict: Dictionary formed by nodes unique as keys, value is True if
|
||
expected value is found after running the commands, False
|
||
if prompt is found before.
|
||
</code></pre></div>
|
||
</dd>
|
||
</dl>
|
||
</dd>
|
||
</dl>
|
||
</section>
|
||
</article>
|
||
<nav id="sidebar">
|
||
<div class="toc">
|
||
<ul>
|
||
<li><a href="#connpy-v630">Connpy (v6.3.0)</a><ul>
|
||
<li><a href="#1-ai-system">1. 🤖 AI System</a><ul>
|
||
<li><a href="#1a-terminal-copilot-ctrlspace">1a. Terminal Copilot (Ctrl+Space)</a></li>
|
||
<li><a href="#1b-ai-chat-conn-ai">1b. AI Chat (conn ai)</a></li>
|
||
<li><a href="#1c-mcp-integration">1c. MCP Integration</a></li>
|
||
<li><a href="#1d-local-interactive-shell-conn-shell">1d. Local Interactive Shell (conn shell)</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#2-automation-playbooks">2. ⚙️ Automation & Playbooks</a><ul>
|
||
<li><a href="#2a-quick-run-conn-run">2a. Quick Run (conn run)</a></li>
|
||
<li><a href="#2b-yaml-playbook-engine">2b. YAML Playbook Engine</a></li>
|
||
<li><a href="#2c-ai-assisted-automation">2c. AI-Assisted Automation</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#3-inventory-management">3. 📂 Inventory Management</a><ul>
|
||
<li><a href="#3a-nodes">3a. Nodes</a></li>
|
||
<li><a href="#3b-profiles">3b. Profiles</a></li>
|
||
<li><a href="#3c-folders-move-copy-list">3c. Folders, Move, Copy, List</a></li>
|
||
<li><a href="#3d-bulk-export-import">3d. Bulk, Export, Import</a></li>
|
||
<li><a href="#3e-tags-system">3e. Tags System</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#4-protocols-connection-features">4. 🔌 Protocols & Connection Features</a><ul>
|
||
<li><a href="#4a-ssh-sftp-telnet-kubectl-docker-aws-ssm">4a. SSH / SFTP / Telnet / kubectl / Docker / AWS SSM</a></li>
|
||
<li><a href="#4b-jumphosts">4b. Jumphosts</a></li>
|
||
<li><a href="#4c-debug-mode-keepalive-logging">4c. Debug Mode, Keepalive, Logging</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#5-remote-capture-conn-capture-core-plugin">5. 🖥️ Remote Capture (conn capture - Core Plugin)</a></li>
|
||
<li><a href="#6-context-filtering">6. 🛡️ Context Filtering</a></li>
|
||
<li><a href="#7-plugin-system">7. 🔌 Plugin System</a></li>
|
||
<li><a href="#8-grpc-client-server-architecture">8. ⚙️ gRPC Client-Server Architecture</a><ul>
|
||
<li><a href="#8a-server-startstoprestartdebug">8a. Server (start/stop/restart/debug)</a></li>
|
||
<li><a href="#8b-client-config">8b. Client Config</a></li>
|
||
<li><a href="#8c-user-management-api-tokens">8c. User Management & API Tokens</a></li>
|
||
<li><a href="#8d-sso-oidc">8d. SSO / OIDC</a></li>
|
||
<li><a href="#8e-login-logout">8e. Login / Logout</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#9-installation-configuration">9. ⚡ Installation & Configuration</a><ul>
|
||
<li><a href="#9a-pip-install">9a. pip install</a></li>
|
||
<li><a href="#9b-shell-completion-fzf">9b. Shell Completion + FZF</a></li>
|
||
<li><a href="#9c-conn-config-options">9c. conn config options</a></li>
|
||
<li><a href="#9d-theming">9d. Theming</a></li>
|
||
</ul>
|
||
</li>
|
||
<li><a href="#10-privacy-security-synchronization-conn-sync">10. 🔒 Privacy, Security & Synchronization (conn sync)</a></li>
|
||
<li><a href="#11-python-api">11. 🐍 Python API</a></li>
|
||
<li><a href="#12-docker-deployment">12. 🐳 Docker Deployment</a></li>
|
||
<li><a href="#13-license">13. 📜 License</a></li>
|
||
</ul>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<ul id="index">
|
||
<li><h3><a href="#header-submodules">Sub-modules</a></h3>
|
||
<ul>
|
||
<li><code><a title="connpy.ai" href="ai.html">connpy.ai</a></code></li>
|
||
<li><code><a title="connpy.cli" href="cli/index.html">connpy.cli</a></code></li>
|
||
<li><code><a title="connpy.grpc_layer" href="grpc_layer/index.html">connpy.grpc_layer</a></code></li>
|
||
<li><code><a title="connpy.mcp_client" href="mcp_client.html">connpy.mcp_client</a></code></li>
|
||
<li><code><a title="connpy.proto" href="proto/index.html">connpy.proto</a></code></li>
|
||
<li><code><a title="connpy.services" href="services/index.html">connpy.services</a></code></li>
|
||
<li><code><a title="connpy.tunnels" href="tunnels.html">connpy.tunnels</a></code></li>
|
||
<li><code><a title="connpy.utils" href="utils.html">connpy.utils</a></code></li>
|
||
</ul>
|
||
</li>
|
||
<li><h3><a href="#header-classes">Classes</a></h3>
|
||
<ul>
|
||
<li>
|
||
<h4><code><a title="connpy.Plugins" href="#connpy.Plugins">Plugins</a></code></h4>
|
||
<ul class="">
|
||
<li><code><a title="connpy.Plugins.verify_script" href="#connpy.Plugins.verify_script">verify_script</a></code></li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<h4><code><a title="connpy.configfile" href="#connpy.configfile">configfile</a></code></h4>
|
||
<ul class="">
|
||
<li><code><a title="connpy.configfile.encrypt" href="#connpy.configfile.encrypt">encrypt</a></code></li>
|
||
<li><code><a title="connpy.configfile.get_effective_setting" href="#connpy.configfile.get_effective_setting">get_effective_setting</a></code></li>
|
||
<li><code><a title="connpy.configfile.getitem" href="#connpy.configfile.getitem">getitem</a></code></li>
|
||
<li><code><a title="connpy.configfile.getitems" href="#connpy.configfile.getitems">getitems</a></code></li>
|
||
<li><code><a title="connpy.configfile.privatekey" href="#connpy.configfile.privatekey">privatekey</a></code></li>
|
||
<li><code><a title="connpy.configfile.publickey" href="#connpy.configfile.publickey">publickey</a></code></li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<h4><code><a title="connpy.node" href="#connpy.node">node</a></code></h4>
|
||
<ul class="">
|
||
<li><code><a title="connpy.node.inject_commands" href="#connpy.node.inject_commands">inject_commands</a></code></li>
|
||
<li><code><a title="connpy.node.interact" href="#connpy.node.interact">interact</a></code></li>
|
||
<li><code><a title="connpy.node.run" href="#connpy.node.run">run</a></code></li>
|
||
<li><code><a title="connpy.node.test" href="#connpy.node.test">test</a></code></li>
|
||
</ul>
|
||
</li>
|
||
<li>
|
||
<h4><code><a title="connpy.nodes" href="#connpy.nodes">nodes</a></code></h4>
|
||
<ul class="">
|
||
<li><code><a title="connpy.nodes.run" href="#connpy.nodes.run">run</a></code></li>
|
||
<li><code><a title="connpy.nodes.test" href="#connpy.nodes.test">test</a></code></li>
|
||
</ul>
|
||
</li>
|
||
</ul>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
</main>
|
||
<footer id="footer">
|
||
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p>
|
||
</footer>
|
||
</body>
|
||
</html>
|