diff --git a/docker-compose.yml b/docker-compose.yml index db3183d..ad4c60c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,8 @@ services: OIKOS_ENV: dev OIKOS_DEBUG: "true" OIKOS_HERMES_AGENT_SLUG: ${OIKOS_HERMES_AGENT_SLUG:-agent:hermes} + volumes: + - ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro ports: - "8090:8090" command: ["api"] diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 18492a0..42738d9 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,12 +8,16 @@ import ( "fmt" "log/slog" "net/http" + "os" + "strings" + "sync" "time" "github.com/dtoro/oikos/internal/db" "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/crypto/ssh" ) // prop is one input-schema property (name → type + description). @@ -323,6 +327,147 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { ORDER BY ts DESC LIMIT $2`, agentID, limit), nil }) + // ─── Phase 5: operational MCP tools ────────────────────────────── + + register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state", + InputSchema: objSchema(), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return queryRows(ctx, pool, ` + SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, + e.attributes->>'lan_ip' AS lan_ip, + st.health, st.last_check_at + FROM entities e + LEFT JOIN entity_status st ON st.entity_id = e.id + WHERE e.type = 'lxc' + ORDER BY (e.attributes->>'pve_id')::int`), nil + }) + + register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", + InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("service_slug is required"), nil + } + rows, err := pool.Query(ctx, ` + SELECT st.health, st.last_check_at, e.attributes->>'url' AS url + FROM entity_status st + JOIN entities e ON e.id = st.entity_id + WHERE e.slug = $1`, slug) + if err != nil { + return textResult(fmt.Sprintf("query error: %v", err)), nil + } + defer rows.Close() + if !rows.Next() { + return textResult(fmt.Sprintf("service not found: %s", slug)), nil + } + var health, lastCheck, url string + rows.Scan(&health, &lastCheck, &url) + if url == "" { + url = "(no URL in entity attributes)" + } + return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil + }) + + register(&mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl", + InputSchema: objSchema( + prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}, + prop{"lines", "integer", "Number of lines (default 50)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + n := int(getFloat(args, "lines", 50)) + if slug == "" { + return textResult("service_slug is required"), nil + } + host, user, err := resolveHost(ctx, pool, slug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + svc := strings.TrimPrefix(slug, "lxc:") + out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }) + + register(&mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host", + InputSchema: objSchema( + prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["service_slug"].(string) + if slug == "" { + return textResult("service_slug is required"), nil + } + host, user, err := resolveHost(ctx, pool, slug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + svc := strings.TrimPrefix(slug, "lxc:") + out, err := sshExec(ctx, host, user, + fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }) + + register(&mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host", + InputSchema: objSchema( + prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}), + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := argsMap(req) + slug, _ := args["lxc_slug"].(string) + if slug == "" { + return textResult("lxc_slug is required"), nil + } + var pveID string + err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID) + if err != nil || pveID == "" { + return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil + } + // Resolve the Proxmox host — find the host that runs this LXC + var hostID uuid.UUID + err = pool.QueryRow(ctx, ` + SELECT t.id FROM entities t + JOIN relationships r ON r.source_id = t.id + JOIN entities s ON s.id = r.target_id + WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL + LIMIT 1`, slug).Scan(&hostID) + if err != nil { + // Fallback: use the inventory host attribute if no relationship + var hostSlug string + err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug) + if err != nil || hostSlug == "" { + return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil + } + var host, user string + host, user, err = resolveHost(ctx, pool, "host:"+hostSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve: %v", err)), nil + } + out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) + if err2 != nil { + return textResult(fmt.Sprintf("ssh: %v", err2)), nil + } + return textResult(out), nil + } + var hostSlug string + pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug) + host, user, err := resolveHost(ctx, pool, hostSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve host: %v", err)), nil + } + out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) + if err != nil { + return textResult(fmt.Sprintf("ssh: %v", err)), nil + } + return textResult(out), nil + }) + return s } @@ -470,4 +615,101 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m data, _ := json.MarshalIndent(items, "", " ") return textResult(string(data)) +} + +// ─── SSH helpers ───────────────────────────────────────────────────────── + +var ( + sshUser string + sshKey []byte + sshPool = make(map[string]*ssh.Client) + sshPoolMu sync.Mutex +) + +func initSSH() { + if sshUser == "" { + sshUser = os.Getenv("OIKOS_SSH_USER") + if sshUser == "" { + sshUser = "root" + } + } + keyPath := os.Getenv("OIKOS_SSH_KEY_PATH") + if keyPath == "" { + keyPath = "/etc/oikos/ssh_key" + } + if len(sshKey) == 0 { + var err error + sshKey, err = os.ReadFile(keyPath) + if err != nil { + slog.Warn("mcp ssh: cannot read key", "path", keyPath, "error", err) + } + } +} + +func sshExec(ctx context.Context, host, user, command string) (string, error) { + initSSH() + if len(sshKey) == 0 { + return "", fmt.Errorf("no SSH key available") + } + if user == "" { + user = sshUser + } + + addr := host + ":22" + signer, err := ssh.ParsePrivateKey(sshKey) + if err != nil { + return "", fmt.Errorf("parse key: %w", err) + } + + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, + } + + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return "", fmt.Errorf("dial %s: %w", host, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", fmt.Errorf("session: %w", err) + } + defer session.Close() + + out, err := session.CombinedOutput(command) + if err != nil && out == nil { + return "", fmt.Errorf("exec: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP string, sshUser string, err error) { + var attrs string + err = pool.QueryRow(ctx, "SELECT attributes::text FROM entities WHERE slug = $1", entitySlug).Scan(&attrs) + if err != nil { + return "", "", fmt.Errorf("entity not found: %s", entitySlug) + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(attrs), &m); err != nil { + return "", "", fmt.Errorf("parse attributes: %w", err) + } + + if ip, ok := m["lan_ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + if mesh, ok := m["mesh"].(map[string]interface{}); ok { + for _, proto := range []string{"netbird", "tailscale"} { + if p, ok := mesh[proto].(map[string]interface{}); ok { + if ip, ok := p["ip"].(string); ok && ip != "" { + return ip, sshUser, nil + } + } + } + } + return "", "", fmt.Errorf("no IP found for %s", entitySlug) } \ No newline at end of file