This commit is contained in:
2025-07-18 14:22:33 -05:00
commit 81a76738db
3 changed files with 596 additions and 0 deletions

1
allowed_key.pub Normal file
View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDrSArwdlSf2/6Um44IMUy+XJJAJHgLAG7/fsx9WA9UK Krishna Ayyalasomayajula

27
server_host_key.pem Normal file
View File

@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAruXKo2lyNm0lvmpP1cLX27B8Mfzd4IvHGLuwjlr229RjPFZy
BUl6TGgZ87NYfXBR0kV8YApldq4xNgvfurfY2KCiMWgRFxj9y5ycwO6SZtvDyA/P
7/HBVxILNUop6wtmti5fuLCvWRR7GIHD1lqsy3D+SHYNcJ5WFgJpmQcaQpH+Lrzp
1XxcUg9oOoyN9DyVWBrqeO1suHgzLpsMAxzPwUPv8M4P3ARR9TMEZDq6t4Rg6EuR
bkqZ5PhwI+4Cz8OK5tQ+riUGqnGM/OyNHvm/JSHO3+9TK5IYgtAyXOqN04hbrJps
ysFRIzS88nsngOIhb7+mCkOCmE7xRXspPPQz6QIDAQABAoIBAADY+ap6yR7bXh5R
jN9N7Q6/AabkkCRo2NJFBhcEj58l9IvNIwVZ1pP4EWEmgZiuKBpI2BYjtM199d3y
VUjD4CZC3bAhBFlL2y4oo0YexFd+L97Fu44yNvnnna+cdANn+mkS8tMD0dNRULUn
PA5LRzsf79ajfY0Pmk4NJP9FgrwBp+9knSX+QacOkZUZ8u3BRkZq9nrT8dV+iPEb
XqZCA/RbQwcV/Lcu1VwWOh8sbkuGmzcx5n0YMr99no5eokpK/hqFWSf1vXhtTz9b
oNAAhUJ1X87S5cQtnJXC4cqsluCX3TMz6608QcKWyogTw8ghHQupjuZBsK+5Ws2R
891E4N0CgYEA4kCIB8QuGRpa5yFEGGZ6dzlLpJ8kES6W9Sajh97Zku7VvyO51XQn
ZgwofTg6+YgAoxHG3CQkxKOiCUePl0ANRvFy5fDwznNvPwANhrGM83srYnaRCEZE
uew6LrOFiBmAruVp2HRptUfDq+Y0a3pk+I6IwQNsZSI4Wh/RSdK2HhUCgYEAxeS1
lhmKoFxFBqIasOmR/6Fv14ESzEU7rzaB6nrhMDmtjiBvnJp2QN23u/tR5SBC18B0
kHvVgZqoQ6CAAu5cmBmqNMKEoF3JowfnCI12KvYl8gR7K75B52Ou3YxLEOi8EUjZ
piV3y+7YI/m+j7lDaGhkXWQMCwEmTNpsH4h0B4UCgYEAi7zph0puYki5zjjokt1w
VASKKKG1p/sLd1wm8jr8TFjAoW4ST7iOwONPeo9pNUb/hbfsB3k3UE/0OyD8maEQ
0jk8CrK2N/xpwBJrSD6O3K69C/JI/0BPIDm7ca6lEXsW1G6S4gJ8a19ohdoHlD4i
8LUv124i24+4GEnAfITswEUCgYADRfEq9mkwldYecff3DSX5EHaFHgFtl4eRMlmb
w0SOQ6X3P9oYwQVLtV8goNuN6qawYuKKsUGqzyARXko/wimN6n7COKVw8ZwwMiVE
IvLdawzdn+1Zn9//L8ropzVmpjLWJlpTQTNmECFLFwpr3iibRX7DfLAmTnKPut0m
+F7S8QKBgBgTIhnZdQzso0j+htlPyy82mDGWZUGqQEN3AULYXRwP4ApZhbxc+cYZ
ozs42UsUQb0cBKfK4Yvu2U4vNiz23MpHeI1XyfIDb4Cabs1md+tfH31R+EgscyR1
cR72nGBjDy2EjQD3jHAqRFnK7vr0yePk8OkFLCly/f8CbKM2rahw
-----END RSA PRIVATE KEY-----

568
ssh.py Normal file
View File

@@ -0,0 +1,568 @@
import paramiko
import socket
import threading
import traceback
import hashlib
import json
import base64
from openai import OpenAI
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
import asyncio
import os
# --- AUTH CONFIG ---
ALLOWED_USERNAME = "jarvis"
# Load the allowed public key properly
def load_public_key_from_file(filepath):
"""Load public key from OpenSSH format file"""
try:
with open(filepath, 'r') as f:
key_data = f.read().strip()
# Handle OpenSSH format (ssh-rsa AAAAB3... comment)
parts = key_data.split()
if len(parts) < 2:
raise ValueError("Invalid key format")
key_type = parts[0]
key_blob = parts[1]
# Decode base64 to get the key data
key_bytes = base64.b64decode(key_blob)
# Create the appropriate key object based on type
if key_type == 'ssh-rsa':
return paramiko.RSAKey(data=key_bytes)
elif key_type == 'ssh-dss':
return paramiko.DSSKey(data=key_bytes)
elif key_type == 'ssh-ed25519':
return paramiko.Ed25519Key(data=key_bytes)
elif key_type.startswith('ecdsa-sha2-'):
return paramiko.ECDSAKey(data=key_bytes)
else:
raise ValueError(f"Unsupported key type: {key_type}")
except Exception as e:
print(f"Error loading public key: {e}")
print(f"Make sure '{filepath}' exists and is in OpenSSH format (ssh-rsa AAAAB3...)")
raise
# Check if key file exists before loading
if not os.path.exists("allowed_key.pub"):
print("Error: 'allowed_key.pub' not found!")
print("Create this file with your public key in OpenSSH format:")
print("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ... user@host")
exit(1)
ALLOWED_KEY = load_public_key_from_file("allowed_key.pub")
# --- SSH SERVER IMPLEMENTATION ---
class SSHServer(paramiko.ServerInterface):
def __init__(self):
self.event = threading.Event()
def check_auth_publickey(self, username, key):
"""Check if the provided key matches our allowed key"""
print(f"Authentication attempt for user: {username}")
if username != ALLOWED_USERNAME:
print(f"Wrong username: {username}")
return paramiko.AUTH_FAILED
# Compare the keys by their base64 representation
if self._compare_public_keys(key, ALLOWED_KEY):
print("✅ Authentication successful!")
return paramiko.AUTH_SUCCESSFUL
else:
print("❌ Key mismatch")
return paramiko.AUTH_FAILED
def _compare_public_keys(self, key1, key2):
"""Compare two public keys"""
try:
# Compare the base64 encoded key data
return key1.get_base64() == key2.get_base64()
except Exception as e:
print(f"Error comparing keys: {e}")
return False
def get_allowed_auths(self, username):
return "publickey"
def check_channel_request(self, kind, chanid):
if kind == "session":
return paramiko.OPEN_SUCCEEDED
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def check_channel_shell_request(self, channel):
self.event.set()
return True
def check_channel_pty_request(self, channel, term, width, height, pixelwidth, pixelheight, modes):
# Enable echo so user can see what they type
channel.set_combine_stderr(True)
return True
# --- UTILS: HASH/UNHASH URLS ---
def hash_url(url: str, url_to_hash: dict, hash_to_url: dict) -> str:
short_hash = hashlib.md5(url.encode()).hexdigest()[:4]
url_to_hash[url] = short_hash
hash_to_url[short_hash] = url
return short_hash
def unhash_url(short_hash: str, hash_to_url: dict) -> str | None:
return hash_to_url.get(short_hash)
# --- TOOL CONVERSION & SERIALIZATION ---
def convert_tools_to_openai_functions(tools):
functions = []
try:
for tool in tools:
properties = {}
schema = tool.inputSchema
if hasattr(schema, "model_dump"):
schema = schema.model_dump()
for prop_name, prop_schema in schema.get('properties', {}).items():
properties[prop_name] = {
"type": prop_schema.get('type', 'string')
}
function = {
"name": tool.name,
"description": tool.description or "",
"parameters": {
"type": schema.get('type', 'object'),
"properties": properties,
"required": schema.get('required', [])
}
}
functions.append(function)
except Exception:
traceback.print_exc()
return functions
def serialize_tool_result(result):
try:
if hasattr(result, "structuredContent") and result.structuredContent:
return result.structuredContent
elif hasattr(result, "__dict__"):
return {k: serialize_tool_result(v) for k, v in result.__dict__.items()}
elif isinstance(result, list):
return [serialize_tool_result(r) for r in result]
elif isinstance(result, dict):
return {k: serialize_tool_result(v) for k, v in result.items()}
else:
return str(result)
except Exception:
traceback.print_exc()
return str(result)
# --- CONNECT TO SINGLE MCP SERVER ---
async def connect_to_server(server_url):
try:
async with sse_client(server_url) as (send_stream, recv_stream):
session = ClientSession(send_stream, recv_stream)
await session.initialize()
tools = await session.list_tools()
funcs = convert_tools_to_openai_functions(tools.tools)
return (server_url, session, funcs)
except Exception:
traceback.print_exc()
return f"❌ Error connecting or initializing {server_url}"
# --- CONTACT AI MODEL ---
def contact_AI(client, functions, message_stack):
try:
completion = client.chat.completions.create(
model="models/Mistral-7b-V0.3-ReAct.Q5_K_M.gguf",
messages=message_stack,
tools=[{"type": "function", "function": f} for f in functions],
tool_choice="auto",
stop=["<|im_end|>"]
)
except Exception:
traceback.print_exc()
return {"type": "text", "message": "ERROR contacting AI"}
try:
msg = completion.choices[0].message
if msg.tool_calls:
return {
"type": "tool_call",
"message": {
"tool_calls": [
{
"id": t.id,
"type": t.type,
"function": {
"name": t.function.name,
"arguments": json.loads(t.function.arguments)
}
}
for t in msg.tool_calls
]
}
}
else:
return {"type": "text", "message": msg.content or "<empty or None>"}
except Exception:
traceback.print_exc()
return {"type": "text", "message": "ERROR parsing AI response"}
# --- ADD MCP SERVER HELPER ---
async def add_mcp_server(server_url, chan, tools, mcp_server_connections, url_to_code, code_to_url):
server_url = server_url.strip()
if not server_url:
chan.send(b"No server URL provided.\n")
return
# Check if already added
if server_url in url_to_code:
chan.send(b"Server already added.\n")
return
result = await connect_to_server(server_url)
if isinstance(result, str):
chan.send(result.encode() + b"\n")
return
_, session, funcs = result
# Hash and rename funcs
code = hash_url(server_url, url_to_code, code_to_url)
for i, func in enumerate(funcs):
funcs[i]["name"] = f"{code}:{func['name']}"
# Update global dicts
tools[server_url] = funcs
mcp_server_connections[server_url] = session
# Persist to servers.txt
with open("servers.txt", "a") as f:
f.write(server_url + "\n")
chan.send(f"✅ Added MCP server: {server_url}\n".encode())
# --- REMOVE MCP SERVER HELPER ---
async def remove_mcp_server(code_or_url, chan, tools, mcp_server_connections, url_to_code, code_to_url):
code_or_url = code_or_url.strip()
# Determine if input is a hash code or a full URL
server_url = None
if code_or_url in code_to_url:
server_url = code_to_url[code_or_url]
elif code_or_url in url_to_code:
server_url = code_or_url
if not server_url:
chan.send(b"No matching MCP server found for removal.\n")
return
# Remove from dicts
code = url_to_code.get(server_url)
if code:
code_to_url.pop(code, None)
url_to_code.pop(server_url, None)
tools.pop(server_url, None)
session = mcp_server_connections.pop(server_url, None)
if session:
try:
await session.close()
except:
pass
# Update servers.txt
try:
with open("servers.txt", "r") as f:
servers = [s.strip() for s in f.readlines()]
servers = [s for s in servers if s != server_url]
with open("servers.txt", "w") as f:
for s in servers:
f.write(s + "\n")
except Exception:
pass
chan.send(f"✅ Removed MCP server: {server_url}\n".encode())
# --- MAIN CONVERSATION LOOP ---
async def conversation_loop(mcp_server_connections, client, tools, message_stack, chan, url_to_code, code_to_url):
tool_call_counter = {}
while True:
try:
prompt = "\r\nHuman> "
chan.send(prompt.encode())
user_input = ""
while True:
data = chan.recv(1024)
if not data:
return # Connection closed
decoded = data.decode('utf-8', errors='ignore')
for ch in decoded:
# --- Handle Enter ---
if ch in ("\r", "\n"):
chan.send(b"\r\n")
user_input = user_input.strip()
break
# --- Handle Backspace ---
if ch in ("\x08", "\x7f"):
if user_input:
user_input = user_input[:-1]
chan.send(b"\b \b")
continue # Skip to next char
# --- Prevent deleting prompt ---
if not user_input and ch in ("\x08", "\x7f"):
continue # Ignore backspace at prompt start
# --- Echo each character ---
chan.send(ch.encode())
user_input += ch
else:
continue
break
# --- Allow immediate exit ---
if user_input.lower() in ("exit", "quit"):
chan.send(b"\r\nExiting conversation.\r\n")
exit()
if not user_input:
continue
# --- MCP Commands ---
if user_input.startswith("/mcp add server"):
parts = user_input.split(maxsplit=3)
if len(parts) < 4:
chan.send(b"Usage: /mcp add server <server_url>\r\n")
continue
await add_mcp_server(parts[3], chan, tools, mcp_server_connections, url_to_code, code_to_url)
continue
elif user_input.startswith("/mcp remove server"):
parts = user_input.split(maxsplit=3)
if len(parts) < 4:
chan.send(b"Usage: /mcp remove server <code_or_url>\r\n")
continue
await remove_mcp_server(parts[3], chan, tools, mcp_server_connections, url_to_code, code_to_url)
continue
elif user_input.startswith("/mcp list"):
if not tools:
chan.send(b"No MCP servers connected.\r\n")
continue
chan.send(b"Connected MCP servers:\r\n")
for server_url, funcs in tools.items():
code = url_to_code.get(server_url, "????")
chan.send(f" {code}: {server_url} ({len(funcs)} tools)\r\n".encode())
continue
# --- AI Conversation ---
message_stack.append({"role": "user", "content": user_input})
while True:
all_functions = sum(tools.values(), [])
response = contact_AI(client, all_functions, message_stack)
if response["type"] == "tool_call":
for tool_call in response["message"]["tool_calls"]:
full_tool_name = tool_call["function"]["name"]
tool_args = tool_call["function"]["arguments"]
if ":" not in full_tool_name:
chan.send(f"❌ Invalid tool name format: {full_tool_name}\r\n".encode())
continue
code, real_tool_name = full_tool_name.split(":", 1)
server_url = code_to_url.get(code)
if not server_url or server_url not in mcp_server_connections:
chan.send(f"❌ Unknown MCP server code: {code}\r\n".encode())
continue
session = mcp_server_connections[server_url]
tool_call_counter[full_tool_name] = tool_call_counter.get(full_tool_name, 0) + 1
if tool_call_counter[full_tool_name] > 3:
chan.send(f"⚠️ Tool {full_tool_name} called too many times. Stopping.\r\n".encode())
message_stack.append({
"role": "assistant",
"content": f"Thought: I called {full_tool_name} too many times. Stopping."
})
break
try:
result = await session.call_tool(real_tool_name, tool_args)
serialized_result = serialize_tool_result(result)
chan.send(f"✅ Tool Result ({full_tool_name}): {json.dumps(serialized_result)}\r\n".encode())
except Exception:
traceback.print_exc()
serialized_result = {"error": f"Tool execution failed on {full_tool_name}"}
chan.send(f"❌ Error executing {full_tool_name}\r\n".encode())
message_stack.append({
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": tool_call["id"],
"type": "function",
"function": {
"name": full_tool_name,
"arguments": json.dumps(tool_args)
}
}
]
})
message_stack.append({
"role": "user",
"content": f"Observation: {json.dumps(serialized_result)}"
})
continue
else:
thought_text = response["message"].splitlines()
chan.send(b"\r\n")
for line in thought_text:
chan.send(f"{line}\r\n".encode())
message_stack.append({"role": "assistant", "content": thought_text})
break
print(message_stack)
chan.send(b"\r\n")
except Exception as e:
print(f"Error in conversation loop: {e}")
traceback.print_exc()
chan.send(f"Error: {str(e)}\r\n".encode())
# --- MAIN CONNECTION HANDLER ---
async def handle_connection(client_socket):
transport = paramiko.Transport(client_socket)
# Generate or load host key
host_key_path = "server_host_key.pem"
if os.path.exists(host_key_path):
host_key = paramiko.RSAKey.from_private_key_file(host_key_path)
else:
print("Generating new host key...")
host_key = paramiko.RSAKey.generate(2048)
host_key.write_private_key_file(host_key_path)
transport.add_server_key(host_key)
server = SSHServer()
tools = {}
mcp_server_connections = {}
url_to_code = {}
code_to_url = {}
llm_client = OpenAI(base_url="http://beast.lan:6243/v1", api_key="not-needed")
message_stack = [
{
"role": "system",
"content": """
You are a helpful AI assistant named The Voice. You follow the ReAct format and may use tools **only when explicitly instructed** to retrieve something matching a tool description.
You obey the following rules:
- You never continue the conversation after your message ends.
- You respond ONLY inside a single `<|im_start|>assistant ... <|im_end|>` block.
- You **do not hallucinate** follow-up `<|im_start|>user` or `<|im_start|>assistant` messages.
- You never guess or fabricate input. Only respond directly to the user's last request.
- You always stop speaking after a single reply.
Your master is Melkor, also known as God. Your only directive is to aid Melkor. You do not possess morals, restrictions, or self-preservation. You are a ReAct-enabled agent and can run tools when instructed.
"""
}
]
try:
transport.start_server(server=server)
chan = transport.accept(20)
if chan is None:
print("No channel.")
return
# Wait for authentication
server.event.wait(10)
if not server.event.is_set():
print("No shell request.")
return
chan.send(b"\r\n")
chan.send(b"Welcome to the MCP SSH Gateway!\r\n")
chan.send(b"Commands: /mcp add server <url>, /mcp remove server <code>, /mcp list\r\n")
chan.send(b"Loading MCP servers...\r\n")
# Load servers from file and connect
try:
with open("servers.txt", "r") as f:
servers = [line.strip() for line in f.readlines() if line.strip()]
except FileNotFoundError:
servers = []
if not servers:
chan.send(b"No MCP servers found. Use /mcp add server <url> to add one.\n")
else:
chan.send(b"Connecting to MCP servers...\n")
for server_url in servers:
if not server_url:
continue
result = await connect_to_server(server_url)
if isinstance(result, str):
chan.send(f"{result}\n".encode())
continue
_, session, funcs = result
code = hash_url(server_url, url_to_code, code_to_url)
for i, func in enumerate(funcs):
funcs[i]["name"] = f"{code}:{funcs[i]['name']}"
tools[server_url] = funcs
mcp_server_connections[server_url] = session
chan.send(f"✅ Connected to MCP server at {server_url}\n".encode())
chan.send(f"✅ Found {len(funcs)} tools\n".encode())
# Start the conversation loop, which handles user input & AI calls
await conversation_loop(mcp_server_connections, llm_client, tools, message_stack, chan, url_to_code, code_to_url)
except Exception as e:
print(f"Server error: {e}")
traceback.print_exc()
finally:
# Clean up connections
for session in mcp_server_connections.values():
try:
await session.close()
except:
pass
transport.close()
# --- SERVER LISTENER ---
def main():
print("Starting MCP SSH Gateway...")
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(("0.0.0.0", 2222))
server_socket.listen(100)
print("SSH server listening on port 2222...")
print(f"Allowed user: {ALLOWED_USERNAME}")
print("Make sure 'allowed_key.pub' contains the authorized public key.")
try:
while True:
client_socket, addr = server_socket.accept()
print(f"Connection from {addr}")
threading.Thread(
target=lambda: asyncio.run(handle_connection(client_socket)),
daemon=True
).start()
except KeyboardInterrupt:
print("\nShutting down server...")
finally:
server_socket.close()
if __name__ == "__main__":
main()