Pulse/internal/servicediscovery/commands.go
2026-03-18 16:06:30 +00:00

551 lines
20 KiB
Go

package servicediscovery
import (
"fmt"
"regexp"
"strings"
)
// safeResourceIDPattern matches valid resource IDs: alphanumeric, dash, underscore, period, colon.
// The first character must be alphanumeric so values cannot be interpreted as CLI flags.
var safeResourceIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]*$`)
// ValidateResourceID checks if a resource ID is safe to use in shell commands.
// Returns an error if the ID contains potentially dangerous characters.
func ValidateResourceID(id string) error {
if id == "" {
return fmt.Errorf("resource ID cannot be empty")
}
if len(id) > 256 {
return fmt.Errorf("resource ID too long (max 256 chars)")
}
if !safeResourceIDPattern.MatchString(id) {
return fmt.Errorf("resource ID contains invalid characters: only alphanumeric, dash, underscore, period, and colon allowed")
}
return nil
}
// shellQuote safely quotes a string for use as a shell argument.
// Uses single quotes and escapes any embedded single quotes.
func shellQuote(s string) string {
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
return "'" + escaped + "'"
}
// DiscoveryCommand represents a command to run during discovery.
type DiscoveryCommand struct {
Name string `json:"name"` // Human-readable name
Command string `json:"command"` // The command template
Description string `json:"description"` // What this discovers
Categories []string `json:"categories"` // What categories of info this provides
Timeout int `json:"timeout"` // Timeout in seconds (0 = default)
Optional bool `json:"optional"` // If true, don't fail if command fails
}
// dockerMountsCommand collects Docker mount metadata without relying on extra
// text utilities (sed/grep), which may be missing in minimal guest images.
const dockerMountsCommand = `sh -c 'docker ps -q 2>/dev/null | head -15 | while read -r id; do name=$(docker inspect --format "{{.Name}}" "$id" 2>/dev/null); name=${name#/}; [ -n "$name" ] || name="$id"; echo "CONTAINER:$name"; docker inspect --format "{{range .Mounts}}{{.Source}}|{{.Destination}}|{{.Type}}{{println}}{{end}}" "$id" 2>/dev/null || true; done; echo docker_mounts_done'`
// GetCommandsForResource returns the commands to run for a given resource type.
func GetCommandsForResource(resourceType ResourceType) []DiscoveryCommand {
switch resourceType {
case ResourceTypeSystemContainer:
return getSystemContainerCommands()
case ResourceTypeVM:
return getVMCommands()
case ResourceTypeDocker:
return getDockerCommands()
case ResourceTypeDockerVM, ResourceTypeDockerSystemContainer:
return getNestedDockerCommands()
case ResourceTypeK8s:
return getK8sCommands()
case ResourceTypeAgent:
return getHostCommands()
default:
return []DiscoveryCommand{}
}
}
// getSystemContainerCommands returns commands for discovering system containers (LXC).
func getSystemContainerCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "os_release",
Command: "cat /etc/os-release",
Description: "Operating system identification",
Categories: []string{"version", "config"},
Optional: true,
},
{
Name: "hostname",
Command: "hostname",
Description: "Container hostname",
Categories: []string{"config"},
Optional: true,
},
{
Name: "running_services",
Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -30 || service --status-all 2>/dev/null | grep '+' | head -30",
Description: "Running services and daemons",
Categories: []string{"service"},
Optional: true,
},
{
Name: "listening_ports",
Command: "ss -tlnp 2>/dev/null | head -25 || netstat -tlnp 2>/dev/null | head -25",
Description: "Network ports listening",
Categories: []string{"port", "network"},
Optional: true,
},
{
Name: "top_processes",
Command: "ps aux --sort=-rss 2>/dev/null | head -15 || ps aux | head -15",
Description: "Top processes by memory",
Categories: []string{"service"},
Optional: true,
},
{
Name: "disk_usage",
Command: "df -h 2>/dev/null | head -15",
Description: "Disk usage and mount points",
Categories: []string{"storage"},
Optional: true,
},
{
Name: "docker_check",
Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -20 || echo 'no_docker'",
Description: "Docker containers if running",
Categories: []string{"service", "container"},
Optional: true,
},
{
Name: "docker_mounts",
Command: dockerMountsCommand,
Description: "Docker container bind mounts (source -> destination)",
Categories: []string{"config", "storage"},
Optional: true,
},
{
Name: "installed_packages",
Command: "dpkg -l 2>/dev/null | grep -E '^ii' | awk '{print $2}' | head -50 || rpm -qa 2>/dev/null | head -50 || apk list --installed 2>/dev/null | head -50",
Description: "Installed packages",
Categories: []string{"version", "service"},
Optional: true,
},
{
Name: "config_files",
Command: "find /etc -name '*.conf' -o -name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -30",
Description: "Configuration files",
Categories: []string{"config"},
Optional: true,
},
{
Name: "cron_jobs",
Command: "crontab -l 2>/dev/null | grep -v '^#' | head -10 || ls -la /etc/cron.d/ 2>/dev/null | head -10",
Description: "Scheduled jobs",
Categories: []string{"service"},
Optional: true,
},
{
Name: "hardware_info",
Command: "lspci 2>/dev/null | head -20 || echo 'no_lspci'",
Description: "Hardware devices (e.g., Coral TPU)",
Categories: []string{"hardware"},
Optional: true,
},
{
Name: "gpu_devices",
Command: "ls -la /dev/dri/ 2>/dev/null; ls -la /dev/apex* 2>/dev/null; nvidia-smi -L 2>/dev/null || echo 'no_gpu'",
Description: "GPU and TPU devices",
Categories: []string{"hardware"},
Optional: true,
},
}
}
// getVMCommands returns commands for discovering VMs (via QEMU guest agent).
func getVMCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "os_release",
Command: "cat /etc/os-release",
Description: "Operating system identification",
Categories: []string{"version", "config"},
Optional: true,
},
{
Name: "hostname",
Command: "hostname",
Description: "VM hostname",
Categories: []string{"config"},
Optional: true,
},
{
Name: "running_services",
Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -30",
Description: "Running services and daemons",
Categories: []string{"service"},
Optional: true,
},
{
Name: "listening_ports",
Command: "ss -tlnp 2>/dev/null | head -25 || netstat -tlnp 2>/dev/null | head -25",
Description: "Network ports listening",
Categories: []string{"port", "network"},
Optional: true,
},
{
Name: "top_processes",
Command: "ps aux --sort=-rss 2>/dev/null | head -15",
Description: "Top processes by memory",
Categories: []string{"service"},
Optional: true,
},
{
Name: "disk_usage",
Command: "df -h 2>/dev/null | head -15",
Description: "Disk usage and mount points",
Categories: []string{"storage"},
Optional: true,
},
{
Name: "docker_check",
Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -20 || echo 'no_docker'",
Description: "Docker containers if running",
Categories: []string{"service", "container"},
Optional: true,
},
{
Name: "docker_mounts",
Command: dockerMountsCommand,
Description: "Docker container bind mounts (source -> destination)",
Categories: []string{"config", "storage"},
Optional: true,
},
{
Name: "hardware_info",
Command: "lspci 2>/dev/null | head -20",
Description: "PCI hardware devices",
Categories: []string{"hardware"},
Optional: true,
},
{
Name: "gpu_devices",
Command: "ls -la /dev/dri/ 2>/dev/null; nvidia-smi -L 2>/dev/null || echo 'no_gpu'",
Description: "GPU devices",
Categories: []string{"hardware"},
Optional: true,
},
}
}
// getDockerCommands returns commands for discovering Docker containers.
// These are run inside the container via docker exec.
func getDockerCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "os_release",
Command: "cat /etc/os-release 2>/dev/null || cat /etc/alpine-release 2>/dev/null || echo 'unknown'",
Description: "Container OS",
Categories: []string{"version"},
Optional: true,
},
{
Name: "processes",
Command: "ps aux 2>/dev/null || echo 'no_ps'",
Description: "Running processes",
Categories: []string{"service"},
Optional: true,
},
{
Name: "listening_ports",
Command: "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo 'no_ss'",
Description: "Listening ports inside container",
Categories: []string{"port"},
Optional: true,
},
{
Name: "env_vars",
Command: "env 2>/dev/null | grep -vE '(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL)' | head -30",
Description: "Environment variables (filtered)",
Categories: []string{"config"},
Optional: true,
},
{
Name: "config_files",
Command: "find /config /data /app /etc -maxdepth 2 -name '*.conf' -o -name '*.yml' -o -name '*.yaml' -o -name '*.json' 2>/dev/null | head -20",
Description: "Configuration files",
Categories: []string{"config"},
Optional: true,
},
}
}
// getNestedDockerCommands returns commands for Docker inside VMs or LXCs.
func getNestedDockerCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "docker_containers",
Command: "docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'",
Description: "All Docker containers",
Categories: []string{"container", "service"},
Optional: false,
},
{
Name: "docker_images",
Command: "docker images --format '{{.Repository}}:{{.Tag}}' | head -20",
Description: "Docker images",
Categories: []string{"version"},
Optional: true,
},
{
Name: "docker_compose",
Command: "find /opt /home /root -name 'docker-compose*.yml' -o -name 'compose*.yml' 2>/dev/null | head -10",
Description: "Docker compose files",
Categories: []string{"config"},
Optional: true,
},
}
}
// getK8sCommands returns commands for discovering Kubernetes pods.
func getK8sCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "processes",
Command: "ps aux 2>/dev/null || echo 'no_ps'",
Description: "Running processes in pod",
Categories: []string{"service"},
Optional: true,
},
{
Name: "listening_ports",
Command: "ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null || echo 'no_ss'",
Description: "Listening ports",
Categories: []string{"port"},
Optional: true,
},
{
Name: "env_vars",
Command: "env 2>/dev/null | grep -vE '(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL)' | head -30",
Description: "Environment variables (filtered)",
Categories: []string{"config"},
Optional: true,
},
}
}
// getHostCommands returns commands for discovering host systems.
func getHostCommands() []DiscoveryCommand {
return []DiscoveryCommand{
{
Name: "os_release",
Command: "cat /etc/os-release",
Description: "Operating system",
Categories: []string{"version", "config"},
Optional: true,
},
{
Name: "hostname",
Command: "hostname -f 2>/dev/null || hostname",
Description: "Full hostname",
Categories: []string{"config"},
Optional: true,
},
{
Name: "running_services",
Command: "systemctl list-units --type=service --state=running --no-pager 2>/dev/null | head -40",
Description: "Running services",
Categories: []string{"service"},
Optional: true,
},
{
Name: "listening_ports",
Command: "ss -tlnp 2>/dev/null | head -30",
Description: "Listening network ports",
Categories: []string{"port", "network"},
Optional: true,
},
{
Name: "docker_containers",
Command: "docker ps --format '{{.Names}}: {{.Image}} ({{.Status}})' 2>/dev/null | head -30 || echo 'no_docker'",
Description: "Docker containers on host",
Categories: []string{"container", "service"},
Optional: true,
},
{
Name: "proxmox_version",
Command: "pveversion 2>/dev/null || echo 'not_proxmox'",
Description: "Proxmox version if applicable",
Categories: []string{"version"},
Optional: true,
},
{
Name: "zfs_pools",
Command: "zpool list 2>/dev/null | head -10 || echo 'no_zfs'",
Description: "ZFS pools",
Categories: []string{"storage"},
Optional: true,
},
{
Name: "disk_usage",
Command: "df -h | head -20",
Description: "Disk usage",
Categories: []string{"storage"},
Optional: true,
},
{
Name: "hardware_info",
Command: "lscpu | head -20",
Description: "CPU information",
Categories: []string{"hardware"},
Optional: true,
},
{
Name: "memory_info",
Command: "free -h",
Description: "Memory information",
Categories: []string{"hardware"},
Optional: true,
},
}
}
// BuildLXCCommand wraps a command for execution in a system container (LXC).
// The vmid is validated to prevent command injection.
func BuildLXCCommand(vmid string, cmd string) string {
if err := ValidateResourceID(vmid); err != nil {
// Don't include the invalid ID in output to prevent any injection
return "sh -c 'echo \"Discovery error: invalid container ID\" >&2; exit 1'"
}
return fmt.Sprintf("pct exec %s -- sh -c %s", vmid, shellQuote(cmd))
}
// BuildVMCommand wraps a command for execution in a VM via QEMU guest agent.
// Note: This requires the guest agent to be running.
// The vmid is validated to prevent command injection.
func BuildVMCommand(vmid string, cmd string) string {
if err := ValidateResourceID(vmid); err != nil {
return "sh -c 'echo \"Discovery error: invalid VM ID\" >&2; exit 1'"
}
// For VMs, we use qm guest exec which requires the guest agent
return fmt.Sprintf("qm guest exec %s -- sh -c %s", vmid, shellQuote(cmd))
}
// BuildDockerCommand wraps a command for execution in a Docker container.
// The containerName is validated to prevent command injection.
// Note: Leading slashes are trimmed as Docker API often returns names with leading /.
func BuildDockerCommand(containerName string, cmd string) string {
// Docker API returns container names with leading slash, trim it
containerName = strings.TrimPrefix(containerName, "/")
if err := ValidateResourceID(containerName); err != nil {
return "sh -c 'echo \"Discovery error: invalid container name\" >&2; exit 1'"
}
return fmt.Sprintf("docker exec %s sh -c %s", shellQuote(containerName), shellQuote(cmd))
}
// BuildNestedDockerCommand builds a command to run inside Docker on a VM/LXC.
// All resource identifiers are validated to prevent command injection.
func BuildNestedDockerCommand(vmid string, isLXC bool, containerName string, cmd string) string {
if err := ValidateResourceID(vmid); err != nil {
return "sh -c 'echo \"Discovery error: invalid VM/LXC ID\" >&2; exit 1'"
}
// Docker API returns container names with leading slash, trim it
containerName = strings.TrimPrefix(containerName, "/")
if err := ValidateResourceID(containerName); err != nil {
return "sh -c 'echo \"Discovery error: invalid container name\" >&2; exit 1'"
}
dockerCmd := BuildDockerCommand(containerName, cmd)
if isLXC {
return BuildLXCCommand(vmid, dockerCmd)
}
return BuildVMCommand(vmid, dockerCmd)
}
// BuildK8sCommand builds a command to run in a Kubernetes pod.
// All identifiers are validated to prevent command injection.
func BuildK8sCommand(namespace, podName, containerName, cmd string) string {
if err := ValidateResourceID(namespace); err != nil {
return "sh -c 'echo \"Discovery error: invalid namespace\" >&2; exit 1'"
}
if err := ValidateResourceID(podName); err != nil {
return "sh -c 'echo \"Discovery error: invalid pod name\" >&2; exit 1'"
}
if containerName != "" {
if err := ValidateResourceID(containerName); err != nil {
return "sh -c 'echo \"Discovery error: invalid container name\" >&2; exit 1'"
}
return fmt.Sprintf("kubectl exec -n %s %s -c %s -- sh -c %s", shellQuote(namespace), shellQuote(podName), shellQuote(containerName), shellQuote(cmd))
}
return fmt.Sprintf("kubectl exec -n %s %s -- sh -c %s", shellQuote(namespace), shellQuote(podName), shellQuote(cmd))
}
// GetCLIAccessTemplate returns a CLI access template for a resource type.
// These are instructions for using pulse_control, NOT literal shell commands.
// Commands via pulse_control run directly on the target where the agent is installed.
func GetCLIAccessTemplate(resourceType ResourceType) string {
switch resourceType {
case ResourceTypeSystemContainer:
// Agent runs ON the system container - commands execute directly inside
return "Use pulse_control with target_host matching this container's hostname. Commands run directly inside the container."
case ResourceTypeVM:
// Agent runs ON the VM - commands execute directly inside the VM
return "Use pulse_control with target_host matching this VM's hostname. Commands run directly inside the VM."
case ResourceTypeDocker:
// Docker container on a host - need docker exec from the host
return "Use pulse_control targeting the Docker host with command: docker exec {container} <your-command>"
case ResourceTypeDockerSystemContainer:
// Docker inside a system container - agent on the container runs docker exec
return "Use pulse_control targeting the system container hostname with command: docker exec {container} <your-command>"
case ResourceTypeDockerVM:
// Docker inside a VM - agent on the VM runs docker exec
return "Use pulse_control targeting the VM hostname with command: docker exec {container} <your-command>"
case ResourceTypeK8s:
return "Use kubectl exec -n {namespace} {pod} -- <your-command>"
case ResourceTypeAgent:
return "Use pulse_control with target_host matching this host. Commands run directly."
default:
return "Use pulse_control with target_host matching the resource hostname."
}
}
// FormatCLIAccess formats a CLI access string with actual values.
func FormatCLIAccess(resourceType ResourceType, vmid, containerName, namespace, podName string) string {
template := GetCLIAccessTemplate(resourceType)
result := template
result = strings.ReplaceAll(result, "{vmid}", vmid)
result = strings.ReplaceAll(result, "{container}", containerName)
result = strings.ReplaceAll(result, "{namespace}", namespace)
result = strings.ReplaceAll(result, "{pod}", podName)
return result
}
// GetCommandCategories returns a unique sorted list of all categories for a resource type.
func GetCommandCategories(resourceType ResourceType) []string {
commands := GetCommandsForResource(resourceType)
categorySet := make(map[string]bool)
for _, cmd := range commands {
for _, cat := range cmd.Categories {
categorySet[cat] = true
}
}
categories := make([]string, 0, len(categorySet))
for cat := range categorySet {
categories = append(categories, cat)
}
// Sort for consistent ordering
for i := 0; i < len(categories)-1; i++ {
for j := i + 1; j < len(categories); j++ {
if categories[i] > categories[j] {
categories[i], categories[j] = categories[j], categories[i]
}
}
}
return categories
}