├── .gitignore ├── .remarkrc ├── A3CF9185-4D22-48D1-9515-851538E8D12B.png ├── LICENCE.txt ├── README.html ├── README.md ├── assets ├── demo.gif └── iTerm2.png ├── cmd └── assh │ └── assh.go ├── demodata.go ├── env.sh ├── go.mod ├── go.sum ├── hosts.go ├── icon.png ├── icons.afdesign ├── icons ├── docs.png ├── help.png ├── icon.png ├── issue.png ├── log.png ├── off.png ├── on.png ├── reload.png ├── settings.png ├── update-available.png ├── update-ok.png ├── url.png └── warning.png ├── info.plist ├── magefile.go ├── magefile_alfred.go ├── magefile_images.go ├── metadata.json ├── modd.conf ├── sources.go ├── sources_config.go ├── sources_history.go ├── sources_hosts.go ├── sources_known.go └── sources_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Build artefacts 3 | /build 4 | /dist 5 | /vendor 6 | 7 | # Vim turds 8 | /tags 9 | *.swp 10 | -------------------------------------------------------------------------------- /.remarkrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "lint": { 4 | "maximum-line-length": 8000 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /A3CF9185-4D22-48D1-9515-851538E8D12B.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/A3CF9185-4D22-48D1-9515-851538E8D12B.png -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | Secure SHell for Alfred. Open SSH connections from Alfred 3. 2 | 3 | The MIT License (MIT) 4 | --------------------- 5 | 6 | Copyright (c) 2016 Dean Jackson 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a 9 | copy of this software and associated documentation files (the 10 | "Software"), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included 17 | in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 20 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 24 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Secure SHell for Alfred 2 | ======================= 3 | 4 | Open SSH/SFTP/mosh connections from [Alfred 3][alfredapp] with autosuggestions based on SSH config files, `/etc/hosts` and your history. 5 | 6 | ![demo](assets/demo.gif) 7 | 8 | 9 | 10 | - [Features](#features) 11 | - [Installation](#installation) 12 | - [Usage](#usage) 13 | - [Configuration](#configuration) 14 | - [Sources](#sources) 15 | - [Advanced configuration](#advanced-configuration) 16 | - [URLs](#urls) 17 | - [Commands](#commands) 18 | - [Using iTerm2](#using-iterm2) 19 | - [Licensing & thanks](#licensing--thanks) 20 | - [Changelog](#changelog) 21 | 22 | 23 | 24 | 25 | Features 26 | -------- 27 | 28 | - Auto-suggest hostnames 29 | - Remembers usernames, so you don't have to type them in every time 30 | - Alternate actions: 31 | - Open connection with **mosh** 32 | - Open **SFTP** connection 33 | - **Ping** host 34 | - Sources (can be managed individually): 35 | - `~/.ssh/config` 36 | - `~/.ssh/known_hosts` 37 | - History (i.e. username + host addresses previously entered by the user) 38 | - `/etc/hosts` 39 | - `/etc/ssh/ssh_config` 40 | 41 | 42 | 43 | Installation 44 | ------------ 45 | 46 | Download [the latest release][gh-releases] and double-click the file to install in Alfred. 47 | 48 | 49 | 50 | Usage 51 | ----- 52 | 53 | The main keyword is `ssh`: 54 | 55 | - `ssh []` — View and filter known SSH connections. 56 | 57 | - `↩` or `⌘+` — Open the connection. 58 | - `⇥` — Expand query to selected connection's title. Useful for adding a port number. 59 | - `⌘+↩` — Open an SFTP connection instead. 60 | - `⌥+↩` — Open a mosh connection instead. 61 | - `⇧+↩` — Ping host. 62 | - `^+↩` — Forget connection (if it's from history). 63 | 64 | Configuration is managed with `sshconf`: 65 | 66 | - `sshconf []` — Edit workflow settings (see [Configuration](#configuration)) 67 | - `An Update is Available!` or `Workflow is Up to Date` — Action to check for an update and install if one is available. 68 | - `Source: XYZ` — Toggle source on/off 69 | - `Log File` — Open workflow's log file in the default app (usually Console.app) 70 | - `Documentation` / `Report Issue` / `Visit Forum` — Open this file, the workflow's issue tracker or forum thread in your browser. 71 | 72 | 73 | 74 | Configuration 75 | ------------- 76 | 77 | Sources can be configured from within the workflow using the `sshconf` keyword. Other settings are managed via the [workflow's configuration sheet][confsheet]. 78 | 79 | 80 | 81 | ### Sources ### 82 | 83 | The following sources are available and can be toggled on/off using `sshconf`: 84 | 85 | | Name | Source | 86 | |---------------------|------------------------| 87 | | SSH Config | `~/.ssh/config` | 88 | | SSH Config (system) | `/etc/ssh/ssh_config` | 89 | | /etc/hosts | `/etc/hosts` | 90 | | History | User-entered hostnames | 91 | | Known Hosts | `~/.ssh/known_hosts` | 92 | 93 | 94 | 95 | ### Advanced configuration ### 96 | 97 | There are several additional settings that can only be edited via the [workflow's configuration sheet][confsheet], which allow you to specify a few commands and applications. 98 | 99 | To understand these, it's necessary to understand a bit about how the workflow works. 100 | 101 | The workflow opens connections either via a URL (`sftp://...` and `ssh://...` by default) or via a shell command (`ping` and `mosh` by default). URLs are passed off to the system, which opens them in the default application. Shell commands are handled by Alfred's [Terminal Command Action][termcmd], which effectively creates a new tab in your default terminal and pastes the command in there. 102 | 103 | 104 | 105 | #### URLs #### 106 | 107 | If you'd like `sftp://...` or `ssh://...` URLs to be passed to a specific application, specify its *name* for `SFTP_APP` or `SSH_APP` respectively, e.g. `Transmit` or `ForkLift` for SFTP, or `Terminal` for SSH. 108 | 109 | 110 | 111 | #### Commands #### 112 | 113 | The handling of shell commands is configured in Alfred's own preferences (see [Using iTerm2](#using-iterm2) for more information). 114 | 115 | There are two commands you can configure in the workflow, `MOSH_CMD` and `SSH_CMD`. 116 | 117 | `MOSH_CMD` sets the command that is pasted in your terminal when the command is run. Normally, the default of `mosh` should be sufficient, but set to a full path if the command can't be found. 118 | 119 | Set `MOSH_CMD` to empty to disable mosh. 120 | 121 | `SSH_CMD` allows you to override the default behaviour of generating and opening an `ssh://...` URL. If `SSH_CMD` is non-empty, a shell command is generated and run in your terminal instead. `SSH_CMD` is the name or path of the `ssh` command. 122 | 123 | Compared to the default `ssh://...` URL method, this has the advantage of running the command in your own shell, so your local configuration files should be loaded before the SSH connection is made. It has the downside of being slower and less well-tested than the default URL method. 124 | 125 | 126 | 127 | #### Using iTerm2 #### 128 | 129 | If you'd prefer to use iTerm2 rather than Terminal.app, there are two steps: 130 | 131 | 1. To have shell commands open in iTerm2, install [@stuartcryan][stuart]'s [iTerm2 plugin for Alfred][iterm-plugin]. 132 | 2. To open `ssh://...` URLs in iTerm2, Set iTerm2 as the default handler for `ssh:` URLs in iTerm2's own preferences under `Profiles > PROFILE_NAME > General > URL Schemes`: 133 | 134 | ![iTerm2 > Preferences > PROFILE_NAME > General > URL Schemes](assets/iTerm2.png) 135 | 136 | 137 | Licensing & thanks 138 | ------------------ 139 | 140 | This workflow is released under the [MIT Licence][mit]. 141 | 142 | It uses the following libraries (all [MIT Licence][mit]): 143 | 144 | - [ssh_config][ssh_config] to parse SSH config files. 145 | - [AwGo][awgo] for the workflowy stuff. 146 | 147 | And icons from or based on the following fonts (all [SIL Licence][sil]): 148 | 149 | - [Material Design Community][material] 150 | - [FontAwesome][fontawesome] 151 | 152 | This workflow started as a port of [@isometry's][isometry] Python [SSH workflow][ssh-breathe] to Go as a testbed for [AwGo][awgo]. It has since gained some additional features. 153 | 154 | If you need Alfred 2 support, check out [@isometry's workflow][ssh-breathe]. 155 | 156 | 157 | 158 | Changelog 159 | --------- 160 | 161 | - **v0.9.0** 162 | - 163 | - **v0.8.0 — 2018-03-17** 164 | - Add option to use `ssh` command instead of URL. 165 | Enables loading of local shell configuration before opening connection. #8 166 | - Add in-workflow configuration of sources 167 | - Add links to docs, issue tracker and forum thread 168 | - **v0.7.1 — 2016-12-12** 169 | - Fix updater bug 170 | - Smarter SSH URLs for hosts from `~/.ssh/config` 171 | - Better removal of duplicates 172 | - **v0.6.0 — 2016-11-09** 173 | - Add in-workflow updates 174 | - **v0.5.0 — 2016-10-31** 175 | - Add support for SSH configuration files (`~/.ssh/config` and `/etc/ssh/ssh_config`) 176 | - Alternate action: open connection with `mosh` 177 | - **v0.4.0 — 2016-05-27** 178 | - Add ability to turn sources of suggestions off #1 179 | - **v0.3.0 — 2016-05-26** 180 | - Alternate action: Open SFTP connection 181 | - Alternate action: Ping host 182 | - Remember connections with usernames, so you don't have to type the username each time 183 | - **v0.2.0 — 2016-05-23** 184 | - First public release 185 | 186 | 187 | [alfredapp]: https://www.alfredapp.com/ 188 | [alfterm]: https://www.alfredapp.com/help/features/terminal/ 189 | [awgo]: https://godoc.org/github.com/deanishe/awgo 190 | [confsheet]: https://www.alfredapp.com/help/workflows/advanced/variables/#environment 191 | [demo]: https://raw.githubusercontent.com/deanishe/alfred-ssh/master/demo.gif "The workflow in action" 192 | [gh-releases]: https://github.com/deanishe/alfred-ssh/releases/latest 193 | [isometry]: https://github.com/isometry 194 | [iterm-plugin]: https://github.com/stuartcryan/custom-iterm-applescripts-for-alfred/ 195 | [iterm-screenshot]: https://raw.githubusercontent.com/deanishe/alfred-ssh/master/iTerm2.png "Setting a handler in iTerm2 Preferences" 196 | [mit]: https://opensource.org/licenses/MIT 197 | [sil]: http://scripts.sil.org/OFL 198 | [ssh_config]: https://github.com/havoc-io/ssh_config 199 | [ssh-breathe]: https://github.com/isometry/alfredworkflows/tree/master/net.isometry.alfred.ssh 200 | [stuart]: https://github.com/stuartcryan/ 201 | [termcmd]: https://www.alfredapp.com/help/workflows/actions/terminal-command/ 202 | [material]: https://materialdesignicons.com/ 203 | [fontawesome]: https://fontawesome.com 204 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/assets/demo.gif -------------------------------------------------------------------------------- /assets/iTerm2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/assets/iTerm2.png -------------------------------------------------------------------------------- /cmd/assh/assh.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-05-26 7 | // 8 | 9 | /* 10 | assh 11 | ==== 12 | 13 | A Script Filter for Alfred 3 for opening SSH connections. Auto-suggests 14 | hosts from ~/.ssh/known_hosts and from /etc/hosts. 15 | 16 | The script filter is implemented as a command-line program (that outputs 17 | JSON). 18 | */ 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "log" 24 | "net/url" 25 | "os" 26 | "path/filepath" 27 | "strings" 28 | "time" 29 | 30 | "strconv" 31 | 32 | "os/exec" 33 | 34 | ssh "github.com/deanishe/alfred-ssh" 35 | aw "github.com/deanishe/awgo" 36 | "github.com/deanishe/awgo/fuzzy" 37 | "github.com/deanishe/awgo/update" 38 | "github.com/deanishe/awgo/util" 39 | 40 | docopt "github.com/docopt/docopt-go" 41 | ) 42 | 43 | const ( 44 | // Name of background job that checks for updates 45 | updateJobName = "checkForUpdate" 46 | // GitHub repo 47 | repo = "deanishe/alfred-ssh" 48 | // Doc & help URLs 49 | issueURL = "https://github.com/deanishe/alfred-ssh/issues" 50 | forumURL = "https://www.alfredforum.com/topic/8956-secure-shell-for-alfred-3-ssh-plus-sftp-mosh-ping-with-autosuggest/" 51 | helpPath = "./README.html" 52 | ) 53 | 54 | // Paths to built-in sources 55 | var ( 56 | SSHUserConfigPath = os.ExpandEnv("$HOME/.ssh/config") 57 | SSHGlobalConfigPath = "/etc/ssh/ssh_config" 58 | SSHKnownHostsPath = os.ExpandEnv("$HOME/.ssh/known_hosts") 59 | EtcHostsPath = "/etc/hosts" 60 | // HistoryVersion = 2 61 | ) 62 | 63 | // Priorities for sources 64 | var ( 65 | PriorityUserConfig = 1 66 | PriorityKnownHosts = 2 67 | PriorityHistory = 3 68 | PriorityGlobalConfig = 4 69 | PriorityEtcHosts = 5 70 | ) 71 | 72 | // Workflow icons 73 | var ( 74 | IconDocs = &aw.Icon{Value: "icons/docs.png"} 75 | IconHelp = &aw.Icon{Value: "icons/help.png"} 76 | IconIssue = &aw.Icon{Value: "icons/issue.png"} 77 | IconLog = &aw.Icon{Value: "icons/log.png"} 78 | IconOff = &aw.Icon{Value: "icons/off.png"} 79 | IconOn = &aw.Icon{Value: "icons/on.png"} 80 | IconReload = &aw.Icon{Value: "icons/reload.png"} 81 | IconSettings = &aw.Icon{Value: "icons/settings.png"} 82 | IconURL = &aw.Icon{Value: "icons/url.png"} 83 | IconUpdateAvailable = &aw.Icon{Value: "icons/update-available.png"} 84 | IconUpdateOK = &aw.Icon{Value: "icons/update-ok.png"} 85 | IconWarning = &aw.Icon{Value: "icons/warning.png"} 86 | IconWorkflow = &aw.Icon{Value: "icon.png"} 87 | ) 88 | 89 | var ( 90 | minScore = 30.0 // Default cut-off for search results 91 | usage = `assh [options] [] 92 | 93 | Display a list of know SSH hosts in Alfred 3. If 94 | is specified, the hostnames will be filtered against it. 95 | 96 | Usage: 97 | assh open 98 | assh search [-d] [] 99 | assh remember 100 | assh forget 101 | assh print (datadir|cachedir|distname|logfile) 102 | assh check 103 | assh config [] 104 | assh toggle 105 | assh --help|--version 106 | 107 | Options: 108 | -h, --help Show this message and exit. 109 | --version Show version information and exit. 110 | -d, --demo Use fake test data instead of real data from the 111 | computer. 112 | Useful for testing, otherwise pointless. Demo 113 | mode can also turned on by setting the 114 | environment variable DEMO_MODE=1 115 | ` 116 | wf *aw.Workflow 117 | ) 118 | 119 | func init() { 120 | aw.IconWarning = IconWarning 121 | 122 | wf = aw.New( 123 | aw.SortOptions( 124 | fuzzy.SeparatorBonus(10.0), 125 | ), 126 | aw.AddMagic( 127 | openMagic{"docs", "Open workflow documentation in your browser", helpPath}, 128 | openMagic{"forum", "Visit the workflow thread on alfredforum.com", forumURL}, 129 | ), 130 | update.GitHub(repo), 131 | aw.HelpURL(issueURL), 132 | ) 133 | } 134 | 135 | // main calls run() via Workflow.Run(). 136 | func main() { wf.Run(run) } 137 | 138 | // Hosts is a collection of Host objects that supports aw.Sortable. 139 | // (and therefore sort.Interface). 140 | type Hosts []ssh.Host 141 | 142 | // Len etc. implement sort.Interface. 143 | func (s Hosts) Len() int { return len(s) } 144 | func (s Hosts) Less(i, j int) bool { return s[i].Hostname() < s[j].Hostname() } 145 | func (s Hosts) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 146 | 147 | // SortKey implements aw.Sortable. 148 | func (s Hosts) SortKey(i int) string { return s[i].Name() } 149 | 150 | // -------------------------------------------------------------------- 151 | // Execute Script Filter 152 | // -------------------------------------------------------------------- 153 | 154 | type options struct { 155 | // Command-line options 156 | Check bool // Download list of available releases 157 | Config bool // Whether to show configuration options 158 | Demo bool `env:"DEMO_MODE"` // Whether to load test data instead of user data 159 | Forget bool // Whether to forget URL 160 | Open bool // Whether to open URL 161 | Print bool // Whether to print a variable 162 | PrintDataDir bool `docopt:"datadir"` 163 | PrintCacheDir bool `docopt:"cachedir"` 164 | PrintDistName bool `docopt:"distname"` 165 | PrintLogFile bool `docopt:"logfile"` 166 | Remember bool // Whether to remember URL 167 | Search bool // Whether to search hosts 168 | Toggle bool // Whether to toggle a setting on/off 169 | RawInput string `docopt:""` // The full, unparsed query 170 | RawURL string `docopt:""` // Input URL 171 | VarName string `docopt:""` // Name of variable to toggle 172 | 173 | // Workflow configuration (environment variables) 174 | DisableConfig bool 175 | DisableEtcConfig bool 176 | DisableEtcHosts bool 177 | DisableHistory bool 178 | DisableKnownHosts bool 179 | ExitOnSuccess bool // Append " && exit" to shell commands 180 | MoshCmd string 181 | SFTPApp string `env:"SFTP_APP"` 182 | SSHApp string `env:"SSH_APP"` 183 | SSHCmd string `env:"SSH_CMD"` 184 | 185 | // Derived configuration 186 | query string // User query. User input is parsed into query and username 187 | url *url.URL // URL to add to history 188 | username string // SSH username. Added later by query parser. 189 | port int // SSH port. Added later by query parser. 190 | historyPath string // Path to history cache file 191 | } 192 | 193 | // MagicAction that opens a given path or URL. 194 | type openMagic struct { 195 | keyword string 196 | description string 197 | target string 198 | } 199 | 200 | func (ma openMagic) Keyword() string { return ma.keyword } 201 | func (ma openMagic) Description() string { return ma.description } 202 | func (ma openMagic) RunText() string { return fmt.Sprintf("Opening %s ...", ma.target) } 203 | func (ma openMagic) Run() error { 204 | log.Printf("[magic] opening %q in default application ...", ma.target) 205 | cmd := exec.Command("/usr/bin/open", ma.target) 206 | _, err := util.RunCmd(cmd) 207 | return err 208 | } 209 | 210 | // parseArgs constructs the program options from command-line arguments and 211 | // environment variables. 212 | func parseArgs() *options { 213 | 214 | var ( 215 | o = &options{} 216 | vstr = fmt.Sprintf("%s/%v (awgo/%v)", wf.Name(), wf.Version(), aw.AwGoVersion) 217 | err error 218 | ) 219 | 220 | // Parse options -------------------------------------------------- 221 | 222 | args, err := docopt.ParseArgs(usage, wf.Args(), vstr) 223 | if err != nil { 224 | panic(fmt.Sprintf("Error parsing CLI options: %v", err)) 225 | } 226 | 227 | if err = args.Bind(o); err != nil { 228 | panic(fmt.Sprintf("Error parsing CLI options: %v", err)) 229 | } 230 | 231 | if err = wf.Config.To(o); err != nil { 232 | panic(fmt.Sprintf("Error loading workflow configuration: %v", err)) 233 | } 234 | 235 | if o.RawURL != "" { 236 | o.url, err = url.Parse(o.RawURL) 237 | if err != nil || !o.url.IsAbs() { 238 | wf.Fatalf("Invalid URL: %s", o.RawURL) 239 | } 240 | } 241 | 242 | if o.Demo { 243 | o.historyPath = filepath.Join(wf.DataDir(), "history.test.json") 244 | } else { 245 | o.historyPath = filepath.Join(wf.DataDir(), "history.json") 246 | } 247 | 248 | if o.RawInput != "" { 249 | o.RawInput = strings.TrimSpace(o.RawInput) 250 | o.query = o.RawInput 251 | } 252 | 253 | return o 254 | } 255 | 256 | // Print a variable to STDOUT 257 | func runPrint(o *options) { 258 | 259 | if o.PrintDataDir { 260 | fmt.Print(wf.DataDir()) 261 | return 262 | 263 | } else if o.PrintCacheDir { 264 | fmt.Print(wf.CacheDir()) 265 | return 266 | 267 | } else if o.PrintLogFile { 268 | fmt.Print(wf.LogFile()) 269 | return 270 | 271 | } else if o.PrintDistName { 272 | name := strings.Replace( 273 | fmt.Sprintf("%s-%s.alfredworkflow", wf.Name(), wf.Version()), 274 | " ", "-", -1) 275 | fmt.Print(name) 276 | 277 | return 278 | 279 | } 280 | } 281 | 282 | // Open a URL in the default or custom application. 283 | func runOpen(o *options) { 284 | wf.Configure(aw.TextErrors(true)) 285 | 286 | var ( 287 | argv = []string{} 288 | sshHdlr = os.Getenv("SSH_APP") 289 | sftpHdlr = os.Getenv("SFTP_APP") 290 | ) 291 | log.Printf("Opening URL %s", o.url) 292 | if o.url.Scheme == "ssh" && sshHdlr != "" { 293 | argv = append(argv, "-a", sshHdlr) 294 | } else if o.url.Scheme == "sftp" && sftpHdlr != "" { 295 | argv = append(argv, "-a", sftpHdlr) 296 | } 297 | argv = append(argv, o.url.String()) 298 | cmd := exec.Command("open", argv...) 299 | log.Printf("Command: %s %+v", cmd.Path, cmd.Args) 300 | out, err := cmd.CombinedOutput() 301 | if err != nil { 302 | wf.Fatal(string(out)) 303 | } 304 | return 305 | 306 | } 307 | 308 | // Check for an update to the workflow 309 | func runUpdate(o *options) { 310 | wf.Configure(aw.TextErrors(true)) 311 | 312 | if err := wf.CheckForUpdate(); err != nil { 313 | wf.FatalError(err) 314 | } 315 | 316 | if wf.UpdateAvailable() { 317 | log.Printf("[update] An update is available") 318 | } else { 319 | log.Printf("[update] Workflow is up to date") 320 | } 321 | } 322 | 323 | // Add host or remove host from history 324 | func runHistory(o *options) { 325 | 326 | if o.DisableHistory { 327 | log.Println("History disabled. Ignoring.") 328 | return 329 | } 330 | 331 | h := ssh.NewHistory(o.historyPath, "history", 1) 332 | if err := h.Load(); err != nil { 333 | log.Printf("Error loading history : %v", err) 334 | panic(err) 335 | } 336 | 337 | host := ssh.NewBaseHostFromURL(o.url) 338 | 339 | if o.Remember { // Add URL to history 340 | if err := h.Add(host); err != nil { 341 | log.Printf("Error adding host %v : %v", host, err) 342 | panic(err) 343 | } 344 | log.Printf("Saved host '%s' to history", host.Name()) 345 | } else { // Remove URL from history 346 | if err := h.Remove(host); err != nil { 347 | log.Printf("Error removing host %v : %v", host, err) 348 | panic(err) 349 | } 350 | log.Printf("Removed '%s' from history", host.Name()) 351 | } 352 | 353 | h.Save() 354 | return 355 | } 356 | 357 | // Alfred Script Filter to view configuration 358 | func runConfig(opts *options) { 359 | 360 | sources := []struct { 361 | title, file, varName string 362 | disabled bool 363 | }{ 364 | {"SSH Config", "~/.ssh/config", "DISABLE_CONFIG", opts.DisableConfig}, 365 | {"SSH Config (system)", "/etc/ssh/ssh_config", 366 | "DISABLE_ETC_CONFIG", opts.DisableEtcConfig}, 367 | {"/etc/hosts", "/etc/hosts", "DISABLE_ETC_HOSTS", opts.DisableEtcHosts}, 368 | {"History", "workflow history", "DISABLE_HISTORY", opts.DisableHistory}, 369 | {"Known Hosts", "~/.ssh/known_hosts", "DISABLE_KNOWN_HOSTS", opts.DisableKnownHosts}, 370 | } 371 | 372 | wf.Var("query", opts.query) 373 | 374 | if wf.UpdateAvailable() { 375 | wf.NewItem("An Update is Available!"). 376 | Subtitle("↩ or ⇥ to install"). 377 | Autocomplete("workflow:update"). 378 | Icon(IconUpdateAvailable). 379 | Valid(false) 380 | } else { 381 | wf.NewItem("Workflow is Up To Date"). 382 | Subtitle("↩ or ⇥ to check for update now"). 383 | Autocomplete("workflow:update"). 384 | Icon(IconUpdateOK). 385 | Valid(false) 386 | } 387 | 388 | for _, src := range sources { 389 | 390 | icon := IconOn 391 | if src.disabled { 392 | icon = IconOff 393 | } 394 | 395 | wf.NewItem("Source: " + src.title). 396 | Subtitle(src.file). 397 | Arg(src.varName). 398 | Valid(true). 399 | Icon(icon) 400 | 401 | } 402 | 403 | wf.NewItem("Log File"). 404 | Subtitle("Open workflow log file"). 405 | Autocomplete("workflow:log"). 406 | Icon(IconLog) 407 | 408 | // Docs & help URLs 409 | wf.NewItem("Documentation"). 410 | Subtitle("Read the workflow docs in your browser"). 411 | Autocomplete("workflow:docs"). 412 | Icon(IconDocs) 413 | 414 | wf.NewItem("Report Issue"). 415 | Subtitle("Open the workflow's issue tracker on GitHub"). 416 | Autocomplete("workflow:help"). 417 | Icon(IconIssue) 418 | 419 | wf.NewItem("Visit Forum"). 420 | Subtitle("Open the workflow's thread on alfredforum.com"). 421 | Autocomplete("workflow:forum"). 422 | Icon(IconURL) 423 | 424 | if opts.query != "" { 425 | wf.Filter(opts.query) 426 | } 427 | 428 | wf.WarnEmpty("No matches found", "Try a different query?") 429 | wf.SendFeedback() 430 | } 431 | 432 | // Toggle a setting on/off 433 | func runToggle(o *options) { 434 | wf.Configure(aw.TextErrors(true)) 435 | 436 | var s = "1" 437 | 438 | if wf.Config.GetBool(o.VarName) { 439 | s = "0" 440 | } 441 | 442 | log.Printf("[toggle] %s -> %q", o.VarName, s) 443 | 444 | if err := wf.Config.Set(o.VarName, s, true).Do(); err != nil { 445 | wf.FatalError(err) 446 | } 447 | 448 | } 449 | 450 | // Alfred Script Filter to search hosts 451 | func runSearch(o *options) { 452 | 453 | var ( 454 | hosts Hosts 455 | host ssh.Host 456 | ) 457 | 458 | // Parse query ---------------------------------------------------- 459 | // Extract username if present 460 | if i := strings.Index(o.query, "@"); i > -1 { 461 | o.username, o.query = o.query[:i], o.query[i+1:] 462 | } 463 | // Extract port if present 464 | if i := strings.Index(o.query, ":"); i > -1 { 465 | var port string 466 | o.query, port = o.query[:i], o.query[i+1:] 467 | if v, err := strconv.Atoi(port); err == nil { 468 | o.port = v 469 | } 470 | } 471 | 472 | log.Printf("query=%v, username=%v, port=%v", o.query, o.username, o.port) 473 | 474 | // Show update status if there's no query 475 | if o.query == "" && wf.UpdateAvailable() { 476 | 477 | // Ensure update notification is top results 478 | wf.Configure(aw.SuppressUIDs(true)) 479 | 480 | wf.NewItem("An Update is Available!"). 481 | Subtitle("↩ or ⇥ to install"). 482 | Valid(false). 483 | Autocomplete("workflow:update"). 484 | Icon(IconUpdateAvailable) 485 | } 486 | 487 | // Load hosts from sources ---------------------------------------- 488 | hosts = loadHosts(o) 489 | totalHosts := len(hosts) 490 | // log.Printf("%d total host(s)", totalHosts) 491 | 492 | // Prepare results for Alfred ------------------------------------- 493 | // seen := map[string]bool{} 494 | d := ssh.Deduplicator{} 495 | for _, host := range hosts { 496 | 497 | // Force use of username/port parsed from input 498 | if o.username != "" { 499 | host.SetUsername(o.username) 500 | } 501 | if o.port != 0 && o.port != 22 { 502 | host.SetPort(o.port) 503 | } 504 | 505 | // Check again if it's a dupe 506 | if !d.IsDuplicate(host) { 507 | itemForHost(host, o) 508 | d.Add(host) 509 | } 510 | } 511 | 512 | // Filter hosts and/or add host from query ------------------------ 513 | if o.query != "" { 514 | // Filter hosts 515 | res := wf.Filter(o.query) 516 | for i, r := range res { 517 | log.Printf("%3d. %5.2f %s", i+1, r.Score, r.SortKey) 518 | } 519 | log.Printf("%d/%d hosts match `%s`", len(res), totalHosts, o.query) 520 | 521 | // Add Host for query if it makes sense 522 | if ssh.IsValidHostname(o.query) { 523 | host = ssh.NewBaseHost(o.RawInput, o.query, "user input", o.username, o.port) 524 | if !d.IsDuplicate(host) { 525 | itemForHost(host, o) 526 | } 527 | } else { 528 | wf.WarnEmpty(fmt.Sprintf("Invalid hostname: %s", o.query), "Enter a different value") 529 | } 530 | } 531 | 532 | wf.WarnEmpty("No matching hosts", "Try different input") 533 | 534 | wf.SendFeedback() 535 | } 536 | 537 | // run executes the workflow. Calls other run* functions based on command-line options. 538 | func run() { 539 | 540 | o := parseArgs() 541 | log.Printf("options=\n%+v\n", o) 542 | 543 | if o.Check { 544 | runUpdate(o) 545 | return 546 | } 547 | 548 | // Run update check 549 | if wf.UpdateCheckDue() && !wf.IsRunning(updateJobName) { 550 | log.Println("Checking for update...") 551 | cmd := exec.Command("./assh", "check") 552 | if err := wf.RunInBackground(updateJobName, cmd); err != nil { 553 | log.Printf("Error running update check: %s", err) 554 | } 555 | } 556 | 557 | if o.Print { 558 | runPrint(o) 559 | return 560 | } else if o.Open { 561 | runOpen(o) 562 | return 563 | } else if o.Remember || o.Forget { 564 | runHistory(o) 565 | return 566 | } else if o.Toggle { 567 | runToggle(o) 568 | return 569 | } else if o.Config { 570 | runConfig(o) 571 | return 572 | } 573 | runSearch(o) 574 | 575 | } 576 | 577 | // itemForHost adds a feedback Item to Workflow wf for Host. 578 | func itemForHost(host ssh.Host, o *options) *aw.Item { 579 | var ( 580 | cmd string 581 | title = host.Name() 582 | comp = host.Name() // Autocomplete 583 | key = host.Name() // Sort key 584 | url = host.SSHURL().String() 585 | uid = host.UID() 586 | subtitle = fmt.Sprintf("%s (from %s)", url, host.Source()) 587 | ) 588 | 589 | if o.username != "" && host.Username() == "" { 590 | host.SetUsername(o.username) 591 | comp = fmt.Sprintf("%s@%s", o.username, host.Name()) 592 | title = comp 593 | } 594 | 595 | if o.port != 0 && o.port != host.Port() { 596 | host.SetPort(o.port) 597 | comp = fmt.Sprintf("%s:%d", comp, o.port) 598 | title = comp 599 | } 600 | 601 | // Feedback item 602 | it := wf.NewItem(title). 603 | Subtitle(subtitle). 604 | Autocomplete(comp). 605 | Arg(url). 606 | Copytext(url). 607 | Largetype(host.CanonicalURL().String()). 608 | UID(uid). 609 | Valid(true). 610 | Icon(IconWorkflow). 611 | Match(key) 612 | 613 | // Variables 614 | it.Var("query", o.RawInput). 615 | Var("name", host.Name()). 616 | Var("hostname", host.Hostname()). 617 | Var("source", host.Source()). 618 | Var("port", fmt.Sprintf("%d", host.Port())). 619 | Var("shell_cmd", "0"). 620 | Var("url", url) 621 | 622 | // Send ssh command via Terminal Command instead of opening URL 623 | if os.Getenv("SSH_CMD") != "" { 624 | cmd = host.SSHCmd(os.Getenv("SSH_CMD")) 625 | if cmd != "" { 626 | if o.ExitOnSuccess { 627 | cmd += " && exit" 628 | } 629 | it.Arg(cmd) 630 | it.Subtitle(fmt.Sprintf("%s (from %s)", cmd, host.Source())) 631 | it.Var("shell_cmd", "1") 632 | } 633 | } 634 | 635 | // Modifiers 636 | 637 | // Open SFTP connection instead 638 | url = host.SFTPURL().String() 639 | it.NewModifier("cmd"). 640 | Arg(url). 641 | Subtitle(fmt.Sprintf("Connect with SFTP (%s)", url)) 642 | 643 | // Open mosh connection instead 644 | if os.Getenv("MOSH_CMD") != "" { 645 | cmd = host.MoshCmd(os.Getenv("MOSH_CMD")) 646 | if cmd != "" { 647 | if o.ExitOnSuccess { 648 | cmd += " && exit" 649 | } 650 | it.NewModifier("alt"). 651 | Subtitle(fmt.Sprintf("Connect with mosh (%s)", cmd)). 652 | Arg(cmd). 653 | Var("shell_cmd", "1") 654 | } 655 | } 656 | 657 | // Ping host 658 | cmd = "ping " + host.Hostname() 659 | if o.ExitOnSuccess { 660 | cmd += " && exit" 661 | } 662 | it.NewModifier("shift"). 663 | Subtitle(fmt.Sprintf("Ping %s", host.Hostname())). 664 | Arg(cmd). 665 | Var("shell_cmd", "1") 666 | 667 | // Delete connection from history 668 | m := it.NewModifier("ctrl") 669 | if host.Source() == "history" { 670 | m.Subtitle("Delete connection from history").Arg(url).Valid(true) 671 | } else { 672 | m.Subtitle("Connection not from history").Valid(false) 673 | } 674 | return it 675 | } 676 | 677 | // loadHosts loads Hosts from all active sources. 678 | func loadHosts(o *options) []ssh.Host { 679 | var start = time.Now() 680 | var hosts Hosts 681 | 682 | if o.Demo { 683 | log.Println("**** Using test data ****") 684 | hosts = append(hosts, ssh.TestHosts()...) 685 | return hosts 686 | } 687 | 688 | sources := ssh.Sources{} 689 | 690 | if !o.DisableHistory { 691 | sources = append(sources, ssh.NewHistory(o.historyPath, "history", PriorityHistory)) 692 | // log.Printf("[source/new/history] %s", aw.ShortenPath(o.historyPath)) 693 | } 694 | if !o.DisableEtcHosts { 695 | sources = append(sources, ssh.NewHostsSource(EtcHostsPath, "/etc/hosts", PriorityEtcHosts)) 696 | // log.Printf("[source/new/hosts] %s", EtcHostsPath) 697 | } 698 | if !o.DisableKnownHosts { 699 | sources = append(sources, ssh.NewKnownSource(SSHKnownHostsPath, "known_hosts", PriorityKnownHosts)) 700 | // log.Printf("[source/new/known_hosts] %s", aw.ShortenPath(SSHKnownHostsPath)) 701 | } 702 | if !o.DisableConfig { 703 | sources = append(sources, ssh.NewConfigSource(SSHUserConfigPath, "~/.ssh/config", PriorityUserConfig)) 704 | // log.Printf("[source/new/config] %s", aw.ShortenPath(SSHUserConfigPath)) 705 | } 706 | if !o.DisableEtcConfig { 707 | sources = append(sources, ssh.NewConfigSource(SSHGlobalConfigPath, "/etc/ssh", PriorityGlobalConfig)) 708 | // log.Printf("[source/new/config] %s", SSHGlobalConfigPath) 709 | } 710 | hosts = append(hosts, sources.Hosts()...) 711 | 712 | log.Printf("%d host(s) loaded in %s", len(hosts), time.Since(start)) 713 | return hosts 714 | } 715 | 716 | /* 717 | // optionSet returns true if environment variable key is set to 1, Y, yes etc. 718 | func optionSet(key string) bool { 719 | v := strings.ToLower(os.Getenv(key)) 720 | if v == "" { 721 | return false 722 | } 723 | if v == "1" || v == "y" || v == "yes" { 724 | return true 725 | } 726 | return false 727 | } 728 | */ 729 | -------------------------------------------------------------------------------- /demodata.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-05-23 7 | // 8 | 9 | package ssh 10 | 11 | // Useful for screenshots 12 | var testHostnames = []string{ 13 | "kurt.bartell.com", 14 | "mail.gierschner.org", 15 | "vpn.johann.com", 16 | "hornich.junitz.com", 17 | "gene.kassulke-spencer.com", 18 | "roland.kassulke-spencer.com", 19 | "ethelyn.kassulke-spencer.com", 20 | "mail.kostolzin.de", 21 | "gateway.kuhlman-wolf.info", 22 | "www.kuhlman-wolf.info", 23 | "monja.kuhlman-wolf.info", 24 | "ftp.kuhlman-wolf.info", 25 | "ermanno.kulas-douglas.biz", 26 | "www.lind-sipes.com", 27 | "zaida.lind-sipes.com", 28 | "antonetta.lockman.com", 29 | "valerius.lockman.com", 30 | "gateway.losekann.com", 31 | "leslee.losekann.com", 32 | "ftp.mayer.biz", 33 | "reiner.roemer.com", 34 | "mail.roemer.com", 35 | "gateway.scholz.net", 36 | "vpn.sipes.com", 37 | "mail.sipes.com", 38 | "wulff.sipes.com", 39 | "elias.wesack.com", 40 | "gateway.wesack.com", 41 | "heinz.zorbach.com", 42 | } 43 | 44 | // TestHosts loads fake test data instead of real hosts. 45 | func TestHosts() []Host { 46 | hosts := make([]Host, len(testHostnames)) 47 | 48 | for i, name := range testHostnames { 49 | hosts[i] = &BaseHost{ 50 | name: name, 51 | hostname: name, 52 | source: "test data", 53 | port: 22, 54 | } 55 | } 56 | 57 | return hosts 58 | } 59 | -------------------------------------------------------------------------------- /env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Source this file to export expected Alfred variables to environment 4 | 5 | # getvar | Read a value from info.plist 6 | getvar() { 7 | local v="$1" 8 | /usr/libexec/PlistBuddy -c "Print :$v" info.plist 9 | } 10 | 11 | export alfred_workflow_bundleid=$( getvar "bundleid" ) 12 | export alfred_workflow_version=$( getvar "version" ) 13 | export alfred_workflow_name=$( getvar "name" ) 14 | export alfred_debug='1' 15 | 16 | export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred/Workflow Data/${alfred_workflow_bundleid}" 17 | export alfred_workflow_data="${HOME}/Library/Application Support/Alfred/Workflow Data/${alfred_workflow_bundleid}" 18 | 19 | # Alfred 3 environment if Alfred 4+ prefs file doesn't exist. 20 | if [[ ! -f "$HOME/Library/Application Support/Alfred/prefs.json" ]]; then 21 | export alfred_workflow_cache="${HOME}/Library/Caches/com.runningwithcrayons.Alfred-3/Workflow Data/${alfred_workflow_bundleid}" 22 | export alfred_workflow_data="${HOME}/Library/Application Support/Alfred 3/Workflow Data/${alfred_workflow_bundleid}" 23 | export alfred_version="3.8.1" 24 | fi 25 | 26 | 27 | export DISABLE_CONFIG=$( getvar "variables:DISABLE_CONFIG" ) 28 | export DISABLE_ETC_CONFIG=$( getvar "variables:DISABLE_ETC_CONFIG" ) 29 | export DISABLE_ETC_HOSTS=$( getvar "variables:DISABLE_ETC_HOSTS" ) 30 | export DISABLE_HISTORY=$( getvar "variables:DISABLE_HISTORY" ) 31 | export DISABLE_KNOWN_HOSTS=$( getvar "variables:DISABLE_KNOWN_HOSTS" ) 32 | export EXIT_ON_SUCCESS=$( getvar "variables:EXIT_ON_SUCCESS" ) 33 | export MOSH_CMD=$( getvar "variables:MOSH_CMD" ) 34 | export SFTP_APP=$( getvar "variables:SFTP_APP" ) 35 | export SSH_APP=$( getvar "variables:SSH_APP" ) 36 | export SSH_CMD=$( getvar "variables:SSH_CMD" ) 37 | 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/deanishe/alfred-ssh 2 | 3 | require ( 4 | github.com/bmatcuk/doublestar v1.1.2 5 | github.com/deanishe/awgo v0.20.2 6 | github.com/disintegration/imaging v1.6.0 7 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 8 | github.com/havoc-io/ssh_config v0.0.0-20150623171730-6438268c5089 9 | github.com/magefile/mage v1.8.0 10 | github.com/pkg/errors v0.8.1 11 | golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 // indirect 12 | golang.org/x/text v0.3.2 13 | howett.net/plist v0.0.0-20181124034731-591f970eefbb 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= 2 | github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 3 | github.com/bmatcuk/doublestar v1.1.2 h1:p0ybUUk/Dh+yetkg3v0Yhi/ujMiet/gztYQnxNXfsyw= 4 | github.com/bmatcuk/doublestar v1.1.2/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= 5 | github.com/deanishe/awgo v0.20.2 h1:2hQwGoMJz8/+1MIXEvE2PQqBDIfIdjJiHWvYbNbZ5gU= 6 | github.com/deanishe/awgo v0.20.2/go.mod h1:2cRIRY+pgEcNHNAXzRyrrIiCpSHpvebc5dyDzeH5bV8= 7 | github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= 8 | github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= 9 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= 10 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= 11 | github.com/havoc-io/ssh_config v0.0.0-20150623171730-6438268c5089 h1:YmL4u5vd+v6ueOLf/Gzc5gw1P65mjCHFeX9SynrIXfU= 12 | github.com/havoc-io/ssh_config v0.0.0-20150623171730-6438268c5089/go.mod h1:WRlj+a0xoh9XT7vOFA0cSp63/QjbTvJPYJZ7FePUaRs= 13 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 14 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 15 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 16 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 17 | github.com/magefile/mage v1.8.0 h1:mzL+xIopvPURVBwHG9A50JcjBO+xV3b5iZ7khFRI+5E= 18 | github.com/magefile/mage v1.8.0/go.mod h1:IUDi13rsHje59lecXokTfGX0QIzO45uVPlXnJYsXepA= 19 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 20 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 21 | golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI= 22 | golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= 23 | golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9 h1:uc17S921SPw5F2gJo7slQ3aqvr2RwpL7eb3+DZncu3s= 24 | golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 25 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 26 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 27 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 28 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 29 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 30 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 31 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 33 | howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M= 34 | howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= 35 | -------------------------------------------------------------------------------- /hosts.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "fmt" 13 | "net" 14 | "net/url" 15 | "regexp" 16 | "strconv" 17 | "strings" 18 | ) 19 | 20 | var ( 21 | hostnameRegex = regexp.MustCompile("^[a-zA-Z0-9.-]+$") 22 | ) 23 | 24 | // Host is a host you can connect to. 25 | type Host interface { 26 | UID() string // Unique ID of host 27 | Name() string // Display name of host 28 | Hostname() string // Qualified hostname 29 | Port() int // Port (22 by default) 30 | SetPort(i int) // Set the Port 31 | Source() string // Display name of source 32 | Username() string // Username if not default 33 | SetUsername(n string) // Set Username 34 | CanonicalURL() *url.URL // Canonical SSH URL 35 | SSHURL() *url.URL // ssh:// URL for this host 36 | SFTPURL() *url.URL // sftp:// URL for this host 37 | SSHCmd(path string) string // Command-line ssh command for this host 38 | MoshCmd(path string) string // Command-line mosh command for this host 39 | } 40 | 41 | // Deduplicator recognises duplicate Hosts. 42 | type Deduplicator struct { 43 | tags map[string]bool 44 | } 45 | 46 | // Add adds a new Host. 47 | func (d *Deduplicator) Add(h Host) { 48 | if d.tags == nil { 49 | d.tags = map[string]bool{} 50 | } 51 | d.tags[h.UID()] = true 52 | // log.Printf("Host: %#v, UID: %s", h, h.UID()) 53 | // d.tags[h.CanonicalURL().String()] = true 54 | // d.tags[h.SSHURL().String()] = true 55 | } 56 | 57 | // IsDuplicate returns true if Host is a duplicate. 58 | func (d *Deduplicator) IsDuplicate(h Host) bool { 59 | if d.tags == nil { 60 | d.tags = map[string]bool{} 61 | } 62 | if dupe := d.tags[h.UID()]; dupe { 63 | return true 64 | } 65 | return false 66 | } 67 | 68 | // FilterDuplicateHosts removes duplicate Hosts. 69 | func FilterDuplicateHosts(hosts []Host) []Host { 70 | clean := []Host{} 71 | d := &Deduplicator{} 72 | 73 | for _, h := range hosts { 74 | if d.IsDuplicate(h) { 75 | continue 76 | } 77 | clean = append(clean, h) 78 | d.Add(h) 79 | } 80 | return clean 81 | } 82 | 83 | // type jsonHost struct { 84 | // Name string `json:"name"` 85 | // Hostname string `json:"hostname"` 86 | // Source string `json:"source"` 87 | // Username string `json:"username"` 88 | // Port int 89 | // } 90 | 91 | // newJSONHost creates a jsonHost object for a Host. 92 | // func newJSONHost(h Host) *jsonHost { 93 | // return &jsonHost{ 94 | // h.Name(), 95 | // h.Hostname(), 96 | // h.Source(), 97 | // h.Username(), 98 | // h.Port(), 99 | // } 100 | // } 101 | 102 | // BaseHost implements Host. 103 | type BaseHost struct { 104 | name string 105 | hostname string 106 | source string 107 | username string 108 | port int 109 | } 110 | 111 | // NewBaseHost creates a new BaseHost object. 112 | func NewBaseHost(name, hostname, source, username string, port int) *BaseHost { 113 | return &BaseHost{name, hostname, source, username, port} 114 | } 115 | 116 | // NewBaseHostFromURL creates a new BaseHost object. 117 | func NewBaseHostFromURL(u *url.URL) *BaseHost { 118 | h := &BaseHost{ 119 | // name: name, 120 | hostname: u.Host, 121 | source: "URL", 122 | port: 22, 123 | } 124 | // Extract port from hostname 125 | if i := strings.Index(u.Host, ":"); i > -1 { 126 | h.hostname = u.Host[:i] 127 | if j, err := strconv.Atoi(u.Host[i+1:]); err == nil { 128 | h.port = j 129 | } 130 | } 131 | if u.User != nil { 132 | h.username = u.User.Username() 133 | } 134 | name := h.hostname 135 | if h.username != "" { 136 | name = h.username + "@" + name 137 | } 138 | if h.port != 22 { 139 | name = fmt.Sprintf("%s:%d", name, h.port) 140 | } 141 | h.name = name 142 | return h 143 | } 144 | 145 | // MarshalJSON exports BaseHost as JSON. 146 | // func (h *BaseHost) MarshalJSON() ([]byte, error) { 147 | // return json.MarshalIndent(newJSONHost(h), "", " ") 148 | // } 149 | 150 | // UnmarshalJSON initialises a BaseHost from JSON. 151 | // func (h *BaseHost) UnmarshalJSON(data []byte) error { 152 | // j := jsonHost{} 153 | // err := json.Unmarshal(data, &j) 154 | // if err != nil { 155 | // return err 156 | // } 157 | // h.name = j.Name 158 | // h.hostname = j.Hostname 159 | // h.source = j.Source 160 | // h.username = j.Username 161 | // h.port = j.Port 162 | // return nil 163 | // } 164 | 165 | // UID implements Host. 166 | func (h *BaseHost) UID() string { return UIDForHost(h) } 167 | 168 | // Name implements Host. 169 | func (h *BaseHost) Name() string { return h.name } 170 | 171 | // Hostname implements Host. 172 | func (h *BaseHost) Hostname() string { return h.hostname } 173 | 174 | // Port implements Host. 175 | func (h *BaseHost) Port() int { 176 | if h.port == 0 { 177 | return 22 178 | } 179 | return h.port 180 | } 181 | 182 | // SetPort implements Host. 183 | func (h *BaseHost) SetPort(i int) { h.port = i } 184 | 185 | // Source implements Host. 186 | func (h *BaseHost) Source() string { return h.source } 187 | 188 | // Username implements Host. 189 | func (h *BaseHost) Username() string { return h.username } 190 | 191 | // SetUsername implemeents Host. 192 | func (h *BaseHost) SetUsername(n string) { h.username = n } 193 | 194 | // CanonicalURL implements Host. 195 | func (h *BaseHost) CanonicalURL() *url.URL { 196 | u := &url.URL{Scheme: "ssh", Host: h.Hostname()} 197 | if h.Username() != "" { 198 | u.User = url.User(h.Username()) 199 | } 200 | if h.Port() == 22 { 201 | u.Host = h.Hostname() 202 | } else { 203 | u.Host = fmt.Sprintf("%s:%d", h.Hostname(), h.Port()) 204 | } 205 | return u 206 | } 207 | 208 | // SSHURL implements Host. 209 | func (h *BaseHost) SSHURL() *url.URL { 210 | u := h.CanonicalURL() 211 | u.Scheme = "ssh" 212 | return u 213 | } 214 | 215 | // SFTPURL implements Host. 216 | func (h *BaseHost) SFTPURL() *url.URL { 217 | u := h.CanonicalURL() 218 | u.Scheme = "sftp" 219 | return u 220 | } 221 | 222 | // MoshCmd implements Host. 223 | func (h *BaseHost) MoshCmd(path string) string { 224 | if path == "" { 225 | path = "mosh" 226 | } 227 | cmd := path + " " 228 | if h.Port() != 22 { 229 | cmd += fmt.Sprintf("--ssh 'ssh -p %d' ", h.Port()) 230 | } 231 | if h.Username() != "" { 232 | cmd += h.Username() + "@" 233 | } 234 | cmd += h.Hostname() 235 | return cmd 236 | } 237 | 238 | // SSHCmd implements Host. 239 | func (h *BaseHost) SSHCmd(path string) string { 240 | if path == "" { 241 | path = "ssh" 242 | } 243 | cmd := path + " " 244 | if h.Port() != 22 { 245 | cmd += fmt.Sprintf("-p %d ", h.Port()) 246 | } 247 | if h.Username() != "" { 248 | cmd += h.Username() + "@" 249 | } 250 | cmd += h.Hostname() 251 | return cmd 252 | } 253 | 254 | // UIDForHost returns a UID for a Host. 255 | func UIDForHost(h Host) string { 256 | uid := h.SSHURL().String() 257 | if h.Port() != 22 && strings.Index(h.SSHURL().Host, ":") < 0 { 258 | uid = fmt.Sprintf("%s:%d", uid, h.Port()) 259 | } 260 | 261 | return fmt.Sprintf("%s||%s", h.Name(), uid) 262 | } 263 | 264 | // IsValidHostname returns true if n is an IP address or hostname. 265 | func IsValidHostname(n string) bool { 266 | if ip := net.ParseIP(n); ip != nil { 267 | return true 268 | } 269 | return hostnameRegex.MatchString(n) 270 | } 271 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- 1 | ./icons/icon.png -------------------------------------------------------------------------------- /icons.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons.afdesign -------------------------------------------------------------------------------- /icons/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/docs.png -------------------------------------------------------------------------------- /icons/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/help.png -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/icon.png -------------------------------------------------------------------------------- /icons/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/issue.png -------------------------------------------------------------------------------- /icons/log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/log.png -------------------------------------------------------------------------------- /icons/off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/off.png -------------------------------------------------------------------------------- /icons/on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/on.png -------------------------------------------------------------------------------- /icons/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/reload.png -------------------------------------------------------------------------------- /icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/settings.png -------------------------------------------------------------------------------- /icons/update-available.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/update-available.png -------------------------------------------------------------------------------- /icons/update-ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/update-ok.png -------------------------------------------------------------------------------- /icons/url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/url.png -------------------------------------------------------------------------------- /icons/warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deanishe/alfred-ssh/dcbeb2c51de68046e14a221f500cccfbc0911b79/icons/warning.png -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | net.deanishe.alfred-ssh 7 | category 8 | Internet 9 | connections 10 | 11 | 0601F1AD-5EA4-4E4E-8497-C1A1D8A348D2 12 | 13 | 14 | destinationuid 15 | 68121A8A-C9BD-42A9-A014-799D3B3544F4 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 24ECFCB6-4E68-46DA-83CC-AFB9A0397458 25 | 26 | 27 | destinationuid 28 | 6E47AB43-D8E5-4D28-A2E8-DD0AF9CEDED0 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | destinationuid 38 | BEDA7962-222B-4001-8A98-6EB2B78342AD 39 | modifiers 40 | 262144 41 | modifiersubtext 42 | 43 | vitoclose 44 | 45 | 46 | 47 | 3AA31B78-5898-4B51-A25F-B970B140ECB1 48 | 49 | 50 | destinationuid 51 | 52720D61-C3AC-4297-8393-48F4D888BBED 52 | modifiers 53 | 0 54 | modifiersubtext 55 | 56 | vitoclose 57 | 58 | 59 | 60 | 4575A48A-57E5-4A98-AFD8-0F611206F38C 61 | 62 | 63 | destinationuid 64 | 042F981F-B8D7-44AA-9AA4-E9D14F71BF97 65 | modifiers 66 | 0 67 | modifiersubtext 68 | 69 | vitoclose 70 | 71 | 72 | 73 | 52720D61-C3AC-4297-8393-48F4D888BBED 74 | 75 | 68121A8A-C9BD-42A9-A014-799D3B3544F4 76 | 77 | 78 | destinationuid 79 | A3CF9185-4D22-48D1-9515-851538E8D12B 80 | modifiers 81 | 0 82 | modifiersubtext 83 | 84 | vitoclose 85 | 86 | 87 | 88 | 6E47AB43-D8E5-4D28-A2E8-DD0AF9CEDED0 89 | 90 | 91 | destinationuid 92 | 3AA31B78-5898-4B51-A25F-B970B140ECB1 93 | modifiers 94 | 0 95 | modifiersubtext 96 | 97 | vitoclose 98 | 99 | 100 | 101 | destinationuid 102 | 8B1647C7-3F08-49D7-9C04-4447395B6CF0 103 | modifiers 104 | 0 105 | modifiersubtext 106 | 107 | vitoclose 108 | 109 | 110 | 111 | destinationuid 112 | D02E857F-D8BD-4AE5-8764-71CFF97757CA 113 | modifiers 114 | 0 115 | modifiersubtext 116 | 117 | vitoclose 118 | 119 | 120 | 121 | 7278AB1E-91D9-4CB7-BF1E-71C29D2F7B60 122 | 123 | 124 | destinationuid 125 | E8C38C50-6B44-4FA1-B74B-511A8939E773 126 | modifiers 127 | 0 128 | modifiersubtext 129 | 130 | vitoclose 131 | 132 | 133 | 134 | 8B1647C7-3F08-49D7-9C04-4447395B6CF0 135 | 136 | 137 | destinationuid 138 | 4575A48A-57E5-4A98-AFD8-0F611206F38C 139 | modifiers 140 | 0 141 | modifiersubtext 142 | 143 | vitoclose 144 | 145 | 146 | 147 | A3CF9185-4D22-48D1-9515-851538E8D12B 148 | 149 | 150 | destinationuid 151 | C894BA15-BCAC-4B78-9125-BCF338A5B1B0 152 | modifiers 153 | 0 154 | modifiersubtext 155 | 156 | vitoclose 157 | 158 | 159 | 160 | A5A07BAF-5D27-4E64-BC61-717AC34E5153 161 | 162 | 163 | destinationuid 164 | 24ECFCB6-4E68-46DA-83CC-AFB9A0397458 165 | modifiers 166 | 0 167 | modifiersubtext 168 | 169 | vitoclose 170 | 171 | 172 | 173 | BEDA7962-222B-4001-8A98-6EB2B78342AD 174 | 175 | 176 | destinationuid 177 | 7278AB1E-91D9-4CB7-BF1E-71C29D2F7B60 178 | modifiers 179 | 0 180 | modifiersubtext 181 | 182 | vitoclose 183 | 184 | 185 | 186 | C894BA15-BCAC-4B78-9125-BCF338A5B1B0 187 | 188 | 189 | destinationuid 190 | 16D8FC6A-552A-44BE-8428-53838B00AF24 191 | modifiers 192 | 0 193 | modifiersubtext 194 | 195 | vitoclose 196 | 197 | 198 | 199 | D02E857F-D8BD-4AE5-8764-71CFF97757CA 200 | 201 | 202 | destinationuid 203 | 1A62410B-26D8-493D-B57F-599DC4A36FD1 204 | modifiers 205 | 0 206 | modifiersubtext 207 | 208 | vitoclose 209 | 210 | 211 | 212 | F6FCC74B-9EC0-47A6-8C0D-B445AD7C9722 213 | 214 | 215 | destinationuid 216 | 24ECFCB6-4E68-46DA-83CC-AFB9A0397458 217 | modifiers 218 | 0 219 | modifiersubtext 220 | 221 | vitoclose 222 | 223 | 224 | 225 | 226 | createdby 227 | Dean Jackson 228 | description 229 | Open SSH connections 230 | disabled 231 | 232 | name 233 | Secure SHell 234 | objects 235 | 236 | 237 | config 238 | 239 | alfredfiltersresults 240 | 241 | alfredfiltersresultsmatchmode 242 | 0 243 | argumenttrimmode 244 | 0 245 | argumenttype 246 | 1 247 | escaping 248 | 102 249 | keyword 250 | sshconf 251 | queuedelaycustom 252 | 3 253 | queuedelayimmediatelyinitially 254 | 255 | queuedelaymode 256 | 0 257 | queuemode 258 | 1 259 | runningsubtext 260 | Loading settings… 261 | script 262 | ./assh config "$1" 263 | scriptargtype 264 | 1 265 | scriptfile 266 | 267 | subtext 268 | View & alter SSH settings 269 | title 270 | SSH Configuration 271 | type 272 | 0 273 | withspace 274 | 275 | 276 | type 277 | alfred.workflow.input.scriptfilter 278 | uid 279 | A3CF9185-4D22-48D1-9515-851538E8D12B 280 | version 281 | 2 282 | 283 | 284 | config 285 | 286 | externaltriggerid 287 | config 288 | passinputasargument 289 | 290 | passvariables 291 | 292 | workflowbundleid 293 | self 294 | 295 | type 296 | alfred.workflow.output.callexternaltrigger 297 | uid 298 | 16D8FC6A-552A-44BE-8428-53838B00AF24 299 | version 300 | 1 301 | 302 | 303 | config 304 | 305 | concurrently 306 | 307 | escaping 308 | 102 309 | script 310 | ./assh toggle "$1" 311 | 312 | echo -n "$query" 313 | scriptargtype 314 | 1 315 | scriptfile 316 | 317 | type 318 | 0 319 | 320 | type 321 | alfred.workflow.action.script 322 | uid 323 | C894BA15-BCAC-4B78-9125-BCF338A5B1B0 324 | version 325 | 2 326 | 327 | 328 | config 329 | 330 | triggerid 331 | config 332 | 333 | type 334 | alfred.workflow.trigger.external 335 | uid 336 | 0601F1AD-5EA4-4E4E-8497-C1A1D8A348D2 337 | version 338 | 1 339 | 340 | 341 | config 342 | 343 | argument 344 | . 345 | /-------------- CONFIG ------------\ 346 | query={query} 347 | variables={allvars} 348 | \-------------- CONFIG ------------/ 349 | cleardebuggertext 350 | 351 | processoutputs 352 | 353 | 354 | type 355 | alfred.workflow.utility.debug 356 | uid 357 | 68121A8A-C9BD-42A9-A014-799D3B3544F4 358 | version 359 | 1 360 | 361 | 362 | config 363 | 364 | alfredfiltersresults 365 | 366 | alfredfiltersresultsmatchmode 367 | 0 368 | argumenttrimmode 369 | 0 370 | argumenttype 371 | 1 372 | escaping 373 | 102 374 | keyword 375 | ssh 376 | queuedelaycustom 377 | 3 378 | queuedelayimmediatelyinitially 379 | 380 | queuedelaymode 381 | 0 382 | queuemode 383 | 1 384 | runningsubtext 385 | Reading history… 386 | script 387 | # Search all hosts 388 | ./assh search "$1" 389 | 390 | scriptargtype 391 | 1 392 | scriptfile 393 | 394 | subtext 395 | 396 | title 397 | Open SSH Connection 398 | type 399 | 0 400 | withspace 401 | 402 | 403 | type 404 | alfred.workflow.input.scriptfilter 405 | uid 406 | 24ECFCB6-4E68-46DA-83CC-AFB9A0397458 407 | version 408 | 2 409 | 410 | 411 | config 412 | 413 | concurrently 414 | 415 | escaping 416 | 102 417 | script 418 | # Add URL to History 419 | ./assh remember "$url" 420 | scriptargtype 421 | 1 422 | scriptfile 423 | 424 | type 425 | 0 426 | 427 | type 428 | alfred.workflow.action.script 429 | uid 430 | 52720D61-C3AC-4297-8393-48F4D888BBED 431 | version 432 | 2 433 | 434 | 435 | config 436 | 437 | action 438 | 0 439 | argument 440 | 0 441 | focusedappvariable 442 | 443 | focusedappvariablename 444 | 445 | hotkey 446 | 0 447 | hotmod 448 | 0 449 | hotstring 450 | 451 | leftcursor 452 | 453 | modsmode 454 | 0 455 | relatedAppsMode 456 | 0 457 | 458 | type 459 | alfred.workflow.trigger.hotkey 460 | uid 461 | A5A07BAF-5D27-4E64-BC61-717AC34E5153 462 | version 463 | 2 464 | 465 | 466 | config 467 | 468 | argument 469 | . 470 | /-------- SSH ---------\ 471 | query="{query}" 472 | variables= 473 | {allvars} 474 | \-------- SSH ---------/ 475 | cleardebuggertext 476 | 477 | processoutputs 478 | 479 | 480 | type 481 | alfred.workflow.utility.debug 482 | uid 483 | 6E47AB43-D8E5-4D28-A2E8-DD0AF9CEDED0 484 | version 485 | 1 486 | 487 | 488 | config 489 | 490 | inputstring 491 | {var:source} 492 | matchcasesensitive 493 | 494 | matchmode 495 | 0 496 | matchstring 497 | user input 498 | 499 | type 500 | alfred.workflow.utility.filter 501 | uid 502 | 3AA31B78-5898-4B51-A25F-B970B140ECB1 503 | version 504 | 1 505 | 506 | 507 | config 508 | 509 | lastpathcomponent 510 | 511 | onlyshowifquerypopulated 512 | 513 | removeextension 514 | 515 | text 516 | {query} 517 | title 518 | ERROR 519 | 520 | type 521 | alfred.workflow.output.notification 522 | uid 523 | 042F981F-B8D7-44AA-9AA4-E9D14F71BF97 524 | version 525 | 1 526 | 527 | 528 | config 529 | 530 | triggerid 531 | ssh 532 | 533 | type 534 | alfred.workflow.trigger.external 535 | uid 536 | F6FCC74B-9EC0-47A6-8C0D-B445AD7C9722 537 | version 538 | 1 539 | 540 | 541 | config 542 | 543 | concurrently 544 | 545 | escaping 546 | 102 547 | script 548 | # Open URL 549 | ./assh open "$1" 550 | scriptargtype 551 | 1 552 | scriptfile 553 | 554 | type 555 | 0 556 | 557 | type 558 | alfred.workflow.action.script 559 | uid 560 | 4575A48A-57E5-4A98-AFD8-0F611206F38C 561 | version 562 | 2 563 | 564 | 565 | config 566 | 567 | inputstring 568 | {var:shell_cmd} 569 | matchcasesensitive 570 | 571 | matchmode 572 | 1 573 | matchstring 574 | 1 575 | 576 | type 577 | alfred.workflow.utility.filter 578 | uid 579 | 8B1647C7-3F08-49D7-9C04-4447395B6CF0 580 | version 581 | 1 582 | 583 | 584 | config 585 | 586 | escaping 587 | 0 588 | script 589 | {query} 590 | 591 | type 592 | alfred.workflow.action.terminalcommand 593 | uid 594 | 1A62410B-26D8-493D-B57F-599DC4A36FD1 595 | version 596 | 1 597 | 598 | 599 | config 600 | 601 | inputstring 602 | {var:shell_cmd} 603 | matchcasesensitive 604 | 605 | matchmode 606 | 0 607 | matchstring 608 | 1 609 | 610 | type 611 | alfred.workflow.utility.filter 612 | uid 613 | D02E857F-D8BD-4AE5-8764-71CFF97757CA 614 | version 615 | 1 616 | 617 | 618 | config 619 | 620 | concurrently 621 | 622 | escaping 623 | 102 624 | script 625 | # Remove URL from History 626 | ./assh forget "$url" 627 | 628 | # Send query to next action 629 | echo -n "$query" 630 | scriptargtype 631 | 1 632 | scriptfile 633 | 634 | type 635 | 0 636 | 637 | type 638 | alfred.workflow.action.script 639 | uid 640 | BEDA7962-222B-4001-8A98-6EB2B78342AD 641 | version 642 | 2 643 | 644 | 645 | config 646 | 647 | externaltriggerid 648 | ssh 649 | passinputasargument 650 | 651 | passvariables 652 | 653 | workflowbundleid 654 | self 655 | 656 | type 657 | alfred.workflow.output.callexternaltrigger 658 | uid 659 | E8C38C50-6B44-4FA1-B74B-511A8939E773 660 | version 661 | 1 662 | 663 | 664 | config 665 | 666 | argument 667 | {var:query} 668 | variables 669 | 670 | 671 | type 672 | alfred.workflow.utility.argument 673 | uid 674 | 7278AB1E-91D9-4CB7-BF1E-71C29D2F7B60 675 | version 676 | 1 677 | 678 | 679 | readme 680 | Secure SHell 681 | ============ 682 | 683 | Rapidly open SSH/SFTP/mosh connections, with suggestions from SSH configuration files and /etc/hosts. 684 | 685 | Remembers connections with usernames, so you don't have to type your username every time. 686 | 687 | 688 | Settings 689 | -------- 690 | 691 | The workflow reads hosts from five sources: 692 | 693 | - ~/.ssh/config 694 | - ~/.ssh/known_hosts 695 | - /etc/ssh/ssh_config 696 | - /etc/hosts 697 | - Its own history 698 | 699 | You can disable any source by setting its corresponding Workflow Environment Variable to 1, so set DISABLE_ETC_HOSTS=1 to ignore /etc/hosts. 700 | 701 | MOSH_CMD specifieds the path to the `mosh` executable. The default, "mosh", should work on most systems, as the command is passed to your terminal application. 702 | 703 | To disable mosh, delete the value for MOSH_CMD. 704 | 705 | The SSH_CMD performs a similar function for the default action (opening SSH connections). 706 | 707 | By default, the workflow generates an ssh://... URL and asks the system to open it (or passes it to the application specified in SSH_APP). 708 | 709 | If you set SSH_CMD, the workflow will instead generate an `ssh` command and run that in your Terminal app. This is slower, but 710 | will load your dotfiles. 711 | 712 | Set EXIT_ON_SUCCESS to 0 to prevent the terminal closing if the ping/mosh command exits cleanly. 713 | 714 | 715 | When removing a connection from the History, the workflow re-opens itself with the previous query. 716 | 717 | The EXTERNAL_TRIGGER setting tells the workflow to re-open itself using the External Trigger instead of calling itself by keyword ("ssh"). 718 | uidata 719 | 720 | 042F981F-B8D7-44AA-9AA4-E9D14F71BF97 721 | 722 | colorindex 723 | 1 724 | note 725 | Show error message if open fails 726 | xpos 727 | 990 728 | ypos 729 | 430 730 | 731 | 0601F1AD-5EA4-4E4E-8497-C1A1D8A348D2 732 | 733 | note 734 | Workflow configuration 735 | xpos 736 | 50 737 | ypos 738 | 50 739 | 740 | 16D8FC6A-552A-44BE-8428-53838B00AF24 741 | 742 | xpos 743 | 750 744 | ypos 745 | 50 746 | 747 | 1A62410B-26D8-493D-B57F-599DC4A36FD1 748 | 749 | colorindex 750 | 7 751 | note 752 | Alternate Action: Run shell command (e.g. ping or mosh) 753 | xpos 754 | 800 755 | ypos 756 | 650 757 | 758 | 24ECFCB6-4E68-46DA-83CC-AFB9A0397458 759 | 760 | note 761 | List and filter hosts 762 | xpos 763 | 270 764 | ypos 765 | 250 766 | 767 | 3AA31B78-5898-4B51-A25F-B970B140ECB1 768 | 769 | colorindex 770 | 2 771 | note 772 | User-entered host 773 | xpos 774 | 670 775 | ypos 776 | 280 777 | 778 | 4575A48A-57E5-4A98-AFD8-0F611206F38C 779 | 780 | colorindex 781 | 4 782 | note 783 | Open SSH/SFTP URL 784 | 785 | Use SSH_APP and SFTP_APP workflow variables to change the application used 786 | xpos 787 | 800 788 | ypos 789 | 430 790 | 791 | 52720D61-C3AC-4297-8393-48F4D888BBED 792 | 793 | colorindex 794 | 2 795 | note 796 | Remember connection in History (if there's a username) 797 | xpos 798 | 800 799 | ypos 800 | 250 801 | 802 | 68121A8A-C9BD-42A9-A014-799D3B3544F4 803 | 804 | xpos 805 | 240 806 | ypos 807 | 80 808 | 809 | 6E47AB43-D8E5-4D28-A2E8-DD0AF9CEDED0 810 | 811 | xpos 812 | 470 813 | ypos 814 | 280 815 | 816 | 7278AB1E-91D9-4CB7-BF1E-71C29D2F7B60 817 | 818 | colorindex 819 | 10 820 | note 821 | Set {query} from workflow variable 822 | xpos 823 | 840 824 | ypos 825 | 870 826 | 827 | 8B1647C7-3F08-49D7-9C04-4447395B6CF0 828 | 829 | colorindex 830 | 4 831 | note 832 | Is not shell command 833 | xpos 834 | 670 835 | ypos 836 | 460 837 | 838 | A3CF9185-4D22-48D1-9515-851538E8D12B 839 | 840 | note 841 | View & edit configuration 842 | xpos 843 | 360 844 | ypos 845 | 50 846 | 847 | A5A07BAF-5D27-4E64-BC61-717AC34E5153 848 | 849 | note 850 | Run workflow 851 | xpos 852 | 50 853 | ypos 854 | 250 855 | 856 | BEDA7962-222B-4001-8A98-6EB2B78342AD 857 | 858 | colorindex 859 | 10 860 | note 861 | Alternate Action: Forget connection from History 862 | xpos 863 | 630 864 | ypos 865 | 840 866 | 867 | C894BA15-BCAC-4B78-9125-BCF338A5B1B0 868 | 869 | note 870 | Toggle setting on/off 871 | xpos 872 | 560 873 | ypos 874 | 50 875 | 876 | D02E857F-D8BD-4AE5-8764-71CFF97757CA 877 | 878 | colorindex 879 | 7 880 | note 881 | Is shell command 882 | xpos 883 | 670 884 | ypos 885 | 680 886 | 887 | E8C38C50-6B44-4FA1-B74B-511A8939E773 888 | 889 | colorindex 890 | 10 891 | note 892 | Re-open Alfred with previous query (External Trigger). 893 | 894 | Disabled by default. Set EXTERNAL_TRIGGER=1 in the Configuration Sheet to enable. 895 | xpos 896 | 990 897 | ypos 898 | 840 899 | 900 | F6FCC74B-9EC0-47A6-8C0D-B445AD7C9722 901 | 902 | note 903 | List and filter hosts 904 | xpos 905 | 50 906 | ypos 907 | 430 908 | 909 | 910 | variables 911 | 912 | DISABLE_CONFIG 913 | 0 914 | DISABLE_ETC_CONFIG 915 | 0 916 | DISABLE_ETC_HOSTS 917 | 0 918 | DISABLE_HISTORY 919 | 0 920 | DISABLE_KNOWN_HOSTS 921 | 0 922 | EXIT_ON_SUCCESS 923 | 1 924 | MOSH_CMD 925 | mosh 926 | SFTP_APP 927 | 928 | SSH_APP 929 | 930 | SSH_CMD 931 | 932 | 933 | version 934 | 0.9.0 935 | webaddress 936 | https://github.com/deanishe/alfred-ssh 937 | 938 | 939 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // +build mage 5 | 6 | package main 7 | 8 | import ( 9 | "archive/zip" 10 | "fmt" 11 | "io" 12 | "io/ioutil" 13 | "os" 14 | "path/filepath" 15 | "regexp" 16 | "unicode" 17 | 18 | "github.com/bmatcuk/doublestar" 19 | "github.com/magefile/mage/mg" 20 | "github.com/magefile/mage/sh" 21 | "golang.org/x/text/transform" 22 | "golang.org/x/text/unicode/norm" 23 | ) 24 | 25 | // Default target to run when none is specified 26 | // If not set, running mage will list available targets 27 | // var Default = Build 28 | 29 | var ( 30 | workDir string 31 | ) 32 | 33 | func init() { 34 | var err error 35 | if workDir, err = os.Getwd(); err != nil { 36 | panic(err) 37 | } 38 | } 39 | 40 | func mod(args ...string) error { 41 | argv := append([]string{"mod"}, args...) 42 | return sh.RunWith(alfredEnv(), "go", argv...) 43 | } 44 | 45 | // Aliases are mage command aliases. 46 | var Aliases = map[string]interface{}{ 47 | "b": Build, 48 | "c": Clean, 49 | "d": Dist, 50 | "l": Link, 51 | } 52 | 53 | // Build builds workflow in ./build 54 | func Build() error { 55 | mg.Deps(cleanBuild, Icons) 56 | // mg.Deps(Deps) 57 | fmt.Println("building ...") 58 | if err := sh.RunWith(alfredEnv(), "go", "build", "-o", "./build/assh", "./cmd/assh"); err != nil { 59 | return err 60 | } 61 | 62 | // link files to ./build 63 | globs := []struct { 64 | glob, dest string 65 | }{ 66 | {"*.png", ""}, 67 | {"info.plist", ""}, 68 | {"*.html", ""}, 69 | {"README.md", ""}, 70 | {"LICENCE.txt", ""}, 71 | {"icons/*.png", ""}, 72 | } 73 | 74 | pairs := []struct { 75 | src, dest string 76 | }{} 77 | 78 | for _, cfg := range globs { 79 | files, err := doublestar.Glob(cfg.glob) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | for _, p := range files { 85 | dest := filepath.Join("./build", cfg.dest, p) 86 | pairs = append(pairs, struct{ src, dest string }{p, dest}) 87 | } 88 | } 89 | 90 | for _, p := range pairs { 91 | 92 | var ( 93 | relPath string 94 | dir = filepath.Dir(p.dest) 95 | err error 96 | ) 97 | 98 | if err = os.MkdirAll(dir, 0755); err != nil { 99 | return err 100 | } 101 | if relPath, err = filepath.Rel(filepath.Dir(p.dest), p.src); err != nil { 102 | return err 103 | } 104 | fmt.Printf("%s --> %s\n", p.dest, relPath) 105 | if err := os.Symlink(relPath, p.dest); err != nil { 106 | return err 107 | } 108 | } 109 | 110 | return nil 111 | } 112 | 113 | // Run run workflow 114 | func Run() error { 115 | mg.Deps(Build) 116 | fmt.Println("running ...") 117 | if err := os.Chdir("./build"); err != nil { 118 | return err 119 | } 120 | defer os.Chdir(workDir) 121 | 122 | return sh.RunWith(alfredEnv(), "./assh", "-h") 123 | } 124 | 125 | // Dist build an .alfredworkflow file in ./dist 126 | func Dist() error { 127 | mg.SerialDeps(Clean, Build) 128 | if err := os.MkdirAll("./dist", 0700); err != nil { 129 | return err 130 | } 131 | 132 | var ( 133 | name = slugify(fmt.Sprintf("%s-%s.alfredworkflow", Name, Version)) 134 | path = filepath.Join("./dist", name) 135 | f *os.File 136 | w *zip.Writer 137 | err error 138 | ) 139 | 140 | fmt.Println("building .alfredworkflow file ...") 141 | 142 | if _, err = os.Stat(path); err == nil { 143 | if err = os.Remove(path); err != nil { 144 | return err 145 | } 146 | fmt.Println("deleted old .alfredworkflow file") 147 | } 148 | 149 | if f, err = os.Create(path); err != nil { 150 | return err 151 | } 152 | defer f.Close() 153 | 154 | w = zip.NewWriter(f) 155 | 156 | err = filepath.Walk("./build", func(path string, fi os.FileInfo, err error) error { 157 | 158 | if err != nil { 159 | return err 160 | } 161 | 162 | if fi.IsDir() { 163 | return nil 164 | } 165 | 166 | var ( 167 | name, orig string 168 | info os.FileInfo 169 | mode os.FileMode 170 | ) 171 | if name, err = filepath.Rel("./build", path); err != nil { 172 | return err 173 | } 174 | 175 | if orig, err = filepath.EvalSymlinks(path); err != nil { 176 | return err 177 | } 178 | if info, err = os.Stat(orig); err != nil { 179 | return err 180 | } 181 | mode = info.Mode() 182 | 183 | fmt.Printf("%v %s\n", mode, name) 184 | 185 | var ( 186 | f *os.File 187 | zf io.Writer 188 | fh *zip.FileHeader 189 | ) 190 | 191 | fh = &zip.FileHeader{ 192 | Name: name, 193 | Method: zip.Deflate, 194 | } 195 | 196 | // fh.SetModTime(fi.ModTime()) 197 | fh.SetMode(mode.Perm()) 198 | 199 | if f, err = os.Open(orig); err != nil { 200 | return err 201 | } 202 | defer f.Close() 203 | 204 | if zf, err = w.CreateHeader(fh); err != nil { 205 | return err 206 | } 207 | if _, err = io.Copy(zf, f); err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | }) 213 | 214 | if err != nil { 215 | return err 216 | } 217 | 218 | if err = w.Close(); err != nil { 219 | return err 220 | } 221 | 222 | fmt.Printf("wrote %s\n", path) 223 | 224 | return nil 225 | } 226 | 227 | var ( 228 | rxAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9.-]+`) 229 | rxMultiDash = regexp.MustCompile(`-+`) 230 | ) 231 | 232 | // make s filesystem- and URL-safe. 233 | func slugify(s string) string { 234 | s = fold(s) 235 | s = rxAlphaNum.ReplaceAllString(s, "-") 236 | s = rxMultiDash.ReplaceAllString(s, "-") 237 | return s 238 | } 239 | 240 | var stripper = transform.Chain(norm.NFD, transform.RemoveFunc(isMn)) 241 | 242 | // isMn returns true if rune is a non-spacing mark 243 | func isMn(r rune) bool { 244 | return unicode.Is(unicode.Mn, r) // Mn: non-spacing mark 245 | } 246 | 247 | // fold strips diacritics from string. 248 | func fold(s string) string { 249 | ascii, _, err := transform.String(stripper, s) 250 | if err != nil { 251 | panic(err) 252 | } 253 | return ascii 254 | } 255 | 256 | // Link symlinks ./build directory to Alfred's workflow directory. 257 | func Link() error { 258 | mg.Deps(Build) 259 | 260 | fmt.Println("linking ./build to workflow directory ...") 261 | target := filepath.Join(WorkflowDir, BundleID) 262 | // fmt.Printf("target: %s\n", target) 263 | 264 | if exists(target) { 265 | fmt.Println("removing existing workflow ...") 266 | } 267 | // try to remove it anyway, as dangling symlinks register as existing 268 | if err := os.RemoveAll(target); err != nil && !os.IsNotExist(err) { 269 | return err 270 | } 271 | 272 | build, err := filepath.Abs("build") 273 | if err != nil { 274 | return err 275 | } 276 | src, err := filepath.Rel(filepath.Dir(target), build) 277 | if err != nil { 278 | return err 279 | } 280 | 281 | if err := os.Symlink(src, target); err != nil { 282 | return err 283 | } 284 | 285 | fmt.Printf("symlinked workflow to %s\n", target) 286 | 287 | return nil 288 | } 289 | 290 | // Icons generate icons 291 | func Icons() error { 292 | 293 | copies := []struct { 294 | src, dest, colour string 295 | }{ 296 | {"docs.png", "help.png", green}, 297 | {"settings.png", "../A3CF9185-4D22-48D1-9515-851538E8D12B.png", ""}, 298 | } 299 | 300 | for i, cfg := range copies { 301 | 302 | src := filepath.Join("icons", cfg.src) 303 | dest := filepath.Join("icons", cfg.dest) 304 | 305 | if exists(dest) { 306 | fmt.Printf("[%d/%d] skipped existing: %s\n", i+1, len(copies), dest) 307 | continue 308 | } 309 | 310 | if err := copyImage(src, dest, cfg.colour); err != nil { 311 | return err 312 | } 313 | fmt.Printf("[%d/%d] copied %s --> %s\n", i+1, len(copies), src, dest) 314 | } 315 | 316 | return nil 317 | } 318 | 319 | // Deps ensure dependencies 320 | func Deps() error { 321 | mg.Deps(cleanDeps) 322 | fmt.Println("downloading deps ...") 323 | return mod("download") 324 | } 325 | 326 | // Vendor copies dependencies to ./vendor 327 | func Vendor() error { 328 | mg.Deps(Deps) 329 | fmt.Println("vendoring deps ...") 330 | return mod("vendor") 331 | } 332 | 333 | // Clean remove build files 334 | func Clean() { 335 | fmt.Println("cleaning ...") 336 | mg.Deps(cleanBuild, cleanMage) 337 | } 338 | 339 | func cleanDeps() error { 340 | return mod("tidy", "-v") 341 | } 342 | 343 | func cleanDir(name string, exclude ...string) error { 344 | 345 | if _, err := os.Stat(name); err != nil { 346 | return nil 347 | } 348 | 349 | infos, err := ioutil.ReadDir(name) 350 | if err != nil { 351 | return err 352 | } 353 | for _, fi := range infos { 354 | 355 | var match bool 356 | for _, glob := range exclude { 357 | if match, err = doublestar.Match(glob, fi.Name()); err != nil { 358 | return err 359 | } else if match { 360 | break 361 | } 362 | } 363 | 364 | if match { 365 | fmt.Printf("excluded: %s\n", fi.Name()) 366 | continue 367 | } 368 | 369 | p := filepath.Join(name, fi.Name()) 370 | if err := os.RemoveAll(p); err != nil { 371 | return err 372 | } 373 | } 374 | return nil 375 | } 376 | 377 | func cleanBuild() error { 378 | return cleanDir("./build") 379 | } 380 | 381 | func cleanMage() error { 382 | return sh.Run("mage", "-clean") 383 | } 384 | 385 | // CleanIcons delete all generated icons from ./icons 386 | func CleanIcons() error { 387 | return cleanDir("./icons") 388 | } 389 | -------------------------------------------------------------------------------- /magefile_alfred.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // +build mage 5 | 6 | package main 7 | 8 | import ( 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "howett.net/plist" 15 | ) 16 | 17 | var ( 18 | // Home = os.ExpandEnv("$HOME") 19 | Library = os.ExpandEnv("$HOME/Library") 20 | 21 | // Workflow configuration 22 | // Read from info.plist 23 | BundleID string 24 | Version string 25 | Name string 26 | 27 | PrefsFile string 28 | SyncFolder string 29 | DataDir string 30 | CacheDir string 31 | WorkflowDir string 32 | AppVersion string 33 | 34 | defaultSyncFolder string 35 | ) 36 | 37 | func init() { 38 | ip := readInfo() 39 | 40 | BundleID = ip.BundleID 41 | if BundleID == "" { 42 | panic("Bundle ID is unset") 43 | } 44 | 45 | Version = ip.Version 46 | Name = ip.Name 47 | 48 | PrefsFile = filepath.Join(Library, "Preferences/com.runningwithcrayons.Alfred-Preferences.plist") 49 | if _, err := os.Stat(PrefsFile); err == nil { 50 | CacheDir = filepath.Join(Library, "Caches/com.runningwithcrayons.Alfred/Workflow Data", ip.BundleID) 51 | DataDir = filepath.Join(Library, "Application Support/Alfred/Workflow Data", ip.BundleID) 52 | defaultSyncFolder = filepath.Join(Library, "Application Support/Alfred") 53 | 54 | } else { 55 | AppVersion = "3.8.1" 56 | PrefsFile = filepath.Join(Library, "Preferences/com.runningwithcrayons.Alfred-Preferences-3.plist") 57 | CacheDir = filepath.Join(Library, "Caches/com.runningwithcrayons.Alfred-3/Workflow Data", ip.BundleID) 58 | DataDir = filepath.Join(Library, "Application Support/Alfred 3/Workflow Data", ip.BundleID) 59 | defaultSyncFolder = filepath.Join(Library, "Application Support/Alfred 3") 60 | 61 | } 62 | SyncFolder = syncFolder() 63 | WorkflowDir = filepath.Join(SyncFolder, "Alfred.alfredpreferences/workflows") 64 | } 65 | 66 | type infoPlist struct { 67 | BundleID string `plist:"bundleid"` 68 | Version string `plist:"version"` 69 | Name string `plist:"name"` 70 | } 71 | 72 | func syncFolder() string { 73 | 74 | var ( 75 | dirs = []string{defaultSyncFolder} 76 | data []byte 77 | err error 78 | ) 79 | 80 | if data, err = ioutil.ReadFile(PrefsFile); err != nil { 81 | panic(err) 82 | } 83 | 84 | p := struct { 85 | SyncFolder string `plist:"syncfolder"` 86 | }{} 87 | 88 | if _, err = plist.Unmarshal(data, &p); err != nil { 89 | panic(err) 90 | } 91 | 92 | if p.SyncFolder != "" { 93 | dirs = append([]string{p.SyncFolder}, dirs...) 94 | } 95 | 96 | for _, p := range dirs { 97 | p = expandPath(p) 98 | if exists(p) { 99 | 100 | return p 101 | } 102 | } 103 | 104 | panic("syncfolder not found") 105 | } 106 | 107 | func readInfo() infoPlist { 108 | data, err := ioutil.ReadFile("info.plist") 109 | if err != nil { 110 | panic(err) 111 | } 112 | 113 | ip := infoPlist{} 114 | if _, err := plist.Unmarshal(data, &ip); err != nil { 115 | panic(err) 116 | } 117 | 118 | return ip 119 | } 120 | 121 | func alfredEnv() map[string]string { 122 | return map[string]string{ 123 | "alfred_workflow_bundleid": BundleID, 124 | "alfred_workflow_version": Version, 125 | "alfred_workflow_name": Name, 126 | "alfred_workflow_cache": CacheDir, 127 | "alfred_workflow_data": DataDir, 128 | "alfred_version": AppVersion, 129 | "GO111MODULE": "on", // for building 130 | } 131 | } 132 | 133 | // expand ~ and variables in path. 134 | func expandPath(path string) string { 135 | if strings.HasPrefix(path, "~") { 136 | path = "${HOME}" + path[1:] 137 | } 138 | 139 | return os.ExpandEnv(path) 140 | } 141 | 142 | func exists(path string) bool { 143 | if _, err := os.Stat(path); err != nil { 144 | if os.IsNotExist(err) { 145 | return false 146 | } 147 | panic(err) 148 | } 149 | 150 | return true 151 | } 152 | -------------------------------------------------------------------------------- /magefile_images.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2019 Dean Jackson 2 | // MIT Licence applies http://opensource.org/licenses/MIT 3 | 4 | // +build mage 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "image" 11 | "image/color" 12 | "image/draw" 13 | "image/png" 14 | "os" 15 | "path/filepath" 16 | "regexp" 17 | "strconv" 18 | 19 | "github.com/disintegration/imaging" 20 | "github.com/magefile/mage/sh" 21 | "github.com/pkg/errors" 22 | ) 23 | 24 | var ( 25 | blue = "5485F3" 26 | green = "03AE03" 27 | purple = "AF49FE" 28 | red = "B00000" 29 | yellow = "F8AC30" 30 | 31 | icons = []struct { 32 | Filename string 33 | Font string 34 | Colour string 35 | Name string 36 | }{ 37 | {"update-available", "material", yellow, "cloud-download"}, 38 | {"update-ok", "material", green, "cloud-done"}, 39 | 40 | {"help", "material", green, "help"}, 41 | {"issue", "fontawesome", yellow, "bug"}, 42 | {"log", "fontawesome", blue, "history"}, 43 | {"url", "fontawesome", blue, "globe"}, 44 | 45 | {"off", "fontawesome", red, "circle-o"}, 46 | {"on", "fontawesome", green, "check-circle-o"}, 47 | {"reload", "fontawesome", yellow, "refresh"}, 48 | } 49 | ) 50 | 51 | func rotateIcon(path string, angles []int) error { 52 | 53 | var ( 54 | dir = filepath.Dir(path) 55 | ext = filepath.Ext(path) 56 | base = filepath.Base(path) 57 | name = base[0 : len(base)-len(ext)] 58 | src image.Image 59 | f *os.File 60 | err error 61 | ) 62 | 63 | if f, err = os.Open(path); err != nil { 64 | return err 65 | } 66 | defer f.Close() 67 | 68 | if src, _, err = image.Decode(f); err != nil { 69 | return err 70 | } 71 | 72 | for i, n := range angles { 73 | 74 | p := filepath.Join(dir, fmt.Sprintf("%s-%d%s", name, n, ext)) 75 | 76 | if exists(p) { 77 | fmt.Printf("[%d/%d] skipped existing: %s\n", i+1, len(angles), p) 78 | continue 79 | } 80 | 81 | dst := imaging.Rotate(src, 360-float64(n), image.Transparent) 82 | dst = imaging.CropCenter(dst, src.Bounds().Dx(), src.Bounds().Dy()) 83 | 84 | if f, err = os.Create(p); err != nil { 85 | return err 86 | } 87 | defer f.Close() 88 | 89 | if err = png.Encode(f, dst); err != nil { 90 | return err 91 | } 92 | 93 | fmt.Printf("wrote %s\n", p) 94 | } 95 | 96 | return nil 97 | } 98 | 99 | // Copy image src to dest. If colour is non-empty, src is re-coloured. 100 | // Colour should be an HTML hex, e.g. "ff33ba". 101 | func copyImage(src, dest, colour string) error { 102 | 103 | if colour == "" { 104 | return sh.Copy(src, dest) 105 | } 106 | 107 | var ( 108 | c *color.RGBA 109 | f *os.File 110 | err error 111 | mask image.Image 112 | ) 113 | 114 | if c, err = parseHex(colour); err != nil { 115 | return err 116 | } 117 | 118 | if f, err = os.Open(src); err != nil { 119 | return errors.Wrap(err, "read image") 120 | } 121 | defer f.Close() 122 | 123 | if mask, _, err = image.Decode(f); err != nil { 124 | return errors.Wrap(err, "decode image") 125 | } 126 | 127 | img := image.NewRGBA(mask.Bounds()) 128 | draw.DrawMask(img, img.Bounds(), &image.Uniform{c}, image.ZP, mask, image.ZP, draw.Src) 129 | 130 | if f, err = os.Create(dest); err != nil { 131 | return errors.Wrap(err, "open new image") 132 | } 133 | defer f.Close() 134 | 135 | if err = png.Encode(f, img); err != nil { 136 | return errors.Wrap(err, "write PNG data") 137 | } 138 | 139 | fmt.Printf("copied %s to %s using colour #%s\n", src, dest, colour) 140 | 141 | return nil 142 | } 143 | 144 | var rxHexColour = regexp.MustCompile(`[a-fA-F0-9]{6}`) 145 | 146 | func parseHex(s string) (*color.RGBA, error) { 147 | 148 | if !rxHexColour.MatchString(s) { 149 | return nil, fmt.Errorf("invalid hex colour: %s", s) 150 | } 151 | 152 | var ( 153 | r, g, b uint64 154 | err error 155 | ) 156 | 157 | if r, err = strconv.ParseUint(s[0:2], 16, 8); err != nil { 158 | return nil, fmt.Errorf("invalid value for red (%s): %v", s[0:2], err) 159 | } 160 | if g, err = strconv.ParseUint(s[2:4], 16, 8); err != nil { 161 | return nil, fmt.Errorf("invalid value for green (%s): %v", s[2:4], err) 162 | } 163 | if b, err = strconv.ParseUint(s[4:6], 16, 8); err != nil { 164 | return nil, fmt.Errorf("invalid value for blue (%s): %v", s[4:6], err) 165 | } 166 | 167 | c := &color.RGBA{ 168 | R: uint8(r), 169 | G: uint8(g), 170 | B: uint8(b), 171 | A: 0xff, 172 | } 173 | 174 | return c, nil 175 | } 176 | -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "alfredworkflow": { 3 | "category": "Internet", 4 | "readme": "Secure SHell\n============\n\nRapidly open SSH/SFTP/mosh connections, with suggestions from SSH configuration files and /etc/hosts.\n\nRemembers connections with usernames, so you don't have to type your username every time.\n\n\nSettings\n--------\n\nThe workflow reads hosts from five sources:\n\n- ~/.ssh/config\n- ~/.ssh/known_hosts\n- /etc/ssh/ssh_config\n- /etc/hosts\n- Its own history\n\nYou can disable any source by setting its corresponding Workflow Environment Variable to 1, so set DISABLE_ETC_HOSTS=1 to ignore /etc/hosts.\n\nMOSH_CMD specifieds the path to the `mosh` executable. The default, \"mosh\", should work on most systems, as the command is passed to your terminal application.\n\nTo disable mosh, delete the value for MOSH_CMD.\n\nThe SSH_CMD performs a similar function for the default action (opening SSH connections).\n\nBy default, the workflow generates an ssh://... URL and asks the system to open it (or passes it to the application specified in SSH_APP).\n\nIf you set SSH_CMD, the workflow will instead generate an `ssh` command and run that in your Terminal app. This is slower, but\nwill load your dotfiles.\n\nSet EXIT_ON_SUCCESS to 0 to prevent the terminal closing if the ping/mosh command exits cleanly.\n\n\nWhen removing a connection from the History, the workflow re-opens itself with the previous query.\n\nThe EXTERNAL_TRIGGER setting tells the workflow to re-open itself using the External Trigger instead of calling itself by keyword (\"ssh\").", 5 | "createdby": "Dean Jackson", 6 | "downloadurl": "https://github.com/deanishe/alfred-ssh/releases/download/v0.8.0/Secure-SHell-0.8.0.alfredworkflow", 7 | "version": "0.8.0", 8 | "bundleid": "net.deanishe.alfred-ssh", 9 | "description": "Open SSH connections", 10 | "name": "Secure SHell", 11 | "webaddress": "https://github.com/deanishe/alfred-ssh" 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /modd.conf: -------------------------------------------------------------------------------- 1 | 2 | # icons/*.png { 3 | # prep: bin/build-workflow.zsh -c 4 | # } 5 | 6 | modd.conf 7 | **/*.go 8 | icons/*.png 9 | magefile*.go 10 | !mage_*.go 11 | !vendor/** { 12 | # prep: echo @mods 13 | # prep: go build -v git.deanishe.net/deanishe/awgo 14 | prep: " 15 | # rebuild workflow 16 | mage -v build \ 17 | && ./build/assh search toot > /dev/null 18 | " 19 | } 20 | -------------------------------------------------------------------------------- /sources.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "log" 13 | "os" 14 | "sort" 15 | ) 16 | 17 | // Source provides Hosts. 18 | type Source interface { 19 | Name() string // Display name of the source 20 | Hosts() []Host // Hosts contained by source 21 | Priority() int // Priority (lower number = higher priority) 22 | } 23 | 24 | // Sources is a priority-sorted list of Sources. 25 | type Sources []Source 26 | 27 | // Hosts returns all hosts from all sources. 28 | func (sl Sources) Hosts() []Host { 29 | var hosts = []Host{} 30 | // var seen = map[string]bool{} 31 | sort.Sort(sl) 32 | for _, s := range sl { 33 | hosts = append(hosts, s.Hosts()...) 34 | } 35 | i := len(hosts) 36 | hosts = FilterDuplicateHosts(hosts) 37 | dupes := i - len(hosts) 38 | if dupes > 0 { 39 | log.Printf("%d duplicate(s) ignored", dupes) 40 | } 41 | return hosts 42 | } 43 | 44 | // Len implements sort.Interface. 45 | func (sl Sources) Len() int { return len(sl) } 46 | 47 | // Less implements sort.Interface. 48 | func (sl Sources) Less(i, j int) bool { return sl[i].Priority() < sl[j].Priority() } 49 | 50 | // Swap implements sort.Interface. 51 | func (sl Sources) Swap(i, j int) { sl[i], sl[j] = sl[j], sl[i] } 52 | 53 | // DefaultSources returns 54 | func DefaultSources() Sources { 55 | s := Sources{ 56 | Source(NewConfigSource(os.ExpandEnv("$HOME/.ssh/config"), "~/.ssh/config", 1)), 57 | Source(NewConfigSource("/etc/ssh/ssh_config", "/etc/ssh", 5)), 58 | Source(NewHostsSource("/etc/hosts", "/etc/hosts", 4)), 59 | } 60 | sort.Sort(s) 61 | return s 62 | } 63 | 64 | type baseSource struct { 65 | Filepath string 66 | name string 67 | hosts []Host 68 | priority int 69 | } 70 | 71 | // Name implements Source. 72 | func (s *baseSource) Name() string { return s.name } 73 | 74 | // Priority implements Source. 75 | func (s *baseSource) Priority() int { return s.priority } 76 | -------------------------------------------------------------------------------- /sources_config.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "fmt" 13 | "log" 14 | "net/url" 15 | "os" 16 | "strconv" 17 | "strings" 18 | 19 | "github.com/havoc-io/ssh_config" 20 | ) 21 | 22 | // ConfigHost is Host parsed from SSH config-format files. 23 | type ConfigHost struct { 24 | BaseHost 25 | forcePort bool 26 | forceUsername bool 27 | } 28 | 29 | // UID implements Host. 30 | func (h *ConfigHost) UID() string { return UIDForHost(h) } 31 | 32 | // SetPort implements Host. 33 | func (h *ConfigHost) SetPort(i int) { 34 | h.port = i 35 | h.forcePort = true 36 | } 37 | 38 | // SetUsername implemeents Host. 39 | func (h *ConfigHost) SetUsername(n string) { 40 | h.username = n 41 | h.forceUsername = true 42 | } 43 | 44 | // SSHURL returns a URL based on the Host value from the config file, 45 | // *not* the Hostname. 46 | func (h *ConfigHost) SSHURL() *url.URL { 47 | u := &url.URL{ 48 | Scheme: "ssh", 49 | Host: h.Name(), 50 | } 51 | if h.forcePort { 52 | u.Host = fmt.Sprintf("%s:%d", u.Host, h.Port()) 53 | } 54 | if h.forceUsername { 55 | u.User = url.User(h.Username()) 56 | } 57 | return u 58 | } 59 | 60 | // MoshCmd implements Host. 61 | func (h *ConfigHost) MoshCmd(path string) string { 62 | if path == "" { 63 | path = "mosh" 64 | } 65 | cmd := path + " " 66 | if h.forcePort { 67 | cmd += fmt.Sprintf("--ssh 'ssh -p %d' ", h.Port()) 68 | } 69 | if h.forceUsername && h.Username() != "" { 70 | cmd += h.Username() + "@" 71 | } 72 | cmd += h.Name() 73 | return cmd 74 | } 75 | 76 | // ConfigSource implements Source for ssh config-formatted files. 77 | type ConfigSource struct { 78 | baseSource 79 | } 80 | 81 | // NewConfigSource creates a new ConfigSource from an ssh configuration file. 82 | func NewConfigSource(path, name string, priority int) *ConfigSource { 83 | s := &ConfigSource{} 84 | s.Filepath = path 85 | s.name = name 86 | s.priority = priority 87 | return s 88 | } 89 | 90 | // Hosts implements Source. 91 | func (s *ConfigSource) Hosts() []Host { 92 | if s.hosts == nil { 93 | hosts := parseConfigFile(s.Filepath) 94 | log.Printf("[source/load/config] %d host(s) in '%s'", len(hosts), s.Name()) 95 | s.hosts = make([]Host, len(hosts)) 96 | for i, h := range hosts { 97 | h.source = s.Name() 98 | s.hosts[i] = Host(h) 99 | } 100 | } 101 | return s.hosts 102 | } 103 | 104 | // parseConfigFile parse an SSH config file. 105 | func parseConfigFile(path string) []*ConfigHost { 106 | var hosts []*ConfigHost 107 | r, err := os.Open(path) 108 | if err != nil { 109 | log.Printf("[config/%s] Error opening file: %s", path, err) 110 | return hosts 111 | } 112 | cfg, err := ssh_config.Parse(r) 113 | if err != nil { 114 | log.Printf("[config/%s] Parse error: %s", path, err) 115 | return hosts 116 | } 117 | 118 | for _, e := range cfg.Hosts { 119 | var ( 120 | p *ssh_config.Param 121 | port = 22 122 | hn string // hostname 123 | user string 124 | ) 125 | 126 | p = e.GetParam(ssh_config.HostKeyword) 127 | if p != nil { 128 | hn = p.Value() 129 | } 130 | 131 | // log.Println(e.String()) 132 | // log.Printf("hostnames=%v", e.Hostnames) 133 | 134 | p = e.GetParam(ssh_config.HostNameKeyword) 135 | if p != nil { 136 | hn = p.Value() 137 | } 138 | 139 | p = e.GetParam(ssh_config.PortKeyword) 140 | if p != nil { 141 | port, err = strconv.Atoi(p.Value()) 142 | if err != nil { 143 | log.Printf("Bad port: %s", err) 144 | port = 22 145 | } 146 | } 147 | // log.Printf("port=%v", port) 148 | 149 | p = e.GetParam(ssh_config.UserKeyword) 150 | if p != nil { 151 | user = p.Value() 152 | } 153 | 154 | for _, n := range e.Hostnames { 155 | if strings.Contains(n, "*") || strings.Contains(n, "!") || strings.Contains(n, "?") { 156 | continue 157 | } 158 | 159 | h := &ConfigHost{} 160 | h.name = n 161 | h.hostname = n 162 | h.port = port 163 | h.username = user 164 | 165 | if hn != "" { 166 | h.hostname = hn 167 | } 168 | // log.Printf("%+v", host) 169 | hosts = append(hosts, h) 170 | } 171 | } 172 | return hosts 173 | } 174 | -------------------------------------------------------------------------------- /sources_history.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "encoding/json" 13 | "io/ioutil" 14 | "log" 15 | "net/url" 16 | "os" 17 | ) 18 | 19 | type historyEntry struct { 20 | Name string `json:"name"` 21 | URL string `json:"url"` 22 | } 23 | 24 | // History is a list of previously opened URLs. 25 | type History struct { 26 | baseSource 27 | d *Deduplicator 28 | } 29 | 30 | // NewHistory initialises a new History struct. You must call History.Load() 31 | // to load cached data. 32 | func NewHistory(path, name string, priority int) *History { 33 | h := &History{} 34 | h.Filepath = path 35 | h.name = name 36 | h.priority = priority 37 | h.d = &Deduplicator{} 38 | return h 39 | } 40 | 41 | // Add adds an item to the History. 42 | func (h *History) Add(host Host) error { 43 | if h.d.IsDuplicate(host) { 44 | log.Printf("[history/%s] Ignoring duplicate: %v", h.Filepath, host) 45 | return nil 46 | } 47 | 48 | h.hosts = append(h.hosts, host) 49 | h.d.Add(host) 50 | 51 | log.Printf("Adding %s to history ...", host.Name()) 52 | 53 | return h.Save() 54 | } 55 | 56 | // Remove removes an item from the History. 57 | func (h *History) Remove(host Host) error { 58 | for i, xh := range h.hosts { 59 | if xh.Name() != host.Name() { 60 | continue 61 | } 62 | if xh.SSHURL().String() == host.SSHURL().String() { 63 | h.hosts = append(h.hosts[0:i], h.hosts[i+1:]...) 64 | log.Printf("Removed '%s' from history", host.Name()) 65 | return h.Save() 66 | } 67 | } 68 | log.Printf("Item not in history: %v", host) 69 | return nil 70 | } 71 | 72 | // Hosts returns all the Hosts in History. 73 | func (h *History) Hosts() []Host { 74 | if h.hosts == nil { 75 | h.Load() 76 | log.Printf("[source/load/history] %d host(s) in '%s'", len(h.hosts), h.Name()) 77 | } 78 | return h.hosts 79 | } 80 | 81 | // Load loads the history from disk. 82 | func (h *History) Load() error { 83 | if _, err := os.Stat(h.Filepath); err != nil { 84 | return nil 85 | } 86 | 87 | urls := []string{} 88 | data, err := ioutil.ReadFile(h.Filepath) 89 | if err != nil { 90 | return err 91 | } 92 | if err = json.Unmarshal(data, &urls); err != nil { 93 | return err 94 | } 95 | h.hosts = make([]Host, len(urls)) 96 | for i, s := range urls { 97 | u, err := url.Parse(s) 98 | if err != nil { 99 | return err 100 | } 101 | host := NewBaseHostFromURL(u) 102 | host.source = h.Name() 103 | if !h.d.IsDuplicate(host) { 104 | h.hosts[i] = host 105 | h.d.Add(host) 106 | } 107 | } 108 | 109 | return nil 110 | } 111 | 112 | // Save saves the History to disk. 113 | func (h *History) Save() error { 114 | 115 | urls := make([]string, len(h.hosts)) 116 | 117 | for i, host := range h.hosts { 118 | urls[i] = host.SSHURL().String() 119 | } 120 | 121 | data, err := json.MarshalIndent(urls, "", " ") 122 | if err != nil { 123 | return err 124 | } 125 | 126 | if err := ioutil.WriteFile(h.Filepath, data, 0600); err != nil { 127 | return err 128 | } 129 | 130 | log.Printf("Saved %d host(s) to history", len(h.hosts)) 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /sources_hosts.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "bufio" 13 | "log" 14 | "net" 15 | "os" 16 | "strings" 17 | ) 18 | 19 | // HostsSource implements Source for a hosts-formatted file. 20 | type HostsSource struct { 21 | baseSource 22 | } 23 | 24 | // NewHostsSource creates a new HostsSource for a hosts-formatted file. 25 | func NewHostsSource(path, name string, priority int) *HostsSource { 26 | s := &HostsSource{} 27 | s.Filepath = path 28 | s.name = name 29 | s.priority = priority 30 | return s 31 | } 32 | 33 | // Hosts implements Source. 34 | func (s *HostsSource) Hosts() []Host { 35 | if s.hosts == nil { 36 | hosts := readHostsFile(s.Filepath) 37 | log.Printf("[source/load/hosts] %d host(s) in '%s'", len(hosts), s.Name()) 38 | s.hosts = make([]Host, len(hosts)) 39 | for i, h := range hosts { 40 | h.source = s.Name() 41 | s.hosts[i] = Host(h) 42 | } 43 | } 44 | return s.hosts 45 | } 46 | 47 | // readHostsFile reads hostnames from hosts-formatted path. 48 | func readHostsFile(path string) []*BaseHost { 49 | var hosts []*BaseHost 50 | 51 | fp, err := os.Open(path) 52 | if err != nil { 53 | log.Printf("[hosts/%s] Error reading file : %v", path, err) 54 | return hosts 55 | } 56 | 57 | scanner := bufio.NewScanner(fp) 58 | scanner.Split(bufio.ScanLines) 59 | 60 | for scanner.Scan() { 61 | 62 | line := scanner.Text() 63 | 64 | // Strip comments 65 | if i := strings.Index(line, "#"); i > -1 { 66 | line = line[:i] 67 | } 68 | 69 | // Ignore empty lines 70 | line = strings.TrimSpace(line) 71 | if line == "" { 72 | continue 73 | } 74 | 75 | // Parse fields 76 | fields := strings.Fields(line) 77 | if len(fields) < 2 { 78 | continue 79 | } 80 | if net.ParseIP(fields[0]) == nil { 81 | log.Printf("[hosts/%s] Invalid IP address : %v", path, fields[0]) 82 | continue 83 | } 84 | 85 | // All other fields are hostnames 86 | for _, s := range fields[1:] { 87 | if s == "broadcasthost" { 88 | continue 89 | } 90 | h := &BaseHost{name: s, hostname: s} 91 | hosts = append(hosts, h) 92 | } 93 | } 94 | 95 | return hosts 96 | } 97 | -------------------------------------------------------------------------------- /sources_known.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-12-11 7 | // 8 | 9 | package ssh 10 | 11 | import ( 12 | "bufio" 13 | "log" 14 | "os" 15 | "strconv" 16 | "strings" 17 | ) 18 | 19 | // KnownSource implements Source for a known_hosts-formatted file. 20 | type KnownSource struct { 21 | baseSource 22 | } 23 | 24 | // NewKnownSource creates a new HostsSource for a hosts-formatted file. 25 | func NewKnownSource(path, name string, priority int) *KnownSource { 26 | s := &KnownSource{} 27 | s.Filepath = path 28 | s.name = name 29 | s.priority = priority 30 | return s 31 | } 32 | 33 | // Hosts implements Source. 34 | func (s *KnownSource) Hosts() []Host { 35 | if s.hosts == nil { 36 | hosts := readKnownHostsFile(s.Filepath) 37 | log.Printf("[source/load/known_hosts] %d host(s) in '%s'", len(hosts), s.Name()) 38 | s.hosts = make([]Host, len(hosts)) 39 | for i, h := range hosts { 40 | h.source = s.Name() 41 | s.hosts[i] = Host(h) 42 | } 43 | } 44 | return s.hosts 45 | } 46 | 47 | // readKnownHostsFile reads hostnames from ~/.ssh/known_hosts. 48 | func readKnownHostsFile(path string) []*BaseHost { 49 | var hosts []*BaseHost 50 | 51 | fp, err := os.Open(path) 52 | if err != nil { 53 | log.Printf("[known_hosts/%s] Error opening file: %v", path, err) 54 | return hosts 55 | } 56 | defer fp.Close() 57 | 58 | scanner := bufio.NewScanner(fp) 59 | scanner.Split(bufio.ScanLines) 60 | 61 | for scanner.Scan() { 62 | line := scanner.Text() 63 | for _, host := range parseKnownHostsLine(line, path) { 64 | hosts = append(hosts, host) 65 | } 66 | } 67 | return hosts 68 | } 69 | 70 | // parseKnownHostsLine extracts the host(s) from a single line in 71 | // ~/.ssh/know_hosts. 72 | func parseKnownHostsLine(line, path string) []*BaseHost { 73 | var hosts []*BaseHost 74 | var hostnames []string 75 | 76 | // Split line on first whitespace. First element is hostname(s), 77 | // second is the key. 78 | i := strings.Index(line, " ") 79 | if i < 0 { 80 | return hosts 81 | } 82 | 83 | line = line[:i] 84 | 85 | // Split hostname on comma. Some entries are of format hostname,ip. 86 | hostnames = append(hostnames, strings.Split(line, ",")...) 87 | 88 | // Parse the found hostnames to see if any specify a non-default 89 | // port. Such entries look like [host.name.here]:NNNN instead of 90 | // host.name.only 91 | var port int 92 | 93 | for _, hostname := range hostnames { 94 | 95 | port = 22 96 | 97 | if strings.HasPrefix(hostname, "[") { 98 | // Assume [ip.addr.goes.here]:NNNN 99 | i = strings.Index(hostname, "]:") 100 | if i < 0 { 101 | log.Printf("[known_hosts/%s] Don't understand hostname : %s", path, hostname) 102 | continue 103 | } 104 | 105 | p, err := strconv.Atoi(hostname[i+2:]) 106 | if err != nil { 107 | log.Printf("[known_hosts/%s] Error parsing hostname `%v` : %v", path, hostname, err) 108 | continue 109 | } 110 | 111 | port = p 112 | hostname = hostname[1:i] 113 | } 114 | 115 | if !IsValidHostname(hostname) { 116 | log.Printf("[known_host] Invalid hostname: %s", hostname) 117 | continue 118 | } 119 | 120 | hosts = append(hosts, &BaseHost{name: hostname, hostname: hostname, port: port}) 121 | } 122 | 123 | return hosts 124 | } 125 | -------------------------------------------------------------------------------- /sources_test.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016 Dean Jackson 3 | // 4 | // MIT Licence. See http://opensource.org/licenses/MIT 5 | // 6 | // Created on 2016-05-27 7 | // 8 | 9 | package ssh 10 | 11 | import "testing" 12 | 13 | type tHost struct { 14 | Hostname string 15 | Port int 16 | } 17 | 18 | var knownHostsTests = []struct { 19 | Line string 20 | Expected []tHost 21 | }{ 22 | // Empty line 23 | {"", []tHost{}}, 24 | // Invalid line 25 | {"nowhitespace", []tHost{}}, 26 | // Simple hostname 27 | {"localhost ssh-rsa AAAA", 28 | []tHost{tHost{Hostname: "localhost", Port: 22}}}, 29 | // Domain 30 | {"github.com ssh-rsa AAAA", 31 | []tHost{tHost{Hostname: "github.com", Port: 22}}}, 32 | // Subdomain 33 | {"gist.github.com ssh-rsa AAAA", 34 | []tHost{tHost{Hostname: "gist.github.com", Port: 22}}}, 35 | // IP address 36 | {"127.0.0.1 ssh-rsa AAAA", 37 | []tHost{tHost{Hostname: "127.0.0.1", Port: 22}}}, 38 | // IP address with port 39 | {"[8.8.8.8]:1234 ssh-rsa AAAA", 40 | []tHost{tHost{Hostname: "8.8.8.8", Port: 1234}}}, 41 | // Hostname with port 42 | {"[printer.clintonmail.com]:1234 ssh-rsa AAAA", 43 | []tHost{tHost{Hostname: "printer.clintonmail.com", Port: 1234}}}, 44 | // Hostname and IP 45 | {"machine.example.com,10.0.0.1 ecdsa-sha2-nistp256 AAAA", 46 | []tHost{ 47 | tHost{Hostname: "machine.example.com", Port: 22}, 48 | tHost{Hostname: "10.0.0.1", Port: 22}, 49 | }}, 50 | // Hostname, IPv4 and IPv6 51 | {"::1,127.0.0.1,localhost ecdsa-sha2-nistp256 AAAA", 52 | []tHost{ 53 | tHost{Hostname: "::1", Port: 22}, 54 | tHost{Hostname: "127.0.0.1", Port: 22}, 55 | tHost{Hostname: "localhost", Port: 22}, 56 | }}, 57 | } 58 | 59 | // TestParseKnownHosts tests parsing of known_hosts lines 60 | func TestParseKnownHosts(t *testing.T) { 61 | for i, kh := range knownHostsTests { 62 | hosts := parseKnownHostsLine(kh.Line, "") 63 | if len(hosts) != len(kh.Expected) { 64 | t.Errorf("[%d] Expected %d hosts, got %d: %s", i+1, len(kh.Expected), len(hosts), kh.Line) 65 | continue 66 | } 67 | 68 | // Test individual Hosts 69 | for j, h := range hosts { 70 | x := kh.Expected[j] 71 | if h.Hostname() != x.Hostname { 72 | t.Errorf("[%d.%d] Expected=%v, Got=%v: %s", i+1, j+1, x.Hostname, h.Hostname(), kh.Line) 73 | } 74 | } 75 | } 76 | } 77 | 78 | var hostnameTests = []struct { 79 | Hostname string 80 | Expected bool 81 | }{ 82 | // Plain old hostnames and IPs 83 | {"localhost", true}, 84 | {"::1", true}, 85 | {"127.0.0.1", true}, 86 | {"google.com", true}, 87 | {"host.google.com", true}, 88 | // Invalid 89 | // With port 90 | {"host.google.com:22", false}, 91 | {"127.0.0.1:22", false}, 92 | {"[::1]:22", false}, 93 | // Bad hostnames 94 | {"host_google_com", false}, 95 | {"host google com", false}, 96 | {"host google com:22", false}, 97 | } 98 | 99 | // TestValidHostname tests validHostname 100 | func TestValidHostname(t *testing.T) { 101 | for i, ht := range hostnameTests { 102 | v := IsValidHostname(ht.Hostname) 103 | if v != ht.Expected { 104 | t.Errorf("[%d] Expected=%v, Got=%v: %s", i+1, ht.Expected, v, ht.Hostname) 105 | } 106 | } 107 | } 108 | --------------------------------------------------------------------------------