From ea601f10112d4524803a8246615227738898282c Mon Sep 17 00:00:00 2001 From: hyung-hwan Date: Fri, 13 Dec 2024 21:49:11 +0900 Subject: [PATCH] many enhancements to the ssh terminal support --- Makefile | 1 + server-proxy.go | 285 ++++++++++++++++++++++++++++++++---------------- server.go | 4 +- xterm.css | 1 - xterm.html | 235 ++++++++++++++++++++++++++++++++------- 5 files changed, 389 insertions(+), 137 deletions(-) diff --git a/Makefile b/Makefile index 7dc0e06..c613ba6 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,7 @@ xterm-addon-fit.js: xterm.css: curl -L -o "$@" https://cdn.jsdelivr.net/npm/@xterm/xterm/css/xterm.min.css + sed -r -i 's|^/\*# sourceMappingURL=/.+ \*/$$||g' "$@" cmd/tls.crt: openssl req -x509 -newkey rsa:4096 -keyout cmd/tls.key -out cmd/tls.crt -sha256 -days 36500 -nodes -subj "/CN=$(NAME)" --addext "subjectAltName=DNS:$(NAME),IP:10.0.0.1,IP:::1" diff --git a/server-proxy.go b/server-proxy.go index 220c988..61a7ebf 100644 --- a/server-proxy.go +++ b/server-proxy.go @@ -11,7 +11,9 @@ import "net/http" import "net/url" import "strconv" import "strings" +import "sync" import "text/template" +import "time" import "unsafe" import "golang.org/x/crypto/ssh" @@ -367,6 +369,7 @@ type server_proxy_xterm_file struct { } type server_proxy_xterm_session_info struct { + RouteName string ConnId string RouteId string } @@ -404,8 +407,9 @@ func (pxy *server_proxy_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.R w.WriteHeader(http.StatusOK) tmpl.Execute(w, &server_proxy_xterm_session_info{ - req.PathValue("conn_id"), - req.PathValue("route_id"), + RouteName: "Terminal", + ConnId: req.PathValue("conn_id"), + RouteId: req.PathValue("route_id"), }) } case "_forbidden": @@ -413,39 +417,122 @@ func (pxy *server_proxy_xterm_file) ServeHTTP(w http.ResponseWriter, req *http.R default: w.WriteHeader(http.StatusNotFound) } + +// TODO: logging.. } // ------------------------------------ type server_proxy_ssh_ws struct { s *Server - h websocket.Handler + ws *websocket.Conn } type json_ssh_ws_event struct { Type string `json:"type"` - Data string `json:"data"` + Data []string `json:"data"` } // TODO: put this task to sync group. // TODO: put the above proxy task to sync group too. +func (pxy *server_proxy_ssh_ws) send_ws_data(ws *websocket.Conn, type_val string, data string) error { + var msg []byte + var err error -func server_proxy_serve_ssh_ws(ws *websocket.Conn, s *Server) { + msg, err = json.Marshal(json_ssh_ws_event{Type: type_val, Data: []string{ data } }) + if err == nil { err = websocket.Message.Send(ws, msg) } + return err +} + +func (pxy *server_proxy_ssh_ws) connect_ssh (ctx context.Context, username string, password string, r *ServerRoute) ( *ssh.Client, *ssh.Session, io.Writer, io.Reader, error) { + var cc *ssh.ClientConfig + var addr net.TCPAddr + var dialer *net.Dialer + var conn net.Conn + var ssh_conn ssh.Conn + var chans <-chan ssh.NewChannel + var reqs <-chan *ssh.Request + var c *ssh.Client + var sess *ssh.Session + var in io.Writer // input to target + var out io.Reader // ooutput from target + var err error + + cc = &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ ssh.Password(password) }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + // Timeout: 2 * time.Second , + } + +// CHECK OPTIONS + // if r.svc_option & RouteOption(ROUTE_OPTION_SSH) == 0 { + // REJECT?? + //} +// TODO: timeout... + + addr = *r.svc_addr; + if addr.IP.To4() != nil { + addr.IP = net.IPv4(127, 0, 0, 1) // net.IPv4loopback is not defined. so use net.IPv4() + } else { + addr.IP = net.IPv6loopback // net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} + } + + dialer = &net.Dialer{} + conn, err = dialer.DialContext(ctx, "tcp", addr.String()) + if err != nil { goto oops } + + ssh_conn, chans, reqs, err = ssh.NewClientConn(conn, addr.String(), cc) + if err != nil { goto oops } + + c = ssh.NewClient(ssh_conn, chans, reqs) + + sess, err = c.NewSession() + if err != nil { goto oops } + + out, err = sess.StdoutPipe() + if err != nil { goto oops } + + in, err = sess.StdinPipe() + if err != nil { goto oops } + + err = sess.RequestPty("xterm", 25, 80, ssh.TerminalModes{}) + if err != nil { goto oops } + + err = sess.Shell() + if err != nil { goto oops } + + return c, sess, in, out, nil + +oops: + if sess != nil { sess.Close() } + if c != nil { c.Close() } + return nil, nil, nil, nil, err +} + +func (pxy *server_proxy_ssh_ws) ServeWebsocket(ws *websocket.Conn) { + var s *Server var req *http.Request var conn_id string var conn_nid uint64 var route_id string var route_nid uint64 var r *ServerRoute - var addr net.TCPAddr - var cc *ssh.ClientConfig + var username string + var password string var c *ssh.Client var sess *ssh.Session var in io.Writer var out io.Reader + var wg sync.WaitGroup + var conn_ready_chan chan bool + var connect_ssh_ctx context.Context + var connect_ssh_cancel context.CancelFunc var err error + s = pxy.s req = ws.Request() + conn_ready_chan = make(chan bool, 3) defer func() { var err interface{} = recover() @@ -457,127 +544,120 @@ func server_proxy_serve_ssh_ws(ws *websocket.Conn, s *Server) { conn_nid, err = strconv.ParseUint(conn_id, 10, int(unsafe.Sizeof(conn_nid) * 8)) if err != nil { - return + // TODO: + goto done } route_nid, err = strconv.ParseUint(route_id, 10, int(unsafe.Sizeof(route_nid) * 8)) if err != nil { - return + // TODO: + goto done } r = s.FindServerRouteById(ConnId(conn_nid), RouteId(route_nid)) if r == nil { // TODO: enhance logging. original request, conn_nid, route_nid + pxy.send_ws_data(ws, "error", fmt.Sprintf("route(%d,%d) not found", conn_nid, route_nid)) s.log.Write("", LOG_ERROR, "No server route(%d,%d) found", conn_nid, route_nid) - return - } - - cc = &ssh.ClientConfig{ - User: "hyung-hwan", - Auth: []ssh.AuthMethod{ - ssh.Password("evianilie99"), - }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + goto done } -// CHECK OPTIONS - // if r.svc_option & RouteOption(ROUTE_OPTION_SSH) == 0 { - // REJECT?? - //} -// TODO: timeout... - addr = *r.svc_addr; - if addr.IP.To4() != nil { - addr.IP = net.IPv4(127, 0, 0, 1) // net.IPv4loopback is not defined. so use net.IPv4() - } else { - addr.IP = net.IPv6loopback // net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} - } - c, err = ssh.Dial("tcp", addr.String(), cc) - if err != nil { - s.log.Write("", LOG_ERROR, "SSH dial error - %s", err.Error()) - return - } - defer c.Close() - - sess, err = c.NewSession() - if err != nil { - s.log.Write("", LOG_ERROR, "SSH session error - %s", err.Error()) - return - } - defer sess.Close() - - out, err = sess.StdoutPipe() - if err != nil { - s.log.Write("", LOG_ERROR, "STDOUT pipe error - ", err.Error()) - return - } - - in, err = sess.StdinPipe() - if err != nil { - s.log.Write("", LOG_ERROR, "STDIN pipe error - ", err.Error()) - return - } - - err = sess.RequestPty("xterm", 40, 80, ssh.TerminalModes{}) - if err != nil { - s.log.Write("", LOG_ERROR, "Request PTY error - ", err.Error()) - return - } - - err = sess.Shell() - if err != nil { - s.log.Write("", LOG_ERROR, "Start shell error - ", err.Error()) - return - } - - // Async reader and writer to websocket + wg.Add(1) go func() { - var buf []byte - var n int - var err error + var conn_ready bool - defer sess.Close() - buf = make([]byte, 1024) - for { - n, err = out.Read(buf) - if err != nil { - if err != io.EOF { - s.log.Write("", LOG_ERROR, "Read from SSH stdout error:", err) - } - return - } - if n > 0 { - _, err = ws.Write(buf[:n]) + defer wg.Done() + defer ws.Close() // dirty way to break the main loop + + conn_ready = <-conn_ready_chan + if conn_ready { // connected + var buf []byte + var n int + var err error + + buf = make([]byte, 2048) + for { + n, err = out.Read(buf) if err != nil { - s.log.Write("", LOG_ERROR, "Write to WebSocket error:", err) - return + if err != io.EOF { + s.log.Write("", LOG_ERROR, "Read from SSH stdout error - %s", err.Error()) + } + break + } + if n > 0 { + err = pxy.send_ws_data(ws, "iov", string(buf[:n])) + if err != nil { + s.log.Write("", LOG_ERROR, "Failed to send to websocket - %s", err.Error()) + break + } } } } }() - // Sync websocket reader and writer to sshIn +ws_recv_loop: for { var msg []byte err = websocket.Message.Receive(ws, &msg) if err != nil { // TODO: check if EOF s.log.Write("", LOG_ERROR, "Failed to read from websocket - %s", err.Error()) - break + goto done } if len(msg) > 0 { var ev json_ssh_ws_event err = json.Unmarshal(msg, &ev) if err == nil { switch ev.Type { - case "key": - in.Write([]byte(ev.Data)) - case "resize": - var sz []string - sz = strings.Fields(ev.Data) - if (len(sz) == 2) { + case "open": + if sess == nil && len(ev.Data) == 2 { + username = string(ev.Data[0]) + password = string(ev.Data[1]) + + connect_ssh_ctx, connect_ssh_cancel = context.WithTimeout(req.Context(), 10 * time.Second) // TODO: configurable timeout + + wg.Add(1) + go func() { + var err error + + defer wg.Done() + c, sess, in, out, err = pxy.connect_ssh(connect_ssh_ctx, username, password, r) + if err != nil { + s.log.Write("", LOG_ERROR, "failed to connect ssh - %s", err.Error()) + pxy.send_ws_data(ws, "error", err.Error()) + ws.Close() // dirty way to flag out the error + } else { + err = pxy.send_ws_data(ws, "status", "opened") + if err != nil { + s.log.Write("", LOG_ERROR, "Failed to write opened event to websocket - %s", err.Error()) + ws.Close() // dirty way to flag out the error + } else { + conn_ready_chan <- true + } + } + connect_ssh_cancel = nil + }() + } + + case "close": + var cancel context.CancelFunc + cancel = connect_ssh_cancel // is it a good way to avoid mutex? + if cancel != nil { cancel() } + break ws_recv_loop + + case "iov": + if sess != nil { + var i int + for i, _ = range ev.Data { + in.Write([]byte(ev.Data[i])) + } + } + + case "size": + if sess != nil && len(ev.Data) == 2 { var rows int var cols int - rows, _ = strconv.Atoi(sz[0]); - cols, _ = strconv.Atoi(sz[1]); + rows, _ = strconv.Atoi(ev.Data[0]) + cols, _ = strconv.Atoi(ev.Data[1]) sess.WindowChange(rows, cols) s.log.Write("", LOG_DEBUG, "Resized terminal to %d,%d", rows, cols) // ignore error @@ -586,4 +666,19 @@ func server_proxy_serve_ssh_ws(ws *websocket.Conn, s *Server) { } } } + + if sess != nil { + err = pxy.send_ws_data(ws, "status", "closed") + if err != nil { + s.log.Write("", LOG_ERROR, "Failed to write closed event to websocket - %s", err.Error()) + } + } + +done: + conn_ready_chan <- false + ws.Close() + if sess != nil { sess.Close() } + if c != nil { c.Close() } + wg.Wait() + s.log.Write("", LOG_DEBUG, "[%s] %s %s - ended", req.RemoteAddr, req.Method, req.URL.String()) } diff --git a/server.go b/server.go index 38883ff..1334bf3 100644 --- a/server.go +++ b/server.go @@ -42,6 +42,7 @@ type Server struct { ext_svcs []Service pxy_addr []string + pxy_ws *server_proxy_ssh_ws pxy_mux *http.ServeMux pxy []*http.Server // proxy server @@ -955,10 +956,11 @@ func NewServer(ctx context.Context, logger Logger, ctl_addrs []string, rpc_addrs // --------------------------------------------------------- + s.pxy_ws = &server_proxy_ssh_ws{s: &s} s.pxy_mux = http.NewServeMux() // TODO: make /_init configurable... s.pxy_mux.Handle("/_ssh-ws/{conn_id}/{route_id}", websocket.Handler(func(ws *websocket.Conn) { - server_proxy_serve_ssh_ws(ws, &s) + s.pxy_ws.ServeWebsocket(ws) })) s.pxy_mux.Handle("/_ssh/{conn_id}/{route_id}/", &server_proxy_xterm_file{s: &s, file: "xterm.html"}) s.pxy_mux.Handle("/_ssh/xterm.js", &server_proxy_xterm_file{s: &s, file: "xterm.js"}) diff --git a/xterm.css b/xterm.css index f8fcefc..bfff09b 100644 --- a/xterm.css +++ b/xterm.css @@ -5,4 +5,3 @@ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files */ .xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative} -/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */ \ No newline at end of file diff --git a/xterm.html b/xterm.html index 8456d6b..06f8629 100644 --- a/xterm.html +++ b/xterm.html @@ -4,73 +4,228 @@ -Terminal +{{ .RouteName }} -
+ +
+
+

{{ .RouteName }}

+
+ +

+ +

+ +
+
+
+ +
+
+
+
+
+ +
+
+
+