package main import ( "fmt" "log" "os" "os/exec" "strings" "time" "github.com/charmbracelet/huh" "golang.org/x/crypto/ssh" ) type App struct { cfg Config flags Flags timeStore *TimeStore // Datenbank-Handler hinzugefügt } func NewApp() (*App, error) { cfg, err := loadConfig() if err != nil { return nil, fmt.Errorf("error loading config: %w", err) } ts, err := NewTimeStore(cfg) if err != nil { return nil, fmt.Errorf("error initializing time store: %w", err) } return &App{ cfg: cfg, timeStore: ts, // TimeStore initialisiert }, nil } func (a *App) Close() error { if a.timeStore != nil { return a.timeStore.Close() } return nil } func (a *App) connect() { if err := a.timeStore.StartTracking(TagWork); err != nil { log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err) } a.wakeWorkstation() // Versuche, Workstation zu wecken sshCon, err := a.newSSHConnection() if err != nil { log.Fatalf("FATAL: Failed to establish primary SSH connection: %v", err) } log.Println("INFO: SSH connection established. Setting up tunnels...") sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) go func() { log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)") if err := sshForwarder.forward(); err != nil { // Logge Fehler, wenn der Listener nicht gestartet werden kann oder abbricht log.Printf("ERROR: SSH forwarder failed: %v", err) } log.Println("INFO: SSH forwarder stopped.") }() rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) go func() { log.Println("INFO: Starting RDP forwarder (local :6000 -> remote workstation:3389)") if err := rdpForwarder.forward(); err != nil { log.Printf("ERROR: RDP forwarder failed: %v", err) } log.Println("INFO: RDP forwarder stopped.") }() time.Sleep(1 * time.Second) log.Println("INFO: Tunnels should be active. You can now connect to localhost:2048 (SSH) or localhost:6000 (RDP).") } func (a *App) runCommand(name string, args ...string) error { log.Printf("INFO: Executing command: %s %s", name, strings.Join(args, " ")) cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin // Wichtig für interaktive Befehle wie ssh err := cmd.Run() if err != nil { log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err) return fmt.Errorf("command execution failed: %w", err) } log.Printf("INFO: Command finished successfully: %s", name) return nil } func (a *App) wakeWorkstation() { log.Println("INFO: Attempting to wake workstation...") innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"", a.cfg.JumpUser, // Benutzer auf dem Jump-Host a.cfg.JumpHost, // Jump-Host a.cfg.WorkstationMac) // MAC-Adresse der Workstation outerSSHCmd := []string{ "-tt", // TTY für die äußere Verbindung "-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den ersten Host fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@host für den ersten Host innerSSHCmd, // Der gesamte innere SSH-Befehl als einzelnes Argument } if err := a.runCommand("ssh", outerSSHCmd...); err != nil { log.Println("WARN: Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.") } else { log.Println("INFO: Wake-on-LAN command executed.") } } func (a *App) connectToJump() { log.Println("INFO: Connecting to Jump Host with Port Forwarding...") sshArgs := []string{ "-tt", // TTY Allokation "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), // Forwarding "-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den Jump-Host fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@jumphost } if err := a.runCommand("ssh", sshArgs...); err != nil { // Fehler wird bereits in runCommand geloggt } } func (a *App) connectToWorkstation() { log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...") sshArgs := []string{ "-tt", // TTY "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), // RDP Tunnel via Workstation SSH "-p", "2048", // Verbinde zum lokalen Port des ersten Tunnels fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), // Benutzer@localhost (geht durch den Tunnel) } if err := a.runCommand("ssh", sshArgs...); err != nil { } } func (a *App) startRDPConnection() { log.Println("INFO: Starting RDP connection to localhost:6000...") rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350 +clipboard /dynamic-resolution", a.cfg.RDPUser, a.cfg.RDPPassword, // SICHERHEITSRISIKO! ) if err := a.runCommand("bash", "-c", rdpCommand); err != nil { } } func (a *App) makeChoice() { var choice string form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("What would you like to do?"). Options( huh.NewOption("Start Work & Connect", "start work"), // Kombinierte Aktion huh.NewOption("Stop Work", "stop work"), huh.NewOption("Start Break", "start break"), huh.NewOption("Stop Break", "stop break"), // huh.NewOption("--- Summaries ---", "").Disabled(true), // Trenner huh.NewOption("Show Today Summary", "show day summary"), huh.NewOption("Show Week Summary", "show week summary"), huh.NewOption("Show Month Summary", "show month summary"), huh.NewOption("Export Yearly Timetable", "export"), // huh.NewOption("--- Connections ---", "").Disabled(true), // Trenner huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"), huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"), huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"), huh.NewOption("Wake Workstation", "wake workstation"), // huh.NewOption("--- Maintenance ---", "").Disabled(true), // Trenner huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), huh.NewOption("Exit", "exit"), ). Value(&choice), // Speichere die Auswahl in 'choice' ), ) err := form.Run() if err != nil { if err == huh.ErrUserAborted { fmt.Println("Operation cancelled.") return } log.Printf("ERROR: Form execution failed: %v", err) return } switch choice { case "start work": a.connect() // Startet Zeit, weckt Rechner, baut Tunnel auf case "stop work": if err := a.timeStore.StopTracking(); err != nil { log.Printf("ERROR: Failed to stop time tracking: %v", err) } if err := a.killForwardings(); err != nil { log.Printf("WARN: Could not kill all forwardings: %v", err) } case "start break": if err := a.timeStore.StartTracking(TagBreak); err != nil { log.Printf("ERROR: Failed to start break tracking: %v", err) } case "stop break": if err := a.timeStore.StartTracking(TagWork); err != nil { log.Printf("ERROR: Failed to stop break (start work): %v", err) } case "show day summary": if err := a.timeStore.ShowSummary("today"); err != nil { log.Printf("ERROR: Failed to show day summary: %v", err) } case "show week summary": if err := a.timeStore.ShowSummary("week"); err != nil { log.Printf("ERROR: Failed to show week summary: %v", err) } case "show month summary": if err := a.timeStore.ShowSummary("month"); err != nil { log.Printf("ERROR: Failed to show month summary: %v", err) } case "export": filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" { // Check ob Flag von CLI kommt filename = a.flags.ExportName } if err := a.timeStore.ExportSummary(filename); err != nil { log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) } case "connect to jump": a.connectToJump() // Blockiert case "connect to workstation": a.connectToWorkstation() // Blockiert case "start rdp connection": a.startRDPConnection() // Blockiert case "wake workstation": a.wakeWorkstation() case "kill tunnels": if err := a.killForwardings(); err != nil { log.Printf("ERROR: Failed to kill forwardings: %v", err) } else { log.Println("INFO: Attempted to kill processes on ports 2048 and 6000.") } case "exit": fmt.Println("Exiting.") return // Beendet die Funktion, was zum Programmende führt default: log.Printf("WARN: Unhandled choice '%s'", choice) } if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { fmt.Println("\nPress Enter to continue...") fmt.Scanln() // Warte auf Enter a.makeChoice() // Rufe Menü erneut auf } } func (a *App) getSSHAuth() ssh.AuthMethod { keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") // Sicherer Standard keyBytes, err := os.ReadFile(keyPath) if err != nil { log.Printf("ERROR: Unable to read private key '%s': %v", keyPath, err) return nil } var key ssh.Signer key, err = ssh.ParsePrivateKey(keyBytes) if err != nil { if _, ok := err.(*ssh.PassphraseMissingError); ok { log.Printf("INFO: Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath) key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) if err != nil { log.Printf("ERROR: Unable to parse private key '%s' with passphrase: %v", keyPath, err) return nil } } else { log.Printf("ERROR: Unable to parse private key '%s': %v", keyPath, err) return nil } } log.Printf("INFO: Successfully loaded private key '%s'", keyPath) return ssh.PublicKeys(key) } func (a *App) newSSHConnection() (*SSHConnection, error) { authMethod := a.getSSHAuth() if authMethod == nil { return nil, fmt.Errorf("SSH authentication method could not be obtained") } sshConfig := &ssh.ClientConfig{ User: a.cfg.SSHUser, Auth: []ssh.AuthMethod{authMethod}, // ACHTUNG: InsecureIgnoreHostKey ist unsicher für Produktionsumgebungen! // Besser: Known Hosts verwenden. HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second, // Etwas längerer Timeout } target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) log.Printf("INFO: Dialing SSH to %s...", target) client, err := ssh.Dial("tcp", target, sshConfig) if err != nil { return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) } log.Printf("INFO: SSH connection to %s successful.", target) session, err := client.NewSession() if err != nil { client.Close() // Client schließen, wenn Session fehlschlägt return nil, fmt.Errorf("failed to create SSH session: %w", err) } session.Close() return &SSHConnection{ client: client, // session: session, // Session wird für Forwarding nicht direkt gebraucht }, nil } func (a *App) killForwardings() error { ports := []string{"2048", "6000"} killedSomething := false var lastErr error log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", ")) for _, port := range ports { // Finde Prozess-ID (PID), die auf dem TCP-Port lauscht // lsof -i tcp: -t gibt nur die PID aus cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t") output, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { log.Printf("INFO: No process found listening on port %s.", port) } else { log.Printf("WARN: 'lsof' command failed for port %s: %v", port, err) lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err) // Letzten Fehler merken } continue // Gehe zum nächsten Port } pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") for pidStr := range pids { pid := strings.TrimSpace(pidStr) if pid == "" { continue } log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port) killCmd := exec.Command("kill", pid) // SIGTERM senden if err := killCmd.Run(); err != nil { log.Printf("WARN: Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err) forceKillCmd := exec.Command("kill", "-9", pid) if err := forceKillCmd.Run(); err != nil { log.Printf("ERROR: Failed to force kill PID %s (port %s): %v", pid, port, err) lastErr = fmt.Errorf("kill -9 failed for PID %s: %w", pid, err) } else { log.Printf("INFO: Force killed PID %s (port %s).", pid, port) killedSomething = true } } else { log.Printf("INFO: Killed PID %s (port %s).", pid, port) killedSomething = true } } } if killedSomething { log.Println("INFO: Finished attempting to kill forwarding processes.") } else { log.Println("INFO: No forwarding processes found or killed.") } return lastErr // Gibt den letzten aufgetretenen Fehler zurück }