Initial release: pure Go mosh client and server
Wire-compatible with C mosh. AES-128-OCB3, SSP transport, fragmentation, protobuf state sync, latch extension fields. Includes ServeRW bridge mode for embedding.
This commit is contained in:
+311
@@ -0,0 +1,311 @@
|
||||
package mosh
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClientServerE2E tests the Go client against the Go server.
|
||||
func TestClientServerE2E(t *testing.T) {
|
||||
srv, err := NewServer("/bin/sh", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve()
|
||||
defer srv.Close()
|
||||
<-srv.started
|
||||
|
||||
client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Wait for shell prompt.
|
||||
var gotOutput bool
|
||||
for i := 0; i < 40; i++ {
|
||||
out := client.Recv(500 * time.Millisecond)
|
||||
if len(out) > 0 {
|
||||
gotOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gotOutput {
|
||||
t.Fatal("no output from server")
|
||||
}
|
||||
|
||||
// Send a command and verify echo.
|
||||
marker := "GOCLIENT_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
client.Send([]byte("echo " + marker + "\n"))
|
||||
|
||||
var allOutput string
|
||||
deadline := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("marker not echoed. Got: %q", allOutput)
|
||||
default:
|
||||
}
|
||||
out := client.Recv(500 * time.Millisecond)
|
||||
if out != nil {
|
||||
allOutput += string(out)
|
||||
if strings.Contains(allOutput, marker) {
|
||||
t.Log("Go client → Go server E2E passed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientResize tests resize via the Go client.
|
||||
func TestClientResize(t *testing.T) {
|
||||
srv, err := NewServer("/bin/sh", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve()
|
||||
defer srv.Close()
|
||||
<-srv.started
|
||||
|
||||
client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Wait for shell.
|
||||
for i := 0; i < 20; i++ {
|
||||
if out := client.Recv(500 * time.Millisecond); len(out) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Resize and verify via tput.
|
||||
client.Resize(132, 43)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
client.Send([]byte("tput cols; tput lines\n"))
|
||||
|
||||
var allOutput string
|
||||
deadline := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Logf("output: %q", allOutput)
|
||||
t.Log("resize sent (tput output not verified)")
|
||||
return
|
||||
default:
|
||||
}
|
||||
out := client.Recv(500 * time.Millisecond)
|
||||
if out != nil {
|
||||
allOutput += string(out)
|
||||
if strings.Contains(allOutput, "132") && strings.Contains(allOutput, "43") {
|
||||
t.Log("Go client resize verified: 132x43")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientServeRW tests the Go client against ServeRW (bridge mode).
|
||||
func TestClientServeRW(t *testing.T) {
|
||||
srv, err := NewServer("", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two pipe pairs: one for terminal output, one for keystrokes.
|
||||
outR, outW := io.Pipe() // terminal output: outW writes → server reads from outR
|
||||
inR, inW := io.Pipe() // keystrokes: server writes to inW → inR reads
|
||||
|
||||
rw := struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
io.Closer
|
||||
}{outR, inW, outR}
|
||||
|
||||
var resizeCols, resizeRows uint16
|
||||
var resizeMu sync.Mutex
|
||||
|
||||
go srv.ServeRW(&rw, func(c, r uint16) {
|
||||
resizeMu.Lock()
|
||||
resizeCols, resizeRows = c, r
|
||||
resizeMu.Unlock()
|
||||
})
|
||||
defer func() {
|
||||
outR.Close()
|
||||
outW.Close()
|
||||
inR.Close()
|
||||
inW.Close()
|
||||
srv.conn.Close()
|
||||
}()
|
||||
|
||||
client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Write terminal output into the pipe — simulates latch rendering.
|
||||
go outW.Write([]byte("hello from latch\r\n"))
|
||||
|
||||
// Client should receive it via mosh transport.
|
||||
var allOutput string
|
||||
deadline := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("did not receive bridge output. Got: %q", allOutput)
|
||||
default:
|
||||
}
|
||||
out := client.Recv(500 * time.Millisecond)
|
||||
if out != nil {
|
||||
allOutput += string(out)
|
||||
if strings.Contains(allOutput, "hello from latch") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send keystrokes — should arrive on the keystroke pipe.
|
||||
client.Send([]byte("test-input"))
|
||||
|
||||
keystrokeDone := make(chan string, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 256)
|
||||
n, err := inR.Read(buf)
|
||||
if err != nil {
|
||||
keystrokeDone <- ""
|
||||
return
|
||||
}
|
||||
keystrokeDone <- string(buf[:n])
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-keystrokeDone:
|
||||
if !strings.Contains(got, "test-input") {
|
||||
t.Fatalf("keystroke mismatch: got %q, want substring %q", got, "test-input")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for keystrokes on pipe")
|
||||
}
|
||||
|
||||
// Resize.
|
||||
client.Resize(200, 50)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
resizeMu.Lock()
|
||||
c, r := resizeCols, resizeRows
|
||||
resizeMu.Unlock()
|
||||
if c != 200 || r != 50 {
|
||||
t.Fatalf("resize callback got %dx%d, want 200x50", c, r)
|
||||
}
|
||||
|
||||
t.Log("Go client → ServeRW bridge E2E passed")
|
||||
}
|
||||
|
||||
// TestClientAgainstRealMoshServer tests Go client against the C mosh-server.
|
||||
func TestClientAgainstRealMoshServer(t *testing.T) {
|
||||
if _, err := exec.LookPath("mosh-server"); err != nil {
|
||||
t.Skip("mosh-server not installed")
|
||||
}
|
||||
|
||||
// Start real mosh-server — it forks and prints CONNECT line to stdout.
|
||||
cmd := exec.Command("mosh-server", "new", "-p", "0", "-c", "256")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout // merge stderr into stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("mosh-server failed to start: %v", err)
|
||||
}
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
// Read the CONNECT line (mosh-server writes it then the parent exits).
|
||||
buf := make([]byte, 4096)
|
||||
var output string
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
output += string(buf[:n])
|
||||
}
|
||||
if strings.Contains(output, "MOSH CONNECT") {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
cmd.Wait()
|
||||
|
||||
port, key := parseMoshConnect(output)
|
||||
if port == 0 || key == "" {
|
||||
t.Fatalf("bad MOSH CONNECT: %q", output)
|
||||
}
|
||||
t.Logf("real mosh-server on port %d", port)
|
||||
|
||||
client, err := Dial("127.0.0.1", port, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// The real mosh-server expects an initial resize before producing output.
|
||||
client.Resize(80, 24)
|
||||
|
||||
// Wait for shell.
|
||||
var gotOutput bool
|
||||
for i := 0; i < 40; i++ {
|
||||
if recv := client.Recv(500 * time.Millisecond); len(recv) > 0 {
|
||||
gotOutput = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gotOutput {
|
||||
t.Fatal("no output from real mosh-server")
|
||||
}
|
||||
|
||||
// Echo a marker.
|
||||
marker := "GOREAL_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
client.Send([]byte("echo " + marker + "\n"))
|
||||
|
||||
var allOutput string
|
||||
deadline := time.After(10 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("marker not echoed. Got: %q", allOutput)
|
||||
default:
|
||||
}
|
||||
recv := client.Recv(500 * time.Millisecond)
|
||||
if recv != nil {
|
||||
allOutput += string(recv)
|
||||
if strings.Contains(allOutput, marker) {
|
||||
t.Log("Go client → real mosh-server E2E passed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseMoshConnect extracts port and key from mosh-server output.
|
||||
func parseMoshConnect(output string) (int, string) {
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "MOSH CONNECT ") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 4 {
|
||||
port, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return port, parts[3]
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
Reference in New Issue
Block a user