From 5b16cef52556b1f2c2231d74ea72e9f8070a5013 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 11 Jan 2026 10:23:03 +0100 Subject: [PATCH] feat: improve security --- app.go | 77 +++++++++++++++++++++++++++++++++++++++++++----------- cmd.go | 69 ++++++++++++++++++++++++++++++++++++++++++++---- config.go | 13 +++++++++ go.mod | 4 +++ go.sum | 8 ++++++ helpers.go | 18 ------------- secrets.go | 28 ++++++++++++++++++++ store.go | 10 ++----- 8 files changed, 181 insertions(+), 46 deletions(-) delete mode 100644 helpers.go create mode 100644 secrets.go diff --git a/app.go b/app.go index afde762..8db7415 100644 --- a/app.go +++ b/app.go @@ -5,11 +5,13 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "strings" "time" "github.com/charmbracelet/huh" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" ) type App struct { @@ -42,8 +44,34 @@ func (a *App) Close() error { return nil } -func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabetyp geändert - if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil { +func (a *App) StartTracking(tag string, withoutTimew bool) error { + if err := a.timeStore.StartTracking(tag); err != nil { + return err + } + + if !withoutTimew { + if err := a.runCommand("timew", "start", tag); err != nil { + slog.Warn("Failed to start timewarrior (ignoring)", "error", err) + } + } + return nil +} + +func (a *App) StopTracking(withoutTimew bool) error { + if err := a.timeStore.StopTracking(); err != nil { + return err + } + + if !withoutTimew { + if err := a.runCommand("timew", "stop"); err != nil { + slog.Warn("Failed to stop timewarrior (ignoring)", "error", err) + } + } + return nil +} + +func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { + if err := a.StartTracking(TagWork, withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) } @@ -69,7 +97,6 @@ func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabet 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.") @@ -137,16 +164,24 @@ func (a *App) connectToWorkstation() { fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), } if err := a.runCommand("ssh", sshArgs...); err != nil { + return } } 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 { + + args := []string{ + fmt.Sprintf("/u:%s", a.cfg.RDPUser), + fmt.Sprintf("/p:%s", a.cfg.SSHPassword), + "/v:127.0.0.1:6000", + "/size:3000x1350", + "+clipboard", + "/dynamic-resolution", + } + + if err := a.runCommand("xfreerdp", args...); err != nil { + return } } @@ -191,18 +226,18 @@ func (a *App) makeChoice() { case "start work": a.connect(withoutTimew) case "stop work": - if err := a.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.StopTracking(withoutTimew); 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, withoutTimew); err != nil { + if err := a.StartTracking(TagBreak, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) } case "stop break": - if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil { + if err := a.StartTracking(TagWork, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) } case "show day summary": @@ -288,11 +323,23 @@ func (a *App) newSSHConnection() (*SSHConnection, error) { return nil, fmt.Errorf("SSH authentication method could not be obtained") } + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home dir: %w", err) + } + knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts") + + hostKeyCallback, err := knownhosts.New(knownHostsPath) + if err != nil { + slog.Warn("Could not load known_hosts file. Please ensure you have connected to the host manually once to populate it.", "path", knownHostsPath) + return nil, fmt.Errorf("failed to create host key callback (check your known_hosts file): %w", err) + } + sshConfig := &ssh.ClientConfig{ User: a.cfg.SSHUser, Auth: []ssh.AuthMethod{authMethod}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, // Etwas längerer Timeout + HostKeyCallback: hostKeyCallback, + Timeout: 10 * time.Second, } target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) @@ -306,8 +353,8 @@ func (a *App) newSSHConnection() (*SSHConnection, error) { 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) + client.Close() + return nil, fmt.Errorf("failed to create SSH session check: %w", err) } session.Close() diff --git a/cmd.go b/cmd.go index 7180482..c53a0ec 100644 --- a/cmd.go +++ b/cmd.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -30,12 +31,70 @@ and other utilities.`, rootCmd.AddCommand(a.connectCommands()) rootCmd.AddCommand(a.wakeCommand()) rootCmd.AddCommand(a.importTimewarriorCommand()) + rootCmd.AddCommand(a.configCommand()) rootCmd.CompletionOptions.DisableDefaultCmd = true return rootCmd } +func (a *App) configCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Manage configuration and secrets", + } + + cmd.AddCommand(&cobra.Command{ + Use: "set-secrets", + Short: "Interactively set passwords in the system keyring", + Long: "Prompts for SSH and RDP passwords and stores them securely in the operating system's keychain/keyring.", + RunE: func(cmd *cobra.Command, args []string) error { + var sshPw, rdpPw string + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("SSH Password"). + Description("Leave empty to keep existing"). + EchoMode(huh.EchoModePassword). + Value(&sshPw), + huh.NewInput(). + Title("RDP Password"). + Description("Leave empty to keep existing"). + EchoMode(huh.EchoModePassword). + Value(&rdpPw), + ), + ) + + if err := form.Run(); err != nil { + return err + } + + if sshPw != "" { + if err := setSecret(keySSHPassword, sshPw); err != nil { + return fmt.Errorf("failed to save SSH password: %w", err) + } + fmt.Println("✓ SSH password saved to keyring.") + } + + if rdpPw != "" { + if err := setSecret(keyRDPPassword, rdpPw); err != nil { + return fmt.Errorf("failed to save RDP password: %w", err) + } + fmt.Println("✓ RDP password saved to keyring.") + } + + if sshPw == "" && rdpPw == "" { + fmt.Println("No changes made.") + } + + return nil + }, + }) + + return cmd +} + func (a *App) startCommand() *cobra.Command { cmd := &cobra.Command{ Use: "start", @@ -73,7 +132,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...") slog.Info("Received signal, cleanup via defer sshCon.Close() will run.") - if err := a.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.StopTracking(withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { slog.Info("Time tracking stopped.") @@ -86,7 +145,7 @@ Use --background (-b) to keep tunnels running in the background without auto-con fmt.Println("Workstation SSH session finished.") slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.") - if err := a.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.StopTracking(withoutTimew); err != nil { slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { slog.Info("Time tracking stopped.") @@ -110,7 +169,7 @@ func (a *App) stopCommand() *cobra.Command { Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.", Run: func(cmd *cobra.Command, args []string) { fmt.Println("Stopping workday procedures...") - if err := a.timeStore.StopTracking(withoutTimew); err != nil { + if err := a.StopTracking(withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) } else { fmt.Println("Time tracking stopped.") @@ -164,7 +223,7 @@ This also stops any currently running timer.`, default: fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) - if err := a.timeStore.StartTracking(tag, withoutTimew); err != nil { + if err := a.StartTracking(tag, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err)) return fmt.Errorf("could not start tracking '%s': %w", tag, err) } @@ -178,7 +237,7 @@ This also stops any currently running timer.`, Short: "Start tracking 'break'", RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("Starting break...") - if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil { + if err := a.StartTracking(TagBreak, withoutTimew); err != nil { slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) return fmt.Errorf("could not start break: %w", err) } diff --git a/config.go b/config.go index bae7e01..85a0bff 100644 --- a/config.go +++ b/config.go @@ -68,5 +68,18 @@ func loadConfig() (Config, error) { cfg.SSHPort = 22 } + if cfg.SSHPassword == "" { + if secret, err := getSecret(keySSHPassword); err == nil { + cfg.SSHPassword = secret + slog.Debug("Loaded SSH password from keyring.") + } + } + if cfg.RDPPassword == "" { + if secret, err := getSecret(keyRDPPassword); err == nil { + cfg.RDPPassword = secret + slog.Debug("Loaded RDP password from keyring.") + } + } + return cfg, nil } diff --git a/go.mod b/go.mod index 7df5042..0bfb4d7 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -27,10 +28,12 @@ require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -57,6 +60,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index cd54cde..a21dc12 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -47,6 +49,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -64,6 +68,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -173,6 +179,8 @@ github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q= github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= diff --git a/helpers.go b/helpers.go deleted file mode 100644 index c12c05c..0000000 --- a/helpers.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "log/slog" - "os" - "os/exec" -) - -func runCommand(name string, args ...string) { - 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("Command execution error", "command", name, "args", args, "error", err) - } -} diff --git a/secrets.go b/secrets.go new file mode 100644 index 0000000..5cc1584 --- /dev/null +++ b/secrets.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + + "github.com/zalando/go-keyring" +) + +const ( + serviceName = "workctl" + keySSHPassword = "ssh-password" + keyRDPPassword = "rdp-password" +) + +func getSecret(key string) (string, error) { + val, err := keyring.Get(serviceName, key) + if err != nil { + return "", err + } + return val, nil +} + +func setSecret(key, value string) error { + if value == "" { + return fmt.Errorf("secret cannot be empty") + } + return keyring.Set(serviceName, key, value) +} diff --git a/store.go b/store.go index 00565c4..12e0b94 100644 --- a/store.go +++ b/store.go @@ -113,7 +113,7 @@ func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { return rowsAffected > 0, nil } -func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { +func (ts *TimeStore) StartTracking(tag string) error { if tag == "" { return fmt.Errorf("cannot start tracking with an empty tag") } @@ -126,9 +126,6 @@ func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { if stopped { slog.Info("Stopped previous time entry.") } - if !withoutTimew { - runCommand("timew", "start", "work") - } query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` _, err = ts.db.Exec(query, tag, now) @@ -139,15 +136,12 @@ func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error { return nil } -func (ts *TimeStore) StopTracking(withoutTimew bool) error { +func (ts *TimeStore) StopTracking() error { now := time.Now() stopped, err := ts.stopCurrentEntry(now) if err != nil { return err } - if !withoutTimew { - runCommand("timew", "stop", "work") - } if stopped { slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339))) } else {