package envdetect import ( "bufio" "bytes" "errors" "fmt" "net" "os" "os/exec" "strconv" "strings" "time" "github.com/rs/zerolog/log" ) type ifaceInfo struct { Name string Flags net.Flags Addrs []net.Addr AddrsErr error } type environmentProbe interface { LookPath(file string) (string, error) CommandCombinedOutput(name string, args ...string) ([]byte, error) Stat(name string) (os.FileInfo, error) ReadFile(name string) ([]byte, error) Interfaces() ([]ifaceInfo, error) } type systemEnvironmentProbe struct{} func (systemEnvironmentProbe) LookPath(file string) (string, error) { return exec.LookPath(file) } func (systemEnvironmentProbe) CommandCombinedOutput(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() } func (systemEnvironmentProbe) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } func (systemEnvironmentProbe) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) } func (systemEnvironmentProbe) Interfaces() ([]ifaceInfo, error) { interfaces, err := net.Interfaces() if err != nil { return nil, err } out := make([]ifaceInfo, 0, len(interfaces)) for _, iface := range interfaces { addrs, addrsErr := iface.Addrs() out = append(out, ifaceInfo{ Name: iface.Name, Flags: iface.Flags, Addrs: addrs, AddrsErr: addrsErr, }) } return out, nil } // Environment represents the runtime environment type. type Environment int const ( // Unknown indicates the detector could not determine the environment. Unknown Environment = iota // Native represents bare metal or virtual machine deployments. Native // DockerHost indicates a container with host networking (shares host stack). DockerHost // DockerBridge indicates a container attached to a bridge/NAT network. DockerBridge // LXCPrivileged covers LXC containers with privileged UID mapping. LXCPrivileged // LXCUnprivileged covers LXC containers with remapped UIDs. LXCUnprivileged ) // String provides a human readable representation of Environment. func (e Environment) String() string { switch e { case Native: return "native" case DockerHost: return "docker_host" case DockerBridge: return "docker_bridge" case LXCPrivileged: return "lxc_privileged" case LXCUnprivileged: return "lxc_unprivileged" default: return "unknown" } } // ScanPolicy defines scanning behavior parameters for a detected environment. type ScanPolicy struct { MaxConcurrent int // Maximum concurrent workers. DialTimeout time.Duration // Timeout for TCP dials. HTTPTimeout time.Duration // Timeout for HTTP requests. MaxHostsPerScan int // Maximum hosts per subnet phase. EnableReverseDNS bool // Whether reverse DNS lookups are allowed. ScanGateways bool // Whether to probe inferred gateway networks. } // DefaultScanPolicy returns baseline scanning parameters. func DefaultScanPolicy() ScanPolicy { return ScanPolicy{ MaxConcurrent: 50, DialTimeout: time.Second, HTTPTimeout: 2 * time.Second, MaxHostsPerScan: 1024, EnableReverseDNS: true, ScanGateways: true, } } // SubnetPhase represents a single phase of network scanning. type SubnetPhase struct { Name string // Name for logging/UI (e.g. "container_network"). Subnets []net.IPNet // Subnets to process during this phase. Confidence float64 // Confidence score (0.0 - 1.0). Priority int // Lower priority runs earlier. } // EnvironmentProfile captures detection results and scanning plan. type EnvironmentProfile struct { Type Environment // Detected environment. Phases []SubnetPhase // Subnet scanning phases. ExtraTargets []net.IP // IPs to always probe. IPBlocklist []net.IP // Individual IPs to skip (auto-populated with configured Proxmox hosts). Policy ScanPolicy // Applied scan policy. Confidence float64 // Overall confidence (0.0 - 1.0). Warnings []string // Non-fatal detection warnings. Metadata map[string]string // Misc metadata (container type, gateway, etc.). } // DetectEnvironment performs environment detection and returns a profile. func DetectEnvironment() (*EnvironmentProfile, error) { return detectEnvironment(systemEnvironmentProbe{}) } func detectEnvironment(probe environmentProbe) (*EnvironmentProfile, error) { profile := &EnvironmentProfile{ Type: Unknown, Phases: []SubnetPhase{}, Policy: DefaultScanPolicy(), Confidence: 0.0, Warnings: []string{}, Metadata: map[string]string{}, } log.Info().Msg("Detecting runtime environment") isContainer, containerType := detectContainer(probe) profile.Metadata["container_detected"] = strconv.FormatBool(isContainer) if containerType != "" { profile.Metadata["container_type"] = containerType } var err error switch { case !isContainer: profile, err = detectNativeEnvironment(profile, probe) case containerType == "docker": profile, err = detectDockerEnvironment(profile, probe) case containerType == "lxc": profile, err = detectLXCEnvironment(profile, probe) default: profile.Type = Unknown profile.Confidence = 0.3 if containerType == "" { profile.Warnings = append(profile.Warnings, "Unable to determine container type; using fallback subnets") } else { profile.Warnings = append(profile.Warnings, fmt.Sprintf("Unsupported container type %q; using fallback subnets", containerType)) } profile, err = addFallbackSubnets(profile) } if err != nil { // Preserve the error for callers while ensuring we still provide a usable profile. profile.Warnings = append(profile.Warnings, err.Error()) } subnetCount := 0 for _, phase := range profile.Phases { subnetCount += len(phase.Subnets) } log.Info(). Str("environment", profile.Type.String()). Int("phase_count", len(profile.Phases)). Int("subnet_count", subnetCount). Float64("confidence", profile.Confidence). Msg("Environment detection completed") return profile, err } // detectContainer inspects the host to determine whether we are inside a container. func detectContainer(probe environmentProbe) (bool, string) { containerType := "" // 1. systemd-detect-virt --container if _, err := probe.LookPath("systemd-detect-virt"); err == nil { output, err := probe.CommandCombinedOutput("systemd-detect-virt", "--container") if len(output) > 0 { result := strings.TrimSpace(string(output)) if result != "" && result != "none" { log.Debug().Str("virt", result).Msg("systemd-detect-virt reported container environment") switch { case strings.Contains(result, "lxc"): return true, "lxc" case strings.Contains(result, "docker"), strings.Contains(result, "containerd"), strings.Contains(result, "podman"): return true, "docker" default: return true, result } } } if err != nil { log.Debug().Err(err).Msg("systemd-detect-virt --container check failed; continuing with heuristics") } } // 2. Marker files if _, err := probe.Stat("/.dockerenv"); err == nil { log.Debug().Msg("Detected /.dockerenv marker (Docker container)") return true, "docker" } if _, err := probe.Stat("/run/.containerenv"); err == nil { log.Debug().Msg("Detected /run/.containerenv marker (Podman/OCI container)") return true, "docker" } // 3. /proc/1/cgroup if data, err := probe.ReadFile("/proc/1/cgroup"); err == nil { text := string(data) switch { case strings.Contains(text, "docker"), strings.Contains(text, "kubepods"), strings.Contains(text, "containerd"): log.Debug().Msg("Detected Docker via /proc/1/cgroup") return true, "docker" case strings.Contains(text, "lxc"), strings.Contains(text, "libcontainer"): log.Debug().Msg("Detected LXC via /proc/1/cgroup") return true, "lxc" } } else { log.Debug().Err(err).Msg("Unable to read /proc/1/cgroup during container detection") } // 4. /proc/1/environ if data, err := probe.ReadFile("/proc/1/environ"); err == nil { text := string(data) switch { case strings.Contains(text, "container=lxc"): log.Debug().Msg("Detected LXC via /proc/1/environ") return true, "lxc" case strings.Contains(text, "container=docker"), strings.Contains(text, "container=podman"): log.Debug().Msg("Detected Docker via /proc/1/environ") return true, "docker" } } else { log.Debug().Err(err).Msg("Unable to read /proc/1/environ during container detection") } log.Debug().Msg("No container markers detected; assuming native environment") return false, containerType } // detectNativeEnvironment builds an EnvironmentProfile for native or VM deployments. func detectNativeEnvironment(profile *EnvironmentProfile, probe environmentProbe) (*EnvironmentProfile, error) { subnets, err := getAllLocalSubnets(probe) if err != nil { return addFallbackSubnets(profileWithWarning(profile, fmt.Sprintf("Failed to enumerate interfaces: %v", err))) } if len(subnets) == 0 { return addFallbackSubnets(profileWithWarning(profile, "No active IPv4 interfaces found; using fallback subnets")) } profile.Type = Native profile.Confidence = 0.95 profile.Metadata["detected_mode"] = "native" profile.Metadata["interface_count"] = strconv.Itoa(len(subnets)) profile.Phases = append(profile.Phases, SubnetPhase{ Name: "local_networks", Subnets: subnets, Confidence: 0.95, Priority: 1, }) return profile, nil } // detectDockerEnvironment determines whether Docker uses host or bridge networking. func detectDockerEnvironment(profile *EnvironmentProfile, probe environmentProbe) (*EnvironmentProfile, error) { hostMode, hostModeWarnings := isDockerHostMode(probe) if len(hostModeWarnings) > 0 { profile.Warnings = append(profile.Warnings, hostModeWarnings...) } if hostMode { subnets, err := getAllLocalSubnets(probe) if err != nil { return addFallbackSubnets(profileWithWarning(profile, fmt.Sprintf("Docker host mode: failed to enumerate subnets: %v", err))) } if len(subnets) == 0 { return addFallbackSubnets(profileWithWarning(profile, "Docker host mode detected but no subnets found; falling back to common subnets")) } profile.Type = DockerHost profile.Confidence = 0.9 profile.Metadata["docker_mode"] = "host" profile.Metadata["interface_count"] = strconv.Itoa(len(subnets)) profile.Phases = append(profile.Phases, SubnetPhase{ Name: "host_networks", Subnets: subnets, Confidence: 0.9, Priority: 1, }) return profile, nil } // Bridge/NAT mode. profile.Type = DockerBridge profile.Confidence = 0.85 profile.Metadata["docker_mode"] = "bridge" containerSubnets, err := getAllLocalSubnets(probe) if err != nil { profile.Warnings = append(profile.Warnings, fmt.Sprintf("Docker bridge: failed to enumerate container subnets: %v", err)) } else if len(containerSubnets) > 0 { profile.Phases = append(profile.Phases, SubnetPhase{ Name: "container_network", Subnets: containerSubnets, Confidence: 0.95, Priority: 1, }) profile.Metadata["container_subnet_count"] = strconv.Itoa(len(containerSubnets)) } else { profile.Warnings = append(profile.Warnings, "Docker bridge: no container subnets detected") } if profile.Policy.ScanGateways { hostSubnets, confidence, warnings := detectHostNetworkFromContainer(probe) if len(warnings) > 0 { profile.Warnings = append(profile.Warnings, warnings...) } if len(hostSubnets) > 0 { profile.Phases = append(profile.Phases, SubnetPhase{ Name: "inferred_host_network", Subnets: hostSubnets, Confidence: confidence, Priority: 2, }) profile.Metadata["inferred_host_subnets"] = strconv.Itoa(len(hostSubnets)) } } if len(profile.Phases) == 0 { return addFallbackSubnets(profileWithWarning(profile, "Docker bridge detection yielded no subnets; adding fallback ranges")) } return profile, nil } // detectLXCEnvironment evaluates privilege level and prepares scanning phases. func detectLXCEnvironment(profile *EnvironmentProfile, probe environmentProbe) (*EnvironmentProfile, error) { privileged, warn := isLXCPrivileged(probe) if warn != "" { profile.Warnings = append(profile.Warnings, warn) } containerSubnets, err := getAllLocalSubnets(probe) if err != nil { profile.Warnings = append(profile.Warnings, fmt.Sprintf("LXC: failed to enumerate container subnets: %v", err)) } if privileged { if len(containerSubnets) == 0 { return addFallbackSubnets(profileWithWarning(profile, "Privileged LXC detected but no subnets found; using fallback subnets")) } profile.Type = LXCPrivileged profile.Confidence = 0.9 profile.Metadata["lxc_privileged"] = "true" profile.Metadata["interface_count"] = strconv.Itoa(len(containerSubnets)) profile.Phases = append(profile.Phases, SubnetPhase{ Name: "lxc_host_networks", Subnets: containerSubnets, Confidence: 0.9, Priority: 1, }) return profile, nil } // Unprivileged container. profile.Type = LXCUnprivileged profile.Confidence = 0.85 profile.Metadata["lxc_privileged"] = "false" if len(containerSubnets) > 0 { profile.Phases = append(profile.Phases, SubnetPhase{ Name: "lxc_container_network", Subnets: containerSubnets, Confidence: 0.9, Priority: 1, }) profile.Metadata["container_subnet_count"] = strconv.Itoa(len(containerSubnets)) } if profile.Policy.ScanGateways { hostSubnets, confidence, warnings := detectHostNetworkFromContainer(probe) if len(warnings) > 0 { profile.Warnings = append(profile.Warnings, warnings...) } if len(hostSubnets) > 0 { profile.Phases = append(profile.Phases, SubnetPhase{ Name: "lxc_parent_network", Subnets: hostSubnets, Confidence: confidence, Priority: 2, }) profile.Metadata["inferred_host_subnets"] = strconv.Itoa(len(hostSubnets)) } } if len(profile.Phases) == 0 { return addFallbackSubnets(profileWithWarning(profile, "Unprivileged LXC detection yielded no subnets; adding fallback ranges")) } return profile, nil } // isDockerHostMode attempts to determine whether Docker is using host networking. func isDockerHostMode(probe environmentProbe) (bool, []string) { var warnings []string interfaces, err := probe.Interfaces() if err != nil { log.Debug().Err(err).Msg("Failed to enumerate interfaces while detecting Docker mode") warnings = append(warnings, fmt.Sprintf("Unable to enumerate interfaces: %v", err)) return false, warnings } routeCount, routeWarn := countKernelRoutes(probe) if routeWarn != "" { warnings = append(warnings, routeWarn) } log.Debug(). Int("interface_count", len(interfaces)). Int("route_count", routeCount). Msg("Docker networking mode heuristics") // Heuristic: host networking tends to expose many interfaces and routes. if len(interfaces) > 3 && routeCount > 5 { return true, warnings } return false, warnings } // isLXCPrivileged inspects UID mappings to determine privilege level. func isLXCPrivileged(probe environmentProbe) (bool, string) { data, err := probe.ReadFile("/proc/self/uid_map") if err != nil { if errors.Is(err, os.ErrPermission) { return false, "Unable to read /proc/self/uid_map (permission denied); assuming unprivileged LXC" } return false, fmt.Sprintf("Unable to read /proc/self/uid_map; assuming unprivileged LXC: %v", err) } fields := strings.Fields(string(data)) if len(fields) < 3 { return false, "Unexpected format in /proc/self/uid_map; assuming unprivileged LXC" } // Format: id_inside id_outside length hostUID, err := strconv.ParseUint(fields[1], 10, 32) if err != nil { return false, "Failed to parse uid_map; assuming unprivileged LXC" } length, err := strconv.ParseUint(fields[2], 10, 32) if err != nil { return false, "Failed to parse uid_map length; assuming unprivileged LXC" } if hostUID == 0 && length >= 4294967295 { return true, "" } return false, "" } // getAllLocalSubnets enumerates non-loopback, UP IPv4 subnets. func getAllLocalSubnets(probe environmentProbe) ([]net.IPNet, error) { interfaces, err := probe.Interfaces() if err != nil { return nil, fmt.Errorf("failed to list interfaces: %w", err) } var subnets []net.IPNet seen := make(map[string]struct{}) for _, iface := range interfaces { if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { continue } if iface.AddrsErr != nil { log.Debug().Err(iface.AddrsErr).Str("interface", iface.Name).Msg("Skipping interface due to address enumeration failure") continue } for _, addr := range iface.Addrs { ipNet, ok := addr.(*net.IPNet) if !ok || ipNet == nil { continue } ip := ipNet.IP.To4() if ip == nil { continue } if ip.IsLoopback() || ip.IsLinkLocalUnicast() { continue } networkIP := ip.Mask(ipNet.Mask) cidr := (&net.IPNet{IP: networkIP, Mask: ipNet.Mask}).String() if _, exists := seen[cidr]; exists { continue } seen[cidr] = struct{}{} normalized := net.IPNet{IP: networkIP, Mask: ipNet.Mask} subnets = append(subnets, normalized) log.Debug(). Str("interface", iface.Name). Str("cidr", normalized.String()). Msg("Discovered local subnet") } } return subnets, nil } // detectHostNetworkFromContainer infers host LAN subnets from container context. func detectHostNetworkFromContainer(probe environmentProbe) ([]net.IPNet, float64, []string) { var warnings []string gateway, err := getDefaultGateway(probe) if err != nil { warnings = append(warnings, fmt.Sprintf("Could not determine default gateway: %v", err)) return tryCommonSubnets(), 0.3, warnings } if gateway == nil || gateway.Equal(net.IPv4zero) { warnings = append(warnings, "Default gateway is unspecified; using common subnet fallback") return tryCommonSubnets(), 0.3, warnings } log.Debug().Str("gateway", gateway.String()).Msg("Default gateway detected") gateway4 := gateway.To4() if gateway4 == nil { warnings = append(warnings, fmt.Sprintf("Default gateway %s is not IPv4; using fallback subnets", gateway.String())) return tryCommonSubnets(), 0.3, warnings } confidence := 0.4 lastOctet := gateway4[3] if lastOctet == 1 || lastOctet == 254 { confidence = 0.7 } else { warnings = append(warnings, fmt.Sprintf("Gateway %s does not end with .1 or .254; confidence reduced", gateway.String())) } hostSubnet := net.IPNet{ IP: net.IPv4(gateway4[0], gateway4[1], gateway4[2], 0), Mask: net.CIDRMask(24, 32), } log.Debug().Str("host_subnet", hostSubnet.String()).Float64("confidence", confidence).Msg("Inferred host subnet from gateway") return []net.IPNet{hostSubnet}, confidence, warnings } // getDefaultGateway parses /proc/net/route for the default gateway. func getDefaultGateway(probe environmentProbe) (net.IP, error) { data, err := probe.ReadFile("/proc/net/route") if err != nil { return nil, fmt.Errorf("failed to read /proc/net/route: %w", err) } scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "Iface") { continue } fields := strings.Fields(line) if len(fields) < 3 { continue } // Destination column (fields[1]) equal to 00000000 indicates default route. if fields[1] != "00000000" { continue } gatewayHex := fields[2] if len(gatewayHex) != 8 { continue } gatewayIP, err := parseHexIP(gatewayHex) if err != nil { return nil, fmt.Errorf("failed to parse default gateway: %w", err) } return gatewayIP, nil } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("failed to parse /proc/net/route: %w", err) } return nil, fmt.Errorf("default gateway not found") } // parseHexIP converts an 8-character little-endian hex string into an IPv4 address. func parseHexIP(hexIP string) (net.IP, error) { if len(hexIP) != 8 { return nil, fmt.Errorf("invalid hex IP length %d", len(hexIP)) } var octets [4]byte for i := 0; i < 4; i++ { part := hexIP[i*2 : i*2+2] val, err := strconv.ParseUint(part, 16, 8) if err != nil { return nil, fmt.Errorf("invalid hex octet %q: %w", part, err) } octets[3-i] = byte(val) // /proc/net/route stores little-endian. } return net.IPv4(octets[0], octets[1], octets[2], octets[3]), nil } // tryCommonSubnets returns common private IPv4 subnets as a conservative fallback. func tryCommonSubnets() []net.IPNet { commonCIDRs := []string{ "192.168.1.0/24", "192.168.0.0/24", "10.0.0.0/24", "172.16.0.0/24", "192.168.2.0/24", } var subnets []net.IPNet for _, cidr := range commonCIDRs { _, network, err := net.ParseCIDR(cidr) if err != nil { continue } subnets = append(subnets, *network) } return subnets } // addFallbackSubnets appends fallback subnet phases and updates confidence. func addFallbackSubnets(profile *EnvironmentProfile) (*EnvironmentProfile, error) { fallback := tryCommonSubnets() if len(fallback) == 0 { return profile, fmt.Errorf("no fallback subnets available") } profile.Phases = append(profile.Phases, SubnetPhase{ Name: "fallback_common_subnets", Subnets: fallback, Confidence: 0.3, Priority: 10, }) if profile.Confidence == 0.0 { profile.Confidence = 0.3 } profile.Warnings = append(profile.Warnings, "Using fallback private subnets; consider manual subnet configuration") return profile, nil } // countKernelRoutes parses /proc/net/route and returns the number of route entries. func countKernelRoutes(probe environmentProbe) (int, string) { data, err := probe.ReadFile("/proc/net/route") if err != nil { return 0, fmt.Sprintf("Unable to read /proc/net/route: %v", err) } count := 0 scanner := bufio.NewScanner(bytes.NewReader(data)) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "Iface") { continue } if strings.TrimSpace(line) == "" { continue } count++ } if err := scanner.Err(); err != nil { return count, fmt.Sprintf("Error scanning /proc/net/route: %v", err) } return count, "" } // profileWithWarning appends a warning and returns the same profile for chaining. func profileWithWarning(profile *EnvironmentProfile, warning string) *EnvironmentProfile { if warning != "" { profile.Warnings = append(profile.Warnings, warning) } return profile }