mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-11 00:09:31 +00:00
d9888f1a56
* Codebase cleanup
317 lines
8.5 KiB
Go
317 lines
8.5 KiB
Go
package forward
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/nvm/kportal/internal/logger"
|
|
)
|
|
|
|
const (
|
|
// maxPIDLength is the maximum length of a valid PID string (9 digits covers PIDs up to 999,999,999)
|
|
maxPIDLength = 9
|
|
// minNetstatFields is the minimum number of fields expected in netstat output
|
|
minNetstatFields = 5
|
|
)
|
|
|
|
// isValidPID validates that a PID string contains only digits
|
|
func isValidPID(pid string) bool {
|
|
if len(pid) == 0 || len(pid) > maxPIDLength {
|
|
return false
|
|
}
|
|
for _, c := range pid {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// processInfo holds information about a process using a port
|
|
type processInfo struct {
|
|
pid string
|
|
name string
|
|
isValid bool
|
|
}
|
|
|
|
// formatProcessInfo formats process information for display
|
|
func formatProcessInfo(info processInfo) string {
|
|
if !info.isValid {
|
|
return "unknown"
|
|
}
|
|
if info.name != "" {
|
|
return fmt.Sprintf("%s (PID %s)", info.name, info.pid)
|
|
}
|
|
return fmt.Sprintf("PID %s", info.pid)
|
|
}
|
|
|
|
// formatProcessList formats a list of processes into a human-readable string.
|
|
// Returns "unknown" if the list is empty.
|
|
func formatProcessList(processes []processInfo) string {
|
|
if len(processes) == 0 {
|
|
return "unknown"
|
|
}
|
|
if len(processes) == 1 {
|
|
return formatProcessInfo(processes[0])
|
|
}
|
|
// Multiple processes - format as comma-separated list
|
|
parts := make([]string, len(processes))
|
|
for i, p := range processes {
|
|
parts[i] = formatProcessInfo(p)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// getProcessNameByPID retrieves the process name for a given PID on Unix systems
|
|
func getProcessNameByPID(pid string) string {
|
|
cmd := exec.Command("ps", "-p", pid, "-o", "comm=")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(output))
|
|
}
|
|
|
|
// getProcessNameByPIDWindows retrieves the process name for a given PID on Windows
|
|
func getProcessNameByPIDWindows(pid string) string {
|
|
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
|
|
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Parse CSV output: "process.exe","1234","Console","1","12,345 K"
|
|
csvLine := strings.TrimSpace(string(output))
|
|
if csvLine == "" {
|
|
return ""
|
|
}
|
|
|
|
parts := strings.Split(csvLine, ",")
|
|
if len(parts) > 0 {
|
|
return strings.Trim(parts[0], "\"")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PortConflict represents a local port that is already in use.
|
|
type PortConflict struct {
|
|
Port int // The conflicting port number
|
|
Resource string // The forward resource that needs this port
|
|
UsedBy string // Process information (PID, command) using the port
|
|
}
|
|
|
|
// PortChecker checks port availability on the local system.
|
|
type PortChecker struct{}
|
|
|
|
// NewPortChecker creates a new PortChecker instance.
|
|
func NewPortChecker() *PortChecker {
|
|
return &PortChecker{}
|
|
}
|
|
|
|
// CheckAvailability checks if the given ports are available for binding.
|
|
// It returns a list of conflicts for ports that are already in use.
|
|
// The skipPorts map contains ports currently managed by kportal that should be excluded from the check.
|
|
func (pc *PortChecker) CheckAvailability(ports []int, skipPorts map[int]bool) []PortConflict {
|
|
var conflicts []PortConflict
|
|
|
|
for _, port := range ports {
|
|
// Skip ports that are already managed by kportal
|
|
if skipPorts[port] {
|
|
continue
|
|
}
|
|
|
|
// Try to bind to the port
|
|
if !pc.isPortAvailable(port) {
|
|
// Port is in use, get process info
|
|
usedBy := pc.getProcessUsingPort(port)
|
|
conflicts = append(conflicts, PortConflict{
|
|
Port: port,
|
|
UsedBy: usedBy,
|
|
})
|
|
}
|
|
}
|
|
|
|
return conflicts
|
|
}
|
|
|
|
// isPortAvailable checks if a port is available by attempting to bind to it.
|
|
func (pc *PortChecker) isPortAvailable(port int) bool {
|
|
// Try to listen on the port
|
|
addr := fmt.Sprintf(":%d", port)
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = listener.Close()
|
|
return true
|
|
}
|
|
|
|
// getProcessUsingPort returns information about the process using the given port.
|
|
// Returns a string like "nginx (PID 1234)" or "unknown" if the process cannot be determined.
|
|
func (pc *PortChecker) getProcessUsingPort(port int) string {
|
|
switch runtime.GOOS {
|
|
case "darwin", "linux":
|
|
return pc.getProcessUsingPortUnix(port)
|
|
case "windows":
|
|
return pc.getProcessUsingPortWindows(port)
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// getProcessUsingPortUnix uses lsof to find the process using a port on Unix systems.
|
|
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
|
// Use lsof to find the process
|
|
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
|
|
// #nosec G204 -- port is an integer from config validation, not user input
|
|
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
|
|
pidStr := strings.TrimSpace(string(output))
|
|
if pidStr == "" {
|
|
return "unknown"
|
|
}
|
|
|
|
// Handle multiple PIDs (multiple processes on same port)
|
|
pids := strings.Split(pidStr, "\n")
|
|
var validProcesses []processInfo
|
|
|
|
for _, pid := range pids {
|
|
pid = strings.TrimSpace(pid)
|
|
if pid == "" {
|
|
continue
|
|
}
|
|
|
|
if !isValidPID(pid) {
|
|
logger.Debug("Invalid PID format from lsof output", map[string]interface{}{
|
|
"port": port,
|
|
"raw_pid": pid,
|
|
})
|
|
continue
|
|
}
|
|
|
|
procName := getProcessNameByPID(pid)
|
|
validProcesses = append(validProcesses, processInfo{
|
|
pid: pid,
|
|
name: procName,
|
|
isValid: true,
|
|
})
|
|
}
|
|
|
|
return formatProcessList(validProcesses)
|
|
}
|
|
|
|
// isListeningState checks if a netstat line indicates a listening state.
|
|
// This handles both English and potentially other locales by checking for common patterns.
|
|
func isListeningState(line string, fields []string) bool {
|
|
upperLine := strings.ToUpper(line)
|
|
|
|
// Check for common listening state indicators across locales
|
|
// English: LISTENING, German: ABHÖREN, French: ÉCOUTE, etc.
|
|
// The most reliable check is the state field position (4th field, 0-indexed = 3)
|
|
// and that it's a TCP connection with 0.0.0.0:0 or *:* as foreign address
|
|
if len(fields) >= minNetstatFields {
|
|
state := strings.ToUpper(fields[3])
|
|
// Common listening state values across Windows locales
|
|
if state == "LISTENING" || state == "ABHÖREN" || state == "ÉCOUTE" ||
|
|
state == "ESCUCHANDO" || state == "ASCOLTO" || state == "NASŁUCHIWANIE" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Fallback: check if line contains LISTENING (most common case)
|
|
return strings.Contains(upperLine, "LISTENING")
|
|
}
|
|
|
|
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
|
|
func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
|
// Use netstat to find the process
|
|
// netstat -ano | findstr :PORT
|
|
cmd := exec.Command("netstat", "-ano")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
portStr := fmt.Sprintf(":%d", port)
|
|
|
|
var validProcesses []processInfo
|
|
|
|
for _, line := range lines {
|
|
if !strings.Contains(line, portStr) {
|
|
continue
|
|
}
|
|
|
|
// Parse the line to extract PID
|
|
// Format: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
|
|
fields := strings.Fields(line)
|
|
if len(fields) < minNetstatFields {
|
|
continue
|
|
}
|
|
|
|
// Check if this is a LISTENING state (locale-aware)
|
|
if !isListeningState(line, fields) {
|
|
continue
|
|
}
|
|
|
|
// Verify the local address field actually contains our port
|
|
// (avoid matching port in foreign address)
|
|
localAddr := fields[1]
|
|
if !strings.HasSuffix(localAddr, portStr) {
|
|
continue
|
|
}
|
|
|
|
pid := fields[len(fields)-1]
|
|
|
|
if !isValidPID(pid) {
|
|
logger.Debug("Invalid PID format from netstat output", map[string]interface{}{
|
|
"port": port,
|
|
"raw_pid": pid,
|
|
"line": line,
|
|
})
|
|
continue
|
|
}
|
|
|
|
procName := getProcessNameByPIDWindows(pid)
|
|
validProcesses = append(validProcesses, processInfo{
|
|
pid: pid,
|
|
name: procName,
|
|
isValid: true,
|
|
})
|
|
}
|
|
|
|
return formatProcessList(validProcesses)
|
|
}
|
|
|
|
// FormatConflicts formats port conflicts into a human-readable error message.
|
|
func FormatConflicts(conflicts []PortConflict) string {
|
|
if len(conflicts) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("\nPort Conflicts Detected:\n")
|
|
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
|
|
|
|
for _, conflict := range conflicts {
|
|
sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port))
|
|
if conflict.Resource != "" {
|
|
sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource))
|
|
}
|
|
sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
sb.WriteString("Action: Stop conflicting processes or change localPort in config.\n")
|
|
|
|
return sb.String()
|
|
}
|