Files
connpy/docs/connpy/index.html
T
fluzzi32 01690c815f feat(grpc,copilot): add remote multi-turn mission execution loop, update pdoc and bump to v6.3.0
- 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.
2026-08-17 10:20:43 -03:00

4227 lines
202 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="&lt;p align=&#34;center&#34;&gt;
&lt;img src=&#34;https://nginx.gederico.dynu.net/images/CONNPY-resized.png&#34; alt=&#34;App Logo&#34;&gt;
&lt;/p&gt; …">
<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&amp;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 &lt;id&gt;</code> (to restore a specific history), <code>--delete &lt;id&gt;</code>, or send a quick single-shot question directly from the terminal prompt:</p>
<pre><code class="language-bash">conn ai &quot;how do i check bgp summary on cisco?&quot;
</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 &amp; 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 &quot;\$\s*$&quot;
conn config --shell-os ubuntu
</code></pre>
<hr>
<h2 id="2-automation-playbooks">2. ⚙️ Automation &amp; 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 &quot;show interface&quot;
</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: &quot;@office&quot;
parallel: true
tasks:
- name: Get interface brief
run: &quot;show ip interface brief&quot;
- name: Check OSPF state
run: &quot;show ip ospf neighbor&quot;
test: &quot;FULL&quot;
</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 &quot;.*-prod&quot; --format &quot;{name} ({host}) runs {protocol}&quot;
</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 &gt; 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: { &quot;os&quot;: &quot;cisco_ios&quot;, &quot;prompt&quot;: &quot;.*#&quot;, &quot;screen_length_command&quot;: &quot;terminal length 0&quot; }
</code></pre>
<hr>
<h2 id="4-protocols-connection-features">4. 🔌 Protocols &amp; 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 &lt;seconds&gt;</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 &quot;port 80&quot;
</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 &lt;name&gt;</code>), custom filters (<code>-f &lt;filter&gt;</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 &quot;.*-prod&quot;
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 &amp; 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 &quot;CI/CD Token&quot; --expires-in 30
conn user --list-tokens
conn user --revoke-token &lt;token_id&gt;
</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 &amp; 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 &quot;$(conn config --completion bash)&quot;
eval &quot;$(conn config --fzf-wrapper bash)&quot;
</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 &amp; 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(&quot;router1&quot;, &quot;1.1.1.1&quot;, user=&quot;admin&quot;)
router.run([&quot;show ip int brief&quot;])
print(router.output)
# 2. Parallel nodes execution with variables
config = connpy.configfile()
nodes_info = config.getitem(&quot;@office&quot;, [&quot;router1&quot;, &quot;router2&quot;])
routers = connpy.nodes(nodes_info, config=config)
variables = {
&quot;router1@office&quot;: {&quot;id&quot;: &quot;1&quot;},
&quot;__global__&quot;: {&quot;mask&quot;: &quot;255.255.255.0&quot;}
}
routers.run([&quot;interface lo{id}&quot;, &quot;ip address 10.0.0.{id} {mask}&quot;], variables)
# 3. AI Copilot prompts
myai = connpy.ai(connpy.configfile())
response = myai.ask(&quot;Show BGP status.&quot;)
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, &#34;plugin_preferences.json&#34;)
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, &#34;plugin_preferences.json&#34;)
try:
with open(path, &#34;w&#34;) as f:
json.dump(self.preferences, f, indent=4)
except OSError as e:
printer.error(f&#34;Failed to save plugin preferences: {e}&#34;)
def verify_script(self, file_path):
&#34;&#34;&#34;
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&#39;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: &#39;Parser&#39; and &#39;Entrypoint&#39;. and/or specific class: Preload.
- &#39;Parser&#39; class must only have an &#39;__init__&#39; method and must assign &#39;self.parser&#39;.
- &#39;Entrypoint&#39; class must have an &#39;__init__&#39; 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.
&#34;&#34;&#34;
with open(file_path, &#39;r&#39;) as file:
source_code = file.read()
try:
tree = ast.parse(source_code)
except SyntaxError as e:
return f&#34;Syntax error in file: {e}&#34;
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 &#39;if __name__ == &#34;__main__&#34;:&#39; block
if not (isinstance(node.test, ast.Compare) and
isinstance(node.test.left, ast.Name) and
node.test.left.id == &#39;__name__&#39; and
((hasattr(ast, &#39;Str&#39;) and isinstance(node.test.comparators[0], getattr(ast, &#39;Str&#39;)) and node.test.comparators[0].s == &#39;__main__&#39;) or
(hasattr(ast, &#39;Constant&#39;) and isinstance(node.test.comparators[0], getattr(ast, &#39;Constant&#39;)) and node.test.comparators[0].value == &#39;__main__&#39;))):
return &#34;Only __name__ == __main__ If is allowed&#34;
elif not isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Import, ast.ImportFrom, ast.Pass)):
return f&#34;Plugin can only have pass, functions, classes and imports. {node} is not allowed&#34; # Reject any other AST types
if isinstance(node, ast.ClassDef):
if node.name == &#39;Parser&#39;:
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 == &#39;__init__&#39; for method in node.body):
return &#34;Parser class should only have __init__ method&#34;
# Check if &#39;self.parser&#39; 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 == &#39;self&#39;]
if &#39;parser&#39; not in assigned_attrs:
return &#34;Parser class should set self.parser&#34;
elif node.name == &#39;Entrypoint&#39;:
has_entrypoint = True
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == &#39;__init__&#39;), None)
if not init_method or len(init_method.args.args) != 4: # self, args, parser, conapp
return &#34;Entrypoint class should have method __init__ and accept only arguments: args, parser and connapp&#34; # &#39;Entrypoint&#39; __init__ does not have correct signature
elif node.name == &#39;Preload&#39;:
has_preload = True
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == &#39;__init__&#39;), None)
if not init_method or len(init_method.args.args) != 2: # self, connapp
return &#34;Preload class should have method __init__ and accept only argument: connapp&#34; # &#39;Preload&#39; __init__ does not have correct signature
# Applying the combination logic based on class presence
if has_parser and not has_entrypoint:
return &#34;Parser requires Entrypoint class to be present.&#34;
elif has_entrypoint and not has_parser:
return &#34;Entrypoint requires Parser class to be present.&#34;
if not (has_parser or has_entrypoint or has_preload):
return &#34;No valid class (Parser, Entrypoint, or Preload) found.&#34;
return False # All requirements met, no error
def _import_from_path(self, path):
spec = importlib.util.spec_from_file_location(&#34;module.name&#34;, path)
module = importlib.util.module_from_spec(spec)
sys.modules[&#34;module.name&#34;] = 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(&#34;.py&#34;):
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) == &#34;remote&#34;:
continue
# Construct the full path
filepath = os.path.join(directory, filename)
check_file = self.verify_script(filepath)
if check_file:
printer.error(f&#34;Failed to load plugin: {filename}. Reason: {check_file}&#34;)
continue
else:
self.plugins[root_filename] = self._import_from_path(filepath)
if hasattr(self.plugins[root_filename], &#34;Parser&#34;):
self.plugin_parsers[root_filename] = self.plugins[root_filename].Parser()
plugin = self.plugin_parsers[root_filename]
# Default to RichHelpFormatter if plugin doesn&#39;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], &#34;Preload&#34;):
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(&#34;.py&#34;):
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(&#34;enabled&#34;, True):
continue
pref = self.preferences.get(name, &#34;local&#34;)
if pref != &#34;remote&#34; and name in self.plugins:
continue
if not force_sync and name in subparsers.choices:
continue
cache_path = os.path.join(cache_dir, f&#34;{name}.py&#34;)
# Hash comparison
remote_hash = info.get(&#34;hash&#34;, &#34;&#34;)
local_hash = &#34;&#34;
if os.path.exists(cache_path):
try:
with open(cache_path, &#34;rb&#34;) 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, &#34;w&#34;) as f:
f.write(source)
except Exception as e:
printer.warning(f&#34;Failed to sync remote plugin {name}: {e}&#34;)
continue
# Verify and load
check_file = self.verify_script(cache_path)
if check_file:
printer.warning(f&#34;Remote plugin {name} failed verification: {check_file}&#34;)
continue
module = self._import_from_path(cache_path)
if hasattr(module, &#34;Parser&#34;):
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&#39;s already there.
if name not in subparsers.choices:
subparsers.add_parser(
name,
parents=[plugin.parser],
add_help=False,
help=f&#34;[remote] {plugin.parser.description}&#34;,
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):
&#34;&#34;&#34;
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&#39;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: &#39;Parser&#39; and &#39;Entrypoint&#39;. and/or specific class: Preload.
- &#39;Parser&#39; class must only have an &#39;__init__&#39; method and must assign &#39;self.parser&#39;.
- &#39;Entrypoint&#39; class must have an &#39;__init__&#39; 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.
&#34;&#34;&#34;
with open(file_path, &#39;r&#39;) as file:
source_code = file.read()
try:
tree = ast.parse(source_code)
except SyntaxError as e:
return f&#34;Syntax error in file: {e}&#34;
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 &#39;if __name__ == &#34;__main__&#34;:&#39; block
if not (isinstance(node.test, ast.Compare) and
isinstance(node.test.left, ast.Name) and
node.test.left.id == &#39;__name__&#39; and
((hasattr(ast, &#39;Str&#39;) and isinstance(node.test.comparators[0], getattr(ast, &#39;Str&#39;)) and node.test.comparators[0].s == &#39;__main__&#39;) or
(hasattr(ast, &#39;Constant&#39;) and isinstance(node.test.comparators[0], getattr(ast, &#39;Constant&#39;)) and node.test.comparators[0].value == &#39;__main__&#39;))):
return &#34;Only __name__ == __main__ If is allowed&#34;
elif not isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Import, ast.ImportFrom, ast.Pass)):
return f&#34;Plugin can only have pass, functions, classes and imports. {node} is not allowed&#34; # Reject any other AST types
if isinstance(node, ast.ClassDef):
if node.name == &#39;Parser&#39;:
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 == &#39;__init__&#39; for method in node.body):
return &#34;Parser class should only have __init__ method&#34;
# Check if &#39;self.parser&#39; 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 == &#39;self&#39;]
if &#39;parser&#39; not in assigned_attrs:
return &#34;Parser class should set self.parser&#34;
elif node.name == &#39;Entrypoint&#39;:
has_entrypoint = True
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == &#39;__init__&#39;), None)
if not init_method or len(init_method.args.args) != 4: # self, args, parser, conapp
return &#34;Entrypoint class should have method __init__ and accept only arguments: args, parser and connapp&#34; # &#39;Entrypoint&#39; __init__ does not have correct signature
elif node.name == &#39;Preload&#39;:
has_preload = True
init_method = next((item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == &#39;__init__&#39;), None)
if not init_method or len(init_method.args.args) != 2: # self, connapp
return &#34;Preload class should have method __init__ and accept only argument: connapp&#34; # &#39;Preload&#39; __init__ does not have correct signature
# Applying the combination logic based on class presence
if has_parser and not has_entrypoint:
return &#34;Parser requires Entrypoint class to be present.&#34;
elif has_entrypoint and not has_parser:
return &#34;Entrypoint requires Parser class to be present.&#34;
if not (has_parser or has_entrypoint or has_preload):
return &#34;No valid class (Parser, Entrypoint, or Preload) found.&#34;
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:
&#39;&#39;&#39; 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.
&#39;&#39;&#39;
def __init__(self, conf = None, key = None, shared_config = None):
self._shared_config = shared_config
&#39;&#39;&#39;
### 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
&#39;&#39;&#39;
home = os.path.expanduser(&#34;~&#34;)
defaultdir = home + &#39;/.config/conn&#39;
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 + &#39;/.folder&#39;
try:
with open(pathfile, &#34;r&#34;) as f:
configdir = f.read().strip()
except (FileNotFoundError, IOError):
with open(pathfile, &#34;w&#34;) as f:
f.write(str(defaultdir))
configdir = defaultdir
self.defaultdir = configdir
self.file = configdir + &#39;/config.yaml&#39;
self.key = key or (configdir + &#39;/.osk&#39;)
# Ensure redirected directories exist
Path(configdir).mkdir(parents=True, exist_ok=True)
Path(f&#34;{configdir}/plugins&#34;).mkdir(parents=True, exist_ok=True)
# Backwards compatibility: Migrate from JSON to YAML only for default path
legacy_json = configdir + &#39;/config.json&#39;
legacy_noext = configdir + &#39;/config&#39;
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, &#39;r&#39;) as f:
old_data = json.load(f)
if not self._validate_config(old_data):
printer.warning(f&#34;Legacy config {legacy_file} has invalid structure, skipping migration.&#34;)
else:
with open(self.file, &#39;w&#39;) 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, &#39;r&#39;) as f:
verify = yaml.safe_load(f)
if not self._validate_config(verify):
os.remove(self.file)
printer.warning(&#34;YAML verification failed after migration, keeping legacy config.&#34;)
else:
# Note: cachefile is derived later, we use temp one for migration sync
temp_cache = configdir + &#39;/.config.cache.json&#39;
with open(temp_cache, &#39;w&#39;) as f:
json.dump(old_data, f)
shutil.move(legacy_file, legacy_file + &#34;.backup&#34;)
printer.success(f&#34;Migrated legacy config ({len(old_data.get(&#39;connections&#39;,{}))} folders/nodes) into YAML and Cache successfully!&#34;)
except Exception as e:
if os.path.exists(self.file):
try: os.remove(self.file)
except OSError: pass
printer.warning(f&#34;Failed to migrate legacy config: {e}&#34;)
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 + &#39;/.osk&#39;)
# Sidecar files always live next to the config file (or in the redirected configdir)
self.cachefile = configdir + &#39;/.config.cache.json&#39;
self.fzf_cachefile = configdir + &#39;/.fzf_nodes_cache.txt&#39;
self.folders_cachefile = configdir + &#39;/.folders_cache.txt&#39;
self.profiles_cachefile = configdir + &#39;/.profiles_cache.txt&#39;
if os.path.exists(self.file):
config = self._loadconfig(self.file)
else:
config = self._createconfig(self.file)
self.config = config[&#34;config&#34;]
self.connections = config[&#34;connections&#34;]
self.profiles = config[&#34;profiles&#34;]
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, &#39;_privatekey_obj&#39;, 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, &#39;_publickey_obj&#39;, 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):
&#34;&#34;&#34;Get config setting with shared fallback for inheritable keys.&#34;&#34;&#34;
val = self.config.get(key)
if key == &#34;ai&#34;:
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 &#34;engineer_api_key&#34; in val or &#34;engineer_auth&#34; in val:
base.pop(&#34;engineer_api_key&#34;, None)
base.pop(&#34;engineer_auth&#34;, None)
# If user defines architect credentials, discard shared ones
if &#34;architect_api_key&#34; in val or &#34;architect_auth&#34; in val:
base.pop(&#34;architect_api_key&#34;, None)
base.pop(&#34;architect_auth&#34;, 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):
&#34;&#34;&#34;Verify config data has the required structure.&#34;&#34;&#34;
if not isinstance(data, dict):
return False
required = {&#34;config&#34;, &#34;connections&#34;, &#34;profiles&#34;}
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 &gt; cache_time:
with open(conf, &#39;r&#39;) 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(&#34;Config file appears corrupt, recovering from cache...&#34;)
with open(self.cachefile, &#39;r&#39;) as f:
data = json.load(f)
if self._validate_config(data):
# Re-write the YAML from good cache
with open(conf, &#39;w&#39;) 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(&#34;Config file is corrupt and no valid cache exists. Creating default config.&#34;)
return self._createconfig(conf)
try:
with open(self.cachefile, &#39;w&#39;) as f:
json.dump(data, f)
except Exception:
pass
return data
else:
with open(self.cachefile, &#39;r&#39;) as f:
data = json.load(f)
if not self._validate_config(data):
# Cache broken, try yaml
with open(conf, &#39;r&#39;) as f:
data = yaml.safe_load(f)
if self._validate_config(data):
return data
# Both broken
printer.error(&#34;Both config and cache are corrupt. Creating default config.&#34;)
return self._createconfig(conf)
return data
def _createconfig(self, conf):
#Create config file (always writes defaults, safe for recovery)
defaultconfig = {&#39;config&#39;: {&#39;case&#39;: False, &#39;idletime&#39;: 30, &#39;fzf&#39;: False}, &#39;connections&#39;: {}, &#39;profiles&#39;: { &#34;default&#34;: { &#34;host&#34;:&#34;&#34;, &#34;protocol&#34;:&#34;ssh&#34;, &#34;port&#34;:&#34;&#34;, &#34;user&#34;:&#34;&#34;, &#34;password&#34;:&#34;&#34;, &#34;options&#34;:&#34;&#34;, &#34;logs&#34;:&#34;&#34;, &#34;tags&#34;: &#34;&#34;, &#34;jumphost&#34;:&#34;&#34;}}}
with open(conf, &#34;w&#34;) as f:
yaml.dump(defaultconfig, f, Dumper=NoAliasDumper, default_flow_style=False, sort_keys=False)
os.chmod(conf, 0o600)
try:
with open(self.cachefile, &#39;w&#39;) as f:
json.dump(defaultconfig, f)
except Exception:
pass
return defaultconfig
@MethodHook
def _saveconfig(self, conf):
#Save config file atomically to prevent corruption
newconfig = {&#34;config&#34;:{}, &#34;connections&#34;: {}, &#34;profiles&#34;: {}}
newconfig[&#34;config&#34;] = self.config
newconfig[&#34;connections&#34;] = self.connections
newconfig[&#34;profiles&#34;] = self.profiles
tmpfile = conf + &#39;.tmp&#39;
try:
with open(tmpfile, &#34;w&#34;) 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, &#34;w&#34;) as f:
json.dump(newconfig, f)
self._generate_nodes_cache()
except (IOError, OSError) as e:
printer.error(f&#34;Failed to save config: {e}&#34;)
# 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, &#34;w&#34;) as f:
f.write(&#34;\n&#34;.join(nodes))
with open(self.folders_cachefile, &#34;w&#34;) as f:
f.write(&#34;\n&#34;.join(folders))
with open(self.profiles_cachefile, &#34;w&#34;) as f:
f.write(&#34;\n&#34;.join(profiles))
except Exception:
pass
def _createkey(self, keyfile):
#Create key file
from Crypto.PublicKey import RSA
key = RSA.generate(2048)
with open(keyfile,&#39;wb&#39;) as f:
f.write(key.export_key(&#39;PEM&#39;))
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(&#34;@&#34;)
if not unique.startswith(&#34;@&#34;):
result = {&#34;id&#34;: uniques[0]}
else:
result = {}
if len(uniques) == 2:
result[&#34;folder&#34;] = uniques[1]
if result[&#34;folder&#34;] == &#34;&#34;:
return False
elif len(uniques) == 3:
result[&#34;folder&#34;] = uniques[2]
result[&#34;subfolder&#34;] = uniques[1]
if result[&#34;folder&#34;] == &#34;&#34; or result[&#34;subfolder&#34;] == &#34;&#34;:
return False
elif len(uniques) &gt; 3:
return False
return result
@MethodHook
def getitem(self, unique, keys = None, extract = False):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
uniques = self._explode_unique(unique)
if unique.startswith(&#34;@&#34;):
if uniques.keys() &gt;= {&#34;folder&#34;, &#34;subfolder&#34;}:
folder = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;subfolder&#34;]]
else:
folder = self.connections[uniques[&#34;folder&#34;]]
newfolder = deepcopy(folder)
newfolder.pop(&#34;type&#34;)
for node_name in folder.keys():
if node_name == &#34;type&#34;:
continue
if &#34;type&#34; in newfolder[node_name].keys():
if newfolder[node_name][&#34;type&#34;] == &#34;subfolder&#34;:
newfolder.pop(node_name)
else:
newfolder[node_name].pop(&#34;type&#34;)
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(&#34;^@(.*)&#34;, str(value))
if profile:
try:
newfolder[node_name][key] = self.profiles[profile.group(1)][key]
except KeyError:
newfolder[node_name][key] = &#34;&#34;
elif value == &#39;&#39; and key == &#34;protocol&#34;:
try:
newfolder[node_name][key] = self.profiles[&#34;default&#34;][key]
except KeyError:
newfolder[node_name][key] = &#34;ssh&#34;
newfolder = {&#34;{}{}&#34;.format(k,unique):v for k,v in newfolder.items()}
return newfolder
else:
if uniques.keys() &gt;= {&#34;folder&#34;, &#34;subfolder&#34;}:
node = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;subfolder&#34;]][uniques[&#34;id&#34;]]
elif &#34;folder&#34; in uniques.keys():
node = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;id&#34;]]
else:
node = self.connections[uniques[&#34;id&#34;]]
newnode = deepcopy(node)
newnode.pop(&#34;type&#34;)
if extract:
for key, value in newnode.items():
profile = re.search(&#34;^@(.*)&#34;, str(value))
if profile:
try:
newnode[key] = self.profiles[profile.group(1)][key]
except KeyError:
newnode[key] = &#34;&#34;
elif value == &#39;&#39; and key == &#34;protocol&#34;:
try:
newnode[key] = self.profiles[&#34;default&#34;][key]
except KeyError:
newnode[key] = &#34;ssh&#34;
return newnode
@MethodHook
def getitems(self, uniques, extract = False):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
nodes = {}
if isinstance(uniques, str):
uniques = [uniques]
for i in uniques:
if i.startswith(&#34;@&#34;):
if not self.config[&#34;case&#34;]:
i = i.lower()
this = self.getitem(i, extract = extract)
nodes.update(this)
else:
if not self.config[&#34;case&#34;]:
i = i.lower()
this = self.getitem(i, extract = extract)
nodes[i] = this
return nodes
@MethodHook
def _connections_add(self,*, id, host, folder=&#39;&#39;, subfolder=&#39;&#39;, options=&#39;&#39;, logs=&#39;&#39;, password=&#39;&#39;, port=&#39;&#39;, protocol=&#39;&#39;, user=&#39;&#39;, tags=&#39;&#39;, jumphost=&#39;&#39;, type = &#34;connection&#34; ):
#Add connection from config
if folder == &#39;&#39;:
self.connections[id] = {&#34;host&#34;: host, &#34;options&#34;: options, &#34;logs&#34;: logs, &#34;password&#34;: password, &#34;port&#34;: port, &#34;protocol&#34;: protocol, &#34;user&#34;: user, &#34;tags&#34;: tags,&#34;jumphost&#34;: jumphost,&#34;type&#34;: type}
elif folder != &#39;&#39; and subfolder == &#39;&#39;:
self.connections[folder][id] = {&#34;host&#34;: host, &#34;options&#34;: options, &#34;logs&#34;: logs, &#34;password&#34;: password, &#34;port&#34;: port, &#34;protocol&#34;: protocol, &#34;user&#34;: user, &#34;tags&#34;: tags, &#34;jumphost&#34;: jumphost, &#34;type&#34;: type}
elif folder != &#39;&#39; and subfolder != &#39;&#39;:
self.connections[folder][subfolder][id] = {&#34;host&#34;: host, &#34;options&#34;: options, &#34;logs&#34;: logs, &#34;password&#34;: password, &#34;port&#34;: port, &#34;protocol&#34;: protocol, &#34;user&#34;: user, &#34;tags&#34;: tags, &#34;jumphost&#34;: jumphost, &#34;type&#34;: type}
@MethodHook
def _connections_del(self,*, id, folder=&#39;&#39;, subfolder=&#39;&#39;):
#Delete connection from config
if folder == &#39;&#39;:
del self.connections[id]
elif folder != &#39;&#39; and subfolder == &#39;&#39;:
del self.connections[folder][id]
elif folder != &#39;&#39; and subfolder != &#39;&#39;:
del self.connections[folder][subfolder][id]
@MethodHook
def _folder_add(self,*, folder, subfolder = &#39;&#39;):
#Add Folder from config
if subfolder == &#39;&#39;:
if folder not in self.connections:
self.connections[folder] = {&#34;type&#34;: &#34;folder&#34;}
else:
if subfolder not in self.connections[folder]:
self.connections[folder][subfolder] = {&#34;type&#34;: &#34;subfolder&#34;}
@MethodHook
def _folder_del(self,*, folder, subfolder=&#39;&#39;):
#Delete folder from config
if subfolder == &#39;&#39;:
del self.connections[folder]
else:
del self.connections[folder][subfolder]
@MethodHook
def _profiles_add(self,*, id, host = &#39;&#39;, options=&#39;&#39;, logs=&#39;&#39;, password=&#39;&#39;, port=&#39;&#39;, protocol=&#39;&#39;, user=&#39;&#39;, tags=&#39;&#39;, jumphost=&#39;&#39; ):
#Add profile from config
self.profiles[id] = {&#34;host&#34;: host, &#34;options&#34;: options, &#34;logs&#34;: logs, &#34;password&#34;: password, &#34;port&#34;: port, &#34;protocol&#34;: protocol, &#34;user&#34;: user, &#34;tags&#34;: tags, &#34;jumphost&#34;: 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(&#34;type&#34;) == &#34;connection&#34;]
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34;]
nodes.extend(layer2)
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34;]
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(&#34;Filter must be a string or a list of strings&#34;)
sys.exit(1)
flags = re.IGNORECASE if not self.config.get(&#34;case&#34;, 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(&#34;type&#34;) == &#34;connection&#34;}
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;folder&#34;]
nodes.update(layer1)
for f in folders:
layer2 = {k + &#34;@&#34; + f:v for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34;}
nodes.update(layer2)
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;subfolder&#34;]
for s in subfolders:
layer3 = {k + &#34;@&#34; + s + &#34;@&#34; + f:v for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34;}
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(&#34;Filter must be a string or a list of strings&#34;)
sys.exit(1)
flat_filter = [&#34;^(?!.*@).+$&#34; if item == &#34;@&#34; 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(&#34;^@(.*)&#34;, str(value))
if profile:
try:
nodes[node][key] = self.profiles[profile.group(1)][key]
except KeyError:
nodes[node][key] = &#34;&#34;
elif value == &#39;&#39; and key == &#34;protocol&#34;:
try:
nodes[node][key] = self.profiles[&#34;default&#34;][key]
except KeyError:
nodes[node][key] = &#34;ssh&#34;
return nodes
@MethodHook
def _getallfolders(self):
#get all folders on configfile
folders = [&#34;@&#34; + k for k,v in self.connections.items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;folder&#34;]
subfolders = []
for f in folders:
s = [&#34;@&#34; + k + f for k,v in self.connections[f[1:]].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;subfolder&#34;]
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(&#34;type&#34;) == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v.get(&#34;password&#34;),list) and &#34;@&#34; + profile in v.get(&#34;password&#34;)))]
folders = [k for k,v in self.connections.items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;folder&#34;]
nodes.extend(layer1)
for f in folders:
layer2 = [k + &#34;@&#34; + f for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v.get(&#34;password&#34;),list) and &#34;@&#34; + profile in v.get(&#34;password&#34;)))]
nodes.extend(layer2)
subfolders = [k for k,v in self.connections[f].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;subfolder&#34;]
for s in subfolders:
layer3 = [k + &#34;@&#34; + s + &#34;@&#34; + f for k,v in self.connections[f][s].items() if isinstance(v, dict) and v.get(&#34;type&#34;) == &#34;connection&#34; and (&#34;@&#34; + profile in v.values() or ( isinstance(v.get(&#34;password&#34;),list) and &#34;@&#34; + profile in v.get(&#34;password&#34;)))]
nodes.extend(layer3)
return nodes
@MethodHook
def encrypt(self, password, keyfile=None):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
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(&#34;utf-8&#34;))
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, &#39;_privatekey_obj&#39;, 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, &#39;_publickey_obj&#39;, 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):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
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(&#34;utf-8&#34;))
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):
&#34;&#34;&#34;Get config setting with shared fallback for inheritable keys.&#34;&#34;&#34;
val = self.config.get(key)
if key == &#34;ai&#34;:
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 &#34;engineer_api_key&#34; in val or &#34;engineer_auth&#34; in val:
base.pop(&#34;engineer_api_key&#34;, None)
base.pop(&#34;engineer_auth&#34;, None)
# If user defines architect credentials, discard shared ones
if &#34;architect_api_key&#34; in val or &#34;architect_auth&#34; in val:
base.pop(&#34;architect_api_key&#34;, None)
base.pop(&#34;architect_auth&#34;, 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):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
uniques = self._explode_unique(unique)
if unique.startswith(&#34;@&#34;):
if uniques.keys() &gt;= {&#34;folder&#34;, &#34;subfolder&#34;}:
folder = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;subfolder&#34;]]
else:
folder = self.connections[uniques[&#34;folder&#34;]]
newfolder = deepcopy(folder)
newfolder.pop(&#34;type&#34;)
for node_name in folder.keys():
if node_name == &#34;type&#34;:
continue
if &#34;type&#34; in newfolder[node_name].keys():
if newfolder[node_name][&#34;type&#34;] == &#34;subfolder&#34;:
newfolder.pop(node_name)
else:
newfolder[node_name].pop(&#34;type&#34;)
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(&#34;^@(.*)&#34;, str(value))
if profile:
try:
newfolder[node_name][key] = self.profiles[profile.group(1)][key]
except KeyError:
newfolder[node_name][key] = &#34;&#34;
elif value == &#39;&#39; and key == &#34;protocol&#34;:
try:
newfolder[node_name][key] = self.profiles[&#34;default&#34;][key]
except KeyError:
newfolder[node_name][key] = &#34;ssh&#34;
newfolder = {&#34;{}{}&#34;.format(k,unique):v for k,v in newfolder.items()}
return newfolder
else:
if uniques.keys() &gt;= {&#34;folder&#34;, &#34;subfolder&#34;}:
node = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;subfolder&#34;]][uniques[&#34;id&#34;]]
elif &#34;folder&#34; in uniques.keys():
node = self.connections[uniques[&#34;folder&#34;]][uniques[&#34;id&#34;]]
else:
node = self.connections[uniques[&#34;id&#34;]]
newnode = deepcopy(node)
newnode.pop(&#34;type&#34;)
if extract:
for key, value in newnode.items():
profile = re.search(&#34;^@(.*)&#34;, str(value))
if profile:
try:
newnode[key] = self.profiles[profile.group(1)][key]
except KeyError:
newnode[key] = &#34;&#34;
elif value == &#39;&#39; and key == &#34;protocol&#34;:
try:
newnode[key] = self.profiles[&#34;default&#34;][key]
except KeyError:
newnode[key] = &#34;ssh&#34;
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):
&#39;&#39;&#39;
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.
&#39;&#39;&#39;
nodes = {}
if isinstance(uniques, str):
uniques = [uniques]
for i in uniques:
if i.startswith(&#34;@&#34;):
if not self.config[&#34;case&#34;]:
i = i.lower()
this = self.getitem(i, extract = extract)
nodes.update(this)
else:
if not self.config[&#34;case&#34;]:
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:
&#39;&#39;&#39; 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.
&#39;&#39;&#39;
def __init__(self, unique, host, options=&#39;&#39;, logs=&#39;&#39;, password=&#39;&#39;, port=&#39;&#39;, protocol=&#39;&#39;, user=&#39;&#39;, config=&#39;&#39;, tags=&#39;&#39;, jumphost=&#39;&#39;):
&#39;&#39;&#39;
### 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 &#34;os&#34;, &#34;prompt&#34; and &#34;screenleght_command&#34;
- jumphost (str): Reference another node to be used as a jumphost
&#39;&#39;&#39;
self.config = config
if config == &#39;&#39;:
self.idletime = 0
self.key = None
else:
self.idletime = config.config[&#34;idletime&#34;]
self.key = config.key
self.unique = unique
attr = {&#34;host&#34;: host, &#34;logs&#34;: logs, &#34;options&#34;:options, &#34;port&#34;: port, &#34;protocol&#34;: protocol, &#34;user&#34;: user, &#34;tags&#34;: tags, &#34;jumphost&#34;: jumphost}
for key in attr:
profile = re.search(&#34;^@(.*)&#34;, str(attr[key]))
if profile and config != &#39;&#39;:
try:
setattr(self,key,config.profiles[profile.group(1)][key])
except KeyError:
setattr(self,key,&#34;&#34;)
elif attr[key] == &#39;&#39; and key == &#34;protocol&#34;:
try:
setattr(self,key,config.profiles[&#34;default&#34;][key])
except (KeyError, AttributeError):
setattr(self,key,&#34;ssh&#34;)
else:
setattr(self,key,attr[key])
if isinstance(password,list):
self.password = []
for i, s in enumerate(password):
profile = re.search(&#34;^@(.*)&#34;, password[i])
if profile and config != &#39;&#39;:
self.password.append(config.profiles[profile.group(1)][&#34;password&#34;])
else:
self.password.append(password[i])
else:
self.password = [password]
if self.jumphost != &#34;&#34; and config != &#39;&#39;:
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(&#39;\\&#39;, &#39;\\\\&#39;).replace(&#39;&#34;&#39;, &#39;\\&#34;&#39;)
self.jumphost = f&#39;-o ProxyCommand=&#34;{escaped}&#34;&#39;
else:
self.jumphost = &#34;&#34;
self.output = &#34;&#34;
self.status = 1
self.result = {}
self.cmd_byte_positions = [(0, None)]
@staticmethod
def _resolve_jumphost_data(jh_dict, config):
&#39;&#39;&#39;Resolve @profile references and normalize passwords in a jumphost dict.&#39;&#39;&#39;
for key in jh_dict:
profile = re.search(&#34;^@(.*)&#34;, str(jh_dict[key]))
if profile:
try:
jh_dict[key] = config.profiles[profile.group(1)][key]
except KeyError:
jh_dict[key] = &#34;&#34;
elif jh_dict[key] == &#39;&#39; and key == &#34;protocol&#34;:
try:
jh_dict[key] = config.profiles[&#34;default&#34;][key]
except KeyError:
jh_dict[key] = &#34;ssh&#34;
if isinstance(jh_dict[&#34;password&#34;], list):
resolved = []
for p in jh_dict[&#34;password&#34;]:
profile = re.search(&#34;^@(.*)&#34;, p)
if profile:
resolved.append(config.profiles[profile.group(1)][&#34;password&#34;])
else:
resolved.append(p)
jh_dict[&#34;password&#34;] = resolved
else:
jh_dict[&#34;password&#34;] = [jh_dict[&#34;password&#34;]]
return jh_dict
def _build_jumphost_chain(self, jumphost_name, config, visited=None, depth=0, target_host=&#34;%h&#34;, target_port=&#34;%p&#34;):
&#39;&#39;&#39;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).
&#39;&#39;&#39;
if depth &gt;= 5:
raise ValueError(&#34;Jumphost chain exceeds maximum depth of 5 hops&#34;)
if visited is None:
visited = []
if jumphost_name in visited:
cycle = &#34; -&gt; &#34;.join(visited + [jumphost_name])
raise ValueError(f&#34;Circular jumphost reference detected: {cycle}&#34;)
visited = visited + [jumphost_name]
jh = config.getitem(jumphost_name)
jh = self._resolve_jumphost_data(jh, config)
passwords = []
inner_proxy_opt = &#34;&#34;
# Recursively resolve inner jumphost
if jh.get(&#34;jumphost&#34;, &#34;&#34;) != &#34;&#34;:
if jh[&#34;protocol&#34;] not in [&#34;ssh&#34;]:
raise ValueError(
f&#34;Jumphost &#39;{jumphost_name}&#39; uses protocol &#39;{jh[&#39;protocol&#39;]}&#39; &#34;
f&#34;which does not support chained jumphosts. &#34;
f&#34;Only SSH jumphosts can have their own jumphosts.&#34;
)
parent_port = jh[&#34;port&#34;] if jh[&#34;port&#34;] != &#34;&#34; else &#34;22&#34;
inner_raw_cmd, inner_passwords = self._build_jumphost_chain(
jh[&#34;jumphost&#34;], config, visited, depth + 1, target_host=jh[&#34;host&#34;], target_port=parent_port
)
passwords = inner_passwords
escaped = inner_raw_cmd.replace(&#39;\\&#39;, &#39;\\\\&#39;).replace(&#39;&#34;&#39;, &#39;\\&#34;&#39;)
inner_proxy_opt = f&#39;-o ProxyCommand=&#34;{escaped}&#34;&#39;
# Collect this hop&#39;s passwords
if jh[&#34;password&#34;] != [&#34;&#34;]:
passwords = passwords + jh[&#34;password&#34;]
t_port = target_port if target_port != &#34;&#34; else &#34;22&#34;
# Build raw command based on protocol
if jh[&#34;protocol&#34;] == &#34;ssh&#34;:
cmd = f&#34;ssh -W {target_host}:{t_port}&#34;
if inner_proxy_opt:
cmd += f&#34; {inner_proxy_opt}&#34;
if jh[&#34;port&#34;] != &#39;&#39;:
cmd += f&#34; -p {jh[&#39;port&#39;]}&#34;
if jh[&#34;options&#34;] != &#39;&#39;:
cmd += f&#34; {jh[&#39;options&#39;]}&#34;
user_host = f&#34;{jh[&#39;user&#39;]}@{jh[&#39;host&#39;]}&#34; if jh[&#39;user&#39;] != &#39;&#39; else jh[&#39;host&#39;]
cmd += f&#34; {user_host}&#34;
elif jh[&#34;protocol&#34;] == &#34;ssm&#34;:
ssm_target = jh[&#34;host&#34;]
ssm_cmd = f&#34;aws ssm start-session --target {ssm_target} --document-name AWS-StartSSHSession --parameters &#39;portNumber=22&#39;&#34;
if isinstance(jh.get(&#34;tags&#34;), dict):
if &#34;profile&#34; in jh[&#34;tags&#34;]:
ssm_cmd += f&#34; --profile {jh[&#39;tags&#39;][&#39;profile&#39;]}&#34;
if &#34;region&#34; in jh[&#34;tags&#34;]:
ssm_cmd += f&#34; --region {jh[&#39;tags&#39;][&#39;region&#39;]}&#34;
if jh[&#34;options&#34;] != &#39;&#39;:
ssm_cmd += f&#34; {jh[&#39;options&#39;]}&#34;
bastion_user_part = f&#34;{jh[&#39;user&#39;]}@{ssm_target}&#34; if jh[&#39;user&#39;] else ssm_target
ssh_opts = &#34;&#34;
if isinstance(jh.get(&#34;tags&#34;), dict) and &#34;ssh_options&#34; in jh[&#34;tags&#34;]:
ssh_opts = f&#34; {jh[&#39;tags&#39;][&#39;ssh_options&#39;]}&#34;
cmd = f&#34;ssh{ssh_opts} -o ProxyCommand=&#39;{ssm_cmd}&#39; -W {target_host}:{t_port} {bastion_user_part}&#34;
elif jh[&#34;protocol&#34;] in [&#34;kubectl&#34;, &#34;docker&#34;]:
nc_cmd = &#34;nc&#34;
if isinstance(jh.get(&#34;tags&#34;), dict) and &#34;nc_command&#34; in jh[&#34;tags&#34;]:
nc_cmd = jh[&#34;tags&#34;][&#34;nc_command&#34;]
if jh[&#34;protocol&#34;] == &#34;kubectl&#34;:
cmd = &#34;kubectl exec &#34;
if jh[&#34;options&#34;] != &#39;&#39;:
cmd += f&#34;{jh[&#39;options&#39;]} &#34;
cmd += f&#34;{jh[&#39;host&#39;]} -i -- {nc_cmd} {target_host} {t_port}&#34;
else:
cmd = &#34;docker &#34;
if jh[&#34;options&#34;] != &#39;&#39;:
cmd += f&#34;{jh[&#39;options&#39;]} &#34;
cmd += f&#34;exec -i {jh[&#39;host&#39;]} {nc_cmd} {target_host} {t_port}&#34;
else:
return &#34;&#34;, 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(&#39;^b[\&#34;\&#39;].+[\&#34;\&#39;]$&#39;, passwd):
dpass.append(passwd)
else:
try:
decrypted = decryptor.decrypt(ast.literal_eval(passwd)).decode(&#34;utf-8&#34;)
dpass.append(decrypted)
except Exception:
printer.error(&#34;Decryption failed: Missing or corrupted key.&#34;)
printer.info(&#34;Verify your RSA key and configuration settings.&#34;)
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(&#34;${unique}&#34;, self.unique)
logfile = logfile.replace(&#34;${host}&#34;, self.host)
logfile = logfile.replace(&#34;${port}&#34;, self.port)
logfile = logfile.replace(&#34;${user}&#34;, self.user)
logfile = logfile.replace(&#34;${protocol}&#34;, self.protocol)
now = datetime.datetime.now()
dateconf = re.search(r&#39;\$\{date \&#39;(.*)\&#39;}&#39;, logfile)
if dateconf:
logfile = re.sub(r&#39;\$\{date (.*)}&#39;,now.strftime(dateconf.group(1)), logfile)
return logfile
@MethodHook
def _logclean(self, logfile, var = False):
&#34;&#34;&#34;Remove special ascii characters and process terminal cursor movements to clean logs.&#34;&#34;&#34;
from .utils import log_cleaner
if var == False:
try:
with open(logfile, &#34;r&#34;) as f:
t = f.read()
except:
return
else:
t = logfile
result = log_cleaner(t)
if var == False:
try:
with open(logfile, &#34;w&#34;) as f:
f.write(result)
except:
pass
return
else:
return result
@MethodHook
def _savelog(self):
&#39;&#39;&#39;Save the log buffer to the file at regular intervals if there are changes.&#39;&#39;&#39;
t = threading.current_thread()
prev_size = 0 # Store the previous size of the buffer
while getattr(t, &#34;do_run&#34;, 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, &#34;w&#34;) as f: # Use &#34;w&#34; 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 &gt;= self.idletime:
self.child.sendcontrol(&#34;e&#34;)
self.lastinput = time()
sleep(1)
def _setup_interact_environment(self, debug=False, logger=None, async_mode=False):
try:
size = re.search(&#39;columns=([0-9]+).*lines=([0-9]+)&#39;,str(os.get_terminal_size()))
self.child.setwinsize(int(size.group(2)),int(size.group(1)))
except OSError:
pass
if logger and self.protocol != &#34;local&#34;:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
# Always initialize self.mylog to capture terminal context for the AI Copilot
if not hasattr(self, &#39;mylog&#39;):
self.mylog = io.BytesIO()
if not async_mode:
self.child.logfile_read = self.mylog
# Only start disk-logging tasks if logfile is configured
if &#39;logfile&#39; 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 &#39;missingtext&#39; in dir(self):
print(self.child.after.decode(), end=&#39;&#39;)
if self.idletime &gt; 0 and not async_mode:
x = threading.Thread(target=self._keepalive)
x.daemon = True
x.start()
if debug:
if &#39;mylog&#39; in dir(self):
if not async_mode:
print(self.mylog.getvalue().decode())
def _teardown_interact_environment(self):
if &#39;logfile&#39; in dir(self) and hasattr(self, &#39;mylog&#39;):
with open(self.logfile, &#34;w&#34;) as f:
f.write(self._logclean(self.mylog.getvalue().decode(), True))
def _is_child_connpy_active(self, child_fd: int) -&gt; bool:
if self.protocol != &#34;local&#34;:
return False
try:
fg_pgid = os.tcgetpgrp(child_fd)
if fg_pgid &lt;= 0:
return False
cmdline_path = f&#34;/proc/{fg_pgid}/cmdline&#34;
if os.path.exists(cmdline_path):
with open(cmdline_path, &#34;rb&#34;) as f:
raw_args = f.read().split(b&#34;\x00&#34;)
cmdline_str = &#34; &#34;.join([arg.decode(errors=&#34;ignore&#34;) for arg in raw_args if arg])
is_active = False
for raw_arg in raw_args:
arg_str = raw_arg.decode(errors=&#34;ignore&#34;)
if not arg_str:
continue
base_name = os.path.basename(arg_str)
if base_name in [&#34;conn&#34;, &#34;connpy&#34;, &#34;connapp&#34;] or &#34;connpy&#34; in arg_str or &#34;connapp&#34; 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&#39;&#39;
if getattr(self, &#39;missingtext&#39;, False):
# If we are missing the password, we MUST show the password prompt
ghost_buffer = (self.child.after or b&#39;&#39;) + (self.child.buffer or b&#39;&#39;)
else:
# We auto-logged in. Hide the messy password negotiation and just keep any pending live stream.
ghost_buffer = self.child.buffer or b&#39;&#39;
# Fix user&#39;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, &#39;missingtext&#39;, False):
ghost_buffer = ghost_buffer.lstrip(b&#39;\r\n &#39;)
if ghost_buffer:
# Add a single clean newline so it doesn&#39;t merge with the Connected message
await local_stream.write(b&#39;\r\n&#39; + ghost_buffer)
if hasattr(self, &#39;mylog&#39;):
self.mylog.write(b&#39;\n&#39; + ghost_buffer)
self.child.buffer = b&#39;&#39;
self.child.before = b&#39;&#39;
# 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, &#39;mylog&#39;) 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&#39;&#39;)
except BlockingIOError:
pass
except OSError:
child_reader_queue.put_nowait(b&#39;&#39;)
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&#39;\x00&#39; 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=&#39;replace&#39;)
return str(val) if val is not None else &#34;unknown&#34;
node_info = {
&#34;name&#34;: to_str(getattr(self, &#39;unique&#39;, &#39;unknown&#39;)),
&#34;host&#34;: to_str(getattr(self, &#39;host&#39;, &#39;unknown&#39;))
}
if isinstance(getattr(self, &#39;tags&#39;, None), dict):
node_info[&#34;os&#34;] = to_str(self.tags.get(&#34;os&#34;, &#34;unknown&#34;))
node_info[&#34;prompt&#34;] = to_str(self.tags.get(&#34;prompt&#34;, r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;))
# 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&#39;\x00&#39;, b&#39;&#39;)
if clean_data:
# Track command boundaries when user hits Enter or presses Ctrl+C
if hasattr(self, &#39;mylog&#39;) and (b&#39;\r&#39; in clean_data or b&#39;\n&#39; in clean_data or b&#39;\x03&#39; in clean_data):
pos = self.mylog.tell()
marker_cmd = &#34;CANCELLED&#34; if b&#39;\x03&#39; in clean_data else None
self.cmd_byte_positions.append((pos, marker_cmd))
if hasattr(self, &#39;current_local_stream&#39;) and self.current_local_stream is not None:
try:
await self.current_local_stream.write(f&#39;\x1b]133;B;{pos}\x07&#39;.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, &#39;missingtext&#39;, 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&#39;&#39;) # Re-put EOF later or handle it
break
chunks.append(extra)
except asyncio.QueueEmpty:
break
has_eof = chunks[-1] == b&#39;&#39;
if has_eof:
chunks.pop()
if chunks:
combined_data = b&#39;&#39;.join(chunks)
if skip_newlines:
stripped = combined_data.lstrip(b&#39;\r\n&#39;)
if stripped:
skip_newlines = False
combined_data = stripped
else:
if has_eof: break
continue
await local_stream.write(combined_data)
if hasattr(self, &#39;mylog&#39;):
self.mylog.write(combined_data)
if has_eof:
break
async def keepalive_task():
while True:
await asyncio.sleep(1)
if time() - self.lastinput &gt;= self.idletime:
try:
self.child.sendcontrol(&#34;e&#34;)
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=&#39;replace&#39;)
cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
with open(self.logfile, &#34;w&#34;) 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, &#39;pid&#39;, None)
if child_pid:
new_cwd = os.readlink(f&#34;/proc/{child_pid}/cwd&#34;)
if new_cwd != last_cwd:
last_cwd = new_cwd
try:
os.chdir(new_cwd)
except Exception:
pass
try:
await local_stream.write(f&#34;\033]7;file://{hostname}{new_cwd}\007&#34;.encode())
except Exception:
pass
if isinstance(self.tags, dict):
self.tags[&#34;cwd&#34;] = 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 == &#34;local&#34;:
tasks.append(asyncio.create_task(pwd_tracker_task()))
if self.idletime &gt; 0:
tasks.append(asyncio.create_task(keepalive_task()))
if hasattr(self, &#39;logfile&#39;) and hasattr(self, &#39;mylog&#39;):
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, &#39;logfile&#39;) and hasattr(self, &#39;mylog&#39;):
try:
raw_log = self.mylog.getvalue().decode(errors=&#39;replace&#39;)
cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
with open(self.logfile, &#34;w&#34;) 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 &amp; ~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):
&#34;&#34;&#34;
Inject a list of commands into the node&#39;s PTY.
Handles screen_length_command, history tracking and delays.
&#34;&#34;&#34;
if not commands:
return
# 0. Clear line
os.write(child_fd, b&#39;\x15&#39;)
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get(&#34;screen_length_command&#34;) if hasattr(self, &#39;tags&#39;) 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&#39;s official history (SKIP if it&#39;s the administrative screen length command)
if cmd != slc and hasattr(self, &#39;cmd_byte_positions&#39;) and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, &#39;mylog&#39;) else 0
self.cmd_byte_positions.append((log_pos, cmd))
if hasattr(self, &#39;current_local_stream&#39;) and self.current_local_stream is not None:
try:
await self.current_local_stream.write(f&#39;\x1b]133;B;{log_pos}\x07&#39;.encode())
except Exception:
pass
# Write physically to PTY
os.write(child_fd, (cmd + &#34;\n&#34;).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):
&#39;&#39;&#39;
Asynchronous interactive session using Smart Tunnel architecture.
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.
&#39;&#39;&#39;
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(&#34;error&#34;, str(connect))
else:
printer.error(f&#34;Connection failed: {str(connect)}&#34;)
sys.exit(1)
def _build_local_copilot_handler(self):
&#34;&#34;&#34;Build copilot handler for local CLI sessions using rich for rendering.&#34;&#34;&#34;
config = getattr(self, &#39;config&#39;, None) if hasattr(self, &#39;config&#39;) else None
return self._copilot_handler(config)
def _copilot_handler(self, config):
&#34;&#34;&#34;Unified copilot handler for local session.&#34;&#34;&#34;
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, &#39;copilot_history&#39;, None),
session_state=getattr(stream, &#39;copilot_state&#39;, 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[&#39;banner_shown&#39;] = 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, &#39;stop_reading&#39;):
stream.stop_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
# 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 (&#34;send_all&#34;, &#34;custom&#34;):
cmds_to_send = commands if action == &#34;send_all&#34; else custom_cmd
await self.inject_commands(cmds_to_send, child_fd)
# Dynamic Wait for Device Prompt Settle
prompt_pattern = node_info.get(&#34;prompt&#34;, r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;)
start_t = time()
last_len = 0
quiet_count = 0
while time() - start_t &lt; 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=&#39;replace&#39;), True).strip()
lines = [l.strip() for l in clean_txt.split(&#39;\n&#39;) if l.strip()]
last_line = lines[-1] if lines else &#34;&#34;
if quiet_count &gt;= 2 and re.search(prompt_pattern, last_line):
break
raw_bytes = self.mylog.getvalue()
continue
if action == &#34;continue&#34;:
continue
break
finally:
print(&#34;\033[2m Returning to session...\033[0m&#34;, flush=True)
# Restart terminal reading to return to interactive SSH/Telnet mode
if hasattr(stream, &#39;start_reading&#39;):
stream.start_reading()
elif hasattr(stream, &#39;_loop&#39;) and hasattr(stream, &#39;stdin_fd&#39;):
stream._loop.add_reader(stream.stdin_fd, stream._read_ready)
os.write(child_fd, b&#39;\x15\r&#39;)
except Exception as e:
import traceback
print(f&#34;\n[ERROR in Copilot Handler] {e}&#34;, flush=True)
traceback.print_exc()
os.write(child_fd, b&#39;\x15\r&#39;)
return handler
@MethodHook
def run(self, commands, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;, stdout = False, timeout = 10, logger = None):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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.
&#39;&#39;&#39;
connect = self._connect(timeout = timeout, logger = logger)
now = datetime.datetime.now().strftime(&#39;%Y-%m-%d_%H%M%S&#39;)
if connect == True:
if logger:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
output = &#39;&#39;
status = &#39;&#39;
if not isinstance(commands, list):
commands = [commands]
if &#34;screen_length_command&#34; in self.tags:
commands.insert(0, self.tags[&#34;screen_length_command&#34;])
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&#34;Error: Variable {e} not defined in task or inventory&#34;
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 &#34;screen_length_command&#34; 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(&#34;output&#34;, output)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) 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(&#34;error&#34;, f&#34;Connection failed: {connect}&#34;)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) as f:
f.write(connect)
f.close()
return connect
@MethodHook
def test(self, commands, expected, vars = None,*, folder = &#39;&#39;, prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;, timeout = 10, logger = None):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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.
&#39;&#39;&#39;
now = datetime.datetime.now().strftime(&#34;%Y-%m-%d_%H-%M-%S&#34;)
connect = self._connect(timeout = timeout, logger = logger)
if connect == True:
if logger:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
output = &#39;&#39;
if not isinstance(commands, list):
commands = [commands]
if not isinstance(expected, list):
expected = [expected]
if &#34;screen_length_command&#34; in self.tags:
commands.insert(0, self.tags[&#34;screen_length_command&#34;])
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&#34;Error: Variable {e} not defined in task or inventory&#34;
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; 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(&#34;output&#34;, output)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) 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&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
cleaned_output = output
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, 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 == &#34;ssh&#34;:
cmd += &#34; -p &#34; + self.port
elif self.protocol == &#34;sftp&#34;:
cmd += &#34; -P &#34; + self.port
if self.options:
opts = self.options
if self.protocol == &#34;sftp&#34;:
# Strip SSH-only flags that sftp doesn&#39;t support
opts = re.sub(r&#39;(?&lt;!\S)-[XxtTAaNf]\b&#39;, &#39;&#39;, opts).strip()
if opts:
cmd += &#34; &#34; + opts
if self.jumphost:
cmd += &#34; &#34; + self.jumphost
user_host = f&#34;{self.user}@{self.host}&#34; if self.user else self.host
cmd += f&#34; {user_host}&#34;
return cmd
@MethodHook
def _generate_telnet_cmd(self):
cmd = f&#34;telnet {self.host}&#34;
if self.port:
cmd += f&#34; {self.port}&#34;
if self.options:
cmd += f&#34; {self.options}&#34;
return cmd
@MethodHook
def _generate_kube_cmd(self):
cmd = f&#34;kubectl exec {self.options} {self.host} -it --&#34;
kube_command = self.tags.get(&#34;kube_command&#34;, &#34;/bin/bash&#34;) if isinstance(self.tags, dict) else &#34;/bin/bash&#34;
cmd += f&#34; {kube_command}&#34;
return cmd
@MethodHook
def _generate_docker_cmd(self):
cmd = f&#34;docker {self.options} exec -it {self.host}&#34;
docker_command = self.tags.get(&#34;docker_command&#34;, &#34;/bin/bash&#34;) if isinstance(self.tags, dict) else &#34;/bin/bash&#34;
cmd += f&#34; {docker_command}&#34;
return cmd
@MethodHook
def _generate_ssm_cmd(self):
region = self.tags.get(&#34;region&#34;, &#34;&#34;) if isinstance(self.tags, dict) else &#34;&#34;
profile = self.tags.get(&#34;profile&#34;, &#34;&#34;) if isinstance(self.tags, dict) else &#34;&#34;
cmd = f&#34;aws ssm start-session --target {self.host}&#34;
if region:
cmd += f&#34; --region {region}&#34;
if profile:
cmd += f&#34; --profile {profile}&#34;
if self.options:
cmd += f&#34; {self.options}&#34;
return cmd
@MethodHook
def _get_cmd(self):
if self.protocol in [&#34;ssh&#34;, &#34;sftp&#34;]:
return self._generate_ssh_sftp_cmd()
elif self.protocol == &#34;telnet&#34;:
return self._generate_telnet_cmd()
elif self.protocol == &#34;kubectl&#34;:
return self._generate_kube_cmd()
elif self.protocol == &#34;docker&#34;:
return self._generate_docker_cmd()
elif self.protocol == &#34;ssm&#34;:
return self._generate_ssm_cmd()
elif self.protocol == &#34;local&#34;:
return self.host
else:
printer.error(f&#34;Invalid protocol: {self.protocol}&#34;)
sys.exit(1)
@MethodHook
def _connect(self, debug=False, timeout=10, max_attempts=3, logger=None):
if self.protocol == &#34;local&#34;:
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 != &#39;&#39;:
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 != &#39;&#39;:
self.logfile = self._logfile()
default_prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;
prompt = self.tags.get(&#34;prompt&#34;, default_prompt) if isinstance(self.tags, dict) else default_prompt
password_prompt = &#39;[p|P]assword:|[u|U]sername:&#39; if self.protocol != &#39;telnet&#39; else &#39;[p|P]assword:&#39;
expects = {
&#34;ssh&#34;: [&#39;yes/no&#39;, &#39;refused&#39;, &#39;supported&#39;, &#39;Invalid|[u|U]sage: ssh&#39;, &#39;ssh-keygen.*\&#34;&#39;, &#39;timeout|timed.out&#39;, &#39;unavailable&#39;, &#39;closed&#39;, password_prompt, prompt, &#39;suspend&#39;, pexpect.EOF, pexpect.TIMEOUT, &#34;No route to host&#34;, &#34;resolve hostname&#34;, &#34;no matching&#34;, &#34;[b|B]ad (owner|permissions)&#34;],
&#34;sftp&#34;: [&#39;yes/no&#39;, &#39;refused&#39;, &#39;supported&#39;, &#39;Invalid|[u|U]sage: sftp&#39;, &#39;ssh-keygen.*\&#34;&#39;, &#39;timeout|timed.out&#39;, &#39;unavailable&#39;, &#39;closed&#39;, password_prompt, prompt, &#39;suspend&#39;, pexpect.EOF, pexpect.TIMEOUT, &#34;No route to host&#34;, &#34;resolve hostname&#34;, &#34;no matching&#34;, &#34;[b|B]ad (owner|permissions)&#34;],
&#34;telnet&#34;: [&#39;[u|U]sername:&#39;, &#39;refused&#39;, &#39;supported&#39;, &#39;invalid|unrecognized option&#39;, &#39;ssh-keygen.*\&#34;&#39;, &#39;timeout|timed.out&#39;, &#39;unavailable&#39;, &#39;closed&#39;, password_prompt, prompt, &#39;suspend&#39;, pexpect.EOF, pexpect.TIMEOUT, &#34;No route to host&#34;, &#34;resolve hostname&#34;, &#34;no matching&#34;, &#34;[b|B]ad (owner|permissions)&#34;],
&#34;kubectl&#34;: [&#39;[u|U]sername:&#39;, &#39;[r|R]efused&#39;, &#39;[E|e]rror&#39;, &#39;DEPRECATED&#39;, pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF, &#34;expired|invalid&#34;],
&#34;docker&#34;: [&#39;[u|U]sername:&#39;, &#39;Cannot&#39;, &#39;[E|e]rror&#39;, &#39;failed&#39;, &#39;not a docker command&#39;, &#39;unknown&#39;, &#39;unable to resolve&#39;, pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF],
&#34;ssm&#34;: [&#39;[u|U]sername:&#39;, &#39;Cannot&#39;, &#39;[E|e]rror&#39;, &#39;failed&#39;, &#39;SessionManagerPlugin&#39;, &#39;[u|U]nknown&#39;, &#39;unable to resolve&#39;, pexpect.TIMEOUT, password_prompt, prompt, pexpect.EOF]
}
error_indices = {
&#34;ssh&#34;: [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
&#34;sftp&#34;: [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
&#34;telnet&#34;: [1, 2, 3, 4, 5, 6, 7, 12, 13, 14, 15, 16],
&#34;kubectl&#34;: [1, 2, 3, 4, 8], # Define error indices for kube
&#34;docker&#34;: [1, 2, 3, 4, 5, 6, 7], # Define error indices for docker
&#34;ssm&#34;: [1, 2, 3, 4, 5, 6, 7]
}
eof_indices = {
&#34;ssh&#34;: [8, 9, 10, 11],
&#34;sftp&#34;: [8, 9, 10, 11],
&#34;telnet&#34;: [8, 9, 10, 11],
&#34;kubectl&#34;: [5, 6, 7], # Define eof indices for kube
&#34;docker&#34;: [8, 9, 10], # Define eof indices for docker
&#34;ssm&#34;: [8, 9, 10]
}
initial_indices = {
&#34;ssh&#34;: [0],
&#34;sftp&#34;: [0],
&#34;telnet&#34;: [0],
&#34;kubectl&#34;: [0], # Define special indices for kube
&#34;docker&#34;: [0], # Define special indices for docker
&#34;ssm&#34;: [0]
}
attempts = 1
while attempts &lt;= max_attempts:
args = shlex.split(cmd)
child = pexpect.spawn(args[0], args[1:])
if isinstance(self.tags, dict) and self.tags.get(&#34;console&#34;):
child.sendline()
if debug:
if logger:
logger(&#34;debug&#34;, f&#34;Command:\n{cmd}&#34;)
self.mylog = io.BytesIO()
self.mylog.write(f&#34;[i] [DEBUG] Command:\r\n {cmd}\r\n&#34;.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 [&#34;ssh&#34;, &#34;sftp&#34;]:
child.sendline(&#39;yes&#39;)
elif self.protocol in [&#34;telnet&#34;, &#34;kubectl&#34;, &#34;docker&#34;, &#34;ssm&#34;]:
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 = &#34;Connection timeout&#34; if results_value == pexpect.TIMEOUT else child.after.decode()
return f&#34;Connection failed code: {results}\n{child.before.decode().lstrip()}{after}{child.readline().decode()}&#34;.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 == &#34;suspend&#34;:
child.sendline(&#34;\r&#34;)
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(&#34;post_connect_commands&#34;):
cmds = self.tags.get(&#34;post_connect_commands&#34;)
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):
&#34;&#34;&#34;
Inject a list of commands into the node&#39;s PTY.
Handles screen_length_command, history tracking and delays.
&#34;&#34;&#34;
if not commands:
return
# 0. Clear line
os.write(child_fd, b&#39;\x15&#39;)
await asyncio.sleep(0.1)
# 1. Prepare list (prepend screen_length if exists)
slc = self.tags.get(&#34;screen_length_command&#34;) if hasattr(self, &#39;tags&#39;) 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&#39;s official history (SKIP if it&#39;s the administrative screen length command)
if cmd != slc and hasattr(self, &#39;cmd_byte_positions&#39;) and self.cmd_byte_positions is not None:
log_pos = self.mylog.tell() if hasattr(self, &#39;mylog&#39;) else 0
self.cmd_byte_positions.append((log_pos, cmd))
if hasattr(self, &#39;current_local_stream&#39;) and self.current_local_stream is not None:
try:
await self.current_local_stream.write(f&#39;\x1b]133;B;{log_pos}\x07&#39;.encode())
except Exception:
pass
# Write physically to PTY
os.write(child_fd, (cmd + &#34;\n&#34;).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):
&#39;&#39;&#39;
Asynchronous interactive session using Smart Tunnel architecture.
Allows multiplexing I/O and handling SIGWINCH events locally without blocking.
&#39;&#39;&#39;
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(&#34;error&#34;, str(connect))
else:
printer.error(f&#34;Connection failed: {str(connect)}&#34;)
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=&#x27;&gt;$|#$|\\$$|&gt;.$|#.$|\\$.$&#x27;,<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 = &#39;&#39;, prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;, stdout = False, timeout = 10, logger = None):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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.
&#39;&#39;&#39;
connect = self._connect(timeout = timeout, logger = logger)
now = datetime.datetime.now().strftime(&#39;%Y-%m-%d_%H%M%S&#39;)
if connect == True:
if logger:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
output = &#39;&#39;
status = &#39;&#39;
if not isinstance(commands, list):
commands = [commands]
if &#34;screen_length_command&#34; in self.tags:
commands.insert(0, self.tags[&#34;screen_length_command&#34;])
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&#34;Error: Variable {e} not defined in task or inventory&#34;
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 &#34;screen_length_command&#34; 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(&#34;output&#34;, output)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) 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(&#34;error&#34;, f&#34;Connection failed: {connect}&#34;)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) 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 "&gt;" or EOF while
routers use "&gt;" 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=&#x27;&gt;$|#$|\\$$|&gt;.$|#.$|\\$.$&#x27;,<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 = &#39;&#39;, prompt = r&#39;&gt;$|#$|\$$|&gt;.$|#.$|\$.$&#39;, timeout = 10, logger = None):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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.
&#39;&#39;&#39;
now = datetime.datetime.now().strftime(&#34;%Y-%m-%d_%H-%M-%S&#34;)
connect = self._connect(timeout = timeout, logger = logger)
if connect == True:
if logger:
port_str = f&#34;:{self.port}&#34; if self.port and self.protocol not in [&#34;ssm&#34;, &#34;kubectl&#34;, &#34;docker&#34;] else &#34;&#34;
logger(&#34;success&#34;, f&#34;Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}&#34;)
if &#34;prompt&#34; in self.tags:
prompt = self.tags[&#34;prompt&#34;]
expects = [prompt, pexpect.EOF, pexpect.TIMEOUT]
output = &#39;&#39;
if not isinstance(commands, list):
commands = [commands]
if not isinstance(expected, list):
expected = [expected]
if &#34;screen_length_command&#34; in self.tags:
commands.insert(0, self.tags[&#34;screen_length_command&#34;])
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&#34;Error: Variable {e} not defined in task or inventory&#34;
self.status = 1
return self.output
result = self.child.expect(expects, timeout = timeout)
if c == commands[0] and &#34;screen_length_command&#34; 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(&#34;output&#34;, output)
if folder != &#39;&#39;:
with open(folder + &#34;/&#34; + self.unique + &#34;_&#34; + now + &#34;.txt&#34;, &#34;w&#34;) 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&#39;(?&lt;!\\)\$&#39;, &#39;&#39;, prompt)
cleaned_output = output
try:
newpattern = f&#34;.*({updatedprompt}).*{e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, cleaned_output)
except re.error:
try:
escaped_e = re.escape(e)
newpattern = f&#34;.*({updatedprompt}).*{escaped_e}.*&#34;
cleaned_output = re.sub(newpattern, &#39;&#39;, 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 "&gt;" or EOF while
routers use "&gt;" 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:
&#39;&#39;&#39; 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.
- &lt;unique&gt; (obj): For each item in nodelist, there is an attribute
generated with the node unique.
&#39;&#39;&#39;
def __init__(self, nodes: dict, config = &#39;&#39;):
&#39;&#39;&#39;
### 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.
&#39;&#39;&#39;
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):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while routers
use &#34;&gt;&#34; or &#34;#&#34;. 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&#39;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.
&#39;&#39;&#39;
args = {}
nodesargs = {}
args[&#34;commands&#34;] = commands
if folder != None:
args[&#34;folder&#34;] = folder
Path(folder).mkdir(parents=True, exist_ok=True)
if prompt != None:
args[&#34;prompt&#34;] = prompt
if stdout != None and on_complete is None:
args[&#34;stdout&#34;] = stdout
if timeout != None:
args[&#34;timeout&#34;] = timeout
output = {}
status = {}
tasks = []
def _run_node(node_obj, node_args, callback):
&#34;&#34;&#34;Wrapper that runs a node and fires the callback on completion.&#34;&#34;&#34;
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][&#34;vars&#34;] = {}
if &#34;__global__&#34; in vars.keys():
nodesargs[n.unique][&#34;vars&#34;].update(vars[&#34;__global__&#34;])
for var_key, var_val in vars.items():
if var_key == &#34;__global__&#34;:
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
# Pass the logger to the node
nodesargs[n.unique][&#34;logger&#34;] = 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):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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&#39;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.
&#39;&#39;&#39;
args = {}
nodesargs = {}
args[&#34;commands&#34;] = commands
args[&#34;expected&#34;] = expected
if folder != None:
args[&#34;folder&#34;] = folder
Path(folder).mkdir(parents=True, exist_ok=True)
if prompt != None:
args[&#34;prompt&#34;] = prompt
if timeout != None:
args[&#34;timeout&#34;] = timeout
output = {}
result = {}
status = {}
tasks = []
def _test_node(node_obj, node_args, callback):
&#34;&#34;&#34;Wrapper that runs a node test and fires the callback on completion.&#34;&#34;&#34;
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][&#34;vars&#34;] = {}
if &#34;__global__&#34; in vars.keys():
nodesargs[n.unique][&#34;vars&#34;].update(vars[&#34;__global__&#34;])
for var_key, var_val in vars.items():
if var_key == &#34;__global__&#34;:
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
nodesargs[n.unique][&#34;logger&#34;] = 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.
- &lt;unique&gt; (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):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while routers
use &#34;&gt;&#34; or &#34;#&#34;. 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&#39;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.
&#39;&#39;&#39;
args = {}
nodesargs = {}
args[&#34;commands&#34;] = commands
if folder != None:
args[&#34;folder&#34;] = folder
Path(folder).mkdir(parents=True, exist_ok=True)
if prompt != None:
args[&#34;prompt&#34;] = prompt
if stdout != None and on_complete is None:
args[&#34;stdout&#34;] = stdout
if timeout != None:
args[&#34;timeout&#34;] = timeout
output = {}
status = {}
tasks = []
def _run_node(node_obj, node_args, callback):
&#34;&#34;&#34;Wrapper that runs a node and fires the callback on completion.&#34;&#34;&#34;
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][&#34;vars&#34;] = {}
if &#34;__global__&#34; in vars.keys():
nodesargs[n.unique][&#34;vars&#34;].update(vars[&#34;__global__&#34;])
for var_key, var_val in vars.items():
if var_key == &#34;__global__&#34;:
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
# Pass the logger to the node
nodesargs[n.unique][&#34;logger&#34;] = 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 "&gt;" or EOF while routers
use "&gt;" 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):
&#39;&#39;&#39;
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 &#34;&gt;&#34; or EOF while
routers use &#34;&gt;&#34; or &#34;#&#34;. 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&#39;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.
&#39;&#39;&#39;
args = {}
nodesargs = {}
args[&#34;commands&#34;] = commands
args[&#34;expected&#34;] = expected
if folder != None:
args[&#34;folder&#34;] = folder
Path(folder).mkdir(parents=True, exist_ok=True)
if prompt != None:
args[&#34;prompt&#34;] = prompt
if timeout != None:
args[&#34;timeout&#34;] = timeout
output = {}
result = {}
status = {}
tasks = []
def _test_node(node_obj, node_args, callback):
&#34;&#34;&#34;Wrapper that runs a node test and fires the callback on completion.&#34;&#34;&#34;
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][&#34;vars&#34;] = {}
if &#34;__global__&#34; in vars.keys():
nodesargs[n.unique][&#34;vars&#34;].update(vars[&#34;__global__&#34;])
for var_key, var_val in vars.items():
if var_key == &#34;__global__&#34;:
continue
try:
if re.search(var_key, n.unique, re.IGNORECASE):
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
except re.error:
if var_key == n.unique:
nodesargs[n.unique][&#34;vars&#34;].update(var_val)
nodesargs[n.unique][&#34;logger&#34;] = 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 "&gt;" or EOF while
routers use "&gt;" 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>