forked from kataras/iris
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathssh.go
More file actions
703 lines (599 loc) · 20.3 KB
/
Copy pathssh.go
File metadata and controls
703 lines (599 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
package iris
// Minimal management over SSH for your Iris & Q web server
//
// Declaration:
//
// iris.SSH.Host = "0.0.0.0:22"
// iris.SSH.KeyPath = "./iris_rsa" // it's auto-generated if not exists
// iris.SSH.Users = iris.Users{"kataras", []byte("pass")}
//
//
// Usage:
// via interactive command shell:
//
// $ ssh kataras@localhost
//
// or via standalone command and exit:
//
// $ ssh kataras@localhost stop
//
//
// Commands available:
//
// stop
// start
// restart
// log
// help
// exit
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"text/template"
"time"
"log"
"github.com/kardianos/osext"
"github.com/kardianos/service"
"github.com/kataras/go-errors"
"github.com/kataras/go-fs"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/terminal"
)
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
// ----------------------------------Iris+SSH-------------------------------------------
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
func _output(format string, a ...interface{}) func(io.Writer) {
if format[len(format)-3:] != "\n" {
format += "\n"
}
msgBytes := []byte(fmt.Sprintf(format, a...))
return func(w io.Writer) {
w.Write(msgBytes)
}
}
type systemServiceWrapper struct{}
func (w *systemServiceWrapper) Start(s service.Service) error {
return nil
}
func (w *systemServiceWrapper) Stop(s service.Service) error {
return nil
}
func (s *SSHServer) bindTo(station *Framework) {
if s.Enabled() && !s.IsListening() { // check if not listening because on restart this block will re-executing,but we don't want to start ssh again, ssh will never stops.
if station.Config.IsDevelopment && s.Logger == nil {
s.Logger = station.Logger
}
// cache the messages to be sent to the channel, no need to produce memory allocations here
statusRunningMsg := _output("The HTTP Server is running.")
statusNotRunningMsg := _output("The HTTP Server is NOT running. ")
serverStoppedMsg := _output("The HTTP Server has been stopped.")
errServerNotReadyMsg := _output("Error: HTTP Server is not even builded yet!")
serverStartedMsg := _output("The HTTP Server has been started.")
serverRestartedMsg := _output("The HTTP Server has been restarted.")
loggerStartedMsg := _output("Logger has been registered to the HTTP Server.\nNew Requests will be printed here.\nYou can still type 'exit' to close this SSH Session.\n\n")
//
sshCommands := Commands{
Command{Name: "status", Description: "Prompts the status of the HTTP Server, is listening(started) or not(stopped).", Action: func(conn ssh.Channel) {
if station.IsRunning() {
statusRunningMsg(conn)
} else {
statusNotRunningMsg(conn)
}
execPath, err := osext.Executable() // this works fine, if the developer builded the go app, if just go run main.go then prints the temporary path which the go tool creates
if err == nil {
conn.Write([]byte("[EXEC] " + execPath + "\n"))
}
}},
// Note for stop If you have opened a tab with Q route:
// in order to see that the http listener has closed you have to close your browser and re-navigate(browsers caches the tcp connection)
Command{Name: "stop", Description: "Stops the HTTP Server.", Action: func(conn ssh.Channel) {
if station.IsRunning() {
station.Close()
//srv.listener = nil used to reopen so let it setted
serverStoppedMsg(conn)
} else {
errServerNotReadyMsg(conn)
}
}},
Command{Name: "start", Description: "Starts the HTTP Server.", Action: func(conn ssh.Channel) {
if !station.IsRunning() {
go station.Reserve()
}
serverStartedMsg(conn)
}},
Command{Name: "restart", Description: "Restarts the HTTP Server.", Action: func(conn ssh.Channel) {
if station.IsRunning() {
station.Close()
//srv.listener = nil used to reopen so let it setted
}
go station.Reserve()
serverRestartedMsg(conn)
}},
/* not ready yet
Command{Name: "service", Description: "[REQUIRES HTTP SERVER's ADMIN PRIVILEGE] Adds the web server to the system services, use it when you want to make your server to autorun on reboot", Action: func(conn ssh.Channel) {
///TODO:
// 1. Unistall service and change the 'service' to 'install service'
// 2. Fix, this current implementation doesn't works on windows 10 it says that the service is not responding to request and start...
// 2.1 the fix is maybe add these and change the s.Install to s.Run to the $DESKTOP/some/q/main.go I will try this
// as the example shows.
// remember: run command line as administrator > sc delete "Iris Web Server - $DATETIME" to delete the service, do it on each test.
svcConfig := &service.Config{
Name: "Iris Web Server - " + time.Now().Format(q.TimeFormat),
DisplayName: "Iris Web Server - " + time.Now().Format(q.TimeFormat),
Description: "The web server which has been registered by SSH interface.",
}
prg := &systemServiceWrapper{}
s, err := service.New(prg, svcConfig)
if err != nil {
conn.Write([]byte(err.Error() + "\n"))
return
}
err = s.Install()
if err != nil {
conn.Write([]byte(err.Error() + "\n"))
return
}
conn.Write([]byte("Service has been registered.\n"))
}},*/
Command{Name: "log", Description: "Adds a logger to the HTTP Server, waits for requests and prints them here.", Action: func(conn ssh.Channel) {
// the ssh user can still write commands, this is not blocking anything.
loggerMiddleware := NewLoggerHandler(conn, true)
station.UseGlobalFunc(loggerMiddleware)
// register to the errors also
errorLoggerHandler := NewLoggerHandler(conn, false)
for k, v := range station.mux.errorHandlers {
errorH := v
// wrap the error handler with the ssh logger middleware
station.mux.errorHandlers[k] = HandlerFunc(func(ctx *Context) {
errorH.Serve(ctx)
errorLoggerHandler(ctx) // after the error handler because that is setting the status code.
})
}
station.mux.build() // rebuild the mux in order the UseGlobalFunc to work at runtime
loggerStartedMsg(conn)
// the middleware will still to run, we could remove it on exit but exit is general command I dont want to touch that
// we could make a command like 'log stop' or on 'stop' to remove the middleware...I will think about it.
}},
}
for _, cmd := range sshCommands {
if _, found := s.Commands.ByName(cmd.Name); !found { // yes, the user can add custom commands too, I will cover this on docs some day, it's not too hard if you see the code.
s.Commands.Add(cmd)
}
}
go func() {
station.Must(s.Listen())
}()
}
}
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
// ----------------------------------SSH implementation---------------------------------
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
var (
// SSHBanner is the banner goes on top of the 'ssh help message'
// it can be changed, defaults is the Iris's banner
SSHBanner = banner
helpMessage = SSHBanner + `
COMMANDS:
{{ range $index, $cmd := .Commands }}
{{- $cmd.Name }} | {{ $cmd.Description }}
{{ end }}
USAGE:
ssh myusername@{{ .Hostname}} {{ .PortDeclaration }} {{ first .Commands}}
or just write the command below
VERSION:
{{ .Version }}
`
helpTmpl *template.Template
)
func init() {
var err error
helpTmpl = template.New("help_message").Funcs(template.FuncMap{"first": func(cmds Commands) string {
if len(cmds) > 0 {
return cmds[0].Name
}
return ""
}})
helpTmpl, err = helpTmpl.Parse(helpMessage)
if err != nil {
panic(err.Error())
}
}
//no need of SSH prefix on these types, we don't have other commands
// use of struct and no global variables because we want each Iris instance to have its own SSH interface.
// Action the command's handler
type Action func(ssh.Channel)
// Command contains the registered SSH commands
// contains a Name which is the payload string
// Description which is the description of the command shows to the admin/user
// Action is the particular command's handler
type Command struct {
Name string
Description string
Action Action
}
// Commands the SSH Commands, it's just a type of []Command
type Commands []Command
// Add adds command(s) to the commands list
func (c *Commands) Add(cmd ...Command) {
pCommands := *c
*c = append(pCommands, cmd...)
}
// ByName returns the command by its Name
// if not found returns a zero-value Command and false as the second output parameter.
func (c *Commands) ByName(commandName string) (cmd Command, found bool) {
pCommands := *c
for _, cmd = range pCommands {
if cmd.Name == commandName {
found = true
return
}
}
return
}
// Users SSH.Users field, it's just map[string][]byte (username:password)
type Users map[string][]byte
func (m Users) exists(username string, pass []byte) bool {
for k, v := range m {
if k == username && bytes.Equal(v, pass) {
return true
}
}
return false
}
// DefaultSSHKeyPath used if SSH.KeyPath is empty. Defaults to: "iris_rsa". It can be changed.
var DefaultSSHKeyPath = "iris_rsa"
var errSSHExecutableNotFound = errors.New(`Cannot generate ssh private key: ssh-keygen couldn't be found. Please specify the ssh[.exe] and ssh-keygen[.exe]
path on your operating system's environment's $PATH or set the configuration field 'Bin'.\n For example, on windows, the path is: C:\\Program Files\\Git\usr\\bin. Error Trace: %q`)
func generateSigner(keypath string, sshKeygenBin string) (ssh.Signer, error) {
if keypath == "" {
keypath = DefaultSSHKeyPath
}
if sshKeygenBin != "" {
// if empty then the user should specify the ssh-keygen bin path (if not setted already)
// on the $PATH system environment, otherwise it will panic.
if sshKeygenBin[len(sshKeygenBin)-1] != os.PathSeparator {
sshKeygenBin += string(os.PathSeparator)
}
sshKeygenBin += "ssh-keygen"
if isWindows {
sshKeygenBin += ".exe"
}
} else {
sshKeygenBin = "ssh-keygen"
}
if !fs.DirectoryExists(keypath) {
os.MkdirAll(filepath.Dir(keypath), os.ModePerm)
keygenCmd := exec.Command(sshKeygenBin, "-f", keypath, "-t", "rsa", "-N", "")
_, err := keygenCmd.Output()
if err != nil {
panic(errSSHExecutableNotFound.Format(err.Error()))
}
}
pemBytes, err := ioutil.ReadFile(keypath)
if err != nil {
return nil, err
}
return ssh.ParsePrivateKey(pemBytes)
}
func validChannel(ch ssh.NewChannel) bool {
if typ := ch.ChannelType(); typ != "session" {
ch.Reject(ssh.UnknownChannelType, typ)
return false
}
return true
}
func execCmd(cmd *exec.Cmd, ch ssh.Channel) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
input, err := cmd.StdinPipe()
if err != nil {
return err
}
if err = cmd.Start(); err != nil {
return err
}
go io.Copy(input, ch)
io.Copy(ch, stdout)
io.Copy(ch.Stderr(), stderr)
if err = cmd.Wait(); err != nil {
return err
}
return nil
}
func sendExitStatus(ch ssh.Channel) {
ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0})
}
var errInvalidSSHCommand = errors.New("Invalid Command: '%s'")
func parsePayload(payload string, prefix string) (string, error) {
payloadUTF8 := strings.Map(func(r rune) rune {
if r >= 32 && r < 127 {
return r
}
return -1
}, payload)
if prefIdx := strings.Index(payloadUTF8, prefix); prefIdx != -1 {
p := strings.TrimSpace(payloadUTF8[prefIdx+len(prefix):])
return p, nil
}
return "", errInvalidSSHCommand.Format(payload)
}
const (
isWindows = runtime.GOOS == "windows"
isMac = runtime.GOOS == "darwin"
)
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
// ----------------------------------SSH Server-----------------------------------------
// -------------------------------------------------------------------------------------
// -------------------------------------------------------------------------------------
// SSHServer : Simple SSH interface for Iris web framework, does not implements the most secure options and code,
// but its should works
// use it at your own risk.
type SSHServer struct {
Bin string // windows: C:/Program Files/Git/usr/bin, it's the ssh[.exe] and ssh-keygen[.exe], we only need the ssh-keygen.
KeyPath string // C:/Users/kataras/.ssh/iris_rsa
Host string // host:port
listener net.Listener
Users Users // map[string][]byte]{ "username":[]byte("password"), "my_second_username" : []byte("my_second_password")}
Commands Commands // Commands{Command{Name: "restart", Description:"restarts & rebuild the server", Action: func(ssh.Channel){}}}
// note for Commands field:
// the default Iris's commands are defined at the end of this file, I tried to make this file as standalone as I can, because it will be used for Iris web framework also.
Shell bool // Set it to true to enable execute terminal's commands(system commands) via ssh if no other command is found from the Commands field. Defaults to false for security reasons
Logger *log.Logger // log.New(...)/ $qinstance.Logger, fill it when you want to receive debug and info/warnings messages
}
// NewSSHServer returns a new empty SSHServer
func NewSSHServer() *SSHServer {
return &SSHServer{}
}
// Enabled returns true if SSH can be started, if Host != ""
func (s *SSHServer) Enabled() bool {
if s == nil {
return false
}
return s.Host != ""
}
// IsListening returns true if ssh server has been started
func (s *SSHServer) IsListening() bool {
return s.Enabled() && s.listener != nil
}
func (s *SSHServer) logf(format string, a ...interface{}) {
if s.Logger != nil {
s.Logger.Printf(format, a...)
}
}
// parsePortSSH receives an addr of form host[:port] and returns the port part of it
// ex: localhost:22 will return the `22`, mydomain.com will return the '22'
func parsePortSSH(addr string) int {
if portIdx := strings.IndexByte(addr, ':'); portIdx != -1 {
afP := addr[portIdx+1:]
p, err := strconv.Atoi(afP)
if err == nil {
return p
}
}
return 22
}
// commands that exists on all ssh interfaces, both Q and Iris
var standardCommands = Commands{Command{Name: "help", Description: "Opens up the assistance"},
Command{Name: "exit", Description: "Exits from the terminal (if interactive shell)"}}
func (s *SSHServer) writeHelp(wr io.Writer) {
port := parsePortSSH(s.Host)
hostname := ParseHostname(s.Host)
defer func() {
if r := recover(); r != nil {
// means that user-dev has old version of Go Programming Language in her/his machine, so print a message to the server terminal
// which will help the dev, NOT the client
s.logf("[IRIS SSH] Help message is disabled, please install Go Programming Language, at least version 1.7: https://golang.org/dl/")
}
}()
data := map[string]interface{}{
"Hostname": hostname, "PortDeclaration": "-p " + strconv.Itoa(port),
"Commands": append(s.Commands, standardCommands...),
"Version": Version,
}
helpTmpl.Execute(wr, data)
}
var (
errUserInvalid = errors.New("Username or Password rejected for: %q")
errServerListen = errors.New("Cannot listen to: %s, Trace: %s")
)
// Listen starts the SSH Server
func (s *SSHServer) Listen() error {
// get the key
privateKey, err := generateSigner(s.KeyPath, s.Bin)
if err != nil {
return err
}
// prepare the server's configuration
cfg := &ssh.ServerConfig{
// NoClientAuth: true to allow anyone to login, nooo
PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
username := c.User()
if !s.Users.exists(username, pass) {
return nil, errUserInvalid.Format(username)
}
return nil, nil
}}
cfg.AddHostKey(privateKey)
// start the server with the configuration we just made.
var lerr error
s.listener, lerr = net.Listen("tcp", s.Host)
if lerr != nil {
return errServerListen.Format(s.Host, lerr.Error())
}
// ready to accept incoming requests
s.logf("SSH Server is running")
for {
conn, err := s.listener.Accept()
if err != nil {
s.logf(err.Error())
continue
}
// handshake first
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
s.logf(err.Error())
continue
}
s.logf("New SSH Connection has been enstablish from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
// discard all global requests
go ssh.DiscardRequests(reqs)
// accept all current chanels
go s.handleChannels(chans)
}
}
func (s *SSHServer) handleChannels(chans <-chan ssh.NewChannel) {
for ch := range chans {
go s.handleChannel(ch)
}
}
var errUnsupportedReqType = errors.New("Unsupported request type: %q")
func (s *SSHServer) handleChannel(newChannel ssh.NewChannel) {
// we working from terminal, so only type of "session" is allowed.
if !validChannel(newChannel) {
return
}
conn, reqs, err := newChannel.Accept()
if err != nil {
s.logf(err.Error())
return
}
go func(in <-chan *ssh.Request) {
defer func() {
conn.Close()
//debug
s.logf("Session closed")
}()
for req := range in {
var err error
defer func() {
if err != nil {
conn.Write([]byte(err.Error()))
}
sendExitStatus(conn)
}()
switch req.Type {
case "pty-req":
{
s.writeHelp(conn)
req.Reply(true, nil)
}
case "shell":
{
// comes after pty-req, this is when the user just use this form: ssh kataras@mydomain.com -p 22
// then we want interactive shell which will execute the commands:
term := terminal.NewTerminal(conn, "> ")
for {
line, lerr := term.ReadLine()
if lerr == io.EOF {
return
}
if lerr != nil {
err = lerr
s.logf(lerr.Error())
continue
}
payload, perr := parsePayload(line, "")
if perr != nil {
err = perr
return
}
if payload == "help" {
s.writeHelp(conn)
continue
} else if payload == "exit" {
return
}
if cmd, found := s.Commands.ByName(payload); found {
cmd.Action(conn)
} else if s.Shell {
// yes every time check that
if isWindows {
execCmd(exec.Command("cmd", "/C", payload), conn)
} else {
execCmd(exec.Command("sh", "-c", payload), conn)
}
} else {
conn.Write([]byte(errInvalidSSHCommand.Format(payload).Error() + "\n"))
}
//s.logf(line)
}
}
case "exec":
{
// this is the place which the user executed something like that: ssh kataras@mydomain.com -p 22 stop
// a direct command, we don' t open the interactive shell, just execute the command and exit.
payload, perr := parsePayload(string(req.Payload), "")
if perr != nil {
err = perr
return
}
if cmd, found := s.Commands.ByName(payload); found {
cmd.Action(conn)
} else if payload == "help" {
s.writeHelp(conn)
} else if s.Shell {
// yes every time check that
if isWindows {
execCmd(exec.Command("cmd", "/C", payload), conn)
} else {
execCmd(exec.Command("sh", "-c", payload), conn)
}
} else {
err = errInvalidSSHCommand.Format(payload)
}
return
}
default:
{
err = errUnsupportedReqType.Format(req.Type)
return
}
}
}
}(reqs)
}
// NewLoggerHandler is a basic Logger middleware/Handler (not an Entry Parser)
func NewLoggerHandler(writer io.Writer, calculateLatency ...bool) HandlerFunc {
shouldNext := false
if len(calculateLatency) > 0 {
shouldNext = calculateLatency[0]
}
return func(ctx *Context) {
var date, status, ip, method, path string
var latency time.Duration
var startTime, endTime time.Time
path = ctx.PathString()
method = ctx.MethodString()
startTime = time.Now()
if shouldNext {
ctx.Next()
}
endTime = time.Now()
latency = endTime.Sub(startTime)
date = endTime.Format("01/02 - 15:04:05")
status = strconv.Itoa(ctx.Response.StatusCode())
ip = ctx.RemoteAddr()
//finally print the logs to the ssh
writer.Write([]byte(fmt.Sprintf("%s %v %4v %s %s %s \n", date, status, latency, ip, method, path)))
}
}