Add WebSocket support and fix Makefile installation

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-27 02:52:52 +01:00
parent 0865aba041
commit 481f8c431d
3 changed files with 45 additions and 96 deletions

View File

@@ -22,10 +22,18 @@ clean:
@echo "Cleaning..."
rm -f $(BINARY_NAME)
install: build
install:
@if [ ! -f $(BINARY_NAME) ]; then \
echo "Error: '$(BINARY_NAME)' binary not found."; \
echo "Please run 'make build' first as a regular user."; \
exit 1; \
fi
@echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..."
# Create user if not exists
id -u grokway &>/dev/null || useradd -r -s /bin/false grokway
if ! id -u grokway >/dev/null 2>&1; then \
echo "Creating grokway user..."; \
useradd -r -s /bin/false grokway; \
fi
# Create directory
mkdir -p $(INSTALL_DIR)
# Copy binary
@@ -41,7 +49,9 @@ install: build
cp $(SERVICE_NAME) $(SYSTEMD_DIR)/
# Update paths in service file just in case they were modified
sed -i 's|WorkingDirectory=.*|WorkingDirectory=$(INSTALL_DIR)|g' $(SYSTEMD_DIR)/$(SERVICE_NAME)
sed -i 's|ExecStart=.*|ExecStart=$(INSTALL_DIR)/$(BINARY_NAME)|g' $(SYSTEMD_DIR)/$(SERVICE_NAME)
# execStart sed commented out to preserve arguments like --ssh :2223
# sed -i 's|ExecStart=.*|ExecStart=$(INSTALL_DIR)/$(BINARY_NAME)|g' $(SYSTEMD_DIR)/$(SERVICE_NAME)
# Instead, ensure the binary path is correct but keep args (this is tricky with sed, simpler to rely on repo file)
systemctl daemon-reload
systemctl enable $(SERVICE_NAME)

View File

@@ -126,32 +126,30 @@ func startHttpProxy(port string) {
// But typically `gliderlabs/ssh` is for allowing the server to be a jump host.
// We want to be an HTTP Gateway.
// Manual channel opening:
if tunnel.Conn == nil {
http.Error(w, "Tunnel Broken", http.StatusBadGateway)
// 1. Hijack the connection to handle bidirectional traffic (WebSockets)
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, bufrw, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer clientConn.Close()
payload := make([]byte, 0) // dynamic payload constructing
// Open channel to client
// 2. Open channel to client
// "forwarded-tcpip" arguments:
// string address that was connected
// uint32 port that was connected
// string originator IP address
// uint32 originator port
// For simplicity, we just say we are connecting to 127.0.0.1:LocalPort
// And originating from 127.0.0.1:0
destHost := "0.0.0.0"
destPort := tunnel.LocalPort
srcHost := "127.0.0.1"
srcPort := uint32(12345) // Random source port, cannot be 0
srcPort := uint32(12345)
payload := make([]byte, 0)
payload = append(payload, []byte{0, 0, 0, uint8(len(destHost))}...)
payload = append(payload, []byte(destHost)...)
payload = append(payload, byte(destPort>>24), byte(destPort>>16), byte(destPort>>8), byte(destPort))
payload = append(payload, []byte{0, 0, 0, uint8(len(srcHost))}...)
payload = append(payload, []byte(srcHost)...)
payload = append(payload, byte(srcPort>>24), byte(srcPort>>16), byte(srcPort>>8), byte(srcPort))
@@ -159,99 +157,40 @@ func startHttpProxy(port string) {
ch, reqs, err := tunnel.Conn.OpenChannel("forwarded-tcpip", payload)
if err != nil {
log.Printf("Failed to open forwarded-tcpip channel: %s", err)
http.Error(w, "Failed to connect to client", http.StatusBadGateway)
return
}
defer ch.Close()
go gossh.DiscardRequests(reqs)
// Pipe HTTP request to the channel?
// No, we can't just pipe the raw HTTP structure unless the client expects it.
// If the client is just a TCP forwarder (ssh -R), it expects a raw TCP stream.
// So we need to act as a TCP proxy.
// But we are in an http.Handler.
// We can hijack the connection or just Re-serialize the HTTP request.
// Re-serializing is safer for now.
// Or we use Hijack to get the raw TCP conn.
// Let's iterate: just write the Request to the channel
// The client will see it as raw bytes on the socket.
// This works if the client is forwarding to a web server.
err = r.Write(ch) // Writes the request wire representation
if err != nil {
http.Error(w, "Failed to write request: "+err.Error(), http.StatusInternalServerError)
return
}
// Read response from channel and write to w
// This is tricky because we need to parse the response to set headers correctly in w.
// Or we can just use Hijack if we want to be a transparent TCP proxy.
// But Hijack breaks HTTP/2 and some middleware.
// Let's use `http.ReadResponse`
// Problem: `r.Write(ch)` might not close the write side, so the server might wait.
// but HTTP 1.1 usually has content-length.
// Using bufio to read the response from the channel.
// resp, err := http.ReadResponse(bufio.NewReader(ch), r)
// ... copy headers, body ...
// For MVP, hijacking is often easiest for "Tunneling"
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer clientConn.Close()
// bidirectional copy
var wg sync.WaitGroup
wg.Add(2)
// We already consumed `r` somewhat? No, Hijack takes over.
// But `http.HandleFunc` might have read the headers.
// The `clientConn` is the raw TCP connection from the Browser/ExternalUser to OUR Proxy.
// We need to replay the request headers!
// Because `http.Server` already read them.
// Actually, `r.Write(ch)` is correct for sending the request to the backend.
// But then we need to pipe `ch` output back to `w`.
// And since we used Hijack, we are responsible for the entire response.
// So:
// 1. Write `r` to `ch`.
// 2. IO Copy `ch` -> `clientConn`.
// 3. Browser -> Backend (Write request + Copy raw stream)
go func() {
defer wg.Done()
r.Write(ch) // Send the request to valid backend
// Also forward any extra body if not fully read?
// r.Write handles body.
// Write the initial request (Method, Path, Headers)
// This sets up the handshake or request.
// Note: We use r.Write to reconstruct the request line and headers.
// For WebSockets, the Body is empty, so this writes headers and returns.
// For POSTs, it writes headers and tries to copy Body.
if err := r.Write(ch); err != nil {
log.Printf("Error writing request to backend: %v", err)
return
}
// Important: Continue copying any subsequent data (like WebSocket frames)
// from the hijacked buffer/connection to the channel.
io.Copy(ch, bufrw)
// e.g. when browser closes or stops sending, we are done here.
}()
// 4. Backend -> Browser (Copy raw stream)
go func() {
defer wg.Done()
io.Copy(clientConn, ch) // Copy response from backend to user
io.Copy(clientConn, ch)
// When backend closes connection, close browser connection
clientConn.Close()
}()
// Wait? No, if we wait for r.Write, it might block if the body is large.
// Actually, standard proxying is complex.
// Simplest "Grokway" logic:
// User runs: ssh -R 80:localhost:8080
// Server listens on a random port (or we map virtual host).
// Let's ignore Hijack for a moment and try standard Request/Response.
// Implementation detail for later.
// Connection is hijacked, do not write to w
wg.Wait()
})

View File

@@ -12,7 +12,7 @@ Group=grokway
WorkingDirectory=/opt/grokway
# Path to the executable.
ExecStart=/opt/grokway/grokway
ExecStart=/opt/grokway/grokway --ssh :2223
# Environment variables.
# You MUST set this to a secure token, otherwise a random one is generated on each start.