├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── main.go ├── pkg └── minssh │ ├── ansi_windows.go │ ├── config.go │ ├── minssh.go │ ├── minssh_unix.go │ └── minssh_windows.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | ### Project Specific ### 2 | /minssh 3 | /build 4 | *.log 5 | 6 | # Created by https://www.gitignore.io/api/go,vim,tags,emacs,macos,windows 7 | 8 | ### Emacs ### 9 | # -*- mode: gitignore; -*- 10 | *~ 11 | \#*\# 12 | /.emacs.desktop 13 | /.emacs.desktop.lock 14 | *.elc 15 | auto-save-list 16 | tramp 17 | .\#* 18 | 19 | # Org-mode 20 | .org-id-locations 21 | *_archive 22 | 23 | # flymake-mode 24 | *_flymake.* 25 | 26 | # eshell files 27 | /eshell/history 28 | /eshell/lastdir 29 | 30 | # elpa packages 31 | /elpa/ 32 | 33 | # reftex files 34 | *.rel 35 | 36 | # AUCTeX auto folder 37 | /auto/ 38 | 39 | # cask packages 40 | .cask/ 41 | dist/ 42 | 43 | # Flycheck 44 | flycheck_*.el 45 | 46 | # server auth directory 47 | /server/ 48 | 49 | # projectiles files 50 | .projectile 51 | 52 | # directory configuration 53 | .dir-locals.el 54 | 55 | ### Go ### 56 | # Binaries for programs and plugins 57 | *.exe 58 | *.dll 59 | *.so 60 | *.dylib 61 | 62 | # Test binary, build with `go test -c` 63 | *.test 64 | 65 | # Output of the go coverage tool, specifically when used with LiteIDE 66 | *.out 67 | 68 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 69 | .glide/ 70 | 71 | ### macOS ### 72 | *.DS_Store 73 | .AppleDouble 74 | .LSOverride 75 | 76 | # Icon must end with two \r 77 | Icon 78 | 79 | # Thumbnails 80 | ._* 81 | 82 | # Files that might appear in the root of a volume 83 | .DocumentRevisions-V100 84 | .fseventsd 85 | .Spotlight-V100 86 | .TemporaryItems 87 | .Trashes 88 | .VolumeIcon.icns 89 | .com.apple.timemachine.donotpresent 90 | 91 | # Directories potentially created on remote AFP share 92 | .AppleDB 93 | .AppleDesktop 94 | Network Trash Folder 95 | Temporary Items 96 | .apdisk 97 | 98 | ### Tags ### 99 | # Ignore tags created by etags, ctags, gtags (GNU global) and cscope 100 | TAGS 101 | .TAGS 102 | !TAGS/ 103 | tags 104 | .tags 105 | !tags/ 106 | gtags.files 107 | GTAGS 108 | GRTAGS 109 | GPATH 110 | GSYMS 111 | cscope.files 112 | cscope.out 113 | cscope.in.out 114 | cscope.po.out 115 | 116 | 117 | ### Vim ### 118 | # swap 119 | [._]*.s[a-v][a-z] 120 | [._]*.sw[a-p] 121 | [._]s[a-v][a-z] 122 | [._]sw[a-p] 123 | # session 124 | Session.vim 125 | # temporary 126 | .netrwhist 127 | # auto-generated tag files 128 | 129 | ### Windows ### 130 | # Windows thumbnail cache files 131 | Thumbs.db 132 | ehthumbs.db 133 | ehthumbs_vista.db 134 | 135 | # Folder config file 136 | Desktop.ini 137 | 138 | # Recycle Bin used on file shares 139 | $RECYCLE.BIN/ 140 | 141 | # Windows Installer files 142 | *.cab 143 | *.msi 144 | *.msm 145 | *.msp 146 | 147 | # Windows shortcuts 148 | *.lnk 149 | 150 | # End of https://www.gitignore.io/api/go,vim,tags,emacs,macos,windows 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tatsushi Demachi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TARGET := minssh 2 | VERSION := v0.2.0 3 | GOX_OS := linux darwin windows 4 | GOX_ARCH := 386 amd64 5 | DATE := $(shell date +%FT%T%z) 6 | RM := rm -f 7 | RM_R := rm -rf 8 | ifeq ($(OS),Windows_NT) 9 | TARGET := $(TARGET).exe 10 | endif 11 | 12 | SHA256 := $(shell sha256sum --help) 13 | ifdef SHA256 # usual linux 14 | SHA256 := sha256sum 15 | else # macOS 16 | SHA256 := shasum 17 | endif 18 | 19 | wildcard-recurse = $(wildcard $1$2)$(foreach d,$(wildcard $1*),$(call wildcard-recurse,$(d)/,$2)) 20 | SOURCES := $(filter-out %_test.go,$(call wildcard-recurse,./,*.go)) 21 | 22 | PKG_NAME := minssh 23 | PLATFORMS := $(foreach arch,$(GOX_ARCH),$(addsuffix _$(arch),$(GOX_OS))) 24 | PLATFORMS_DIR := $(addprefix build/platforms/,$(PLATFORMS)) 25 | RELEASE_PREFIX := build/release/$(VERSION)/$(PKG_NAME)_$(VERSION)_ 26 | 27 | OBJECTS := $(addprefix $(RELEASE_PREFIX),$(addsuffix .tar.gz,$(filter-out windows%,$(PLATFORMS)))) 28 | OBJECTS += $(addprefix $(RELEASE_PREFIX),$(addsuffix .zip,$(filter windows%,$(PLATFORMS)))) 29 | OBJECTS += $(RELEASE_PREFIX)checksums.txt 30 | 31 | $(TARGET): $(SOURCES) 32 | go build -ldflags \ 33 | "-X main.commitHash=$$(git rev-parse --short HEAD 2>/dev/null) \ 34 | -X main.buildDate=$(DATE) \ 35 | -w -s" 36 | 37 | build/platforms: 38 | mkdir -p build/platforms 39 | 40 | $(PLATFORMS_DIR): build/platforms 41 | gox -os="$(GOX_OS)" -arch="$(GOX_ARCH)" \ 42 | -output "build/platforms/{{.OS}}_{{.Arch}}/{{.Dir}}" \ 43 | -ldflags "-X main.commitHash=$$(git rev-parse --short HEAD 2>/dev/null) \ 44 | -X main.buildDate=$(DATE) \ 45 | -w -s" 46 | 47 | build/release/$(VERSION): 48 | mkdir -p build/release/$(VERSION) 49 | 50 | $(RELEASE_PREFIX)%.tar.gz: $(PLATFORMS_DIR) build/release/$(VERSION) 51 | cd build/platforms/$(*F) && tar czf $(CURDIR)/$@ ./* 52 | 53 | $(RELEASE_PREFIX)%.zip: $(PLATFORMS_DIR) build/release/$(VERSION) 54 | cd build/platforms/$(*F) && zip $(CURDIR)/$@ ./* 55 | 56 | $(RELEASE_PREFIX)checksums.txt: build/release/$(VERSION) 57 | cd build/release/$(VERSION) && $(SHA256) *.tar.gz *.zip > $(@F) 58 | 59 | .PHONY: release 60 | release: $(OBJECTS) 61 | 62 | .PHONY: clean 63 | clean: 64 | $(RM) $(TARGET) 65 | $(RM_R) build 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MinSSH 2 | ====== 3 | 4 | MinSSH is a SSH client with minimum functions written in Go. It can run on 5 | Linux, macOS and __Windows__. 6 | 7 | ## Features 8 | 9 | - Support command, subsystem and interactive shell mode 10 | - Work on Windows Command Prompt and PowerShell (Especially work well on 11 | Windows 10 AU or later) 12 | - Can read OpenSSH `known_hosts` file and verify host 13 | - Support OpenSSH public key, keyboard interactive and password authentication 14 | 15 | ## Install 16 | 17 | Download your platform binary from 18 | [Release page](https://github.com/tatsushid/minssh/releases) and put it 19 | somewhere you like. 20 | 21 | If you'd like to build it by yourself, please use `go get`: 22 | 23 | ```shellsession 24 | $ go get -u github.com/tatsushid/minssh 25 | ``` 26 | 27 | ## Usage 28 | 29 | Run the command like 30 | 31 | ```shellsession 32 | $ minssh user@hostname 33 | ``` 34 | 35 | You can see command options by 36 | 37 | ```shellsession 38 | $ minssh -help 39 | ``` 40 | 41 | If you run this on MSYS2/Cygwin with Mintty, please wrap this by 42 | [winpty](https://github.com/rprichard/winpty) like 43 | 44 | ```shellsession 45 | $ winpty minssh user@hostname 46 | ``` 47 | 48 | It saves its own data in 49 | 50 | - `$HOME/.minssh/` (Linux, macOS) 51 | - `%APPDATA%\minssh\` (Windows) 52 | 53 | ## Contribution 54 | 55 | 1. Fork ([https://github.com/tatsushid/minssh/fork](https://github.com/tatsushid/minssh/fork)) 56 | 2. Create a feature branch 57 | 3. Commit your changes 58 | 4. Rebase your local changes against the master branch 59 | 5. Run test suite with the `go test ./...` command and confirm that it passes 60 | 6. Run `go fmt` 61 | 7. Create new Pull Request 62 | 63 | ## License 64 | 65 | MinSSH is under MIT license. See the [LICENSE](./LICENSE) file for details. 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/tatsushid/minssh/pkg/minssh" 14 | ) 15 | 16 | var defaultKnownHostsFiles = []string{ 17 | "known_hosts", 18 | "known_hosts2", 19 | } 20 | 21 | var defaultIdentityFiles = []string{ 22 | "id_rsa", 23 | "id_dsa", 24 | "id_ecdsa", 25 | "id_ed25519", 26 | } 27 | 28 | type strSliceValue []string 29 | 30 | func (v *strSliceValue) Set(s string) error { 31 | *v = append(*v, s) 32 | return nil 33 | } 34 | 35 | func (v *strSliceValue) String() string { 36 | return "" // no default 37 | } 38 | 39 | func getAppName() (appName string) { 40 | appName = filepath.Base(os.Args[0]) 41 | appName = strings.TrimSuffix(appName, filepath.Ext(appName)) 42 | return 43 | } 44 | 45 | type app struct { 46 | name string 47 | flagSet *flag.FlagSet 48 | conf *minssh.Config 49 | dir string 50 | homeDir string 51 | logFile *os.File 52 | } 53 | 54 | func (a *app) initApp() (err error) { 55 | a.conf = minssh.NewConfig() 56 | 57 | if a.conf.Logger == nil { 58 | a.conf.Logger = log.New(ioutil.Discard, a.name+" ", log.LstdFlags) 59 | } 60 | 61 | dir := os.Getenv("HOME") 62 | a.homeDir = dir 63 | if dir == "" && runtime.GOOS == "windows" { 64 | dir = os.Getenv("APPDATA") 65 | a.homeDir = os.Getenv("USERPROFILE") 66 | } 67 | if runtime.GOOS == "windows" { 68 | a.dir = filepath.Join(dir, a.name) 69 | } else { 70 | a.dir = filepath.Join(dir, "."+a.name) 71 | } 72 | 73 | err = os.MkdirAll(a.dir, 0700) 74 | if err != nil { 75 | return fmt.Errorf("failed to create an application directory: %s", err) 76 | } 77 | 78 | for i, f := range defaultKnownHostsFiles { 79 | f = filepath.Join(a.dir, f) 80 | if _, err := os.Lstat(f); err == nil { 81 | a.conf.KnownHostsFiles = append(a.conf.KnownHostsFiles, f) 82 | } else if os.IsNotExist(err) && i == 0 { 83 | // if there isn't "known_host" file, create a empty file 84 | if fh, err := os.OpenFile(f, os.O_RDONLY|os.O_CREATE, 0600); err == nil { 85 | fh.Close() 86 | a.conf.KnownHostsFiles = append(a.conf.KnownHostsFiles, f) 87 | } 88 | } 89 | } 90 | 91 | return 92 | } 93 | 94 | func (a *app) parseArgs() (err error) { 95 | var ( 96 | logPath string 97 | useOpenSSHFiles bool 98 | showVersion bool 99 | ) 100 | 101 | a.flagSet.Var((*strSliceValue)(&a.conf.IdentityFiles), "i", "use `identity_file` for public key authentication. this can be called multiple times") 102 | a.flagSet.IntVar(&a.conf.Port, "p", 22, "specify ssh server `port`") 103 | a.flagSet.BoolVar(&a.conf.IsSubsystem, "s", false, "treat command as subsystem") 104 | a.flagSet.StringVar(&logPath, "E", "", "specify `log_file` path. if it isn't set, it discards all log outputs") 105 | a.flagSet.BoolVar(&useOpenSSHFiles, "U", false, "use keys and known_hosts files in OpenSSH's '.ssh' directory") 106 | a.flagSet.BoolVar(&a.conf.NoTTY, "T", false, "disable pseudo-terminal allocation") 107 | a.flagSet.BoolVar(&showVersion, "V", false, "show version and exit") 108 | a.flagSet.Parse(os.Args[1:]) 109 | 110 | if showVersion { 111 | fmt.Println(version()) 112 | os.Exit(0) 113 | } 114 | 115 | if len(a.conf.IdentityFiles) == 0 { 116 | for _, f := range defaultIdentityFiles { 117 | f = filepath.Join(a.dir, f) 118 | if _, err := os.Lstat(f); err == nil { 119 | a.conf.IdentityFiles = append(a.conf.IdentityFiles) 120 | } 121 | } 122 | if useOpenSSHFiles { 123 | for _, f := range defaultIdentityFiles { 124 | f = filepath.Join(a.homeDir, ".ssh", f) 125 | if _, err := os.Lstat(f); err == nil { 126 | a.conf.IdentityFiles = append(a.conf.IdentityFiles, f) 127 | } 128 | } 129 | } 130 | } 131 | 132 | if useOpenSSHFiles { 133 | for _, f := range defaultKnownHostsFiles { 134 | f = filepath.Join(a.homeDir, ".ssh", f) 135 | if _, err := os.Lstat(f); err == nil { 136 | a.conf.KnownHostsFiles = append(a.conf.KnownHostsFiles, f) 137 | } 138 | } 139 | } 140 | 141 | if logPath != "" { 142 | a.logFile, err = os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) 143 | if err != nil { 144 | fmt.Fprintf(os.Stderr, "failed to open logfile: %s\n", err) 145 | fmt.Fprintln(os.Stderr, "will not log, just ignore it") 146 | } else { 147 | a.conf.Logger = log.New(a.logFile, a.name+" ", log.LstdFlags) 148 | } 149 | } 150 | 151 | userHost := a.flagSet.Arg(0) 152 | if userHost == "" { 153 | return fmt.Errorf("ssh server host must be specified") 154 | } 155 | 156 | if i := strings.Index(userHost, "@"); i != -1 { 157 | a.conf.User = userHost[:i] 158 | a.conf.Host = userHost[i+1:] 159 | } else { 160 | a.conf.Host = userHost 161 | } 162 | 163 | if a.flagSet.NArg() > 1 { 164 | a.conf.Command = strings.Join(a.flagSet.Args()[1:], " ") 165 | } 166 | 167 | return 168 | } 169 | 170 | func (a *app) run() (exitCode int) { 171 | exitCode = 1 172 | 173 | err := a.initApp() 174 | if err != nil { 175 | fmt.Fprintln(os.Stderr, err) 176 | return 177 | } 178 | 179 | err = a.parseArgs() 180 | if a.logFile != nil { 181 | defer a.logFile.Close() 182 | } 183 | if err != nil { 184 | fmt.Fprintln(os.Stderr, err) 185 | return 186 | } 187 | 188 | if a.conf.Command == "" && !a.conf.NoTTY { 189 | if ok, err := minssh.IsTerminal(); !ok { 190 | fmt.Fprintln(os.Stderr, err) 191 | return 192 | } 193 | } 194 | 195 | ms, err := minssh.Open(a.conf) 196 | if err != nil { 197 | fmt.Fprintln(os.Stderr, err) 198 | return 199 | } 200 | defer ms.Close() 201 | 202 | err = ms.Run() 203 | if err != nil { 204 | fmt.Fprintln(os.Stderr, err) 205 | return 206 | } 207 | 208 | return 0 209 | } 210 | 211 | func main() { 212 | appName := getAppName() 213 | a := &app{ 214 | name: appName, 215 | flagSet: flag.NewFlagSet(appName, flag.ExitOnError), 216 | } 217 | a.flagSet.Usage = func() { 218 | fmt.Fprintf(os.Stderr, "Usage: %s [options] [user@]hostname\n\n", os.Args[0]) 219 | fmt.Fprintf(os.Stderr, "Options:\n") 220 | a.flagSet.PrintDefaults() 221 | fmt.Fprintf(os.Stderr, "\nVersion:\n %s", version()) 222 | } 223 | 224 | os.Exit(a.run()) 225 | } 226 | -------------------------------------------------------------------------------- /pkg/minssh/ansi_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package minssh 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "os" 9 | "unicode/utf16" 10 | "unicode/utf8" 11 | "unsafe" 12 | 13 | ansiterm "github.com/Azure/go-ansiterm" 14 | "github.com/Azure/go-ansiterm/winterm" 15 | ) 16 | 17 | // virtual key codes from 18 | // https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd375731.aspx 19 | const ( 20 | vk_lbutton = 1 + iota 21 | vk_rbutton 22 | vk_cancel 23 | vk_mbutton 24 | vk_xbutton1 25 | vk_xbutton2 26 | _ // undefined 27 | vk_back 28 | vk_tab 29 | _ // reserved 30 | _ // reserved 31 | vk_clear 32 | vk_return 33 | _ // undefined 34 | _ // undefined 35 | vk_shift 36 | vk_control 37 | vk_menu 38 | vk_pause 39 | vk_capital 40 | vk_kana // or vk_hanguel, vk_hangul 41 | _ 42 | vk_junja 43 | vk_final 44 | vk_hanja // or vk_kanji 45 | _ // undefined 46 | vk_escape 47 | vk_convert 48 | vk_nonconvert 49 | vk_accept 50 | vk_modechange 51 | vk_space 52 | vk_prior 53 | vk_next 54 | vk_end 55 | vk_home 56 | vk_left 57 | vk_up 58 | vk_right 59 | vk_down 60 | vk_select 61 | vk_print 62 | vk_execute 63 | vk_snapshot 64 | vk_insert 65 | vk_delete 66 | vk_help 67 | // numbers and alphabets 68 | ) 69 | 70 | const ( 71 | vk_lwin = 0x5B + iota 72 | vk_rwin 73 | vk_apps 74 | _ 75 | vk_sleep 76 | vk_numpad0 77 | vk_numpat1 78 | vk_numpat2 79 | vk_numpat3 80 | vk_numpat4 81 | vk_numpat5 82 | vk_numpat6 83 | vk_numpat7 84 | vk_numpat8 85 | vk_numpat9 86 | vk_multiply 87 | vk_add 88 | vk_separator 89 | vk_subtract 90 | vk_decimal 91 | vk_divide 92 | vk_f1 93 | vk_f2 94 | vk_f3 95 | vk_f4 96 | vk_f5 97 | vk_f6 98 | vk_f7 99 | vk_f8 100 | vk_f9 101 | vk_f10 102 | vk_f11 103 | vk_f12 104 | vk_f13 105 | vk_f14 106 | vk_f15 107 | vk_f16 108 | vk_f17 109 | vk_f18 110 | vk_f19 111 | vk_f20 112 | vk_f21 113 | vk_f22 114 | vk_f23 115 | vk_f24 116 | _ // unassigned 117 | _ // unassigned 118 | vk_numlock 119 | vk_scroll 120 | // unassigned and OEM specific 121 | ) 122 | 123 | const ( 124 | vk_lshift = 0xA0 + iota 125 | vk_rshift 126 | vk_lcontrol 127 | vk_rcontrol 128 | vk_lmenu 129 | vk_rmenu 130 | vk_browser_back 131 | vk_browser_forward 132 | vk_browser_refresh 133 | vk_browser_stop 134 | vk_browser_search 135 | vk_browser_favorites 136 | vk_browser_home 137 | vk_volume_mute 138 | vk_volume_down 139 | vk_volume_up 140 | vk_media_next_track 141 | vk_media_prev_track 142 | vk_media_stop 143 | vk_media_play_pause 144 | vk_launch_mail 145 | vk_launch_media_select 146 | vk_launch_app1 147 | vk_launch_app2 148 | _ // reserved 149 | _ // reserved 150 | vk_oem_1 151 | vk_oem_plus 152 | vk_oem_comma 153 | vk_oem_minus 154 | vk_oem_period 155 | vk_oem_2 156 | vk_oem_3 157 | // reserved and unassigned 158 | ) 159 | 160 | const ( 161 | vk_oem_4 = 0xDB + iota 162 | vk_oem_5 163 | vk_oem_6 164 | vk_oem_7 165 | vk_oem_8 166 | _ // reserved 167 | _ // OEM specific 168 | vk_oem_102 169 | _ // OEM specific 170 | _ // OEM specific 171 | vk_processkey 172 | _ // OEM specific 173 | vk_packet 174 | // unassigned and OEM specific 175 | ) 176 | 177 | const ( 178 | vk_attn = 0xF6 + iota 179 | vk_crsel 180 | vk_exsel 181 | vk_ereof 182 | vk_play 183 | vk_zoom 184 | vk_noname 185 | vk_pa1 186 | vk_oem_clear 187 | ) 188 | 189 | // control key states from 190 | // https://msdn.microsoft.com/ja-jp/library/windows/desktop/ms684166(v=vs.85).aspx 191 | const ( 192 | capslock_on = 0x0080 193 | enhanced_key = 0x0100 194 | left_alt_pressed = 0x0002 195 | left_ctrl_pressed = 0x0008 196 | numlock_on = 0x0020 197 | right_alt_pressed = 0x0001 198 | right_ctrl_pressed = 0x0004 199 | scrolllock_on = 0x0040 200 | shift_pressed = 0x0010 201 | ) 202 | 203 | var arrowKeyMap = map[int]byte{ 204 | vk_up: 'A', 205 | vk_down: 'B', 206 | vk_right: 'C', 207 | vk_left: 'D', 208 | vk_home: 'H', 209 | vk_end: 'F', 210 | } 211 | 212 | var f1ToF4KeyMap = map[int]byte{ 213 | vk_f1: 'P', 214 | vk_f2: 'Q', 215 | vk_f3: 'R', 216 | vk_f4: 'S', 217 | } 218 | 219 | var f5toF12KeyMap = map[int][2]byte{ 220 | vk_f5: [2]byte{'1', '5'}, 221 | vk_f6: [2]byte{'1', '7'}, 222 | vk_f7: [2]byte{'1', '8'}, 223 | vk_f8: [2]byte{'1', '9'}, 224 | vk_f9: [2]byte{'2', '0'}, 225 | vk_f10: [2]byte{'2', '1'}, 226 | vk_f11: [2]byte{'2', '3'}, 227 | vk_f12: [2]byte{'2', '4'}, 228 | } 229 | 230 | type ansiReader struct { 231 | fd uintptr 232 | file *os.File 233 | lastRune rune 234 | } 235 | 236 | func NewAnsiReader(f *os.File) *ansiReader { 237 | return &ansiReader{fd: f.Fd(), file: f, lastRune: 0} 238 | } 239 | 240 | func (ar *ansiReader) Read(b []byte) (n int, err error) { 241 | var nr, i dword 242 | records := make([]inputRecord, 2) 243 | 244 | nr, err = readConsoleInput(ar.fd, records) 245 | if err != nil { 246 | return 0, err 247 | } 248 | if nr == 0 { 249 | return 0, io.EOF 250 | } 251 | 252 | b = b[:0] 253 | for i = 0; i < nr; i++ { 254 | record := records[i] 255 | switch record.eventType { 256 | case keyEvent: 257 | ev := (*keyEventRecord)(unsafe.Pointer(&record.event[0])) 258 | if ev.keyDown != int32(1) { 259 | continue // only need keydown event, ignore keyup 260 | } 261 | 262 | if seq := ar.toAnsiSeq(ev); seq != nil { 263 | b = append(b, seq...) 264 | continue 265 | } 266 | 267 | r := rune(ev.unicodeChar) 268 | if ar.lastRune != 0 { 269 | r = utf16.DecodeRune(ar.lastRune, r) 270 | ar.lastRune = 0 271 | } else if utf16.IsSurrogate(r) { 272 | ar.lastRune = r // save half for next time 273 | continue 274 | } 275 | 276 | if r == 0 { 277 | continue 278 | } 279 | ne := utf8.EncodeRune(b[len(b):cap(b)], r) 280 | b = b[:len(b)+ne] 281 | case mouseEvent, windowBufferSizeEvent, focusEvent, menuEvent: 282 | // just ignore, do nothing 283 | default: 284 | // unknown event, do nothing 285 | } 286 | } 287 | 288 | return len(b), nil 289 | } 290 | 291 | func (ar *ansiReader) Close() error { 292 | return ar.file.Close() 293 | } 294 | 295 | func (ar *ansiReader) Fd() uintptr { 296 | return ar.fd 297 | } 298 | 299 | // toAnsiSeq converts a key event to a ansi sequence based on "Input Sequences" 300 | // section at 301 | // https://msdn.microsoft.com/ja-jp/library/windows/desktop/mt638032(v=vs.85).aspx 302 | func (ar *ansiReader) toAnsiSeq(k *keyEventRecord) []byte { 303 | switch vk := int(k.virtualKeyCode); vk { 304 | case vk_up, vk_down, vk_right, vk_left, vk_home, vk_end: 305 | if k.controlKeyState&(left_ctrl_pressed|right_ctrl_pressed) != 0 { 306 | return []byte(fmt.Sprintf("\033[1;5%c", arrowKeyMap[vk])) 307 | } else { 308 | return []byte(fmt.Sprintf("\033[%c", arrowKeyMap[vk])) 309 | } 310 | case vk_back: 311 | if k.controlKeyState&(left_alt_pressed|right_alt_pressed) != 0 && 312 | k.controlKeyState&(left_ctrl_pressed|right_ctrl_pressed) == 0 { 313 | return []byte{0x1b, '\b'} 314 | } 315 | return []byte{0x7f} 316 | case vk_pause: 317 | return []byte{0x1a} 318 | case vk_escape: 319 | return []byte{0x1b} 320 | case vk_insert: 321 | return []byte(fmt.Sprintf("\033[2~")) 322 | case vk_delete: 323 | return []byte(fmt.Sprintf("\033[3~")) 324 | case vk_prior: 325 | return []byte(fmt.Sprintf("\033[5~")) 326 | case vk_next: 327 | return []byte(fmt.Sprintf("\033[6~")) 328 | case vk_f1, vk_f2, vk_f3, vk_f4: 329 | return []byte(fmt.Sprintf("\033O%c", f1ToF4KeyMap[vk])) 330 | case vk_f5, vk_f6, vk_f7, vk_f8, vk_f9, vk_f10, vk_f11, vk_f12: 331 | return []byte(fmt.Sprintf("\033[%c%c~", f5toF12KeyMap[vk][0], f5toF12KeyMap[vk][1])) 332 | } 333 | 334 | if 0x20 <= k.unicodeChar && k.unicodeChar <= 0x7E && 335 | k.controlKeyState&(left_alt_pressed|right_alt_pressed) != 0 && 336 | k.controlKeyState&(left_ctrl_pressed|right_ctrl_pressed) == 0 { 337 | return []byte(fmt.Sprintf("\033%c", k.unicodeChar)) 338 | } 339 | return nil 340 | } 341 | 342 | type ansiWriter struct { 343 | fd uintptr 344 | file *os.File 345 | parser *ansiterm.AnsiParser 346 | } 347 | 348 | func NewAnsiWriter(f *os.File) *ansiWriter { 349 | aw := &ansiWriter{fd: f.Fd(), file: f} 350 | aw.parser = ansiterm.CreateParser("Ground", winterm.CreateWinEventHandler(f.Fd(), f)) 351 | return aw 352 | } 353 | 354 | func (aw *ansiWriter) Write(p []byte) (n int, err error) { 355 | if len(p) == 0 { 356 | return 0, nil 357 | } 358 | return aw.parser.Parse(p) 359 | } 360 | 361 | func (aw *ansiWriter) Close() error { 362 | return aw.file.Close() 363 | } 364 | 365 | func (aw *ansiWriter) Fd() uintptr { 366 | return aw.fd 367 | } 368 | -------------------------------------------------------------------------------- /pkg/minssh/config.go: -------------------------------------------------------------------------------- 1 | package minssh 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type Config struct { 10 | User string 11 | Host string 12 | Port int 13 | Logger *log.Logger 14 | KnownHostsFiles []string 15 | IdentityFiles []string 16 | Command string 17 | IsSubsystem bool 18 | NoTTY bool 19 | } 20 | 21 | func NewConfig() *Config { 22 | return &Config{ 23 | User: getDefaultUser(), 24 | Host: "", 25 | Port: 22, 26 | Logger: log.New(ioutil.Discard, "minssh ", log.LstdFlags), 27 | } 28 | } 29 | 30 | func getDefaultUser() (username string) { 31 | for _, envKey := range []string{"LOGNAME", "USER", "LNAME", "USERNAME"} { 32 | username = os.Getenv(envKey) 33 | if username != "" { 34 | return 35 | } 36 | } 37 | 38 | return 39 | } 40 | -------------------------------------------------------------------------------- /pkg/minssh/minssh.go: -------------------------------------------------------------------------------- 1 | package minssh 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "net" 12 | "os" 13 | "os/signal" 14 | "runtime" 15 | "strings" 16 | "sync" 17 | "syscall" 18 | 19 | "golang.org/x/crypto/ssh" 20 | "golang.org/x/crypto/ssh/knownhosts" 21 | "golang.org/x/crypto/ssh/terminal" 22 | ) 23 | 24 | const ( 25 | defaultTermName string = "xterm" 26 | maxPromptTries int = 3 27 | ) 28 | 29 | type MinSSH struct { 30 | conf *Config 31 | 32 | conn *ssh.Client 33 | sess *ssh.Session 34 | 35 | rStdin io.WriteCloser 36 | rStdout io.Reader 37 | rStderr io.Reader 38 | 39 | sys *sysInfo 40 | 41 | wg sync.WaitGroup 42 | } 43 | 44 | func IsTerminal() (bool, error) { 45 | if !terminal.IsTerminal(int(os.Stdin.Fd())) || !terminal.IsTerminal(int(os.Stdout.Fd())) { 46 | s := "cannot run on non-terminal device." 47 | if runtime.GOOS == "windows" { 48 | s += " if you use mintty on Cygwin/MSYS, please wrap this by winpty" 49 | } 50 | return false, fmt.Errorf(s) 51 | } 52 | return true, nil 53 | } 54 | 55 | func readPassword(ttyin, ttyout *os.File, prompt string) (password string, err error) { 56 | state, err := terminal.GetState(int(ttyin.Fd())) 57 | if err != nil { 58 | return "", fmt.Errorf("failed to get terminal state: %s", err) 59 | } 60 | 61 | stopC := make(chan struct{}) 62 | defer func() { 63 | close(stopC) 64 | }() 65 | 66 | go func() { 67 | sigC := make(chan os.Signal, 1) 68 | signal.Notify(sigC, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 69 | select { 70 | case <-sigC: 71 | terminal.Restore(int(ttyin.Fd()), state) 72 | os.Exit(1) 73 | case <-stopC: 74 | } 75 | }() 76 | 77 | if prompt == "" { 78 | fmt.Fprint(ttyout, "Password: ") 79 | } else { 80 | fmt.Fprint(ttyout, prompt) 81 | } 82 | 83 | b, err := terminal.ReadPassword(int(ttyin.Fd())) 84 | if err != nil { 85 | return "", fmt.Errorf("failed to read password: %s", err) 86 | } 87 | 88 | fmt.Fprint(ttyout, "\n") 89 | 90 | return string(b), nil 91 | } 92 | 93 | func askAddingUnknownHostKey(address string, remote net.Addr, key ssh.PublicKey) (bool, error) { 94 | stopC := make(chan struct{}) 95 | defer func() { 96 | close(stopC) 97 | }() 98 | 99 | go func() { 100 | sigC := make(chan os.Signal, 1) 101 | signal.Notify(sigC, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 102 | select { 103 | case <-sigC: 104 | os.Exit(1) 105 | case <-stopC: 106 | } 107 | }() 108 | 109 | fmt.Printf("The authenticity of host '%s (%s)' can't be established.\n", address, remote.String()) 110 | fmt.Printf("RSA key fingerprint is %s\n", ssh.FingerprintSHA256(key)) 111 | fmt.Printf("Are you sure you want to continue connecting (yes/no)? ") 112 | 113 | b := bufio.NewReader(os.Stdin) 114 | for { 115 | answer, err := b.ReadString('\n') 116 | if err != nil { 117 | return false, fmt.Errorf("failed to read answer: %s", err) 118 | } 119 | answer = string(strings.ToLower(strings.TrimSpace(answer))) 120 | if answer == "yes" { 121 | return true, nil 122 | } else if answer == "no" { 123 | return false, nil 124 | } 125 | fmt.Print("Please type 'yes' or 'no': ") 126 | } 127 | return false, nil 128 | } 129 | 130 | func askDecodingEncryptedKey(keyPath string) (bool, error) { 131 | stopC := make(chan struct{}) 132 | defer func() { 133 | close(stopC) 134 | }() 135 | 136 | go func() { 137 | sigC := make(chan os.Signal, 1) 138 | signal.Notify(sigC, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 139 | select { 140 | case <-sigC: 141 | os.Exit(1) 142 | case <-stopC: 143 | } 144 | }() 145 | 146 | fmt.Printf("%q is encrypted\n", keyPath) 147 | fmt.Printf("do you want to decrypt it (yes/no)? ") 148 | 149 | b := bufio.NewReader(os.Stdin) 150 | for { 151 | answer, err := b.ReadString('\n') 152 | if err != nil { 153 | return false, fmt.Errorf("failed to read answer: %s", err) 154 | } 155 | answer = string(strings.ToLower(strings.TrimSpace(answer))) 156 | if answer == "yes" { 157 | return true, nil 158 | } else if answer == "no" { 159 | return false, nil 160 | } 161 | fmt.Print("Please type 'yes' or 'no': ") 162 | } 163 | return false, nil 164 | } 165 | 166 | func Open(conf *Config) (ms *MinSSH, err error) { 167 | ms = &MinSSH{conf: conf, sys: &sysInfo{}} 168 | 169 | config := &ssh.ClientConfig{ 170 | User: ms.conf.User, 171 | Auth: []ssh.AuthMethod{ 172 | ssh.PublicKeysCallback(ms.getSigners), 173 | ssh.RetryableAuthMethod(ssh.KeyboardInteractive(ms.keyboardInteractiveChallenge), maxPromptTries), 174 | ssh.RetryableAuthMethod(ssh.PasswordCallback(ms.passwordCallback), maxPromptTries), 175 | }, 176 | HostKeyCallback: ms.verifyAndAppendNew, 177 | } 178 | 179 | if ms.conn, err = ssh.Dial("tcp", ms.Hostport(), config); err != nil { 180 | return nil, fmt.Errorf("cannot connect to %s: %s", ms.Hostport(), err) 181 | } 182 | 183 | if ms.sess, err = ms.conn.NewSession(); err != nil { 184 | return nil, fmt.Errorf("cannot create session: %s", err) 185 | } 186 | 187 | return ms, nil 188 | } 189 | 190 | func (ms *MinSSH) verifyAndAppendNew(hostname string, remote net.Addr, key ssh.PublicKey) error { 191 | if len(ms.conf.KnownHostsFiles) == 0 { 192 | return fmt.Errorf("there is no knownhosts file") 193 | } 194 | 195 | hostKeyCallback, err := knownhosts.New(ms.conf.KnownHostsFiles...) 196 | if err != nil { 197 | return fmt.Errorf("failed to load knownhosts files: %s", err) 198 | } 199 | 200 | err = hostKeyCallback(hostname, remote, key) 201 | if err == nil { 202 | return nil 203 | } 204 | 205 | keyErr, ok := err.(*knownhosts.KeyError) 206 | if !ok || len(keyErr.Want) > 0 { 207 | return err 208 | } 209 | 210 | if answer, err := askAddingUnknownHostKey(hostname, remote, key); err != nil || !answer { 211 | msg := "host key verification failed" 212 | if err != nil { 213 | msg += ": " + err.Error() 214 | } 215 | return fmt.Errorf(msg) 216 | } 217 | 218 | f, err := os.OpenFile(ms.conf.KnownHostsFiles[0], os.O_WRONLY|os.O_APPEND, 0600) 219 | if err != nil { 220 | return fmt.Errorf("failed to add new host key: %s", err) 221 | } 222 | defer f.Close() 223 | 224 | var addrs []string 225 | if remote.String() == hostname { 226 | addrs = []string{hostname} 227 | } else { 228 | addrs = []string{hostname, remote.String()} 229 | } 230 | 231 | entry := knownhosts.Line(addrs, key) 232 | if _, err = f.WriteString(entry + "\n"); err != nil { 233 | return fmt.Errorf("failed to add new host key: %s", err) 234 | } 235 | 236 | return nil 237 | } 238 | 239 | func (ms *MinSSH) getSigners() (signers []ssh.Signer, err error) { 240 | ttyin, ttyout, err := openTTY() 241 | if err != nil { 242 | return signers, fmt.Errorf("failed to open tty: %s", err) 243 | } 244 | defer closeTTY(ttyin, ttyout) 245 | 246 | for _, identityFile := range ms.conf.IdentityFiles { 247 | identityFile = os.ExpandEnv(identityFile) 248 | key, err := ioutil.ReadFile(identityFile) 249 | if err != nil { 250 | ms.conf.Logger.Printf("failed to read private key %q: %s\n", identityFile, err) 251 | continue 252 | } 253 | block, _ := pem.Decode(key) 254 | if x509.IsEncryptedPEMBlock(block) { 255 | if answer, err := askDecodingEncryptedKey(identityFile); err != nil || !answer { 256 | if err != nil { 257 | ms.conf.Logger.Printf("failed to decrypt private key: %s\n", err) 258 | } else { 259 | ms.conf.Logger.Printf("cancel decrypting private key\n") 260 | } 261 | continue 262 | } 263 | password, err := readPassword(ttyin, ttyout, "password for decrypting key: ") 264 | if err != nil { 265 | ms.conf.Logger.Printf("failed to decrypt private key: %s\n", err) 266 | continue 267 | } 268 | block.Bytes, err = x509.DecryptPEMBlock(block, []byte(password)) 269 | if err != nil { 270 | ms.conf.Logger.Printf("failed to decrypt private key: %s\n", err) 271 | continue 272 | } 273 | block.Headers = make(map[string]string) 274 | key = pem.EncodeToMemory(block) 275 | } 276 | signer, err := ssh.ParsePrivateKey(key) 277 | if err != nil { 278 | ms.conf.Logger.Printf("failed to parse private key: %s\n", err) 279 | continue 280 | } 281 | signers = append(signers, signer) 282 | } 283 | 284 | return signers, nil 285 | } 286 | 287 | func (ms *MinSSH) keyboardInteractiveChallenge(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 288 | ttyin, ttyout, err := openTTY() 289 | if err != nil { 290 | return answers, fmt.Errorf("failed to open tty: %s", err) 291 | } 292 | defer closeTTY(ttyin, ttyout) 293 | 294 | answers = make([]string, len(questions)) 295 | var strs []string 296 | if len(questions) > 0 { 297 | if user != "" { 298 | strs = append(strs) 299 | } 300 | if instruction != "" { 301 | strs = append(strs) 302 | } 303 | if len(strs) > 0 { 304 | fmt.Fprintln(ttyout, strings.Join(strs, " ")) 305 | } else { 306 | fmt.Fprintf(ttyout, "Keyboard interactive challenge for %s@%s\n", ms.conf.User, ms.conf.Host) 307 | } 308 | } 309 | for i, q := range questions { 310 | res, err := readPassword(ttyin, ttyout, q) 311 | if err != nil { 312 | return answers, err 313 | } 314 | answers[i] = res 315 | } 316 | return answers, err 317 | } 318 | 319 | func (ms *MinSSH) passwordCallback() (secret string, err error) { 320 | ttyin, ttyout, err := openTTY() 321 | if err != nil { 322 | return secret, fmt.Errorf("failed to open tty: %s", err) 323 | } 324 | defer closeTTY(ttyin, ttyout) 325 | 326 | fmt.Fprintf(ttyout, "Password authentication for %s@%s\n", ms.conf.User, ms.conf.Host) 327 | return readPassword(ttyin, ttyout, "Password: ") 328 | } 329 | 330 | func (ms *MinSSH) Close() { 331 | err := ms.restoreLocalTerminalMode() 332 | if err != nil { 333 | ms.conf.Logger.Println(err) 334 | } 335 | if ms.sess != nil { 336 | ms.sess.Close() 337 | } 338 | if ms.conn != nil { 339 | ms.conn.Close() 340 | } 341 | } 342 | 343 | func (ms *MinSSH) Hostport() string { 344 | return fmt.Sprintf("%s:%d", ms.conf.Host, ms.conf.Port) 345 | } 346 | 347 | func (ms *MinSSH) prepareRemoteTerminal() (err error) { 348 | termName := os.Getenv("TERM") 349 | if termName == "" { 350 | termName = defaultTermName 351 | } 352 | 353 | w, h, err := ms.getWindowSize() 354 | if err != nil { 355 | return fmt.Errorf("failed to get terminal width and height: %s", err) 356 | } 357 | 358 | if !ms.conf.NoTTY { 359 | if err = ms.sess.RequestPty(termName, h, w, ssh.TerminalModes{}); err != nil { 360 | return fmt.Errorf("request for pseudo terminal failed: %s", err) 361 | } 362 | } 363 | 364 | if ms.rStdin, err = ms.sess.StdinPipe(); err != nil { 365 | return fmt.Errorf("failed to get remote stdin pipe: %s", err) 366 | } 367 | 368 | if ms.rStdout, err = ms.sess.StdoutPipe(); err != nil { 369 | return fmt.Errorf("failed to get remote stdout pipe: %s", err) 370 | } 371 | 372 | if ms.rStderr, err = ms.sess.StderrPipe(); err != nil { 373 | return fmt.Errorf("failed to get remote stderr pipe: %s", err) 374 | } 375 | 376 | if err = ms.sess.Shell(); err != nil { 377 | return fmt.Errorf("failed to start shell: %s", err) 378 | } 379 | 380 | return nil 381 | } 382 | 383 | func (ms *MinSSH) prepareLocalTerminal() (err error) { 384 | if err = ms.changeLocalTerminalMode(); err != nil { 385 | return fmt.Errorf("failed to change local terminal mode: %s", err) 386 | } 387 | 388 | return nil 389 | } 390 | 391 | func (ms *MinSSH) watchSignals() chan os.Signal { 392 | sigC := make(chan os.Signal, 1) 393 | signal.Notify(sigC, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGQUIT) 394 | return sigC 395 | } 396 | 397 | type windowChangeReq struct { 398 | W, H, Wpx, Hpx uint32 399 | } 400 | 401 | func (ms *MinSSH) invokeResizeTerminal(ctx context.Context) { 402 | ch := ms.watchTerminalResize(ctx) 403 | 404 | ms.wg.Add(1) 405 | go func() { 406 | defer ms.wg.Done() 407 | 408 | w, h, err := ms.getWindowSize() 409 | if err != nil { 410 | ms.conf.Logger.Printf("failed to get current window size: %s\n", err) 411 | } 412 | 413 | for { 414 | select { 415 | case <-ctx.Done(): 416 | return 417 | case _, ok := <-ch: 418 | if !ok { 419 | return 420 | } 421 | } 422 | newW, newH, err := ms.getWindowSize() 423 | if err != nil { 424 | ms.conf.Logger.Printf("failed to get new window size: %s\n", err) 425 | continue 426 | } 427 | if newW == w && newH == h { 428 | continue 429 | } 430 | _, err = ms.sess.SendRequest("window-change", false, ssh.Marshal( 431 | windowChangeReq{W: uint32(newW), H: uint32(newH)}, 432 | )) 433 | if err != nil { 434 | ms.conf.Logger.Printf("failed to set new window size: %s\n", err) 435 | } else { 436 | w = newW 437 | h = newH 438 | } 439 | } 440 | }() 441 | } 442 | 443 | func (ms *MinSSH) invokeInOutPipes() { 444 | go func() { 445 | err := ms.copyToStdout() 446 | if err != nil { 447 | ms.conf.Logger.Printf("failed to copy remote stdout to local one: %s\n", err) 448 | } 449 | }() 450 | 451 | go func() { 452 | err := ms.copyToStderr() 453 | if err != nil { 454 | ms.conf.Logger.Printf("failed to copy remote stderr to local one: %s\n", err) 455 | } 456 | }() 457 | 458 | go func() { 459 | buf := make([]byte, 128) 460 | for { 461 | n, err := ms.readFromStdin(buf) 462 | if err != nil { 463 | if err != io.EOF { 464 | ms.conf.Logger.Printf("failed to read bytes from local stdin: %s\n", err) 465 | } 466 | ms.rStdin.Close() 467 | return 468 | } 469 | if n > 0 { 470 | _, err := ms.rStdin.Write(buf[:n]) 471 | if err != nil { 472 | ms.conf.Logger.Printf("failed to write bytes to remote stdin: %s\n", err) 473 | return 474 | } 475 | } 476 | } 477 | }() 478 | } 479 | 480 | func (ms *MinSSH) printExitMessage(err error) { 481 | fmt.Printf("ssh connection to %s closed ", ms.conf.Host) 482 | if err != nil { 483 | switch e := err.(type) { 484 | case *ssh.ExitMissingError: 485 | fmt.Printf("but remote didn't send exit status: %s\n", e) 486 | case *ssh.ExitError: 487 | fmt.Printf("with error: %s\n", e) 488 | default: 489 | fmt.Printf("with unknown error: %s\n", err) 490 | } 491 | } else { 492 | fmt.Println("successfully") 493 | } 494 | } 495 | 496 | func (ms *MinSSH) Run() (err error) { 497 | if ms.conf.Command != "" { 498 | err = ms.RunCommand() 499 | } else { 500 | err = ms.RunInteractive() 501 | } 502 | return 503 | } 504 | 505 | func (ms *MinSSH) RunCommand() error { 506 | ms.sess.Stdin = os.Stdin 507 | ms.sess.Stdout = os.Stdout 508 | ms.sess.Stderr = os.Stderr 509 | 510 | sigC := ms.watchSignals() 511 | defer func() { 512 | signal.Stop(sigC) 513 | }() 514 | 515 | sessC := make(chan error) 516 | go func() { 517 | sessC <- ms.sess.Run(ms.conf.Command) 518 | }() 519 | 520 | select { 521 | case <-sigC: 522 | fmt.Println("got signal") 523 | case err := <-sessC: 524 | ms.printExitMessage(err) 525 | } 526 | 527 | return nil 528 | } 529 | 530 | func (ms *MinSSH) RunSubsystem() error { 531 | ms.sess.Stdin = os.Stdin 532 | ms.sess.Stdout = os.Stdout 533 | ms.sess.Stderr = os.Stderr 534 | 535 | sigC := ms.watchSignals() 536 | defer func() { 537 | signal.Stop(sigC) 538 | }() 539 | 540 | sessC := make(chan error) 541 | go func() { 542 | sessC <- ms.sess.RequestSubsystem(ms.conf.Command) 543 | }() 544 | 545 | select { 546 | case <-sigC: 547 | fmt.Println("got signal") 548 | case err := <-sessC: 549 | ms.printExitMessage(err) 550 | } 551 | 552 | return nil 553 | } 554 | 555 | func (ms *MinSSH) RunInteractive() error { 556 | if err := ms.prepareRemoteTerminal(); err != nil { 557 | return err 558 | } 559 | if err := ms.prepareLocalTerminal(); err != nil { 560 | return err 561 | } 562 | 563 | sigC := ms.watchSignals() 564 | 565 | ctx, cancel := context.WithCancel(context.Background()) 566 | defer func() { 567 | signal.Stop(sigC) 568 | cancel() 569 | ms.wg.Wait() 570 | }() 571 | 572 | ms.invokeResizeTerminal(ctx) 573 | ms.invokeInOutPipes() 574 | 575 | sessC := make(chan error) 576 | go func() { 577 | sessC <- ms.sess.Wait() 578 | }() 579 | 580 | select { 581 | case <-sigC: 582 | fmt.Println("got signal") 583 | case err := <-sessC: 584 | ms.printExitMessage(err) 585 | } 586 | 587 | return nil 588 | } 589 | -------------------------------------------------------------------------------- /pkg/minssh/minssh_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows,!plan9,!nacl 2 | 3 | package minssh 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | 13 | "golang.org/x/crypto/ssh/terminal" 14 | ) 15 | 16 | const ( 17 | pathTTY string = "/dev/tty" 18 | ) 19 | 20 | type sysInfo struct { 21 | origMode *terminal.State 22 | } 23 | 24 | func openTTY() (ttyin, ttyout *os.File, err error) { 25 | if terminal.IsTerminal(int(os.Stdin.Fd())) { 26 | ttyin = os.Stdin 27 | } else { 28 | ttyin, err = os.OpenFile(pathTTY, os.O_RDWR, 0) 29 | if err != nil { 30 | return nil, nil, err 31 | } 32 | } 33 | if terminal.IsTerminal(int(os.Stdout.Fd())) { 34 | ttyout = os.Stdout 35 | } else { 36 | ttyout, err = os.OpenFile(pathTTY, os.O_RDWR, 0) 37 | if err != nil { 38 | return nil, nil, err 39 | } 40 | } 41 | return 42 | } 43 | 44 | func closeTTY(ttyin, ttyout *os.File) { 45 | if ttyin != os.Stdin { 46 | ttyin.Close() 47 | } 48 | if ttyout != os.Stdout { 49 | ttyout.Close() 50 | } 51 | } 52 | 53 | func (ms *MinSSH) changeLocalTerminalMode() (err error) { 54 | if ms.sys.origMode, err = terminal.MakeRaw(int(os.Stdin.Fd())); err != nil { 55 | return fmt.Errorf("failed to set stdin to raw mode: %s", err) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (ms *MinSSH) restoreLocalTerminalMode() error { 62 | if ms.sys.origMode != nil { 63 | return terminal.Restore(int(os.Stdin.Fd()), ms.sys.origMode) 64 | } 65 | return nil 66 | } 67 | 68 | func (ms *MinSSH) getWindowSize() (width, height int, err error) { 69 | return terminal.GetSize(int(os.Stdin.Fd())) 70 | } 71 | 72 | func (ms *MinSSH) watchTerminalResize(ctx context.Context) <-chan struct{} { 73 | ch := make(chan struct{}, 1) 74 | sigC := make(chan os.Signal, 1) 75 | signal.Notify(sigC, syscall.SIGWINCH) 76 | 77 | ms.wg.Add(1) 78 | go func() { 79 | defer func() { 80 | signal.Reset(syscall.SIGWINCH) 81 | signal.Stop(sigC) 82 | close(ch) 83 | ms.wg.Done() 84 | }() 85 | 86 | for { 87 | select { 88 | case <-ctx.Done(): 89 | return 90 | case <-sigC: 91 | ch <- struct{}{} 92 | } 93 | } 94 | }() 95 | 96 | return ch 97 | } 98 | 99 | func (ms *MinSSH) readFromStdin(b []byte) (n int, err error) { 100 | return os.Stdin.Read(b) 101 | } 102 | 103 | func (ms *MinSSH) copyToStdout() (err error) { 104 | _, err = io.Copy(os.Stdout, ms.rStdout) 105 | return 106 | } 107 | 108 | func (ms *MinSSH) copyToStderr() (err error) { 109 | _, err = io.Copy(os.Stderr, ms.rStderr) 110 | return 111 | } 112 | -------------------------------------------------------------------------------- /pkg/minssh/minssh_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package minssh 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | "syscall" 12 | "time" 13 | "unsafe" 14 | ) 15 | 16 | type ( 17 | short int16 18 | word uint16 19 | dword uint32 20 | wchar uint16 21 | 22 | coord struct { 23 | x short 24 | y short 25 | } 26 | smallRect struct { 27 | left short 28 | top short 29 | right short 30 | bottom short 31 | } 32 | consoleScreenBufferInfo struct { 33 | size coord 34 | cursorPosition coord 35 | attributes word 36 | window smallRect 37 | maximumWindowSize coord 38 | } 39 | inputRecord struct { 40 | eventType word 41 | _ word // Padding. Event struct is aligned Dword 42 | event [16]byte // union struct's largest bytes 43 | } 44 | keyEventRecord struct { 45 | keyDown int32 46 | repeatCount word 47 | virtualKeyCode word 48 | virtualScanCode word 49 | unicodeChar wchar 50 | controlKeyState dword 51 | } 52 | mouseEventRecord struct { 53 | mousePosition coord 54 | buttonState dword 55 | controlKeyState dword 56 | eventFlags dword 57 | } 58 | windowBufferSizeRecord struct { 59 | size coord 60 | } 61 | ) 62 | 63 | const ( 64 | enableEchoInput = 0x0004 65 | enableExtendedFlags = 0x0080 66 | enableInsertMode = 0x0020 67 | enableLineInput = 0x0002 68 | enableMouseInput = 0x0010 69 | enableProcessedInput = 0x0001 70 | enableQuickEditMode = 0x0040 71 | enableWindowInput = 0x0008 72 | enableAutoPosition = 0x0100 // not in doc but it is said available 73 | enableVirtualTerminalInput = 0x0200 74 | 75 | enableProcessedOutput = 0x0001 76 | enableWrapAtEolOutput = 0x0002 77 | enableVirtualTerminalProcessing = 0x0004 78 | disableNewlineAutoReturn = 0x0008 79 | enableLvbGridWorldwide = 0x0010 80 | 81 | focusEvent = 0x0010 82 | keyEvent = 0x0001 83 | menuEvent = 0x0008 84 | mouseEvent = 0x0002 85 | windowBufferSizeEvent = 0x0004 86 | 87 | errorAccessDenied syscall.Errno = 5 88 | errorInvalidHandle syscall.Errno = 6 89 | errorInvalidParameter syscall.Errno = 87 90 | ) 91 | 92 | var kernel32 = syscall.NewLazyDLL("kernel32.dll") 93 | var ( 94 | procGetConsoleMode = kernel32.NewProc("GetConsoleMode") 95 | procSetConsoleMode = kernel32.NewProc("SetConsoleMode") 96 | procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") 97 | procReadConsoleInput = kernel32.NewProc("ReadConsoleInputW") 98 | procAttachConsole = kernel32.NewProc("AttachConsole") 99 | procAllocConsole = kernel32.NewProc("AllocConsole") 100 | ) 101 | 102 | type sysInfo struct { 103 | stdinMode dword 104 | stdoutMode dword 105 | stderrMode dword 106 | emuStdin bool 107 | emuStdout bool 108 | lastRune rune 109 | } 110 | 111 | func getConsoleMode(fd uintptr) (mode dword, err error) { 112 | r1, _, e1 := syscall.Syscall(procGetConsoleMode.Addr(), 2, fd, uintptr(unsafe.Pointer(&mode)), 0) 113 | if r1 == 0 { 114 | if e1 != 0 { 115 | err = error(e1) 116 | } else { 117 | err = syscall.EINVAL 118 | } 119 | } 120 | 121 | return 122 | } 123 | 124 | func setConsoleMode(fd uintptr, mode dword) (err error) { 125 | r1, _, e1 := syscall.Syscall(procSetConsoleMode.Addr(), 2, fd, uintptr(mode), 0) 126 | if r1 == 0 { 127 | if e1 != 0 { 128 | err = error(e1) 129 | } else { 130 | err = syscall.EINVAL 131 | } 132 | } 133 | 134 | return 135 | } 136 | 137 | func getConsoleScreenBufferInfo() (info consoleScreenBufferInfo, err error) { 138 | r1, _, e1 := syscall.Syscall(procGetConsoleScreenBufferInfo.Addr(), 2, os.Stdout.Fd(), uintptr(unsafe.Pointer(&info)), 0) 139 | if r1 == 0 { 140 | if e1 != 0 { 141 | err = error(e1) 142 | } else { 143 | err = syscall.EINVAL 144 | } 145 | } 146 | 147 | return 148 | } 149 | 150 | func readConsoleInput(fd uintptr, records []inputRecord) (n dword, err error) { 151 | if len(records) == 0 { 152 | return 0, nil 153 | } 154 | 155 | r1, _, e1 := syscall.Syscall6(procReadConsoleInput.Addr(), 4, fd, uintptr(unsafe.Pointer(&records[0])), uintptr(len(records)), uintptr(unsafe.Pointer(&n)), 0, 0) 156 | if r1 == 0 { 157 | if e1 != 0 { 158 | err = error(e1) 159 | } else { 160 | err = syscall.EINVAL 161 | } 162 | } 163 | 164 | return 165 | } 166 | 167 | func attachConsole(pid dword) (err error) { 168 | r1, _, e1 := syscall.Syscall(procAttachConsole.Addr(), 1, uintptr(pid), 0, 0) 169 | if r1 == 0 { 170 | if e1 != 0 { 171 | err = error(e1) 172 | } else { 173 | err = syscall.EINVAL 174 | } 175 | } 176 | 177 | return 178 | 179 | } 180 | 181 | func allocConsole() (err error) { 182 | r1, _, e1 := syscall.Syscall(procAllocConsole.Addr(), 0, 0, 0, 0) 183 | if r1 == 0 { 184 | if e1 != 0 { 185 | err = error(e1) 186 | } else { 187 | err = syscall.EINVAL 188 | } 189 | } 190 | 191 | return 192 | } 193 | 194 | func isTerminal(fd uintptr) bool { 195 | _, err := getConsoleMode(fd) 196 | if err != nil { 197 | return false 198 | } 199 | return true 200 | } 201 | 202 | const ( 203 | conin string = "CONIN$" 204 | conout string = "CONOUT$" 205 | ) 206 | 207 | func openTTY() (ttyin, ttyout *os.File, err error) { 208 | if !isTerminal(os.Stdin.Fd()) || !isTerminal(os.Stdout.Fd()) { 209 | err = attachConsole(dword(os.Getpid())) 210 | if err != nil && err == error(errorInvalidHandle) { 211 | err = allocConsole() 212 | if err != nil { 213 | return nil, nil, err 214 | } 215 | } 216 | } 217 | 218 | if isTerminal(os.Stdin.Fd()) { 219 | ttyin = os.Stdin 220 | } else { 221 | ttyin, err = os.OpenFile(conin, os.O_RDWR, 0) 222 | if err != nil { 223 | return nil, nil, err 224 | } 225 | } 226 | 227 | if isTerminal(os.Stdout.Fd()) { 228 | ttyout = os.Stdout 229 | } else { 230 | ttyout, err = os.OpenFile(conout, os.O_RDWR, 0) 231 | if err != nil { 232 | return nil, nil, err 233 | } 234 | } 235 | return 236 | } 237 | 238 | func closeTTY(ttyin, ttyout *os.File) { 239 | if ttyin != os.Stdin { 240 | ttyin.Close() 241 | } 242 | if ttyout != os.Stdout { 243 | ttyout.Close() 244 | } 245 | } 246 | 247 | func (ms *MinSSH) changeLocalTerminalMode() error { 248 | var err error 249 | 250 | ms.sys.stdinMode, err = getConsoleMode(os.Stdin.Fd()) 251 | if err != nil { 252 | return fmt.Errorf("failed to get local stdin mode: %s", err) 253 | } 254 | 255 | ms.sys.stdoutMode, err = getConsoleMode(os.Stdout.Fd()) 256 | if err != nil { 257 | return fmt.Errorf("failed to get local stdout mode: %s", err) 258 | } 259 | 260 | ms.sys.stderrMode, err = getConsoleMode(os.Stderr.Fd()) 261 | if err != nil { 262 | return fmt.Errorf("failed to get local stderr mode: %s", err) 263 | } 264 | 265 | newBaseMode := ms.sys.stdinMode &^ (enableEchoInput | enableProcessedInput | enableLineInput) 266 | newMode := newBaseMode | enableVirtualTerminalInput 267 | err = setConsoleMode(os.Stdin.Fd(), newMode) 268 | if err != nil { 269 | ms.conf.Logger.Printf("failed to set local stdin mode with 'EnableVirtualTerminalInput': %s\n", err) 270 | err = setConsoleMode(os.Stdin.Fd(), newBaseMode) 271 | if err != nil { 272 | return fmt.Errorf("failed to set local stdin mode: %s", err) 273 | } 274 | ms.conf.Logger.Println("stdin fallback to internal input emulator") 275 | ms.sys.emuStdin = true 276 | } 277 | 278 | newMode = ms.sys.stdoutMode | enableVirtualTerminalProcessing | disableNewlineAutoReturn 279 | err = setConsoleMode(os.Stdout.Fd(), newMode) 280 | if err != nil { 281 | ms.conf.Logger.Printf("failed to set local stdout mode with 'EnableVirtualTerminalProcessing' and 'DisableNewlineAutoReturn': %s\n", err) 282 | 283 | newMode = ms.sys.stdoutMode | enableVirtualTerminalProcessing 284 | err = setConsoleMode(os.Stdout.Fd(), newMode) 285 | if err != nil { 286 | ms.conf.Logger.Printf("failed to set local stdout mode with 'EnableVirtualTerminalProcessing': %s\n", err) 287 | ms.conf.Logger.Println("stdout fallback to internal output emulator") 288 | ms.sys.stdoutMode = 0 // don't have to restore stdout mode 289 | ms.sys.emuStdout = true 290 | } 291 | } 292 | 293 | if ms.sys.emuStdout { 294 | ms.conf.Logger.Println("stderr fallback to internal output emulator") 295 | ms.sys.stderrMode = 0 296 | } else { 297 | newMode = ms.sys.stdoutMode | enableVirtualTerminalProcessing | disableNewlineAutoReturn 298 | err = setConsoleMode(os.Stderr.Fd(), newMode) 299 | if err != nil { 300 | ms.conf.Logger.Printf("failed to set local stderr mode with 'EnableVirtualTerminalProcessing' and 'DisableNewlineAutoReturn': %s\n", err) 301 | 302 | newMode = ms.sys.stdoutMode | enableVirtualTerminalProcessing 303 | err = setConsoleMode(os.Stderr.Fd(), newMode) 304 | if err != nil { 305 | ms.conf.Logger.Printf("failed to set local stderr mode with 'EnableVirtualTerminalProcessing': %s\n", err) 306 | ms.conf.Logger.Println("stderr fallback to internal output emulator") 307 | } 308 | } 309 | } 310 | 311 | return nil 312 | } 313 | 314 | func (ms *MinSSH) restoreLocalTerminalMode() error { 315 | var inE, outE, errE error 316 | if ms.sys.stdinMode > 0 { 317 | inE = setConsoleMode(os.Stdin.Fd(), ms.sys.stdinMode) 318 | } 319 | if ms.sys.stdoutMode > 0 { 320 | outE = setConsoleMode(os.Stdout.Fd(), ms.sys.stdoutMode) 321 | } 322 | if ms.sys.stderrMode > 0 { 323 | errE = setConsoleMode(os.Stderr.Fd(), ms.sys.stderrMode) 324 | } 325 | if inE != nil || outE != nil || errE != nil { 326 | errs := make([]string, 0, 3) 327 | if inE != nil { 328 | errs = append(errs, fmt.Sprintf("stdin: %d", inE)) 329 | } 330 | if outE != nil { 331 | errs = append(errs, fmt.Sprintf("stdout: %d", outE)) 332 | } 333 | if errE != nil { 334 | errs = append(errs, fmt.Sprintf("stderr: %d", errE)) 335 | } 336 | 337 | emsg := "failed to restore " 338 | emsg += strings.Join(errs, ", ") 339 | return fmt.Errorf(emsg) 340 | } 341 | return nil 342 | } 343 | 344 | func (ms *MinSSH) getWindowSize() (width, height int, err error) { 345 | info, err := getConsoleScreenBufferInfo() 346 | if err != nil { 347 | return 0, 0, err 348 | } 349 | return int(info.window.right - info.window.left + 1), int(info.window.bottom - info.window.top + 1), nil 350 | } 351 | 352 | func (ms *MinSSH) watchTerminalResize(ctx context.Context) <-chan struct{} { 353 | ch := make(chan struct{}, 1) 354 | 355 | ms.wg.Add(1) 356 | go func() { 357 | defer ms.wg.Done() 358 | 359 | ticker := time.NewTicker(2 * time.Second) 360 | defer func() { 361 | ticker.Stop() 362 | close(ch) 363 | }() 364 | 365 | for { 366 | select { 367 | case <-ctx.Done(): 368 | return 369 | case <-ticker.C: 370 | ch <- struct{}{} 371 | } 372 | } 373 | }() 374 | 375 | return ch 376 | } 377 | 378 | func (ms *MinSSH) readFromStdin(b []byte) (n int, err error) { 379 | var stdin io.Reader 380 | if ms.sys.emuStdin { 381 | stdin = NewAnsiReader(os.Stdin) 382 | } else { 383 | stdin = os.Stdin 384 | } 385 | return stdin.Read(b) 386 | } 387 | 388 | func (ms *MinSSH) copyToStdout() (err error) { 389 | var stdout io.Writer 390 | if ms.sys.emuStdout { 391 | stdout = NewAnsiWriter(os.Stdout) 392 | } else { 393 | stdout = os.Stdout 394 | } 395 | _, err = io.Copy(stdout, ms.rStdout) 396 | return 397 | } 398 | 399 | func (ms *MinSSH) copyToStderr() (err error) { 400 | var stderr io.Writer 401 | if ms.sys.emuStdout { 402 | stderr = NewAnsiWriter(os.Stderr) 403 | } else { 404 | stderr = os.Stderr 405 | } 406 | _, err = io.Copy(stderr, ms.rStderr) 407 | return 408 | } 409 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // version string of the command 4 | const Version string = "v0.2.0" 5 | 6 | var ( 7 | // git commit hash embedded by packaging script 8 | commitHash string 9 | 10 | // package build date embedded by packaging script 11 | buildDate string 12 | ) 13 | 14 | func version() (ver string) { 15 | ver = Version 16 | if commitHash != "" { 17 | ver += "-" + commitHash 18 | } 19 | if buildDate != "" { 20 | ver += " BuildDate: " + buildDate 21 | } 22 | return 23 | } 24 | --------------------------------------------------------------------------------