package iris // Minimal managment 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" "github.com/iris-contrib/logger" "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.Servers.Main() != nil && station.Servers.Main().IsListening() { 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) { srv := station.Servers.Main() if srv != nil && srv.IsListening() { srv.Close() srv.listener = nil serverStoppedMsg(conn) } else { errServerNotReadyMsg(conn) } }}, Command{Name: "start", Description: "Starts the HTTP Server.", Action: func(conn ssh.Channel) { srv := station.Servers.Main() if !srv.IsListening() { go srv.Open(srv.Handler) } serverStartedMsg(conn) }}, Command{Name: "restart", Description: "Restarts the HTTP Server.", Action: func(conn ssh.Channel) { srv := station.Servers.Main() if srv != nil && srv.IsListening() { srv.Close() srv.listener = nil } go srv.Open(srv.Handler) 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 *logger.Logger // log.New(...)/ $qinstance.Logger, fill it when you want to receive debug and info/warnings messages } // 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...) } } // parseHostname receives an addr of form host[:port] and returns the hostname part of it // ex: localhost:8080 will return the `localhost`, mydomain.com:22 will return the 'mydomain' func parseHostname(addr string) string { idx := strings.IndexByte(addr, ':') if idx == 0 { // only port, then return 0.0.0.0 return "0.0.0.0" } else if idx > 0 { return addr[0:idx] } // it's already hostname return addr } // parsePort receives an addr of form host[:port] and returns the port part of it // ex: localhost:8080 will return the `8080`, mydomain.com will return the '22' func parsePort(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 := parsePort(s.Host) hostname := parseHostname(s.Host) 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))) } }