many enhancements to the ssh terminal support

This commit is contained in:
hyung-hwan 2024-12-13 21:49:11 +09:00
parent 61c13bd4e8
commit ea601f1011
5 changed files with 389 additions and 137 deletions

View File

@ -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"

View File

@ -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
goto done
}
cc = &ssh.ClientConfig{
User: "hyung-hwan",
Auth: []ssh.AuthMethod{
ssh.Password("evianilie99"),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
// 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 conn_ready bool
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
defer sess.Close()
buf = make([]byte, 1024)
buf = make([]byte, 2048)
for {
n, err = out.Read(buf)
if err != nil {
if err != io.EOF {
s.log.Write("", LOG_ERROR, "Read from SSH stdout error:", err)
s.log.Write("", LOG_ERROR, "Read from SSH stdout error - %s", err.Error())
}
return
break
}
if n > 0 {
_, err = ws.Write(buf[:n])
err = pxy.send_ws_data(ws, "iov", string(buf[:n]))
if err != nil {
s.log.Write("", LOG_ERROR, "Write to WebSocket error:", err)
return
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())
}

View File

@ -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"})

View File

@ -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 */

View File

@ -4,73 +4,228 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal</title>
<title>{{ .RouteName }}</title>
<link rel="stylesheet" href="/_ssh/xterm.css" />
<style>
body {
margin: 0;
height: 100%;
}
#terminal-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
}
#terminal-status-container {
vertical-align: middle;
padding: 3px;
}
#terminal-status {
display: inline-block;
font-weight: bold;
height: 100%;
vertical-align: middle;
}
#terminal-errmsg {
display: inline-block;
color: red;
height: 100%;
vertical-align: middle;
}
#terminal-control {
display: inline-block;
height: 100%;
vertical-align: middle;
}
#terminal-control button {
vertical-align: middle;
}
#terminal-view-container {
flex: 1;
width: 100%;
min-height: 0;
/*height: 100%;*/
}
#login-container {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99999;
}
#login-form-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
text-align: center;
}
</style>
<script src="/_ssh/xterm.js"></script>
<script src="/_ssh/xterm-addon-fit.js"></script>
<script>
window.addEventListener("load", function(event) {
const term = new window.Terminal();
const fit = new window.FitAddon.FitAddon();
const textDecoder = new TextDecoder();
window.onload = function(event) {
const terminal_container = document.getElementById('terminal-container');
const terminal_status = document.getElementById('terminal-status');
const terminal_errmsg = document.getElementById('terminal-errmsg');
const terminal_view_container = document.getElementById('terminal-view-container');
const terminal_disconnect = document.getElementById('terminal-disconnect');
const login_container = document.getElementById('login-container');
const login_form = document.getElementById('login-form');
const username_field = document.getElementById('username');
const password_field= document.getElementById('password');
const term = new window.Terminal();
const fit_addon = new window.FitAddon.FitAddon();
const text_decoder = new TextDecoder();
term.loadAddon(fit_addon)
term.open(terminal_view_container);
let set_terminal_status = function(msg, errmsg) {
if (msg != null) terminal_status.innerHTML = msg;
if (errmsg != null) terminal_errmsg.innerHTML = errmsg
}
let toggle_login_form = function(visible) {
login_container.style.visibility = (visible? 'visible': 'hidden');
terminal_disconnect.style.visibility = (visible? 'hidden': 'visible');
if (visible) username_field.focus();
else term.focus();
}
let resize_term_unconnected = function() {
fit_addon.fit();
}
toggle_login_form(true);
window.onresize = resize_term_unconnected;
resize_term_unconnected()
login_form.onsubmit = async function(event) {
event.preventDefault();
toggle_login_form(false)
const username = username_field.value.trim();
const password = password_field.value.trim();
let currentLocation = window.location.host;
let prefix = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const socket = new WebSocket(prefix + currentLocation + '/_ssh-ws/{{ .ConnId }}/{{ .RouteId }}');
const socket = new WebSocket(prefix + window.location.host + '/_ssh-ws/{{ .ConnId }}/{{ .RouteId }}');
socket.binaryType = 'arraybuffer';
term.loadAddon(fit)
term.open(document.getElementById('terminal-container'));
term.writeln('Connecting...');
set_terminal_status('Connecting...', '');
const fit_term = function() {
fit.fit();
socket.send(JSON.stringify({ type: 'resize', data: term.rows.toString() + " " + term.cols.toString() }));
const resize_term_connected = function() {
fit_addon.fit();
if (socket.readyState == 1) // if open
socket.send(JSON.stringify({ type: 'size', data: [term.rows.toString(), term.cols.toString()] }));
};
socket.onopen = function () {
term.writeln('Connected');
fit_term();
socket.send(JSON.stringify({ type: 'open', data: [username, password]}));
};
socket.onmessage = function(event) {
if (typeof event.data === 'string') {
term.write(event.data);
} else {
const text = textDecoder.decode(new Uint8Array(event.data));
term.write(text);
try {
let event_text;
event_text = (typeof event.data === 'string')? event.data: text_decoder.decode(new Uint8Array(event.data));
const msg = JSON.parse(event_text);
if (msg.type == "iov") {
for (const data of msg.data) term.write(data);
} else if (msg.type == "status") {
if (msg.data.length >= 1) {
if (msg.data[0] == 'opened') {
set_terminal_status('Connected', '');
resize_term_connected()
} else if (msg.data[0] == 'closed') {
// doesn't really matter
// socket.onclose() will be executed anyway
}
}
} else if (msg.type == "error") {
toggle_login_form(true);
window.onresize = resize_term_unconnected;
set_terminal_status(null, msg.data.join(' '))
}
} catch (e) {
set_terminal_status('Disconnected', e);
toggle_login_form(true)
window.onresize = resize_term_unconnected;
}
};
socket.onerror = function (event) {
console.error('WebSocket error:', event);
term.writeln('WebSocket error. See console for details.');
socket.onerror = function(event) {
set_terminal_status('Disconnected', event);
toggle_login_form(true)
window.onresize = resize_term_unconnected;
term.write("\r\n")
};
socket.onclose = function () {
term.writeln('Disconnected');
socket.onclose = function() {
set_terminal_status('Disconnected', null);
toggle_login_form(true)
window.onresize = resize_term_unconnected;
term.write("\r\n")
};
term.onData(function(data) {
socket.send(JSON.stringify({ type: 'key', data: data }));
if (socket.readyState == 1) // if open
socket.send(JSON.stringify({ type: 'iov', data: [data] }));
});
window.addEventListener('resize', fit_term);
});
window.onresize = resize_term_connected;
terminal_disconnect.onclick = function(event) {
socket.send(JSON.stringify({ type: 'close', data: [""] }));
//socket.close()
};
}
};
</script>
</head>
<body>
<div id="terminal-container"></div>
<div id="login-container">
<div id="login-form-container">
<h2>{{ .RouteName }}</h2>
<form id="login-form">
<label>
Username: <input type="text" id="username" required />
</label>
<br /><br />
<label>
Password: <input type="password" id="password" required />
</label>
<br /><br />
<button type="submit">Connect</button>
</form>
</div>
</div>
<div id="terminal-container">
<div id="terminal-status-container">
<div id="terminal-status"></div>
<div id="terminal-errmsg"></div>
<div id="terminal-control">
<button id="terminal-disconnect" type="button">Disconnect</button>
</div>
</div>
<div id="terminal-view-container"></div>
</div>
</body>
</html>