-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add background key rotation via LaunchAgent #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
901b56c
7e077a5
7243e97
c127680
481f3e1
db4be56
a57dde0
24d622a
5bf9612
e9cc706
42c08e7
8f1554c
ae432d3
1339a33
f654518
efe8313
03e71f5
1cb0233
a3aba0f
a172fba
549c643
2498576
908ab15
65a437e
2073881
a736de9
c054471
5c65e05
f287946
481fdb0
7c69d6c
464cabb
bc06aac
a45e52f
dd4fae1
5eb1547
1ed17cd
fe2e9ef
a858119
c557362
1497242
a7b27dd
88b5b42
82a269f
2b37caa
ffa6a85
fc9c04e
de7b7d6
00d25c6
1d3b22e
8c2e154
e67b4c0
fd6e0c1
48c6da8
5db9d0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -79,6 +79,19 @@ type ConnectionStatus struct { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func main() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.SetFlags(log.LstdFlags | log.Lshortfile) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check for headless mode flags | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(os.Args) > 1 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| switch os.Args[1] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case "--rotate-keys": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runHeadlessKeyRotation() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case "--version": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fmt.Printf("cassh %s (commit %s)\n", version, buildCommit) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.Println("Starting cassh-menubar...") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Initialize native notifications (request permission) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -397,10 +410,19 @@ func handleConnectionAction(connID string) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Find the connection index for UI updates | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connIdx := -1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for i, c := range cfg.User.Connections { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if c.ID == connID { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| connIdx = i | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if conn.Type == config.ConnectionTypeEnterprise { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| generateCertForConnection(conn) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| refreshKeyForConnection(conn) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| refreshKeyForConnection(conn, connIdx) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -478,7 +500,7 @@ func generateCertForConnection(conn *config.Connection) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // refreshKeyForConnection handles key refresh for personal GitHub connection | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func refreshKeyForConnection(conn *config.Connection) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func refreshKeyForConnection(conn *config.Connection, connIdx int) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check if gh CLI is authenticated | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ghStatus := checkGHAuth() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if !ghStatus.Installed { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -494,6 +516,9 @@ func refreshKeyForConnection(conn *config.Connection) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := rotatePersonalGitHubSSH(conn); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.Printf("Failed to rotate key: %v", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sendNotification("cassh", fmt.Sprintf("Failed to rotate key: %v", err), false) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if connIdx >= 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| updateConnectionStatus(connIdx) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -502,6 +527,11 @@ func refreshKeyForConnection(conn *config.Connection) { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.Printf("Failed to save config after key rotation: %v", err) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Update UI immediately | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if connIdx >= 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| updateConnectionStatus(connIdx) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sendNotification("cassh", fmt.Sprintf("SSH key rotated for %s", conn.Name), false) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.Printf("Rotated SSH key for: %s", conn.Name) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1182,7 +1212,6 @@ func formatDuration(d time.Duration) string { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Sprintf("%d minutes", minutes) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // uninstallCassh removes cassh and all its data from the system | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func uninstallCassh() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Show confirmation dialog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1214,16 +1243,21 @@ func uninstallCassh() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| removeGitConfigForConnection(&conn) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. Unregister from login items (SMAppService) and remove LaunchAgent | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 2. Unregister from login items (SMAppService) and remove LaunchAgents | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| unregisterAsLoginItem() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Remove user-level LaunchAgent | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Remove user-level LaunchAgents | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userLaunchAgentPath := filepath.Join(homeDir, "Library", "LaunchAgents", "com.shawnschwartz.cassh.plist") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exec.Command("launchctl", "unload", userLaunchAgentPath).Run() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.Remove(userLaunchAgentPath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Remove system-level LaunchAgent (installed by PKG) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userRotateAgentPath := filepath.Join(homeDir, "Library", "LaunchAgents", "com.shawnschwartz.cassh.rotate.plist") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exec.Command("launchctl", "unload", userRotateAgentPath).Run() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.Remove(userRotateAgentPath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Remove system-level LaunchAgents (installed by PKG) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| systemLaunchAgentPath := "/Library/LaunchAgents/com.shawnschwartz.cassh.plist" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exec.Command("launchctl", "unload", systemLaunchAgentPath).Run() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // System LaunchAgent requires admin to remove - will be handled by the uninstall script | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| systemRotateAgentPath := "/Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| exec.Command("launchctl", "unload", systemRotateAgentPath).Run() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // System LaunchAgents require admin to remove - will be handled by the uninstall script | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 3. Remove Application Support directory (contains user config) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| appSupportDir := filepath.Join(homeDir, "Library", "Application Support", "cassh") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1264,7 +1298,7 @@ LAUNCH_AGENT='/Library/LaunchAgents/com.shawnschwartz.cassh.plist' | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Create AppleScript to run with admin privileges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cat > /tmp/cassh_uninstall.scpt << 'APPLESCRIPT' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| do shell script "rm -rf '/Applications/cassh.app' '/Library/LaunchAgents/com.shawnschwartz.cassh.plist' 2>/dev/null || true" with prompt "cassh needs to remove the application." with administrator privileges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| do shell script "rm -rf '/Applications/cassh.app' '/Library/LaunchAgents/com.shawnschwartz.cassh.plist' '/Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist' 2>/dev/null || true" with prompt "cassh needs to remove the application." with administrator privileges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| APPLESCRIPT | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if osascript /tmp/cassh_uninstall.scpt 2>/dev/null; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1279,12 +1313,13 @@ rm -f /tmp/cassh_uninstall.scpt | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rm -f "$0" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `, appPath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if appPath != "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Can delete app without admin privileges, but still try to remove system LaunchAgent | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Can delete app without admin privileges, but still try to remove system LaunchAgents | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| uninstallScript = fmt.Sprintf(`#!/bin/bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sleep 2 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rm -rf '%s' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try to remove system LaunchAgent (may fail without admin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Try to remove system LaunchAgents (may fail without admin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rm -f /Library/LaunchAgents/com.shawnschwartz.cassh.plist 2>/dev/null || true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rm -f /Library/LaunchAgents/com.shawnschwartz.cassh.rotate.plist 2>/dev/null || true | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| osascript -e 'display notification "cassh has been uninstalled" with title "Uninstall Complete"' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rm -f "$0" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `, appPath) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -2014,8 +2049,8 @@ func findGHBinary() string { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check common Homebrew locations | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| commonPaths := []string{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/opt/homebrew/bin/gh", // Apple Silicon | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/usr/local/bin/gh", // Intel Mac | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/opt/homebrew/bin/gh", // Apple Silicon | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/usr/local/bin/gh", // Intel Mac | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "/home/linuxbrew/.linuxbrew/bin/gh", // Linux Homebrew | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -2113,6 +2148,28 @@ func generateSSHKeyForPersonal(conn *config.Connection) error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // getKeyTitle generates a unique SSH key title for GitHub | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Format: cassh-{connID}@{hostname} (e.g., cassh-personal-123@MacBook-Pro) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func getKeyTitle(connID string) string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hostname, err := os.Hostname() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil || hostname == "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hostname = "unknown" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Clean hostname - replace dots with dashes for cleaner display | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hostname = strings.ReplaceAll(hostname, ".", "-") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Truncate long hostnames | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(hostname) > 30 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hostname = hostname[:30] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Sprintf("cassh-%s@%s", connID, hostname) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // getLegacyKeyTitle returns the old key title format without hostname | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Used for backwards compatibility when looking up existing keys | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func getLegacyKeyTitle(connID string) string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Sprintf("cassh-%s", connID) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2167
to
+2171
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // rotatePersonalGitHubSSH rotates the personal GitHub SSH key, deleting the old key if present | |
| func rotatePersonalGitHubSSH(conn *config.Connection, keyPath string) error { | |
| // Delete old key if we have the key ID | |
| keyID := conn.GitHubKeyID | |
| if keyID == "" { | |
| // Try to find by current title | |
| keyID = findGitHubKeyIDByTitle(getKeyTitle(conn.ID)) | |
| if keyID == "" { | |
| // Try to find by legacy title | |
| keyID = findGitHubKeyIDByTitle(getLegacyKeyTitle(conn.ID)) | |
| } | |
| } | |
| if keyID != "" { | |
| err := deleteGitHubKeyByID(keyID) | |
| if err != nil { | |
| log.Printf("Failed to delete old GitHub SSH key (ID: %s): %v", keyID, err) | |
| } else { | |
| log.Printf("Deleted old GitHub SSH key (ID: %s)", keyID) | |
| } | |
| } | |
| // Upload new key | |
| title := getKeyTitle(conn.ID) | |
| newKeyID, err := uploadSSHKeyToGitHub(keyPath, title) | |
| if err != nil { | |
| return fmt.Errorf("failed to upload new SSH key: %w", err) | |
| } | |
| conn.GitHubKeyID = newKeyID | |
| return nil | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot please open a new pull request to apply changes based on this feedback
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getLegacyKeyTitle function is defined but never used in the codebase. If backward compatibility with old key titles is needed during rotation (to find and delete old keys created before this PR), this function should be called in the rotatePersonalGitHubSSH or deleteSSHKeyFromGitHub functions. Otherwise, this function should be removed as dead code.
| // getLegacyKeyTitle returns the old key title format without hostname | |
| // Used for backwards compatibility when looking up existing keys | |
| func getLegacyKeyTitle(connID string) string { | |
| return fmt.Sprintf("cassh-%s", connID) | |
| } |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The key title parsing logic assumes exactly 6 fields in the output, with the title being everything before the last 5 fields. However, if the gh CLI output format changes in future versions, or if there are edge cases with fewer fields, this could fail silently. Consider adding validation or logging when the field count is unexpected, and handling the case where len(fields) is between 1 and 5 more explicitly.
| fields := strings.Fields(line) | |
| if len(fields) >= 5 { | |
| keyTitle := fields[0] | |
| keyID := fields[len(fields)-2] // Key ID is the second-to-last field | |
| // Check for exact match with new title format | |
| if keyTitle == title { | |
| return keyID | |
| } | |
| // Check for exact match with legacy title format (if applicable) | |
| if legacyTitle != "" && keyTitle == legacyTitle { | |
| log.Printf("Found key with legacy title format: %s", legacyTitle) | |
| return keyID | |
| } | |
| } | |
| line = strings.TrimSpace(line) | |
| if line == "" { | |
| continue | |
| } | |
| fields := strings.Fields(line) | |
| // We expect at least 6 fields: | |
| // TITLE (may contain spaces) + TYPE + KEY + ADDED + KEY_ID + KEY_TYPE | |
| if len(fields) < 2 { | |
| log.Printf("Unexpected ssh-key list line (too few fields): %q", line) | |
| continue | |
| } | |
| if len(fields) < 6 { | |
| log.Printf("Unexpected ssh-key list field count (%d) for line: %q", len(fields), line) | |
| continue | |
| } | |
| // Title is everything before the last 5 fields | |
| titleFieldCount := len(fields) - 5 | |
| keyTitle := strings.Join(fields[:titleFieldCount], " ") | |
| keyID := fields[len(fields)-2] // Key ID is the second-to-last field | |
| // Check for exact match with new title format | |
| if keyTitle == title { | |
| return keyID | |
| } | |
| // Check for exact match with legacy title format (if applicable) | |
| if legacyTitle != "" && keyTitle == legacyTitle { | |
| log.Printf("Found key with legacy title format: %s", legacyTitle) | |
| return keyID | |
| } |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The key rotation logic changes the key title format from 'cassh-{connID}' to 'cassh-{connID}@{hostname}', but there's no migration path for existing keys. When rotatePersonalGitHubSSH runs, it attempts to delete the old key using conn.GitHubKeyID, but if that fails or is empty, old keys with the legacy title format will remain orphaned on GitHub. Consider attempting to find and delete legacy-format keys by calling findGitHubKeyIDByTitle with both the new and legacy title formats.
| log.Printf("Warning: failed to delete old key (ID: %s): %v", keyID, err) | |
| log.Printf("Warning: failed to delete old key (ID: %s): %v", keyID, err) | |
| // Attempt migration cleanup: look for keys by both new and legacy titles | |
| if conn.ID != "" { | |
| currentTitle := getKeyTitle(conn.ID) | |
| if currentTitle != "" { | |
| if altID := findGitHubKeyIDByTitle(currentTitle); altID != "" && altID != keyID { | |
| if err := deleteSSHKeyFromGitHub(altID); err != nil { | |
| log.Printf("Warning: failed to delete key found by current title (ID: %s): %v", altID, err) | |
| } else { | |
| log.Printf("Deleted GitHub SSH key found by current title (ID: %s)", altID) | |
| } | |
| } | |
| } | |
| legacyTitle := getLegacyKeyTitle(conn.ID) | |
| if legacyTitle != "" { | |
| if altID := findGitHubKeyIDByTitle(legacyTitle); altID != "" && altID != keyID { | |
| if err := deleteSSHKeyFromGitHub(altID); err != nil { | |
| log.Printf("Warning: failed to delete key found by legacy title (ID: %s): %v", altID, err) | |
| } else { | |
| log.Printf("Deleted GitHub SSH key found by legacy title (ID: %s)", altID) | |
| } | |
| } | |
| } | |
| } |
Copilot
AI
Dec 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function exits with code 0 even when some key rotations fail. If rotation fails for one or more connections (line 2412 continues on error), but succeeds for others, the function still exits with 0 indicating success. This could mask partial failures in monitoring systems. Consider tracking failed rotations and exiting with a non-zero code if any rotations failed, or provide more nuanced exit codes (e.g., 0 for full success, 1 for partial failure, 2 for complete failure).
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The runHeadlessKeyRotation function modifies user configuration by calling config.SaveUserConfig without any locking mechanism. If the GUI app is running simultaneously and also modifying the configuration (e.g., during user-initiated changes), there could be a race condition leading to lost updates. Consider implementing file locking or using atomic file operations to ensure concurrent modifications don't corrupt the configuration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot open a new pull request to apply changes based on this feedback
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||||||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||||||||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||||||||||
| <plist version="1.0"> | ||||||||||||
| <dict> | ||||||||||||
| <key>Label</key> | ||||||||||||
| <string>com.shawnschwartz.cassh.rotate</string> | ||||||||||||
|
|
||||||||||||
| <key>ProgramArguments</key> | ||||||||||||
| <array> | ||||||||||||
| <string>/Applications/cassh.app/Contents/MacOS/cassh</string> | ||||||||||||
| <string>--rotate-keys</string> | ||||||||||||
| </array> | ||||||||||||
|
|
||||||||||||
| <!-- Run every hour (no immediate execution on load) --> | ||||||||||||
| <key>StartInterval</key> | ||||||||||||
| <integer>3600</integer> | ||||||||||||
|
||||||||||||
| <integer>3600</integer> | |
| <integer>3600</integer> | |
| <key>RunAtLoad</key> | |
| <false/> |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment on line 14 states 'Run every hour (no immediate execution on load)', but StartInterval actually triggers immediately on load and then every hour after that according to macOS LaunchAgent documentation. If you truly want to prevent immediate execution on load, you should use StartCalendarInterval instead of StartInterval, or add RunAtLoad set to false explicitly.
| <!-- Do not run immediately when the job is loaded --> | |
| <key>RunAtLoad</key> | |
| <false/> |
Copilot
AI
Jan 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logging paths use world-readable /tmp directory which could expose sensitive information. SSH key rotation logs may contain usernames, connection names, or error messages with sensitive details. Consider using a user-specific log directory like ~/Library/Logs/cassh-rotate.log or /var/log/cassh-rotate.log with appropriate permissions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getLegacyKeyTitle function is defined but never used in the codebase. If this is intended for backward compatibility with existing keys that were created with the old title format (without hostname), it should be called somewhere in the key rotation or lookup logic. Otherwise, when users upgrade, the system won't be able to find and delete their old keys from GitHub that were created with the legacy format. Consider either using this function in findGitHubKeyIDByTitle as a fallback, or removing it if not needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot open a new pull request to apply changes based on this feedback
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot open a new pull request to apply changes based on this feedback