package main import ( "fmt" "log/slog" "os" "os/exec" "strings" "time" "github.com/charmbracelet/huh" "golang.org/x/crypto/ssh" ) type App struct { cfg Config flags Flags timeStore *TimeStore } 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, }, nil } func (a *App) Close() error { if a.timeStore != nil { return a.timeStore.Close() } return nil } func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert if err := a.timeStore.StartTracking(TagWork); err != nil { slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) } a.wakeWorkstation() sshCon, err := a.newSSHConnection() if err != nil { return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) } slog.Info("SSH connection established. Setting up tunnels...") sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) go func() { slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)") if err := sshForwarder.forward(); err != nil { slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err)) } slog.Info("SSH forwarder stopped.") }() rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) go func() { slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)") if err := rdpForwarder.forward(); err != nil { // slog.Error(fmt.Sprintf("RDP forwarder failed: %v", err) slog.Error(fmt.Sprintf("ERROR: RDP forwarder failed: %v", err)) } slog.Info("RDP forwarder stopped.") }() time.Sleep(500 * time.Millisecond) return sshCon, nil } func (a *App) runCommand(name string, args ...string) error { slog.Info(fmt.Sprintf("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 err := cmd.Run() if err != nil { slog.Error(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err)) return fmt.Errorf("command execution failed: %w", err) } slog.Info(fmt.Sprintf("Command finished successfully: %s", name)) return nil } func (a *App) wakeWorkstation() { slog.Info("Attempting to wake workstation...") innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"", a.cfg.JumpUser, a.cfg.JumpHost, a.cfg.WorkstationMac) outerSSHCmd := []string{ "-tt", "-p", fmt.Sprintf("%d", a.cfg.SSHPort), fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), innerSSHCmd, } if err := a.runCommand("ssh", outerSSHCmd...); err != nil { slog.Warn("Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.") } else { slog.Info("Wake-on-LAN command executed.") } } func (a *App) connectToJump() { slog.Info("Connecting to Jump Host with Port Forwarding...") sshArgs := []string{ "-tt", "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), "-p", fmt.Sprintf("%d", a.cfg.SSHPort), fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), } if err := a.runCommand("ssh", sshArgs...); err != nil { } } func (a *App) connectToWorkstation() { slog.Info("Connecting to Workstation via local tunnel (localhost:2048)...") sshArgs := []string{ "-tt", "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), "-p", "2048", fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), } if err := a.runCommand("ssh", sshArgs...); err != nil { } } func (a *App) startRDPConnection() { slog.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.SSHPassword, ) 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"), huh.NewOption("Stop Work", "stop work"), huh.NewOption("Start Break", "start break"), huh.NewOption("Stop Break", "stop break"), 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("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("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), huh.NewOption("Exit", "exit"), ). Value(&choice), ), ) err := form.Run() if err != nil { if err == huh.ErrUserAborted { fmt.Println("Operation cancelled.") return } slog.Error(fmt.Sprintf("Form execution failed: %v", err)) return } switch choice { case "start work": a.connect() case "stop work": if err := a.timeStore.StopTracking(); err != nil { slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } if err := a.killForwardings(); err != nil { slog.Warn(fmt.Sprintf("Could not kill all forwardings: %v", err)) } case "start break": if err := a.timeStore.StartTracking(TagBreak); err != nil { slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) } case "stop break": if err := a.timeStore.StartTracking(TagWork); err != nil { slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) } case "show day summary": if err := a.timeStore.ShowSummary("today"); err != nil { slog.Error(fmt.Sprintf("Failed to show day summary: %v", err)) } case "show week summary": if err := a.timeStore.ShowSummary("week"); err != nil { slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err)) } case "show month summary": if err := a.timeStore.ShowSummary("month"); err != nil { slog.Error(fmt.Sprintf("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" { filename = a.flags.ExportName } if err := a.timeStore.ExportSummary(filename); err != nil { slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err)) } case "connect to jump": a.connectToJump() case "connect to workstation": a.connectToWorkstation() case "start rdp connection": a.startRDPConnection() case "wake workstation": a.wakeWorkstation() case "kill tunnels": if err := a.killForwardings(); err != nil { slog.Error(fmt.Sprintf("Failed to kill forwardings: %v", err)) } else { slog.Info("Attempted to kill processes on ports 2048 and 6000.") } case "exit": fmt.Println("Exiting.") return default: slog.Warn(fmt.Sprintf("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() a.makeChoice() } } func (a *App) getSSHAuth() ssh.AuthMethod { keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") keyBytes, err := os.ReadFile(keyPath) if err != nil { slog.Error(fmt.Sprintf("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 { slog.Info(fmt.Sprintf("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 { slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err)) return nil } } else { slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err)) return nil } } slog.Info(fmt.Sprintf("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}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 10 * time.Second, // Etwas längerer Timeout } target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) slog.Info(fmt.Sprintf("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) } slog.Info(fmt.Sprintf("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, }, nil } func (a *App) killForwardings() error { ports := []string{"2048", "6000"} killedSomething := false var lastErr error slog.Info(fmt.Sprintf("Attempting to kill processes listening on ports: %v", strings.Join(ports, ", "))) for _, port := range ports { 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 { slog.Info(fmt.Sprintf("No process found listening on port %s.", port)) } else { slog.Warn(fmt.Sprintf("'lsof' command failed for port %s: %v", port, err)) lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err) } continue } pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") for pidStr := range pids { pid := strings.TrimSpace(pidStr) if pid == "" { continue } slog.Info(fmt.Sprintf("Found process PID %s on port %s. Attempting to kill...", pid, port)) killCmd := exec.Command("kill", pid) if err := killCmd.Run(); err != nil { slog.Warn(fmt.Sprintf("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 { slog.Error(fmt.Sprintf("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 { slog.Info(fmt.Sprintf("Force killed PID %s (port %s).", pid, port)) killedSomething = true } } else { slog.Info(fmt.Sprintf("Killed PID %s (port %s).", pid, port)) killedSomething = true } } } if killedSomething { slog.Info("Finished attempting to kill forwarding processes.") } else { slog.Info("No forwarding processes found or killed.") } return lastErr }