mirror of
https://github.com/fosrl/newt.git
synced 2026-10-27 05:59:06 +01:00
@@ -0,0 +1,225 @@
|
||||
package browsergateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// sshClientMsg is a JSON message sent from the browser to the proxy.
|
||||
type sshClientMsg struct {
|
||||
// type: "auth" | "data" | "resize"
|
||||
Type string `json:"type"`
|
||||
Password string `json:"password,omitempty"` // used when type="auth"
|
||||
Data string `json:"data,omitempty"` // used when type="data"
|
||||
Cols uint32 `json:"cols,omitempty"` // used when type="resize"
|
||||
Rows uint32 `json:"rows,omitempty"` // used when type="resize"
|
||||
}
|
||||
|
||||
// sshServerMsg is a JSON message sent from the proxy back to the browser.
|
||||
type sshServerMsg struct {
|
||||
// type: "data" | "error"
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HandleSSH is an http.HandlerFunc for SSH-over-WebSocket connections.
|
||||
func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// -- Validate auth token from query parameter before upgrading --
|
||||
token := r.URL.Query().Get("authToken")
|
||||
if subtle.ConstantTimeCompare([]byte(token), []byte(g.authToken)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// In proxy mode we also need host + username from query params.
|
||||
var target, username string
|
||||
if g.nativeSSH == nil {
|
||||
host := r.URL.Query().Get("host")
|
||||
port := r.URL.Query().Get("port")
|
||||
username = r.URL.Query().Get("username")
|
||||
if host == "" || username == "" {
|
||||
http.Error(w, "missing host or username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
target = net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
Subprotocols: []string{"ssh"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("SSH websocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
ws.SetReadLimit(-1)
|
||||
defer ws.CloseNow() //nolint:errcheck
|
||||
|
||||
if g.nativeSSH != nil {
|
||||
if err := serveNativeSSHSession(ctx, ws, *g.nativeSSH); err != nil {
|
||||
log.Printf("SSH native session error: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := serveSSHSession(ctx, ws, target, username, g.authToken); err != nil {
|
||||
log.Printf("SSH session error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveSSHSession(ctx context.Context, ws *websocket.Conn, target, username, _ string) error {
|
||||
// -- Wait for the auth message from the client to get the password --
|
||||
_, authBytes, err := ws.Read(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read auth message: %w", err)
|
||||
}
|
||||
var authMsg sshClientMsg
|
||||
if err := json.Unmarshal(authBytes, &authMsg); err != nil || authMsg.Type != "auth" {
|
||||
return fmt.Errorf("expected auth message, got: %s", authBytes)
|
||||
}
|
||||
password := authMsg.Password
|
||||
|
||||
// -- Dial the SSH server --
|
||||
log.Printf("SSH: connecting to %s as %s", target, username)
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: username,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(password),
|
||||
},
|
||||
// HostKeyCallback is intentionally InsecureIgnoreHostKey for this dev
|
||||
// proxy. In production, verify against a known-hosts store.
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
sshClient, err := ssh.Dial("tcp", target, sshCfg)
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("SSH dial failed: %v", err))
|
||||
return fmt.Errorf("ssh dial %s: %w", target, err)
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
// -- Open an interactive session --
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to open SSH session: %v", err))
|
||||
return fmt.Errorf("ssh new session: %w", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
// Request a PTY.
|
||||
if err := sess.RequestPty("xterm-256color", 24, 80, ssh.TerminalModes{
|
||||
ssh.ECHO: 1,
|
||||
ssh.TTY_OP_ISPEED: 38400,
|
||||
ssh.TTY_OP_OSPEED: 38400,
|
||||
}); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to request PTY: %v", err))
|
||||
return fmt.Errorf("ssh request pty: %w", err)
|
||||
}
|
||||
|
||||
stdinPipe, err := sess.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdin pipe: %w", err)
|
||||
}
|
||||
stdoutPipe, err := sess.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stdout pipe: %w", err)
|
||||
}
|
||||
stderrPipe, err := sess.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := sess.Shell(); err != nil {
|
||||
sendSSHError(ctx, ws, fmt.Sprintf("Failed to start shell: %v", err))
|
||||
return fmt.Errorf("ssh shell: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("SSH: session established with %s", target)
|
||||
|
||||
// -- Pump SSH stdout/stderr → WebSocket --
|
||||
sessCtx, cancelSess := context.WithCancel(ctx)
|
||||
defer cancelSess()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stdoutPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
cancelSess()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := stderrPipe.Read(buf)
|
||||
if n > 0 {
|
||||
msg := sshServerMsg{Type: "data", Data: string(buf[:n])}
|
||||
b, _ := json.Marshal(msg)
|
||||
if writeErr := ws.Write(sessCtx, websocket.MessageText, b); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// -- Pump WebSocket input → SSH stdin / resize --
|
||||
for {
|
||||
_, msgBytes, readErr := ws.Read(sessCtx)
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg sshClientMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "data":
|
||||
if _, err := stdinPipe.Write([]byte(msg.Data)); err != nil {
|
||||
return fmt.Errorf("write ssh stdin: %w", err)
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = sess.WindowChange(int(msg.Rows), int(msg.Cols))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSSHError sends an error message to the browser and logs it.
|
||||
func sendSSHError(ctx context.Context, ws *websocket.Conn, msg string) {
|
||||
log.Printf("SSH error: %s", msg)
|
||||
b, _ := json.Marshal(sshServerMsg{Type: "error", Error: msg})
|
||||
_ = ws.Write(ctx, websocket.MessageText, b)
|
||||
}
|
||||
Reference in New Issue
Block a user